Skip to main content

mvm_runtime/
config.rs

1use mvm_core::config::{ARCH, fc_version};
2use serde::{Deserialize, Serialize};
3use std::io::Write;
4use std::path::PathBuf;
5
6pub const VM_NAME: &str = "mvm";
7pub const API_SOCKET: &str = "/tmp/firecracker.socket";
8pub const TAP_DEV: &str = "tap0";
9pub const TAP_IP: &str = "172.16.0.1";
10pub const MASK_SHORT: &str = "/30";
11pub const GUEST_IP: &str = "172.16.0.2";
12pub const FC_MAC: &str = "06:00:AC:10:00:02";
13/// Path inside the Lima VM (~ expands to the VM user's home)
14pub const MICROVM_DIR: &str = "~/microvm";
15
16// --- Multi-VM bridge networking ---
17pub const BRIDGE_DEV: &str = "br-mvm";
18pub const BRIDGE_IP: &str = "172.16.0.1";
19pub const BRIDGE_CIDR: &str = "172.16.0.1/24";
20/// Directory holding per-VM state: ~/microvm/vms/<name>/
21pub const VMS_DIR: &str = "~/microvm/vms";
22
23/// Per-VM network + filesystem identity, derived from a slot index.
24#[derive(Debug, Clone)]
25pub struct VmSlot {
26    pub name: String,
27    pub index: u8,
28    pub tap_dev: String,
29    pub mac: String,
30    pub guest_ip: String,
31    pub vm_dir: String,
32    pub api_socket: String,
33}
34
35impl VmSlot {
36    /// Create a slot from a name and 0-based index.
37    /// Index N → guest IP 172.16.0.{N+2}, TAP tap{N}.
38    pub fn new(name: &str, index: u8) -> Self {
39        let ip_octet = index + 2;
40        Self {
41            name: name.to_string(),
42            index,
43            tap_dev: format!("tap{}", index),
44            mac: format!("06:00:AC:10:00:{:02x}", ip_octet),
45            guest_ip: format!("172.16.0.{}", ip_octet),
46            vm_dir: format!("{}/{}", VMS_DIR, name),
47            api_socket: format!("{}/{}/fc.socket", VMS_DIR, name),
48        }
49    }
50}
51
52#[derive(Debug, Serialize, Deserialize, Default)]
53pub struct MvmState {
54    pub kernel: String,
55    pub rootfs: String,
56    pub ssh_key: String,
57    #[serde(default)]
58    pub fc_pid: Option<u32>,
59}
60
61/// Run mode info persisted at `~/microvm/.mvm-run-info` so `status` can
62/// distinguish dev-mode VMs from flake-built VMs.
63#[derive(Debug, Serialize, Deserialize, Default)]
64pub struct RunInfo {
65    /// "dev" or "flake"
66    pub mode: String,
67    #[serde(default)]
68    pub name: Option<String>,
69    #[serde(default)]
70    pub revision: Option<String>,
71    #[serde(default)]
72    pub flake_ref: Option<String>,
73    #[serde(default)]
74    pub guest_ip: Option<String>,
75    #[serde(default)]
76    pub profile: Option<String>,
77    pub guest_user: String,
78    pub cpus: u32,
79    pub memory: u32,
80}
81
82/// Find the lima.yaml.tera template file.
83/// Looks in: 1) resources/ next to the binary, 2) source tree, 3) sibling project
84pub(crate) fn find_lima_template() -> anyhow::Result<PathBuf> {
85    let exe_dir = std::env::current_exe()?.parent().unwrap().to_path_buf();
86
87    // Check next to binary
88    let candidate = exe_dir.join("resources").join("lima.yaml.tera");
89    if candidate.exists() {
90        return Ok(candidate);
91    }
92
93    // Check in the source tree (development mode)
94    let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
95    let candidate = manifest_dir.join("resources").join("lima.yaml.tera");
96    if candidate.exists() {
97        return Ok(candidate);
98    }
99
100    // Check workspace root (crate is at crates/mvm-runtime/)
101    let workspace_root = manifest_dir.parent().unwrap().parent().unwrap();
102    let candidate = workspace_root.join("resources").join("lima.yaml.tera");
103    if candidate.exists() {
104        return Ok(candidate);
105    }
106
107    // Check sibling project (plain yaml fallback)
108    let candidate = workspace_root
109        .parent()
110        .unwrap()
111        .join("firecracker-lima-vm")
112        .join("lima.yaml");
113    if candidate.exists() {
114        return Ok(candidate);
115    }
116
117    anyhow::bail!(
118        "Cannot find lima.yaml.tera. Place it in resources/ or ensure ../firecracker-lima-vm/lima.yaml exists."
119    )
120}
121
122/// Options for customizing Lima template rendering.
123#[derive(Debug, Default)]
124pub struct LimaRenderOptions {
125    /// Path to a custom lima.yaml.tera template. If `None`, the bundled template is used.
126    pub template_path: Option<PathBuf>,
127    /// Extra Tera context variables injected alongside the built-in ones.
128    /// These take precedence over built-in values if keys collide.
129    pub extra_context: std::collections::HashMap<String, String>,
130    /// Number of vCPUs for the Lima VM.
131    pub cpus: Option<u32>,
132    /// Memory in GiB for the Lima VM.
133    pub memory_gib: Option<u32>,
134    /// SSH local port binding for Lima.
135    pub ssh_port: Option<u16>,
136}
137
138/// Render the Lima YAML template with config values and return a temp file.
139/// The caller must hold the returned NamedTempFile until limactl has read it.
140pub fn render_lima_yaml() -> anyhow::Result<tempfile::NamedTempFile> {
141    render_lima_yaml_with(&LimaRenderOptions::default())
142}
143
144/// Render the Lima YAML template with custom options.
145pub fn render_lima_yaml_with(opts: &LimaRenderOptions) -> anyhow::Result<tempfile::NamedTempFile> {
146    let template_path = match &opts.template_path {
147        Some(p) => {
148            if !p.exists() {
149                anyhow::bail!("Custom Lima template not found: {}", p.display());
150            }
151            p.clone()
152        }
153        None => find_lima_template()?,
154    };
155
156    let template_str = std::fs::read_to_string(&template_path)
157        .map_err(|e| anyhow::anyhow!("Failed to read {}: {}", template_path.display(), e))?;
158
159    let mut tera = tera::Tera::default();
160    tera.add_raw_template("lima.yaml", &template_str)
161        .map_err(|e| anyhow::anyhow!("Failed to parse Lima template: {}", e))?;
162
163    let mut ctx = tera::Context::new();
164    ctx.insert("vm_name", VM_NAME);
165    ctx.insert("fc_version", &fc_version());
166    ctx.insert("arch", ARCH);
167    ctx.insert("tap_ip", TAP_IP);
168    ctx.insert("guest_ip", GUEST_IP);
169    ctx.insert("microvm_dir", MICROVM_DIR);
170
171    if let Some(cpus) = opts.cpus {
172        ctx.insert("lima_cpus", &cpus);
173    }
174    if let Some(mem) = opts.memory_gib {
175        ctx.insert("lima_memory", &mem);
176    }
177    if let Some(port) = opts.ssh_port {
178        ctx.insert("ssh_port", &port);
179    } else if let Ok(port_env) = std::env::var("MVM_SSH_PORT")
180        && let Ok(p) = port_env.parse::<u16>()
181    {
182        ctx.insert("ssh_port", &p);
183    }
184
185    for (key, value) in &opts.extra_context {
186        ctx.insert(key, value);
187    }
188
189    let rendered = tera
190        .render("lima.yaml", &ctx)
191        .map_err(|e| anyhow::anyhow!("Failed to render Lima template: {}", e))?;
192
193    let mut tmp = tempfile::Builder::new()
194        .prefix("mvm-lima-")
195        .suffix(".yaml")
196        .tempfile()
197        .map_err(|e| anyhow::anyhow!("Failed to create temp file: {}", e))?;
198
199    tmp.write_all(rendered.as_bytes())
200        .map_err(|e| anyhow::anyhow!("Failed to write rendered yaml: {}", e))?;
201
202    Ok(tmp)
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use std::io::Read;
209
210    #[test]
211    fn test_constants_non_empty() {
212        assert!(!VM_NAME.is_empty());
213        assert!(!mvm_core::config::fc_version().is_empty());
214        assert!(!mvm_core::config::ARCH.is_empty());
215        assert!(!API_SOCKET.is_empty());
216        assert!(!TAP_DEV.is_empty());
217        assert!(!TAP_IP.is_empty());
218        assert!(!GUEST_IP.is_empty());
219        assert!(!FC_MAC.is_empty());
220        assert!(!BRIDGE_DEV.is_empty());
221        assert!(!BRIDGE_IP.is_empty());
222        assert!(!BRIDGE_CIDR.is_empty());
223        assert!(!VMS_DIR.is_empty());
224    }
225
226    #[test]
227    fn test_vm_slot_new_index_0() {
228        let slot = VmSlot::new("gw", 0);
229        assert_eq!(slot.name, "gw");
230        assert_eq!(slot.index, 0);
231        assert_eq!(slot.tap_dev, "tap0");
232        assert_eq!(slot.mac, "06:00:AC:10:00:02");
233        assert_eq!(slot.guest_ip, "172.16.0.2");
234        assert!(slot.vm_dir.ends_with("/vms/gw"));
235        assert!(slot.api_socket.ends_with("/vms/gw/fc.socket"));
236    }
237
238    #[test]
239    fn test_vm_slot_new_index_1() {
240        let slot = VmSlot::new("w1", 1);
241        assert_eq!(slot.index, 1);
242        assert_eq!(slot.tap_dev, "tap1");
243        assert_eq!(slot.mac, "06:00:AC:10:00:03");
244        assert_eq!(slot.guest_ip, "172.16.0.3");
245    }
246
247    #[test]
248    fn test_vm_slot_new_index_10() {
249        let slot = VmSlot::new("worker-10", 10);
250        assert_eq!(slot.tap_dev, "tap10");
251        assert_eq!(slot.mac, "06:00:AC:10:00:0c");
252        assert_eq!(slot.guest_ip, "172.16.0.12");
253    }
254
255    #[test]
256    fn test_fc_version_starts_with_v() {
257        assert!(
258            mvm_core::config::fc_version().starts_with('v'),
259            "FC_VERSION should start with 'v'"
260        );
261    }
262
263    #[test]
264    fn test_ip_addresses_are_in_same_subnet() {
265        // TAP_IP and GUEST_IP should share the 172.16.0.x prefix
266        assert!(TAP_IP.starts_with("172.16.0."));
267        assert!(GUEST_IP.starts_with("172.16.0."));
268    }
269
270    #[test]
271    fn test_mvm_state_json_roundtrip() {
272        let state = MvmState {
273            kernel: "vmlinux-5.10.217".to_string(),
274            rootfs: "ubuntu-24.04.ext4".to_string(),
275            ssh_key: "ubuntu-24.04.id_rsa".to_string(),
276            fc_pid: Some(12345),
277        };
278
279        let json = serde_json::to_string(&state).unwrap();
280        let parsed: MvmState = serde_json::from_str(&json).unwrap();
281
282        assert_eq!(parsed.kernel, "vmlinux-5.10.217");
283        assert_eq!(parsed.rootfs, "ubuntu-24.04.ext4");
284        assert_eq!(parsed.ssh_key, "ubuntu-24.04.id_rsa");
285        assert_eq!(parsed.fc_pid, Some(12345));
286    }
287
288    #[test]
289    fn test_mvm_state_json_without_pid() {
290        let json = r#"{"kernel":"k","rootfs":"r","ssh_key":"s"}"#;
291        let state: MvmState = serde_json::from_str(json).unwrap();
292        assert_eq!(state.fc_pid, None);
293    }
294
295    #[test]
296    fn test_mvm_state_default() {
297        let state = MvmState::default();
298        assert!(state.kernel.is_empty());
299        assert!(state.rootfs.is_empty());
300        assert!(state.ssh_key.is_empty());
301        assert_eq!(state.fc_pid, None);
302    }
303
304    #[test]
305    fn test_run_info_json_roundtrip() {
306        let info = RunInfo {
307            mode: "flake".to_string(),
308            name: Some("gw".to_string()),
309            revision: Some("abc123".to_string()),
310            flake_ref: Some("/home/user/project".to_string()),
311            guest_ip: Some("172.16.0.2".to_string()),
312            profile: Some("gateway".to_string()),
313            guest_user: "root".to_string(),
314            cpus: 4,
315            memory: 2048,
316        };
317        let json = serde_json::to_string(&info).unwrap();
318        let parsed: RunInfo = serde_json::from_str(&json).unwrap();
319        assert_eq!(parsed.mode, "flake");
320        assert_eq!(parsed.name.as_deref(), Some("gw"));
321        assert_eq!(parsed.revision.as_deref(), Some("abc123"));
322        assert_eq!(parsed.flake_ref.as_deref(), Some("/home/user/project"));
323        assert_eq!(parsed.guest_ip.as_deref(), Some("172.16.0.2"));
324        assert_eq!(parsed.profile.as_deref(), Some("gateway"));
325        assert_eq!(parsed.guest_user, "root");
326        assert_eq!(parsed.cpus, 4);
327        assert_eq!(parsed.memory, 2048);
328    }
329
330    #[test]
331    fn test_run_info_default() {
332        let info = RunInfo::default();
333        assert!(info.mode.is_empty());
334        assert!(info.name.is_none());
335        assert!(info.revision.is_none());
336        assert!(info.flake_ref.is_none());
337        assert!(info.guest_ip.is_none());
338        assert!(info.profile.is_none());
339        assert!(info.guest_user.is_empty());
340        assert_eq!(info.cpus, 0);
341        assert_eq!(info.memory, 0);
342    }
343
344    #[test]
345    fn test_run_info_minimal_json() {
346        let json = r#"{"mode":"dev","guest_user":"mvm","cpus":2,"memory":1024}"#;
347        let info: RunInfo = serde_json::from_str(json).unwrap();
348        assert_eq!(info.mode, "dev");
349        assert!(info.revision.is_none());
350        assert!(info.flake_ref.is_none());
351    }
352
353    #[test]
354    fn test_production_mode_disabled_by_default() {
355        // Without env var set, should be false
356        unsafe { std::env::remove_var("MVM_PRODUCTION") };
357        assert!(!mvm_core::config::is_production_mode());
358    }
359
360    #[test]
361    fn test_find_lima_template_succeeds() {
362        // Should find resources/lima.yaml.tera in the source tree
363        let path = find_lima_template().unwrap();
364        assert!(path.exists());
365        assert!(path.to_str().unwrap().contains("lima.yaml"));
366    }
367
368    #[test]
369    fn test_render_lima_yaml_produces_valid_output() {
370        let tmp = render_lima_yaml().unwrap();
371        let mut content = String::new();
372        std::fs::File::open(tmp.path())
373            .unwrap()
374            .read_to_string(&mut content)
375            .unwrap();
376
377        // Should contain Lima YAML structure
378        assert!(content.contains("nestedVirtualization: true"));
379        assert!(content.contains("writable: true"));
380
381        // Lima's own template variable should be preserved (raw block unwrapped)
382        assert!(content.contains("{{.User}}"));
383
384        // Tera tags should NOT appear in output
385        assert!(!content.contains("{% raw %}"));
386        assert!(!content.contains("{% endraw %}"));
387    }
388
389    #[test]
390    fn test_render_lima_yaml_temp_file_has_yaml_suffix() {
391        let tmp = render_lima_yaml().unwrap();
392        let path_str = tmp.path().to_str().unwrap();
393        assert!(path_str.ends_with(".yaml"));
394        assert!(path_str.contains("mvm-lima-"));
395    }
396
397    #[test]
398    fn test_render_with_extra_context() {
399        let mut extra = std::collections::HashMap::new();
400        extra.insert("vm_name".to_string(), "custom-vm".to_string());
401        let opts = LimaRenderOptions {
402            extra_context: extra,
403            ..Default::default()
404        };
405        // Should succeed — extra context overrides vm_name but template
406        // doesn't directly embed it in a visible way. Just verify no error.
407        let tmp = render_lima_yaml_with(&opts).unwrap();
408        assert!(tmp.path().exists());
409    }
410
411    #[test]
412    fn test_render_with_custom_template() {
413        let mut custom = tempfile::NamedTempFile::new().unwrap();
414        std::io::Write::write_all(&mut custom, b"custom: {{ vm_name }}").unwrap();
415
416        let opts = LimaRenderOptions {
417            template_path: Some(custom.path().to_path_buf()),
418            ..Default::default()
419        };
420        let tmp = render_lima_yaml_with(&opts).unwrap();
421        let mut content = String::new();
422        std::fs::File::open(tmp.path())
423            .unwrap()
424            .read_to_string(&mut content)
425            .unwrap();
426        assert_eq!(content, "custom: mvm");
427    }
428
429    #[test]
430    fn test_render_with_missing_custom_template_fails() {
431        let opts = LimaRenderOptions {
432            template_path: Some(PathBuf::from("/nonexistent/template.tera")),
433            ..Default::default()
434        };
435        assert!(render_lima_yaml_with(&opts).is_err());
436    }
437
438    #[test]
439    fn test_render_lima_yaml_includes_nix_profile() {
440        let tmp = render_lima_yaml().unwrap();
441        let mut content = String::new();
442        std::fs::File::open(tmp.path())
443            .unwrap()
444            .read_to_string(&mut content)
445            .unwrap();
446        assert!(
447            content.contains("mvm-nix.sh"),
448            "Lima template should install Nix profile.d script"
449        );
450        assert!(
451            content.contains("nix-daemon.sh"),
452            "Lima template should source nix-daemon.sh"
453        );
454    }
455
456    #[test]
457    fn test_render_lima_yaml_includes_mvm_tools_profile() {
458        let tmp = render_lima_yaml().unwrap();
459        let mut content = String::new();
460        std::fs::File::open(tmp.path())
461            .unwrap()
462            .read_to_string(&mut content)
463            .unwrap();
464        assert!(
465            content.contains("mvm-tools.sh"),
466            "Lima template should install mvm-tools profile.d script"
467        );
468        assert!(
469            content.contains("MVM_FC_VERSION"),
470            "Lima template should export MVM_FC_VERSION"
471        );
472    }
473
474    #[test]
475    fn test_render_with_lima_resources() {
476        let opts = LimaRenderOptions {
477            cpus: Some(8),
478            memory_gib: Some(16),
479            ..Default::default()
480        };
481        let tmp = render_lima_yaml_with(&opts).unwrap();
482        let mut content = String::new();
483        std::fs::File::open(tmp.path())
484            .unwrap()
485            .read_to_string(&mut content)
486            .unwrap();
487        assert!(
488            content.contains("cpus: 8"),
489            "Rendered YAML should contain cpus: 8, got:\n{}",
490            content
491        );
492        assert!(
493            content.contains(r#"memory: "16GiB""#),
494            "Rendered YAML should contain memory: \"16GiB\", got:\n{}",
495            content
496        );
497    }
498
499    #[test]
500    fn test_render_without_lima_resources_omits_fields() {
501        let tmp = render_lima_yaml().unwrap();
502        let mut content = String::new();
503        std::fs::File::open(tmp.path())
504            .unwrap()
505            .read_to_string(&mut content)
506            .unwrap();
507        assert!(
508            !content.contains("cpus:"),
509            "Default render should not contain cpus field"
510        );
511        assert!(
512            !content.contains("memory:"),
513            "Default render should not contain memory field"
514        );
515    }
516}