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#[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#[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
127fn find_images_dir() -> Result<PathBuf> {
134 let exe_dir = std::env::current_exe()?.parent().unwrap().to_path_buf();
135
136 let candidate = exe_dir.join("images");
138 if candidate.exists() {
139 return Ok(candidate);
140 }
141
142 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
152pub fn find_config(name_or_path: &str) -> Result<(PathBuf, MvmImageConfig)> {
158 let path = Path::new(name_or_path);
159
160 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 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 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
192pub 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
201pub 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
210fn 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
252fn 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 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
344fn repair_dev_null() -> Result<()> {
353 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
365fn 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 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
405fn 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
415fn 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
455pub 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()?;
466
467 firecracker::install()?;
469
470 ensure_base_assets()?;
472 ensure_squashfs_tools()?;
473
474 ui::info(&format!("Building image '{}'...", name));
476
477 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 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 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 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 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 let host_init = generate_host_init(&config);
642 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 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 let vm_elf_path = run_in_vm_stdout(&format!(
687 "echo $HOME/microvm/images/{name}.$(uname -m).elf",
688 name = name,
689 ))?;
690
691 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#[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 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}