1use std::net::Ipv4Addr;
13#[cfg(target_os = "macos")]
14use std::os::fd::RawFd;
15use std::path::PathBuf;
16
17use async_trait::async_trait;
18use serde::{Deserialize, Serialize};
19
20use crate::config::ResourceLimits;
21use crate::error::Result;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct FsMount {
28 pub tag: String,
30 pub host_path: PathBuf,
32 pub read_only: bool,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Entrypoint {
39 pub executable: String,
41 pub args: Vec<String>,
43 pub env: Vec<(String, String)>,
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct TeeInstanceConfig {
50 pub config_path: PathBuf,
52 pub tee_type: String,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct NetworkInstanceConfig {
59 pub net_socket_path: PathBuf,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub net_stats_path: Option<PathBuf>,
65
66 #[cfg(target_os = "macos")]
68 #[serde(default)]
69 pub net_socket_fd: Option<RawFd>,
70
71 #[cfg(target_os = "macos")]
73 #[serde(default)]
74 pub net_proxy_fd: Option<RawFd>,
75
76 pub ip_address: Ipv4Addr,
78
79 pub gateway: Ipv4Addr,
81
82 pub prefix_len: u8,
84
85 pub mac_address: [u8; 6],
87
88 #[serde(default)]
90 pub dns_servers: Vec<Ipv4Addr>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct InstanceSpec {
99 pub box_id: String,
101
102 pub vcpus: u8,
104
105 pub memory_mib: u32,
107
108 pub rootfs_path: PathBuf,
110
111 pub exec_socket_path: PathBuf,
113
114 #[serde(default)]
116 pub pty_socket_path: PathBuf,
117
118 #[serde(default)]
120 pub attest_socket_path: PathBuf,
121
122 #[serde(default)]
124 pub port_forward_socket_path: PathBuf,
125
126 pub fs_mounts: Vec<FsMount>,
128
129 pub entrypoint: Entrypoint,
131
132 #[serde(default)]
135 pub ksm: bool,
136
137 #[serde(default)]
141 pub snapshot_mem_file: Option<String>,
142
143 #[serde(default)]
146 pub snapshot_sock: Option<String>,
147
148 #[serde(default)]
154 pub restore_from: Option<String>,
155
156 pub console_output: Option<PathBuf>,
158
159 pub workdir: String,
161
162 pub tee_config: Option<TeeInstanceConfig>,
164
165 #[serde(default)]
167 pub port_map: Vec<String>,
168
169 #[serde(default)]
172 pub user: Option<String>,
173
174 #[serde(default)]
177 pub network: Option<NetworkInstanceConfig>,
178
179 #[serde(default)]
181 pub resource_limits: ResourceLimits,
182
183 #[serde(default)]
186 pub log_config: crate::log::LogConfig,
187}
188
189impl Default for InstanceSpec {
190 fn default() -> Self {
191 Self {
192 box_id: String::new(),
193 vcpus: 2,
194 memory_mib: 512,
195 rootfs_path: PathBuf::new(),
196 exec_socket_path: PathBuf::new(),
197 pty_socket_path: PathBuf::new(),
198 attest_socket_path: PathBuf::new(),
199 port_forward_socket_path: PathBuf::new(),
200 fs_mounts: Vec::new(),
201 entrypoint: Entrypoint {
202 executable: String::new(),
203 args: Vec::new(),
204 env: Vec::new(),
205 },
206 ksm: false,
207 snapshot_mem_file: None,
208 snapshot_sock: None,
209 restore_from: None,
210 console_output: None,
211 workdir: "/".to_string(),
212 tee_config: None,
213 port_map: Vec::new(),
214 user: None,
215 network: None,
216 resource_limits: ResourceLimits::default(),
217 log_config: crate::log::LogConfig::default(),
218 }
219 }
220}
221
222#[derive(Debug, Clone, Default)]
226pub struct VmMetrics {
227 pub cpu_percent: Option<f32>,
229 pub memory_bytes: Option<u64>,
231}
232
233pub const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 10_000;
235
236pub fn parse_signal_name(name: &str) -> i32 {
241 let upper = name.trim().to_uppercase();
242 let short = upper.strip_prefix("SIG").unwrap_or(&upper);
243 match short {
244 "HUP" => 1,
245 "INT" => 2,
246 "QUIT" => 3,
247 "ILL" => 4,
248 "ABRT" => 6,
249 "FPE" => 8,
250 "KILL" => 9,
251 "USR1" => 10,
252 "SEGV" => 11,
253 "USR2" => 12,
254 "PIPE" => 13,
255 "ALRM" | "ALARM" => 14,
256 "TERM" => 15,
257 "CHLD" | "CLD" => 17,
258 "CONT" => 18,
259 "STOP" => 19,
260 "TSTP" => 20,
261 "WINCH" => 28,
262 _ => name.trim().parse::<i32>().unwrap_or(15),
263 }
264}
265
266pub trait VmHandler: Send + Sync {
271 fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()>;
273
274 fn metrics(&self) -> VmMetrics;
276
277 fn is_running(&self) -> bool;
279
280 fn has_exited(&self) -> bool {
290 #[cfg(target_os = "linux")]
291 {
292 linux_process_exited(self.pid())
293 }
294 #[cfg(not(target_os = "linux"))]
295 {
296 !self.is_running()
297 }
298 }
299
300 fn pid(&self) -> u32;
302
303 fn exit_code(&self) -> Option<i32> {
308 None
309 }
310
311 fn try_wait_exit(&mut self) -> Result<Option<i32>> {
317 Ok(None)
318 }
319}
320
321#[cfg(target_os = "linux")]
328pub(crate) fn linux_process_exited(pid: u32) -> bool {
329 match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
330 Ok(stat) => match stat.rfind(')') {
331 Some(idx) => {
332 let state = stat[idx + 1..].trim_start().chars().next();
333 matches!(state, Some('Z') | Some('X'))
334 }
335 None => false,
337 },
338 Err(_) => true,
340 }
341}
342
343#[async_trait]
350pub trait VmmProvider: Send + Sync {
351 async fn start(&self, spec: &InstanceSpec) -> Result<Box<dyn VmHandler>>;
353}
354
355#[cfg(test)]
356mod tests {
357 use super::*;
358 use crate::config::ResourceLimits;
359
360 #[cfg(target_os = "linux")]
361 #[test]
362 fn test_linux_process_exited_current_process_is_alive() {
363 assert!(!linux_process_exited(std::process::id()));
365 }
366
367 #[cfg(target_os = "linux")]
368 #[test]
369 fn test_linux_process_exited_missing_pid_is_exited() {
370 assert!(linux_process_exited(0x7fff_fffe));
372 }
373
374 #[test]
375 fn test_parse_signal_name_term() {
376 assert_eq!(parse_signal_name("SIGTERM"), 15);
377 assert_eq!(parse_signal_name("TERM"), 15);
378 assert_eq!(parse_signal_name("15"), 15);
379 }
380
381 #[test]
382 fn test_parse_signal_name_variants() {
383 assert_eq!(parse_signal_name("SIGKILL"), 9);
384 assert_eq!(parse_signal_name("KILL"), 9);
385 assert_eq!(parse_signal_name("SIGHUP"), 1);
386 assert_eq!(parse_signal_name("SIGQUIT"), 3);
387 assert_eq!(parse_signal_name("SIGINT"), 2);
388 assert_eq!(parse_signal_name("SIGUSR1"), 10);
389 assert_eq!(parse_signal_name("SIGUSR2"), 12);
390 }
391
392 #[test]
393 fn test_parse_signal_name_numeric() {
394 assert_eq!(parse_signal_name("9"), 9);
395 assert_eq!(parse_signal_name("1"), 1);
396 }
397
398 #[test]
399 fn test_parse_signal_name_unknown_defaults_to_sigterm() {
400 assert_eq!(parse_signal_name("SIGFOO"), 15);
401 assert_eq!(parse_signal_name(""), 15);
402 assert_eq!(parse_signal_name("notasignal"), 15);
403 }
404
405 #[test]
406 fn test_parse_signal_name_case_insensitive() {
407 assert_eq!(parse_signal_name("sigterm"), 15);
408 assert_eq!(parse_signal_name("Sigterm"), 15);
409 }
410
411 #[test]
412 fn test_instance_spec_default_values() {
413 let spec = InstanceSpec::default();
414 assert_eq!(spec.vcpus, 2);
415 assert_eq!(spec.memory_mib, 512);
416 assert_eq!(spec.workdir, "/");
417 assert!(spec.box_id.is_empty());
418 assert!(spec.fs_mounts.is_empty());
419 assert!(spec.port_map.is_empty());
420 assert!(spec.tee_config.is_none());
421 assert!(spec.user.is_none());
422 assert!(spec.network.is_none());
423 assert!(spec.console_output.is_none());
424 }
425
426 #[test]
427 fn test_instance_spec_serde_roundtrip() {
428 let spec = InstanceSpec {
429 box_id: "test-box-123".to_string(),
430 ksm: false,
431 snapshot_mem_file: None,
432 snapshot_sock: None,
433 restore_from: None,
434 vcpus: 4,
435 memory_mib: 2048,
436 rootfs_path: PathBuf::from("/tmp/rootfs"),
437 exec_socket_path: PathBuf::from("/tmp/exec.sock"),
438 pty_socket_path: PathBuf::from("/tmp/pty.sock"),
439 attest_socket_path: PathBuf::from("/tmp/attest.sock"),
440 port_forward_socket_path: PathBuf::from("/tmp/portfwd.sock"),
441 fs_mounts: vec![FsMount {
442 tag: "workspace".to_string(),
443 host_path: PathBuf::from("/home/user/project"),
444 read_only: false,
445 }],
446 entrypoint: Entrypoint {
447 executable: "/usr/bin/agent".to_string(),
448 args: vec!["--port".to_string(), "8080".to_string()],
449 env: vec![("HOME".to_string(), "/root".to_string())],
450 },
451 console_output: Some(PathBuf::from("/tmp/console.log")),
452 workdir: "/app".to_string(),
453 tee_config: None,
454 port_map: vec!["8080:80".to_string()],
455 user: Some("1000:1000".to_string()),
456 network: None,
457 resource_limits: ResourceLimits::default(),
458 log_config: crate::log::LogConfig::default(),
459 };
460
461 let json = serde_json::to_string(&spec).unwrap();
462 let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
463
464 assert_eq!(deserialized.box_id, "test-box-123");
465 assert_eq!(deserialized.vcpus, 4);
466 assert_eq!(deserialized.memory_mib, 2048);
467 assert_eq!(deserialized.workdir, "/app");
468 assert_eq!(deserialized.fs_mounts.len(), 1);
469 assert_eq!(deserialized.fs_mounts[0].tag, "workspace");
470 assert!(!deserialized.fs_mounts[0].read_only);
471 assert_eq!(deserialized.entrypoint.executable, "/usr/bin/agent");
472 assert_eq!(deserialized.entrypoint.args.len(), 2);
473 assert_eq!(deserialized.entrypoint.env.len(), 1);
474 assert_eq!(
475 deserialized.port_forward_socket_path,
476 PathBuf::from("/tmp/portfwd.sock")
477 );
478 assert_eq!(deserialized.port_map, vec!["8080:80"]);
479 assert_eq!(deserialized.user, Some("1000:1000".to_string()));
480 }
481
482 #[test]
483 fn test_instance_spec_with_tee_config() {
484 let spec = InstanceSpec {
485 tee_config: Some(TeeInstanceConfig {
486 config_path: PathBuf::from("/etc/tee.json"),
487 tee_type: "snp".to_string(),
488 }),
489 ..Default::default()
490 };
491
492 let json = serde_json::to_string(&spec).unwrap();
493 let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
494
495 let tee = deserialized.tee_config.unwrap();
496 assert_eq!(tee.tee_type, "snp");
497 assert_eq!(tee.config_path, PathBuf::from("/etc/tee.json"));
498 }
499
500 #[test]
501 fn test_instance_spec_with_network() {
502 let spec = InstanceSpec {
503 network: Some(NetworkInstanceConfig {
504 net_socket_path: PathBuf::from("/tmp/net.sock"),
505 net_stats_path: Some(PathBuf::from("/tmp/net.stats.json")),
506 #[cfg(target_os = "macos")]
507 net_socket_fd: Some(42),
508 #[cfg(target_os = "macos")]
509 net_proxy_fd: Some(43),
510 ip_address: "10.0.0.2".parse().unwrap(),
511 gateway: "10.0.0.1".parse().unwrap(),
512 prefix_len: 24,
513 mac_address: [0x02, 0x42, 0xac, 0x11, 0x00, 0x02],
514 dns_servers: vec!["8.8.8.8".parse().unwrap()],
515 }),
516 ..Default::default()
517 };
518
519 let json = serde_json::to_string(&spec).unwrap();
520 let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
521
522 let net = deserialized.network.unwrap();
523 assert_eq!(
524 net.net_stats_path,
525 Some(PathBuf::from("/tmp/net.stats.json"))
526 );
527 #[cfg(target_os = "macos")]
528 assert_eq!(net.net_socket_fd, Some(42));
529 #[cfg(target_os = "macos")]
530 assert_eq!(net.net_proxy_fd, Some(43));
531 assert_eq!(net.ip_address, "10.0.0.2".parse::<Ipv4Addr>().unwrap());
532 assert_eq!(net.gateway, "10.0.0.1".parse::<Ipv4Addr>().unwrap());
533 assert_eq!(net.prefix_len, 24);
534 assert_eq!(net.dns_servers.len(), 1);
535 }
536
537 #[test]
538 fn test_fs_mount_serde() {
539 let mount = FsMount {
540 tag: "data".to_string(),
541 host_path: PathBuf::from("/mnt/data"),
542 read_only: true,
543 };
544
545 let json = serde_json::to_string(&mount).unwrap();
546 let deserialized: FsMount = serde_json::from_str(&json).unwrap();
547
548 assert_eq!(deserialized.tag, "data");
549 assert_eq!(deserialized.host_path, PathBuf::from("/mnt/data"));
550 assert!(deserialized.read_only);
551 }
552
553 #[test]
554 fn test_entrypoint_serde() {
555 let ep = Entrypoint {
556 executable: "/bin/sh".to_string(),
557 args: vec!["-c".to_string(), "echo hello".to_string()],
558 env: vec![
559 ("PATH".to_string(), "/usr/bin".to_string()),
560 ("HOME".to_string(), "/root".to_string()),
561 ],
562 };
563
564 let json = serde_json::to_string(&ep).unwrap();
565 let deserialized: Entrypoint = serde_json::from_str(&json).unwrap();
566
567 assert_eq!(deserialized.executable, "/bin/sh");
568 assert_eq!(deserialized.args, vec!["-c", "echo hello"]);
569 assert_eq!(deserialized.env.len(), 2);
570 }
571
572 #[test]
573 fn test_instance_spec_deserialize_missing_optional_fields() {
574 let json = r#"{
575 "box_id": "min",
576 "vcpus": 1,
577 "memory_mib": 256,
578 "rootfs_path": "/rootfs",
579 "exec_socket_path": "/exec.sock",
580 "fs_mounts": [],
581 "entrypoint": {"executable": "/bin/sh", "args": [], "env": []},
582 "console_output": null,
583 "workdir": "/"
584 }"#;
585
586 let spec: InstanceSpec = serde_json::from_str(json).unwrap();
587 assert_eq!(spec.box_id, "min");
588 assert!(spec.port_map.is_empty());
589 assert!(spec.user.is_none());
590 assert!(spec.network.is_none());
591 assert!(spec.tee_config.is_none());
592 }
593
594 #[test]
595 fn test_resource_limits_in_spec() {
596 let spec = InstanceSpec {
597 resource_limits: ResourceLimits {
598 pids_limit: Some(100),
599 cpuset_cpus: Some("0-3".to_string()),
600 ..Default::default()
601 },
602 ..Default::default()
603 };
604
605 let json = serde_json::to_string(&spec).unwrap();
606 let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
607
608 assert_eq!(deserialized.resource_limits.pids_limit, Some(100));
609 assert_eq!(
610 deserialized.resource_limits.cpuset_cpus,
611 Some("0-3".to_string())
612 );
613 }
614
615 #[test]
616 fn test_vm_metrics_default() {
617 let m = VmMetrics::default();
618 assert!(m.cpu_percent.is_none());
619 assert!(m.memory_bytes.is_none());
620 }
621
622 #[test]
623 fn test_vm_metrics_clone() {
624 let m = VmMetrics {
625 cpu_percent: Some(50.0),
626 memory_bytes: Some(1024 * 1024),
627 };
628 let cloned = m.clone();
629 assert_eq!(cloned.cpu_percent, Some(50.0));
630 assert_eq!(cloned.memory_bytes, Some(1024 * 1024));
631 }
632}