Skip to main content

mvm_runtime/vm/
image.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use serde::Deserialize;
6
7use super::{firecracker, lima};
8use crate::config::MICROVM_DIR;
9use crate::shell::{run_in_vm, run_in_vm_stdout, run_in_vm_visible};
10use crate::ui;
11
12// ---------------------------------------------------------------------------
13// Mvmfile.toml config structs
14// ---------------------------------------------------------------------------
15
16#[derive(Debug, Deserialize)]
17pub struct MvmImageConfig {
18    pub image: ImageSection,
19    #[serde(default)]
20    pub resources: ResourceSection,
21    #[serde(default)]
22    pub packages: PackageSection,
23    #[serde(default)]
24    pub run: Vec<RunSection>,
25    #[serde(default)]
26    pub volumes: Vec<VolumeSection>,
27    #[serde(default)]
28    pub services: Vec<ServiceSection>,
29}
30
31#[derive(Debug, Deserialize)]
32pub struct ImageSection {
33    pub name: String,
34    #[serde(default = "default_base")]
35    pub base: String,
36    #[serde(default = "default_disk")]
37    pub disk: String,
38}
39
40fn default_base() -> String {
41    "ubuntu".to_string()
42}
43fn default_disk() -> String {
44    "4G".to_string()
45}
46
47#[derive(Debug, Deserialize)]
48pub struct ResourceSection {
49    #[serde(default = "default_memory")]
50    pub memory: u32,
51    #[serde(default = "default_cpus")]
52    pub cpus: u32,
53}
54
55impl Default for ResourceSection {
56    fn default() -> Self {
57        Self {
58            memory: default_memory(),
59            cpus: default_cpus(),
60        }
61    }
62}
63
64fn default_memory() -> u32 {
65    2048
66}
67fn default_cpus() -> u32 {
68    2
69}
70
71#[derive(Debug, Deserialize, Default)]
72pub struct PackageSection {
73    #[serde(default)]
74    pub apt: Vec<String>,
75}
76
77#[derive(Debug, Deserialize)]
78pub struct RunSection {
79    pub command: String,
80}
81
82#[derive(Debug, Deserialize, Clone)]
83pub struct VolumeSection {
84    pub guest: String,
85    pub size: String,
86    #[serde(default)]
87    pub default_host: Option<String>,
88}
89
90#[derive(Debug, Deserialize)]
91pub struct ServiceSection {
92    pub name: String,
93    pub command: String,
94    #[serde(default)]
95    pub after: Option<String>,
96    #[serde(default = "default_restart")]
97    pub restart: String,
98    #[serde(default)]
99    pub env: HashMap<String, String>,
100}
101
102fn default_restart() -> String {
103    "on-failure".to_string()
104}
105
106// ---------------------------------------------------------------------------
107// Runtime config (for `mvm start --config`)
108// ---------------------------------------------------------------------------
109
110#[derive(Debug, Deserialize, Default)]
111pub struct RuntimeConfig {
112    #[serde(default)]
113    pub cpus: Option<u32>,
114    #[serde(default)]
115    pub memory: Option<u32>,
116    #[serde(default)]
117    pub volumes: Vec<RuntimeVolume>,
118}
119
120#[derive(Debug, Deserialize, Clone)]
121pub struct RuntimeVolume {
122    pub host: String,
123    pub guest: String,
124    pub size: String,
125}
126
127// ---------------------------------------------------------------------------
128// Config discovery and parsing
129// ---------------------------------------------------------------------------
130
131/// Find the built-in images directory (e.g., images/openclaw/).
132/// Same lookup pattern as config::find_lima_template().
133fn find_images_dir() -> Result<PathBuf> {
134    let exe_dir = std::env::current_exe()?.parent().unwrap().to_path_buf();
135
136    // Next to binary
137    let candidate = exe_dir.join("images");
138    if candidate.exists() {
139        return Ok(candidate);
140    }
141
142    // Source tree (development mode)
143    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
144    let candidate = manifest_dir.join("images");
145    if candidate.exists() {
146        return Ok(candidate);
147    }
148
149    anyhow::bail!("Cannot find built-in images directory")
150}
151
152/// Find Mvmfile.toml by name (built-in) or path.
153///
154/// - If `name_or_path` is a directory, look for Mvmfile.toml inside it.
155/// - If `name_or_path` is a file, use it directly.
156/// - Otherwise, treat it as a built-in image name (e.g., "openclaw").
157pub fn find_config(name_or_path: &str) -> Result<(PathBuf, MvmImageConfig)> {
158    let path = Path::new(name_or_path);
159
160    // Direct path to a file
161    if path.is_file() {
162        let config = parse_config(path)?;
163        return Ok((path.parent().unwrap_or(path).to_path_buf(), config));
164    }
165
166    // Directory containing Mvmfile.toml
167    if path.is_dir() {
168        let toml_path = path.join("Mvmfile.toml");
169        if toml_path.exists() {
170            let config = parse_config(&toml_path)?;
171            return Ok((path.to_path_buf(), config));
172        }
173        anyhow::bail!("No Mvmfile.toml found in {}", path.display());
174    }
175
176    // Built-in image name
177    let images_dir = find_images_dir()?;
178    let toml_path = images_dir.join(name_or_path).join("Mvmfile.toml");
179    if toml_path.exists() {
180        let config = parse_config(&toml_path)?;
181        return Ok((toml_path.parent().unwrap().to_path_buf(), config));
182    }
183
184    anyhow::bail!(
185        "Image '{}' not found. Looked in:\n  - {}\n  - built-in images at {}",
186        name_or_path,
187        Path::new(name_or_path).display(),
188        images_dir.display(),
189    )
190}
191
192/// Parse a Mvmfile.toml file.
193pub fn parse_config(path: &Path) -> Result<MvmImageConfig> {
194    let content = std::fs::read_to_string(path)
195        .with_context(|| format!("Failed to read {}", path.display()))?;
196    let config: MvmImageConfig =
197        toml::from_str(&content).with_context(|| format!("Failed to parse {}", path.display()))?;
198    Ok(config)
199}
200
201/// Parse a runtime config file (for `mvm start --config`).
202pub fn parse_runtime_config(path: &str) -> Result<RuntimeConfig> {
203    let content =
204        std::fs::read_to_string(path).with_context(|| format!("Failed to read {}", path))?;
205    let config: RuntimeConfig =
206        toml::from_str(&content).with_context(|| format!("Failed to parse {}", path))?;
207    Ok(config)
208}
209
210// ---------------------------------------------------------------------------
211// Service unit generation
212// ---------------------------------------------------------------------------
213
214/// Generate a systemd .service unit from a ServiceSection.
215fn generate_service_unit(svc: &ServiceSection) -> String {
216    let after = svc.after.as_deref().unwrap_or("network-online.target");
217    let wants = after;
218
219    let env_lines: String = svc
220        .env
221        .iter()
222        .map(|(k, v)| format!("Environment={}={}", k, v))
223        .collect::<Vec<_>>()
224        .join("\n");
225
226    format!(
227        r#"[Unit]
228Description={name}
229After={after} ssh.service
230Wants={wants}
231
232[Service]
233Type=simple
234ExecStartPre=/bin/bash -c 'for i in $(seq 1 60); do ip route show default 2>/dev/null | grep -q default && exit 0; sleep 1; done; exit 0'
235ExecStart={command}
236Restart={restart}
237RestartSec=5
238{env_lines}
239
240[Install]
241WantedBy=multi-user.target
242"#,
243        name = svc.name,
244        after = after,
245        wants = wants,
246        command = svc.command,
247        restart = svc.restart,
248        env_lines = env_lines,
249    )
250}
251
252// ---------------------------------------------------------------------------
253// host_init.sh generation
254// ---------------------------------------------------------------------------
255
256/// Generate the host_init.sh script from the image config.
257/// This script is embedded in the ELF and runs on the host before the VM boots.
258fn generate_host_init(config: &MvmImageConfig) -> String {
259    let name = &config.image.name;
260    let cpus = config.resources.cpus;
261    let memory = config.resources.memory;
262
263    // Generate volume creation blocks
264    let mut volume_blocks = String::new();
265    for vol in &config.volumes {
266        let fallback = format!("$HOME/.{}", name);
267        let host_default = vol.default_host.as_deref().unwrap_or(fallback.as_str());
268        let guest_basename = vol.guest.trim_start_matches('/').replace('/', "-");
269        volume_blocks.push_str(&format!(
270            r#"
271VOL_PATH="{host}/{basename}.img"
272if [ ! -e "$VOL_PATH" ]; then
273    mkdir -p "$(dirname "$VOL_PATH")"
274    echo "[{name}] Creating volume {guest} ({size})..."
275    truncate --size {size} "$VOL_PATH.tmp"
276    mke2fs -t ext4 -q "$VOL_PATH.tmp"
277    mv "$VOL_PATH.tmp" "$VOL_PATH"
278fi
279DEFAULT_VOLS+=(-v "$VOL_PATH:{guest}:ext4")
280"#,
281            host = host_default,
282            basename = guest_basename,
283            name = name,
284            guest = vol.guest,
285            size = vol.size,
286        ));
287    }
288
289    format!(
290        r##"#!/bin/bash
291set -e
292
293CPUS={cpus}
294MEMORY={memory}
295BAKE_ARGS=()
296CLI_VOLS=()
297DEFAULT_VOLS=()
298
299# Parse CLI args
300while [[ $# -gt 0 ]]; do
301    case "$1" in
302        -c|--cpus) CPUS="$2"; shift 2 ;;
303        -m|--memory) MEMORY="$2"; shift 2 ;;
304        -v|--volume) CLI_VOLS+=("$2"); shift 2 ;;
305        --) shift; BAKE_ARGS+=("$@"); break ;;
306        *) BAKE_ARGS+=("$1"); shift ;;
307    esac
308done
309
310# Create default volume disk images
311{volume_blocks}
312
313# Use CLI volumes if provided, otherwise defaults
314if [ ${{#CLI_VOLS[@]}} -gt 0 ]; then
315    for v in "${{CLI_VOLS[@]}}"; do
316        IFS=':' read -r host guest size <<< "$v"
317        if [ ! -e "$host" ]; then
318            mkdir -p "$(dirname "$host")"
319            echo "[{name}] Creating volume $guest ($size)..."
320            truncate --size "$size" "$host.tmp"
321            mke2fs -t ext4 -q "$host.tmp"
322            mv "$host.tmp" "$host"
323        fi
324        BAKE_ARGS+=(-v "$host:$guest:ext4")
325    done
326else
327    BAKE_ARGS+=("${{DEFAULT_VOLS[@]}}")
328fi
329
330# Check /dev/kvm
331[ -c /dev/kvm ] || {{ echo "ERROR: /dev/kvm not found. KVM is required."; exit 1; }}
332[ -r /dev/kvm ] || {{ echo "ERROR: Cannot read /dev/kvm. Check permissions."; exit 1; }}
333
334export BAKE_RUN_VM=1
335exec "$BAKE_EXE" --cpus "$CPUS" --memory "$MEMORY" "${{BAKE_ARGS[@]}}"
336"##,
337        cpus = cpus,
338        memory = memory,
339        volume_blocks = volume_blocks,
340        name = name,
341    )
342}
343
344// ---------------------------------------------------------------------------
345// Build
346// ---------------------------------------------------------------------------
347
348/// Repair /dev/null inside the Lima VM if it has wrong permissions or was
349/// deleted.  This can happen when a previous `mvm build` was Ctrl-C'd while
350/// bind mounts were active, causing `rm -rf` to destroy device nodes through
351/// the mount.
352fn repair_dev_null() -> Result<()> {
353    // Test if /dev/null is writable.  We redirect to /dev/zero so bash won't
354    // skip the command if /dev/null itself is broken.
355    let check = run_in_vm("test -c /dev/null -a -w /dev/null")?;
356    if !check.status.success() {
357        ui::warn("Repairing /dev/null in Lima VM...");
358        run_in_vm(
359            "sudo rm -f /dev/null && sudo mknod /dev/null c 1 3 && sudo chmod 666 /dev/null",
360        )?;
361    }
362    Ok(())
363}
364
365/// Ensure the base rootfs (S3 squashfs) and kernel are available and valid.
366fn ensure_base_assets() -> Result<()> {
367    let sp = ui::spinner("Checking base assets...");
368    let has_kernel = run_in_vm(&format!(
369        "ls {dir}/vmlinux-* >/dev/null 2>&1",
370        dir = MICROVM_DIR,
371    ))?;
372    let has_rootfs = run_in_vm(&format!(
373        "ls {dir}/ubuntu-*.squashfs.upstream >/dev/null 2>&1",
374        dir = MICROVM_DIR,
375    ))?;
376
377    if !has_kernel.status.success() || !has_rootfs.status.success() {
378        sp.finish_and_clear();
379        ui::info("Downloading base assets...");
380        firecracker::download_assets()?;
381        return Ok(());
382    }
383
384    // Verify squashfs integrity (a previous interrupted build may have corrupted it)
385    // Use -l to list all files, which reads inode/directory tables — catches more
386    // corruption than -s which only reads the superblock.
387    sp.set_message("Validating base rootfs integrity...");
388    let valid = run_in_vm(&format!(
389        "unsquashfs -l {dir}/ubuntu-*.squashfs.upstream >/dev/null 2>&1",
390        dir = MICROVM_DIR,
391    ))?;
392    sp.finish_and_clear();
393
394    if !valid.status.success() {
395        ui::warn("Base rootfs is corrupted. Re-downloading...");
396        run_in_vm(&format!(
397            "rm -f {dir}/ubuntu-*.squashfs.upstream",
398            dir = MICROVM_DIR
399        ))?;
400        firecracker::download_assets()?;
401    }
402    Ok(())
403}
404
405/// Ensure squashfs-tools is available in the Lima VM.
406fn ensure_squashfs_tools() -> Result<()> {
407    let has_it = run_in_vm("command -v mksquashfs >/dev/null 2>&1")?;
408    if !has_it.status.success() {
409        ui::info("Installing squashfs-tools...");
410        run_in_vm_visible("sudo apt-get update -qq && sudo apt-get install -y squashfs-tools")?;
411    }
412    Ok(())
413}
414
415/// Ensure the bake binary is available in the Lima VM.
416fn ensure_bake() -> Result<()> {
417    let has_it = run_in_vm(&format!("test -x {dir}/tools/bake", dir = MICROVM_DIR))?;
418    if has_it.status.success() {
419        return Ok(());
420    }
421
422    ui::info("Downloading bake tool...");
423    run_in_vm_visible(&format!(
424        r#"
425        mkdir -p {dir}/tools
426        ARCH=$(uname -m)
427        if [ "$ARCH" = "aarch64" ]; then BAKE_ARCH="arm64"; else BAKE_ARCH="amd64"; fi
428
429        # Extract bake binary from the container image
430        # The bake tool is distributed as a container image at ghcr.io/losfair/bake
431        # We pull it with Docker or download the binary directly if available
432        if command -v docker >/dev/null 2>&1; then
433            CID=$(docker create --platform "linux/$BAKE_ARCH" ghcr.io/losfair/bake:sha-42fbc25 true)
434            docker cp "$CID:/opt/bake/bake.$BAKE_ARCH" {dir}/tools/bake
435            docker rm "$CID" >/dev/null
436        else
437            echo "[mvm] Docker not available. Installing Docker to fetch bake..."
438            curl -fsSL https://get.docker.com | sudo sh
439            sudo usermod -aG docker $(whoami)
440            newgrp docker <<EONG
441            CID=\$(docker create --platform "linux/$BAKE_ARCH" ghcr.io/losfair/bake:sha-42fbc25 true)
442            docker cp "\$CID:/opt/bake/bake.$BAKE_ARCH" {dir}/tools/bake
443            docker rm "\$CID" >/dev/null
444EONG
445        fi
446
447        chmod +x {dir}/tools/bake
448        echo "[mvm] bake installed."
449        "#,
450        dir = MICROVM_DIR,
451    ))?;
452    Ok(())
453}
454
455/// Build a microVM image from a Mvmfile.toml config.
456///
457/// Returns the path to the built .elf file.
458pub fn build(name_or_path: &str, output: Option<&str>) -> Result<String> {
459    let (_config_dir, config) = find_config(name_or_path)?;
460    let name = &config.image.name;
461
462    lima::require_running()?;
463
464    // Repair /dev/null if a previous interrupted build destroyed it
465    repair_dev_null()?;
466
467    // Ensure Firecracker is installed (needed for bake packaging)
468    firecracker::install()?;
469
470    // Ensure prerequisites
471    ensure_base_assets()?;
472    ensure_squashfs_tools()?;
473
474    // Phase 1: Build rootfs via chroot
475    ui::info(&format!("Building image '{}'...", name));
476
477    // Build the apt-get install line
478    let packages = if config.packages.apt.is_empty() {
479        String::new()
480    } else {
481        format!(
482            "sudo chroot \"$BUILD_DIR\" env DEBIAN_FRONTEND=noninteractive apt-get install -y -o Dpkg::Options::=\"--force-confdef\" -o Dpkg::Options::=\"--force-confold\" {}",
483            config.packages.apt.join(" ")
484        )
485    };
486
487    // Build the run commands
488    let run_commands: String = config
489        .run
490        .iter()
491        .map(|r| {
492            format!(
493                "echo '[mvm] Running: {}...'\nsudo chroot \"$BUILD_DIR\" env DEBIAN_FRONTEND=noninteractive bash -c '{}'",
494                r.command.chars().take(60).collect::<String>(),
495                r.command.replace('\'', "'\\''"),
496            )
497        })
498        .collect::<Vec<_>>()
499        .join("\n");
500
501    // Build the service injection
502    let service_injection: String = config
503        .services
504        .iter()
505        .map(|svc| {
506            let unit = generate_service_unit(svc);
507            format!(
508                r#"
509sudo tee "$BUILD_DIR/etc/systemd/system/{name}.service" > /dev/null << 'SVCEOF'
510{unit}
511SVCEOF
512sudo chroot "$BUILD_DIR" systemctl enable {name}.service 2>/dev/null || true
513"#,
514                name = svc.name,
515                unit = unit,
516            )
517        })
518        .collect::<Vec<_>>()
519        .join("\n");
520
521    // Phase 1: chroot build
522    run_in_vm_visible(&format!(
523        r#"
524set -euo pipefail
525BUILD_DIR="$HOME/microvm/build-{name}"
526IMAGES_DIR="$HOME/microvm/images"
527mkdir -p "$IMAGES_DIR"
528
529# Clean previous build (unmount any leftover bind mounts first)
530# NOTE: Do NOT redirect to /dev/null here — if a previous interrupted build
531# destroyed /dev/null via the bind mount, the redirect fails and bash skips
532# the umount entirely, making the next rm -rf even more destructive.
533if [ -d "$BUILD_DIR" ]; then
534    sudo umount "$BUILD_DIR/dev/pts" 2>/dev/zero || true
535    sudo umount "$BUILD_DIR/dev" 2>/dev/zero || true
536    sudo umount "$BUILD_DIR/sys" 2>/dev/zero || true
537    sudo umount "$BUILD_DIR/proc" 2>/dev/zero || true
538    # Safety: verify no bind mounts remain before removing
539    if mountpoint -q "$BUILD_DIR/dev" 2>/dev/zero; then
540        echo "[mvm] ERROR: $BUILD_DIR/dev is still mounted. Aborting cleanup." >&2
541        exit 1
542    fi
543    sudo rm -rf "$BUILD_DIR"
544fi
545
546# Extract base rootfs
547SQUASHFS=$(ls $HOME/microvm/ubuntu-*.squashfs.upstream 2>/dev/null | tail -1)
548if [ -z "$SQUASHFS" ]; then
549    echo "[mvm] ERROR: No base rootfs found. Run 'mvm setup' first." >&2
550    exit 1
551fi
552echo "[mvm] Extracting base rootfs..."
553sudo unsquashfs -d "$BUILD_DIR" "$SQUASHFS"
554
555# Bind mount for chroot
556sudo mount --bind /proc "$BUILD_DIR/proc"
557sudo mount --bind /sys "$BUILD_DIR/sys"
558sudo mount --bind /dev "$BUILD_DIR/dev"
559sudo mount --bind /dev/pts "$BUILD_DIR/dev/pts"
560
561cleanup() {{
562    sudo umount "$BUILD_DIR/dev/pts" 2>/dev/zero || true
563    sudo umount "$BUILD_DIR/dev" 2>/dev/zero || true
564    sudo umount "$BUILD_DIR/sys" 2>/dev/zero || true
565    sudo umount "$BUILD_DIR/proc" 2>/dev/zero || true
566}}
567trap cleanup EXIT
568
569# Set up resolv.conf for network access inside chroot
570sudo cp /etc/resolv.conf "$BUILD_DIR/etc/resolv.conf" 2>/dev/null || true
571
572# Ensure /tmp is writable inside chroot (needed by apt for GPG temp files)
573sudo chmod 1777 "$BUILD_DIR/tmp"
574
575# Ensure apt cache directories exist inside chroot
576sudo mkdir -p "$BUILD_DIR/var/cache/apt/archives/partial"
577sudo mkdir -p "$BUILD_DIR/var/lib/apt/lists/partial"
578sudo mkdir -p "$BUILD_DIR/var/log/apt"
579
580# Suppress all interactive prompts during package installation
581export DEBIAN_FRONTEND=noninteractive
582
583# Install packages
584echo "[mvm] Installing packages..."
585sudo chroot "$BUILD_DIR" env DEBIAN_FRONTEND=noninteractive apt-get update -qq
586{packages}
587
588# Configure SSH
589echo "[mvm] Configuring SSH..."
590sudo chroot "$BUILD_DIR" env DEBIAN_FRONTEND=noninteractive bash -c '
591    apt-get install -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" openssh-server 2>/dev/null || true
592    mkdir -p /run/sshd /root/.ssh
593    chmod 700 /root/.ssh
594    sed -i "s/#PermitRootLogin.*/PermitRootLogin yes/" /etc/ssh/sshd_config
595    sed -i "s/#PubkeyAuthentication.*/PubkeyAuthentication yes/" /etc/ssh/sshd_config
596'
597
598# Run custom commands
599{run_commands}
600
601# Inject systemd services
602{service_injection}
603
604# Generate SSH keypair
605echo "[mvm] Generating SSH keys..."
606rm -f "$IMAGES_DIR/{name}.id_rsa" "$IMAGES_DIR/{name}.id_rsa.pub"
607ssh-keygen -f "$IMAGES_DIR/{name}.id_rsa" -N '' -q
608sudo mkdir -p "$BUILD_DIR/root/.ssh"
609sudo cp "$IMAGES_DIR/{name}.id_rsa.pub" "$BUILD_DIR/root/.ssh/authorized_keys"
610sudo chown -R root:root "$BUILD_DIR/root/.ssh"
611rm -f "$IMAGES_DIR/{name}.id_rsa.pub"
612
613# Clean up chroot mounts
614cleanup
615trap - EXIT
616
617echo "[mvm] Phase 1 complete: rootfs built."
618        "#,
619        name = name,
620        packages = packages,
621        run_commands = run_commands,
622        service_injection = service_injection,
623    ))?;
624
625    // Phase 2: Create squashfs
626    ui::info("Creating squashfs...");
627    run_in_vm_visible(&format!(
628        r#"
629set -euo pipefail
630BUILD_DIR="$HOME/microvm/build-{name}"
631IMAGES_DIR="$HOME/microvm/images"
632
633sudo mksquashfs "$BUILD_DIR" "$IMAGES_DIR/{name}.squashfs" -comp zstd -noappend
634sudo rm -rf "$BUILD_DIR"
635echo "[mvm] Phase 2 complete: squashfs created."
636        "#,
637        name = name,
638    ))?;
639
640    // Generate host_init.sh
641    let host_init = generate_host_init(&config);
642    // Write it into the Lima VM
643    run_in_vm(&format!(
644        r#"cat > $HOME/microvm/images/{name}.host_init.sh << 'HOSTINITEOF'
645{host_init}
646HOSTINITEOF
647chmod +x $HOME/microvm/images/{name}.host_init.sh"#,
648        name = name,
649        host_init = host_init,
650    ))?;
651
652    // Phase 3: Package with bake
653    ui::info("Packaging with bake...");
654    ensure_bake()?;
655
656    run_in_vm_visible(&format!(
657        r#"
658set -euo pipefail
659IMAGES_DIR="$HOME/microvm/images"
660BAKE="$HOME/microvm/tools/bake"
661KERNEL=$(ls $HOME/microvm/vmlinux-* 2>/dev/null | tail -1)
662FC=$(which firecracker)
663
664if [ -z "$KERNEL" ]; then
665    echo "[mvm] ERROR: No kernel found." >&2
666    exit 1
667fi
668
669"$BAKE" \
670    --input "$BAKE" \
671    --kernel "$KERNEL" \
672    --firecracker "$FC" \
673    --rootfs "$IMAGES_DIR/{name}.squashfs" \
674    --entrypoint /sbin/init \
675    --init-script "$IMAGES_DIR/{name}.host_init.sh" \
676    --output "$IMAGES_DIR/{name}.$(uname -m).elf"
677
678echo ""
679echo "[mvm] Build complete!"
680ls -lh "$IMAGES_DIR/{name}.$(uname -m).elf"
681        "#,
682        name = name,
683    ))?;
684
685    // Get the default path inside the Lima VM
686    let vm_elf_path = run_in_vm_stdout(&format!(
687        "echo $HOME/microvm/images/{name}.$(uname -m).elf",
688        name = name,
689    ))?;
690
691    // If --output was given, copy the ELF to the requested host path
692    let final_path = if let Some(out) = output {
693        use std::process::Command;
694        let status = Command::new("limactl")
695            .args([
696                "copy",
697                &format!("{}:{}", crate::config::VM_NAME, vm_elf_path.trim()),
698                out,
699            ])
700            .status()
701            .context("Failed to copy ELF from Lima VM")?;
702        if !status.success() {
703            anyhow::bail!("Failed to copy ELF to {}", out);
704        }
705        out.to_string()
706    } else {
707        vm_elf_path
708    };
709
710    Ok(final_path)
711}
712
713// ---------------------------------------------------------------------------
714// Tests
715// ---------------------------------------------------------------------------
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720
721    #[test]
722    fn test_parse_minimal_config() {
723        let toml = r#"
724[image]
725name = "test"
726"#;
727        let config: MvmImageConfig = toml::from_str(toml).unwrap();
728        assert_eq!(config.image.name, "test");
729        assert_eq!(config.image.base, "ubuntu");
730        assert_eq!(config.image.disk, "4G");
731        assert_eq!(config.resources.memory, 2048);
732        assert_eq!(config.resources.cpus, 2);
733        assert!(config.packages.apt.is_empty());
734        assert!(config.run.is_empty());
735        assert!(config.volumes.is_empty());
736        assert!(config.services.is_empty());
737    }
738
739    #[test]
740    fn test_parse_full_config() {
741        let toml = r#"
742[image]
743name = "openclaw"
744base = "ubuntu"
745disk = "4G"
746
747[resources]
748memory = 4096
749cpus = 4
750
751[packages]
752apt = ["curl", "wget"]
753
754[[run]]
755command = "echo hello"
756
757[[run]]
758command = "echo world"
759
760[[volumes]]
761guest = "/data"
762size = "2G"
763default_host = "~/.mydata"
764
765[[services]]
766name = "myservice"
767command = "/usr/bin/myapp"
768after = "network-online.target"
769restart = "always"
770"#;
771        let config: MvmImageConfig = toml::from_str(toml).unwrap();
772        assert_eq!(config.image.name, "openclaw");
773        assert_eq!(config.resources.memory, 4096);
774        assert_eq!(config.resources.cpus, 4);
775        assert_eq!(config.packages.apt, vec!["curl", "wget"]);
776        assert_eq!(config.run.len(), 2);
777        assert_eq!(config.volumes.len(), 1);
778        assert_eq!(config.volumes[0].guest, "/data");
779        assert_eq!(config.services.len(), 1);
780        assert_eq!(config.services[0].name, "myservice");
781    }
782
783    #[test]
784    fn test_generate_service_unit() {
785        let svc = ServiceSection {
786            name: "test".to_string(),
787            command: "/usr/bin/test".to_string(),
788            after: Some("network.target".to_string()),
789            restart: "always".to_string(),
790            env: HashMap::from([("HOME".to_string(), "/root".to_string())]),
791        };
792        let unit = generate_service_unit(&svc);
793        assert!(unit.contains("Description=test"));
794        assert!(unit.contains("ExecStart=/usr/bin/test"));
795        assert!(unit.contains("After=network.target"));
796        assert!(unit.contains("Restart=always"));
797        assert!(unit.contains("Environment=HOME=/root"));
798    }
799
800    #[test]
801    fn test_generate_host_init() {
802        let config = MvmImageConfig {
803            image: ImageSection {
804                name: "test".to_string(),
805                base: "ubuntu".to_string(),
806                disk: "4G".to_string(),
807            },
808            resources: ResourceSection {
809                memory: 2048,
810                cpus: 2,
811            },
812            packages: PackageSection::default(),
813            run: vec![],
814            volumes: vec![VolumeSection {
815                guest: "/data".to_string(),
816                size: "2G".to_string(),
817                default_host: Some("~/.testdata".to_string()),
818            }],
819            services: vec![],
820        };
821        let script = generate_host_init(&config);
822        assert!(script.contains("CPUS=2"));
823        assert!(script.contains("MEMORY=2048"));
824        assert!(script.contains("/data"));
825        assert!(script.contains("2G"));
826        assert!(script.contains("BAKE_RUN_VM=1"));
827    }
828
829    #[test]
830    fn test_parse_runtime_config() {
831        let toml = r#"
832cpus = 4
833memory = 4096
834
835[[volumes]]
836host = "~/.mydata"
837guest = "/data"
838size = "8G"
839"#;
840        let config: RuntimeConfig = toml::from_str(toml).unwrap();
841        assert_eq!(config.cpus, Some(4));
842        assert_eq!(config.memory, Some(4096));
843        assert_eq!(config.volumes.len(), 1);
844        assert_eq!(config.volumes[0].host, "~/.mydata");
845    }
846
847    #[test]
848    fn test_parse_empty_runtime_config() {
849        let toml = "";
850        let config: RuntimeConfig = toml::from_str(toml).unwrap();
851        assert_eq!(config.cpus, None);
852        assert_eq!(config.memory, None);
853        assert!(config.volumes.is_empty());
854    }
855
856    #[test]
857    fn test_find_builtin_config() {
858        // Should find the built-in openclaw config in the source tree
859        let result = find_config("openclaw");
860        if let Ok((dir, config)) = result {
861            assert_eq!(config.image.name, "openclaw");
862            assert!(dir.ends_with("openclaw"));
863        }
864    }
865
866    #[test]
867    fn test_find_builtin_example_config() {
868        let result = find_config("example");
869        if let Ok((dir, config)) = result {
870            assert_eq!(config.image.name, "example");
871            assert_eq!(config.resources.memory, 1024);
872            assert_eq!(config.resources.cpus, 1);
873            assert_eq!(config.packages.apt.len(), 6);
874            assert_eq!(config.run.len(), 2);
875            assert_eq!(config.volumes.len(), 1);
876            assert_eq!(config.volumes[0].guest, "/data");
877            assert_eq!(config.services.len(), 1);
878            assert_eq!(config.services[0].name, "myapp");
879            assert!(dir.ends_with("example"));
880        }
881    }
882}