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";
13pub const MICROVM_DIR: &str = "~/microvm";
15
16pub 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";
20pub const VMS_DIR: &str = "~/microvm/vms";
22
23#[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 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#[derive(Debug, Serialize, Deserialize, Default)]
64pub struct RunInfo {
65 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
82pub(crate) fn find_lima_template() -> anyhow::Result<PathBuf> {
85 let exe_dir = std::env::current_exe()?.parent().unwrap().to_path_buf();
86
87 let candidate = exe_dir.join("resources").join("lima.yaml.tera");
89 if candidate.exists() {
90 return Ok(candidate);
91 }
92
93 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 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 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#[derive(Debug, Default)]
124pub struct LimaRenderOptions {
125 pub template_path: Option<PathBuf>,
127 pub extra_context: std::collections::HashMap<String, String>,
130 pub cpus: Option<u32>,
132 pub memory_gib: Option<u32>,
134 pub ssh_port: Option<u16>,
136}
137
138pub fn render_lima_yaml() -> anyhow::Result<tempfile::NamedTempFile> {
141 render_lima_yaml_with(&LimaRenderOptions::default())
142}
143
144pub 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 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 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 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 assert!(content.contains("nestedVirtualization: true"));
379 assert!(content.contains("writable: true"));
380
381 assert!(content.contains("{{.User}}"));
383
384 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 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}