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, DEFAULT_VCPUS};
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 #[cfg(target_os = "macos")]
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub bridge_socket_dir: Option<PathBuf>,
80
81 pub ip_address: Ipv4Addr,
83
84 pub gateway: Ipv4Addr,
86
87 pub prefix_len: u8,
89
90 pub mac_address: [u8; 6],
92
93 #[serde(default)]
95 pub dns_servers: Vec<Ipv4Addr>,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct InstanceSpec {
104 pub box_id: String,
106
107 pub vcpus: u8,
109
110 pub memory_mib: u32,
112
113 pub rootfs_path: PathBuf,
115
116 pub exec_socket_path: PathBuf,
118
119 #[serde(default)]
121 pub pty_socket_path: PathBuf,
122
123 #[serde(default)]
125 pub attest_socket_path: PathBuf,
126
127 #[serde(default)]
129 pub port_forward_socket_path: PathBuf,
130
131 pub fs_mounts: Vec<FsMount>,
133
134 pub entrypoint: Entrypoint,
136
137 #[serde(default)]
140 pub ksm: bool,
141
142 #[serde(default)]
146 pub snapshot_mem_file: Option<String>,
147
148 #[serde(default)]
151 pub snapshot_sock: Option<String>,
152
153 #[serde(default)]
159 pub restore_from: Option<String>,
160
161 pub console_output: Option<PathBuf>,
163
164 pub workdir: String,
166
167 pub tee_config: Option<TeeInstanceConfig>,
169
170 #[serde(default)]
172 pub port_map: Vec<String>,
173
174 #[serde(default)]
177 pub user: Option<String>,
178
179 #[serde(default)]
182 pub network: Option<NetworkInstanceConfig>,
183
184 #[serde(default)]
186 pub resource_limits: ResourceLimits,
187
188 #[serde(default)]
191 pub log_config: crate::log::LogConfig,
192}
193
194impl Default for InstanceSpec {
195 fn default() -> Self {
196 Self {
197 box_id: String::new(),
198 vcpus: DEFAULT_VCPUS as u8,
199 memory_mib: 512,
200 rootfs_path: PathBuf::new(),
201 exec_socket_path: PathBuf::new(),
202 pty_socket_path: PathBuf::new(),
203 attest_socket_path: PathBuf::new(),
204 port_forward_socket_path: PathBuf::new(),
205 fs_mounts: Vec::new(),
206 entrypoint: Entrypoint {
207 executable: String::new(),
208 args: Vec::new(),
209 env: Vec::new(),
210 },
211 ksm: false,
212 snapshot_mem_file: None,
213 snapshot_sock: None,
214 restore_from: None,
215 console_output: None,
216 workdir: "/".to_string(),
217 tee_config: None,
218 port_map: Vec::new(),
219 user: None,
220 network: None,
221 resource_limits: ResourceLimits::default(),
222 log_config: crate::log::LogConfig::default(),
223 }
224 }
225}
226
227#[derive(Debug, Clone, Default)]
231pub struct VmMetrics {
232 pub cpu_percent: Option<f32>,
234 pub memory_bytes: Option<u64>,
236}
237
238pub const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 10_000;
240
241pub fn parse_signal_name(name: &str) -> i32 {
246 let upper = name.trim().to_uppercase();
247 let short = upper.strip_prefix("SIG").unwrap_or(&upper);
248 match short {
249 "HUP" => 1,
250 "INT" => 2,
251 "QUIT" => 3,
252 "ILL" => 4,
253 "ABRT" => 6,
254 "FPE" => 8,
255 "KILL" => 9,
256 "USR1" => 10,
257 "SEGV" => 11,
258 "USR2" => 12,
259 "PIPE" => 13,
260 "ALRM" | "ALARM" => 14,
261 "TERM" => 15,
262 "CHLD" | "CLD" => 17,
263 "CONT" => 18,
264 "STOP" => 19,
265 "TSTP" => 20,
266 "WINCH" => 28,
267 _ => name.trim().parse::<i32>().unwrap_or(15),
268 }
269}
270
271pub trait VmHandler: Send + Sync {
276 fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()>;
278
279 fn metrics(&self) -> VmMetrics;
281
282 fn is_running(&self) -> bool;
284
285 fn has_exited(&self) -> bool {
295 #[cfg(target_os = "linux")]
296 {
297 linux_process_exited(self.pid())
298 }
299 #[cfg(not(target_os = "linux"))]
300 {
301 !self.is_running()
302 }
303 }
304
305 fn pid(&self) -> u32;
307
308 fn exit_code(&self) -> Option<i32> {
313 None
314 }
315
316 fn try_wait_exit(&mut self) -> Result<Option<i32>> {
322 Ok(None)
323 }
324}
325
326#[cfg(target_os = "linux")]
333pub(crate) fn linux_process_exited(pid: u32) -> bool {
334 match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
335 Ok(stat) => match stat.rfind(')') {
336 Some(idx) => {
337 let state = stat[idx + 1..].trim_start().chars().next();
338 matches!(state, Some('Z') | Some('X'))
339 }
340 None => false,
342 },
343 Err(_) => true,
345 }
346}
347
348#[async_trait]
355pub trait VmmProvider: Send + Sync {
356 async fn start(&self, spec: &InstanceSpec) -> Result<Box<dyn VmHandler>>;
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use crate::config::ResourceLimits;
364
365 #[cfg(target_os = "linux")]
366 #[test]
367 fn test_linux_process_exited_current_process_is_alive() {
368 assert!(!linux_process_exited(std::process::id()));
370 }
371
372 #[cfg(target_os = "linux")]
373 #[test]
374 fn test_linux_process_exited_missing_pid_is_exited() {
375 assert!(linux_process_exited(0x7fff_fffe));
377 }
378
379 #[test]
380 fn test_parse_signal_name_term() {
381 assert_eq!(parse_signal_name("SIGTERM"), 15);
382 assert_eq!(parse_signal_name("TERM"), 15);
383 assert_eq!(parse_signal_name("15"), 15);
384 }
385
386 #[test]
387 fn test_parse_signal_name_variants() {
388 assert_eq!(parse_signal_name("SIGKILL"), 9);
389 assert_eq!(parse_signal_name("KILL"), 9);
390 assert_eq!(parse_signal_name("SIGHUP"), 1);
391 assert_eq!(parse_signal_name("SIGQUIT"), 3);
392 assert_eq!(parse_signal_name("SIGINT"), 2);
393 assert_eq!(parse_signal_name("SIGUSR1"), 10);
394 assert_eq!(parse_signal_name("SIGUSR2"), 12);
395 }
396
397 #[test]
398 fn test_parse_signal_name_numeric() {
399 assert_eq!(parse_signal_name("9"), 9);
400 assert_eq!(parse_signal_name("1"), 1);
401 }
402
403 #[test]
404 fn test_parse_signal_name_unknown_defaults_to_sigterm() {
405 assert_eq!(parse_signal_name("SIGFOO"), 15);
406 assert_eq!(parse_signal_name(""), 15);
407 assert_eq!(parse_signal_name("notasignal"), 15);
408 }
409
410 #[test]
411 fn test_parse_signal_name_case_insensitive() {
412 assert_eq!(parse_signal_name("sigterm"), 15);
413 assert_eq!(parse_signal_name("Sigterm"), 15);
414 }
415
416 #[test]
417 fn test_instance_spec_default_values() {
418 let spec = InstanceSpec::default();
419 assert_eq!(spec.vcpus, DEFAULT_VCPUS as u8);
420 assert_eq!(spec.memory_mib, 512);
421 assert_eq!(spec.workdir, "/");
422 assert!(spec.box_id.is_empty());
423 assert!(spec.fs_mounts.is_empty());
424 assert!(spec.port_map.is_empty());
425 assert!(spec.tee_config.is_none());
426 assert!(spec.user.is_none());
427 assert!(spec.network.is_none());
428 assert!(spec.console_output.is_none());
429 }
430
431 #[test]
432 fn test_instance_spec_serde_roundtrip() {
433 let spec = InstanceSpec {
434 box_id: "test-box-123".to_string(),
435 ksm: false,
436 snapshot_mem_file: None,
437 snapshot_sock: None,
438 restore_from: None,
439 vcpus: 4,
440 memory_mib: 2048,
441 rootfs_path: PathBuf::from("/tmp/rootfs"),
442 exec_socket_path: PathBuf::from("/tmp/exec.sock"),
443 pty_socket_path: PathBuf::from("/tmp/pty.sock"),
444 attest_socket_path: PathBuf::from("/tmp/attest.sock"),
445 port_forward_socket_path: PathBuf::from("/tmp/portfwd.sock"),
446 fs_mounts: vec![FsMount {
447 tag: "workspace".to_string(),
448 host_path: PathBuf::from("/home/user/project"),
449 read_only: false,
450 }],
451 entrypoint: Entrypoint {
452 executable: "/usr/bin/agent".to_string(),
453 args: vec!["--port".to_string(), "8080".to_string()],
454 env: vec![("HOME".to_string(), "/root".to_string())],
455 },
456 console_output: Some(PathBuf::from("/tmp/console.log")),
457 workdir: "/app".to_string(),
458 tee_config: None,
459 port_map: vec!["8080:80".to_string()],
460 user: Some("1000:1000".to_string()),
461 network: None,
462 resource_limits: ResourceLimits::default(),
463 log_config: crate::log::LogConfig::default(),
464 };
465
466 let json = serde_json::to_string(&spec).unwrap();
467 let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
468
469 assert_eq!(deserialized.box_id, "test-box-123");
470 assert_eq!(deserialized.vcpus, 4);
471 assert_eq!(deserialized.memory_mib, 2048);
472 assert_eq!(deserialized.workdir, "/app");
473 assert_eq!(deserialized.fs_mounts.len(), 1);
474 assert_eq!(deserialized.fs_mounts[0].tag, "workspace");
475 assert!(!deserialized.fs_mounts[0].read_only);
476 assert_eq!(deserialized.entrypoint.executable, "/usr/bin/agent");
477 assert_eq!(deserialized.entrypoint.args.len(), 2);
478 assert_eq!(deserialized.entrypoint.env.len(), 1);
479 assert_eq!(
480 deserialized.port_forward_socket_path,
481 PathBuf::from("/tmp/portfwd.sock")
482 );
483 assert_eq!(deserialized.port_map, vec!["8080:80"]);
484 assert_eq!(deserialized.user, Some("1000:1000".to_string()));
485 }
486
487 #[test]
488 fn test_instance_spec_with_tee_config() {
489 let spec = InstanceSpec {
490 tee_config: Some(TeeInstanceConfig {
491 config_path: PathBuf::from("/etc/tee.json"),
492 tee_type: "snp".to_string(),
493 }),
494 ..Default::default()
495 };
496
497 let json = serde_json::to_string(&spec).unwrap();
498 let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
499
500 let tee = deserialized.tee_config.unwrap();
501 assert_eq!(tee.tee_type, "snp");
502 assert_eq!(tee.config_path, PathBuf::from("/etc/tee.json"));
503 }
504
505 #[test]
506 fn test_instance_spec_with_network() {
507 let spec = InstanceSpec {
508 network: Some(NetworkInstanceConfig {
509 net_socket_path: PathBuf::from("/tmp/net.sock"),
510 net_stats_path: Some(PathBuf::from("/tmp/net.stats.json")),
511 #[cfg(target_os = "macos")]
512 net_socket_fd: Some(42),
513 #[cfg(target_os = "macos")]
514 net_proxy_fd: Some(43),
515 #[cfg(target_os = "macos")]
516 bridge_socket_dir: Some(PathBuf::from("/tmp/a3s-switch")),
517 ip_address: "10.0.0.2".parse().unwrap(),
518 gateway: "10.0.0.1".parse().unwrap(),
519 prefix_len: 24,
520 mac_address: [0x02, 0x42, 0xac, 0x11, 0x00, 0x02],
521 dns_servers: vec!["8.8.8.8".parse().unwrap()],
522 }),
523 ..Default::default()
524 };
525
526 let json = serde_json::to_string(&spec).unwrap();
527 let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
528
529 let net = deserialized.network.unwrap();
530 assert_eq!(
531 net.net_stats_path,
532 Some(PathBuf::from("/tmp/net.stats.json"))
533 );
534 #[cfg(target_os = "macos")]
535 assert_eq!(net.net_socket_fd, Some(42));
536 #[cfg(target_os = "macos")]
537 assert_eq!(net.net_proxy_fd, Some(43));
538 assert_eq!(net.ip_address, "10.0.0.2".parse::<Ipv4Addr>().unwrap());
539 assert_eq!(net.gateway, "10.0.0.1".parse::<Ipv4Addr>().unwrap());
540 assert_eq!(net.prefix_len, 24);
541 assert_eq!(net.dns_servers.len(), 1);
542 }
543
544 #[test]
545 fn test_fs_mount_serde() {
546 let mount = FsMount {
547 tag: "data".to_string(),
548 host_path: PathBuf::from("/mnt/data"),
549 read_only: true,
550 };
551
552 let json = serde_json::to_string(&mount).unwrap();
553 let deserialized: FsMount = serde_json::from_str(&json).unwrap();
554
555 assert_eq!(deserialized.tag, "data");
556 assert_eq!(deserialized.host_path, PathBuf::from("/mnt/data"));
557 assert!(deserialized.read_only);
558 }
559
560 #[test]
561 fn test_entrypoint_serde() {
562 let ep = Entrypoint {
563 executable: "/bin/sh".to_string(),
564 args: vec!["-c".to_string(), "echo hello".to_string()],
565 env: vec![
566 ("PATH".to_string(), "/usr/bin".to_string()),
567 ("HOME".to_string(), "/root".to_string()),
568 ],
569 };
570
571 let json = serde_json::to_string(&ep).unwrap();
572 let deserialized: Entrypoint = serde_json::from_str(&json).unwrap();
573
574 assert_eq!(deserialized.executable, "/bin/sh");
575 assert_eq!(deserialized.args, vec!["-c", "echo hello"]);
576 assert_eq!(deserialized.env.len(), 2);
577 }
578
579 #[test]
580 fn test_instance_spec_deserialize_missing_optional_fields() {
581 let json = r#"{
582 "box_id": "min",
583 "vcpus": 1,
584 "memory_mib": 256,
585 "rootfs_path": "/rootfs",
586 "exec_socket_path": "/exec.sock",
587 "fs_mounts": [],
588 "entrypoint": {"executable": "/bin/sh", "args": [], "env": []},
589 "console_output": null,
590 "workdir": "/"
591 }"#;
592
593 let spec: InstanceSpec = serde_json::from_str(json).unwrap();
594 assert_eq!(spec.box_id, "min");
595 assert!(spec.port_map.is_empty());
596 assert!(spec.user.is_none());
597 assert!(spec.network.is_none());
598 assert!(spec.tee_config.is_none());
599 }
600
601 #[test]
602 fn test_resource_limits_in_spec() {
603 let spec = InstanceSpec {
604 resource_limits: ResourceLimits {
605 pids_limit: Some(100),
606 cpuset_cpus: Some("0-3".to_string()),
607 ..Default::default()
608 },
609 ..Default::default()
610 };
611
612 let json = serde_json::to_string(&spec).unwrap();
613 let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
614
615 assert_eq!(deserialized.resource_limits.pids_limit, Some(100));
616 assert_eq!(
617 deserialized.resource_limits.cpuset_cpus,
618 Some("0-3".to_string())
619 );
620 }
621
622 #[test]
623 fn test_vm_metrics_default() {
624 let m = VmMetrics::default();
625 assert!(m.cpu_percent.is_none());
626 assert!(m.memory_bytes.is_none());
627 }
628
629 #[test]
630 fn test_vm_metrics_clone() {
631 let m = VmMetrics {
632 cpu_percent: Some(50.0),
633 memory_bytes: Some(1024 * 1024),
634 };
635 let cloned = m.clone();
636 assert_eq!(cloned.cpu_percent, Some(50.0));
637 assert_eq!(cloned.memory_bytes, Some(1024 * 1024));
638 }
639}