1use std::fmt;
13use std::net::Ipv4Addr;
14#[cfg(unix)]
15use std::os::fd::RawFd;
16use std::path::{Path, PathBuf};
17
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20
21use crate::config::{ResourceLimits, DEFAULT_VCPUS};
22use crate::error::Result;
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct FsMount {
29 pub tag: String,
31 pub host_path: PathBuf,
33 pub read_only: bool,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Entrypoint {
40 pub executable: String,
42 pub args: Vec<String>,
44 pub env: Vec<(String, String)>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct TeeInstanceConfig {
51 pub config_path: PathBuf,
53 pub tee_type: String,
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct NetworkInstanceConfig {
60 pub net_socket_path: PathBuf,
62
63 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub net_stats_path: Option<PathBuf>,
66
67 #[cfg(unix)]
69 #[serde(default)]
70 pub net_socket_fd: Option<RawFd>,
71
72 #[cfg(unix)]
74 #[serde(default)]
75 pub net_proxy_fd: Option<RawFd>,
76
77 #[cfg(unix)]
79 #[serde(default, skip_serializing_if = "Option::is_none")]
80 pub bridge_socket_dir: Option<PathBuf>,
81
82 pub ip_address: Ipv4Addr,
84
85 pub gateway: Ipv4Addr,
87
88 pub prefix_len: u8,
90
91 pub mac_address: [u8; 6],
93
94 #[serde(default)]
96 pub dns_servers: Vec<Ipv4Addr>,
97}
98
99pub const GUEST_EXT4_ROOT_DEVICE: &str = "/dev/vda";
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct RawBlockDevice {
110 pub id: String,
111 pub path: PathBuf,
112 #[serde(default)]
113 pub read_only: bool,
114}
115
116impl RawBlockDevice {
117 pub fn new(id: impl Into<String>, path: impl Into<PathBuf>, read_only: bool) -> Self {
118 Self {
119 id: id.into(),
120 path: path.into(),
121 read_only,
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
134#[serde(tag = "kind", rename_all = "snake_case")]
135pub enum RootfsSource {
136 Directory { path: PathBuf },
138 Ext4Disk {
140 path: PathBuf,
141 #[serde(default)]
142 read_only: bool,
143 },
144}
145
146#[derive(Deserialize)]
147#[serde(tag = "kind", rename_all = "snake_case")]
148enum TaggedRootfsSource {
149 Directory {
150 path: PathBuf,
151 },
152 Ext4Disk {
153 path: PathBuf,
154 #[serde(default)]
155 read_only: bool,
156 },
157}
158
159#[derive(Deserialize)]
160#[serde(untagged)]
161enum RootfsSourceRepresentation {
162 LegacyDirectory(PathBuf),
163 Tagged(TaggedRootfsSource),
164}
165
166impl<'de> Deserialize<'de> for RootfsSource {
167 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
168 where
169 D: serde::Deserializer<'de>,
170 {
171 Ok(
172 match RootfsSourceRepresentation::deserialize(deserializer)? {
173 RootfsSourceRepresentation::LegacyDirectory(path) => Self::Directory { path },
174 RootfsSourceRepresentation::Tagged(TaggedRootfsSource::Directory { path }) => {
175 Self::Directory { path }
176 }
177 RootfsSourceRepresentation::Tagged(TaggedRootfsSource::Ext4Disk {
178 path,
179 read_only,
180 }) => Self::Ext4Disk { path, read_only },
181 },
182 )
183 }
184}
185
186impl RootfsSource {
187 pub fn directory(path: impl Into<PathBuf>) -> Self {
189 Self::Directory { path: path.into() }
190 }
191
192 pub fn ext4_disk(path: impl Into<PathBuf>, read_only: bool) -> Self {
194 Self::Ext4Disk {
195 path: path.into(),
196 read_only,
197 }
198 }
199
200 pub fn path(&self) -> &Path {
202 match self {
203 Self::Directory { path } | Self::Ext4Disk { path, .. } => path,
204 }
205 }
206
207 pub fn directory_path(&self) -> Option<&Path> {
209 match self {
210 Self::Directory { path } => Some(path),
211 Self::Ext4Disk { .. } => None,
212 }
213 }
214}
215
216impl Default for RootfsSource {
217 fn default() -> Self {
218 Self::directory(PathBuf::new())
219 }
220}
221
222impl fmt::Display for RootfsSource {
223 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
224 match self {
225 Self::Directory { path } => write!(formatter, "directory:{}", path.display()),
226 Self::Ext4Disk { path, .. } => write!(formatter, "ext4-disk:{}", path.display()),
227 }
228 }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct InstanceSpec {
237 pub box_id: String,
239
240 pub vcpus: u8,
242
243 pub memory_mib: u32,
245
246 #[serde(alias = "rootfs_path")]
248 pub rootfs: RootfsSource,
249
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
252 pub block_devices: Vec<RawBlockDevice>,
253
254 pub exec_socket_path: PathBuf,
256
257 #[serde(default)]
259 pub pty_socket_path: PathBuf,
260
261 #[serde(default)]
263 pub attest_socket_path: PathBuf,
264
265 #[serde(default)]
267 pub port_forward_socket_path: PathBuf,
268
269 pub fs_mounts: Vec<FsMount>,
271
272 pub entrypoint: Entrypoint,
274
275 #[serde(default)]
278 pub ksm: bool,
279
280 #[serde(default)]
284 pub snapshot_mem_file: Option<String>,
285
286 #[serde(default)]
289 pub snapshot_sock: Option<String>,
290
291 #[serde(default)]
297 pub restore_from: Option<String>,
298
299 pub console_output: Option<PathBuf>,
301
302 pub workdir: String,
304
305 pub tee_config: Option<TeeInstanceConfig>,
307
308 #[serde(default)]
310 pub port_map: Vec<String>,
311
312 #[serde(default)]
315 pub user: Option<String>,
316
317 #[serde(default)]
320 pub network: Option<NetworkInstanceConfig>,
321
322 #[serde(default)]
324 pub disable_tsi: bool,
325
326 #[serde(default)]
328 pub resource_limits: ResourceLimits,
329
330 #[serde(default)]
333 pub log_config: crate::log::LogConfig,
334}
335
336impl Default for InstanceSpec {
337 fn default() -> Self {
338 Self {
339 box_id: String::new(),
340 vcpus: DEFAULT_VCPUS as u8,
341 memory_mib: 512,
342 rootfs: RootfsSource::default(),
343 block_devices: Vec::new(),
344 exec_socket_path: PathBuf::new(),
345 pty_socket_path: PathBuf::new(),
346 attest_socket_path: PathBuf::new(),
347 port_forward_socket_path: PathBuf::new(),
348 fs_mounts: Vec::new(),
349 entrypoint: Entrypoint {
350 executable: String::new(),
351 args: Vec::new(),
352 env: Vec::new(),
353 },
354 ksm: false,
355 snapshot_mem_file: None,
356 snapshot_sock: None,
357 restore_from: None,
358 console_output: None,
359 workdir: "/".to_string(),
360 tee_config: None,
361 port_map: Vec::new(),
362 user: None,
363 network: None,
364 disable_tsi: false,
365 resource_limits: ResourceLimits::default(),
366 log_config: crate::log::LogConfig::default(),
367 }
368 }
369}
370
371#[derive(Debug, Clone, Default)]
375pub struct VmMetrics {
376 pub cpu_percent: Option<f32>,
378 pub memory_bytes: Option<u64>,
380}
381
382pub const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 10_000;
384
385pub fn parse_signal_name(name: &str) -> i32 {
390 let upper = name.trim().to_uppercase();
391 let short = upper.strip_prefix("SIG").unwrap_or(&upper);
392 match short {
393 "HUP" => 1,
394 "INT" => 2,
395 "QUIT" => 3,
396 "ILL" => 4,
397 "ABRT" => 6,
398 "FPE" => 8,
399 "KILL" => 9,
400 "USR1" => 10,
401 "SEGV" => 11,
402 "USR2" => 12,
403 "PIPE" => 13,
404 "ALRM" | "ALARM" => 14,
405 "TERM" => 15,
406 "CHLD" | "CLD" => 17,
407 "CONT" => 18,
408 "STOP" => 19,
409 "TSTP" => 20,
410 "WINCH" => 28,
411 _ => name.trim().parse::<i32>().unwrap_or(15),
412 }
413}
414
415pub trait VmHandler: Send + Sync {
420 fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()>;
422
423 fn metrics(&self) -> VmMetrics;
425
426 fn is_running(&self) -> bool;
428
429 fn has_exited(&self) -> bool {
439 #[cfg(target_os = "linux")]
440 {
441 linux_process_exited(self.pid())
442 }
443 #[cfg(not(target_os = "linux"))]
444 {
445 !self.is_running()
446 }
447 }
448
449 fn pid(&self) -> u32;
451
452 fn exit_code(&self) -> Option<i32> {
457 None
458 }
459
460 fn try_wait_exit(&mut self) -> Result<Option<i32>> {
466 Ok(None)
467 }
468}
469
470#[cfg(target_os = "linux")]
477pub(crate) fn linux_process_exited(pid: u32) -> bool {
478 match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
479 Ok(stat) => match stat.rfind(')') {
480 Some(idx) => {
481 let state = stat[idx + 1..].trim_start().chars().next();
482 matches!(state, Some('Z') | Some('X'))
483 }
484 None => false,
486 },
487 Err(_) => true,
489 }
490}
491
492#[async_trait]
499pub trait VmmProvider: Send + Sync {
500 async fn start(&self, spec: &InstanceSpec) -> Result<Box<dyn VmHandler>>;
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::config::ResourceLimits;
508
509 #[cfg(target_os = "linux")]
510 #[test]
511 fn test_linux_process_exited_current_process_is_alive() {
512 assert!(!linux_process_exited(std::process::id()));
514 }
515
516 #[cfg(target_os = "linux")]
517 #[test]
518 fn test_linux_process_exited_missing_pid_is_exited() {
519 assert!(linux_process_exited(0x7fff_fffe));
521 }
522
523 #[test]
524 fn test_parse_signal_name_term() {
525 assert_eq!(parse_signal_name("SIGTERM"), 15);
526 assert_eq!(parse_signal_name("TERM"), 15);
527 assert_eq!(parse_signal_name("15"), 15);
528 }
529
530 #[test]
531 fn test_parse_signal_name_variants() {
532 assert_eq!(parse_signal_name("SIGKILL"), 9);
533 assert_eq!(parse_signal_name("KILL"), 9);
534 assert_eq!(parse_signal_name("SIGHUP"), 1);
535 assert_eq!(parse_signal_name("SIGQUIT"), 3);
536 assert_eq!(parse_signal_name("SIGINT"), 2);
537 assert_eq!(parse_signal_name("SIGUSR1"), 10);
538 assert_eq!(parse_signal_name("SIGUSR2"), 12);
539 }
540
541 #[test]
542 fn test_parse_signal_name_numeric() {
543 assert_eq!(parse_signal_name("9"), 9);
544 assert_eq!(parse_signal_name("1"), 1);
545 }
546
547 #[test]
548 fn test_parse_signal_name_unknown_defaults_to_sigterm() {
549 assert_eq!(parse_signal_name("SIGFOO"), 15);
550 assert_eq!(parse_signal_name(""), 15);
551 assert_eq!(parse_signal_name("notasignal"), 15);
552 }
553
554 #[test]
555 fn test_parse_signal_name_case_insensitive() {
556 assert_eq!(parse_signal_name("sigterm"), 15);
557 assert_eq!(parse_signal_name("Sigterm"), 15);
558 }
559
560 #[test]
561 fn test_instance_spec_default_values() {
562 let spec = InstanceSpec::default();
563 assert_eq!(spec.vcpus, DEFAULT_VCPUS as u8);
564 assert_eq!(spec.memory_mib, 512);
565 assert_eq!(spec.workdir, "/");
566 assert!(spec.box_id.is_empty());
567 assert!(spec.fs_mounts.is_empty());
568 assert!(spec.port_map.is_empty());
569 assert!(spec.tee_config.is_none());
570 assert!(spec.user.is_none());
571 assert!(spec.network.is_none());
572 assert!(!spec.disable_tsi);
573 assert!(spec.console_output.is_none());
574 }
575
576 #[test]
577 fn test_instance_spec_missing_disable_tsi_keeps_legacy_default() {
578 let mut value = serde_json::to_value(InstanceSpec::default()).unwrap();
579 value
580 .as_object_mut()
581 .unwrap()
582 .remove("disable_tsi")
583 .unwrap();
584
585 let spec: InstanceSpec = serde_json::from_value(value).unwrap();
586
587 assert!(!spec.disable_tsi);
588 }
589
590 #[test]
591 fn test_ext4_disk_rootfs_serde_roundtrip() {
592 let rootfs = RootfsSource::Ext4Disk {
593 path: PathBuf::from("/tmp/rootfs.ext4"),
594 read_only: true,
595 };
596
597 let json = serde_json::to_value(&rootfs).unwrap();
598 assert_eq!(json["kind"], "ext4_disk");
599 assert_eq!(json["path"], "/tmp/rootfs.ext4");
600 assert_eq!(json["read_only"], true);
601
602 let decoded: RootfsSource = serde_json::from_value(json).unwrap();
603 assert_eq!(decoded, rootfs);
604 }
605
606 #[test]
607 fn test_instance_spec_accepts_legacy_rootfs_path() {
608 let json = r#"{
609 "box_id": "legacy",
610 "vcpus": 1,
611 "memory_mib": 256,
612 "rootfs_path": "/legacy/rootfs",
613 "exec_socket_path": "/exec.sock",
614 "fs_mounts": [],
615 "entrypoint": {"executable": "/bin/sh", "args": [], "env": []},
616 "console_output": null,
617 "workdir": "/"
618 }"#;
619
620 let spec: InstanceSpec = serde_json::from_str(json).unwrap();
621 assert_eq!(
622 spec.rootfs,
623 RootfsSource::Directory {
624 path: PathBuf::from("/legacy/rootfs")
625 }
626 );
627 }
628
629 #[test]
630 fn test_instance_spec_serde_roundtrip() {
631 let spec = InstanceSpec {
632 box_id: "test-box-123".to_string(),
633 ksm: false,
634 snapshot_mem_file: None,
635 snapshot_sock: None,
636 restore_from: None,
637 vcpus: 4,
638 memory_mib: 2048,
639 rootfs: RootfsSource::directory("/tmp/rootfs"),
640 block_devices: vec![RawBlockDevice::new("data", "/tmp/data.ext4", true)],
641 exec_socket_path: PathBuf::from("/tmp/exec.sock"),
642 pty_socket_path: PathBuf::from("/tmp/pty.sock"),
643 attest_socket_path: PathBuf::from("/tmp/attest.sock"),
644 port_forward_socket_path: PathBuf::from("/tmp/portfwd.sock"),
645 fs_mounts: vec![FsMount {
646 tag: "workspace".to_string(),
647 host_path: PathBuf::from("/home/user/project"),
648 read_only: false,
649 }],
650 entrypoint: Entrypoint {
651 executable: "/usr/bin/agent".to_string(),
652 args: vec!["--port".to_string(), "8080".to_string()],
653 env: vec![("HOME".to_string(), "/root".to_string())],
654 },
655 console_output: Some(PathBuf::from("/tmp/console.log")),
656 workdir: "/app".to_string(),
657 tee_config: None,
658 port_map: vec!["8080:80".to_string()],
659 user: Some("1000:1000".to_string()),
660 network: None,
661 disable_tsi: true,
662 resource_limits: ResourceLimits::default(),
663 log_config: crate::log::LogConfig::default(),
664 };
665
666 let json = serde_json::to_string(&spec).unwrap();
667 let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
668
669 assert_eq!(deserialized.box_id, "test-box-123");
670 assert_eq!(deserialized.vcpus, 4);
671 assert_eq!(deserialized.memory_mib, 2048);
672 assert_eq!(deserialized.rootfs, RootfsSource::directory("/tmp/rootfs"));
673 assert_eq!(deserialized.block_devices, spec.block_devices);
674 assert_eq!(deserialized.workdir, "/app");
675 assert_eq!(deserialized.fs_mounts.len(), 1);
676 assert_eq!(deserialized.fs_mounts[0].tag, "workspace");
677 assert!(!deserialized.fs_mounts[0].read_only);
678 assert_eq!(deserialized.entrypoint.executable, "/usr/bin/agent");
679 assert_eq!(deserialized.entrypoint.args.len(), 2);
680 assert_eq!(deserialized.entrypoint.env.len(), 1);
681 assert_eq!(
682 deserialized.port_forward_socket_path,
683 PathBuf::from("/tmp/portfwd.sock")
684 );
685 assert_eq!(deserialized.port_map, vec!["8080:80"]);
686 assert_eq!(deserialized.user, Some("1000:1000".to_string()));
687 assert!(deserialized.disable_tsi);
688 }
689
690 #[test]
691 fn instance_spec_omits_empty_auxiliary_block_device_list() {
692 let value = serde_json::to_value(InstanceSpec::default()).unwrap();
693 assert!(value.get("block_devices").is_none());
694
695 let decoded: InstanceSpec = serde_json::from_value(value).unwrap();
696 assert!(decoded.block_devices.is_empty());
697 }
698
699 #[test]
700 fn test_instance_spec_with_tee_config() {
701 let spec = InstanceSpec {
702 tee_config: Some(TeeInstanceConfig {
703 config_path: PathBuf::from("/etc/tee.json"),
704 tee_type: "snp".to_string(),
705 }),
706 ..Default::default()
707 };
708
709 let json = serde_json::to_string(&spec).unwrap();
710 let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
711
712 let tee = deserialized.tee_config.unwrap();
713 assert_eq!(tee.tee_type, "snp");
714 assert_eq!(tee.config_path, PathBuf::from("/etc/tee.json"));
715 }
716
717 #[test]
718 fn test_instance_spec_with_network() {
719 let spec = InstanceSpec {
720 network: Some(NetworkInstanceConfig {
721 net_socket_path: PathBuf::from("/tmp/net.sock"),
722 net_stats_path: Some(PathBuf::from("/tmp/net.stats.json")),
723 #[cfg(unix)]
724 net_socket_fd: Some(42),
725 #[cfg(unix)]
726 net_proxy_fd: Some(43),
727 #[cfg(unix)]
728 bridge_socket_dir: Some(PathBuf::from("/tmp/a3s-switch")),
729 ip_address: "10.0.0.2".parse().unwrap(),
730 gateway: "10.0.0.1".parse().unwrap(),
731 prefix_len: 24,
732 mac_address: [0x02, 0x42, 0xac, 0x11, 0x00, 0x02],
733 dns_servers: vec!["8.8.8.8".parse().unwrap()],
734 }),
735 ..Default::default()
736 };
737
738 let json = serde_json::to_string(&spec).unwrap();
739 let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
740
741 let net = deserialized.network.unwrap();
742 assert_eq!(
743 net.net_stats_path,
744 Some(PathBuf::from("/tmp/net.stats.json"))
745 );
746 #[cfg(unix)]
747 assert_eq!(net.net_socket_fd, Some(42));
748 #[cfg(unix)]
749 assert_eq!(net.net_proxy_fd, Some(43));
750 assert_eq!(net.ip_address, "10.0.0.2".parse::<Ipv4Addr>().unwrap());
751 assert_eq!(net.gateway, "10.0.0.1".parse::<Ipv4Addr>().unwrap());
752 assert_eq!(net.prefix_len, 24);
753 assert_eq!(net.dns_servers.len(), 1);
754 }
755
756 #[test]
757 fn test_fs_mount_serde() {
758 let mount = FsMount {
759 tag: "data".to_string(),
760 host_path: PathBuf::from("/mnt/data"),
761 read_only: true,
762 };
763
764 let json = serde_json::to_string(&mount).unwrap();
765 let deserialized: FsMount = serde_json::from_str(&json).unwrap();
766
767 assert_eq!(deserialized.tag, "data");
768 assert_eq!(deserialized.host_path, PathBuf::from("/mnt/data"));
769 assert!(deserialized.read_only);
770 }
771
772 #[test]
773 fn test_entrypoint_serde() {
774 let ep = Entrypoint {
775 executable: "/bin/sh".to_string(),
776 args: vec!["-c".to_string(), "echo hello".to_string()],
777 env: vec![
778 ("PATH".to_string(), "/usr/bin".to_string()),
779 ("HOME".to_string(), "/root".to_string()),
780 ],
781 };
782
783 let json = serde_json::to_string(&ep).unwrap();
784 let deserialized: Entrypoint = serde_json::from_str(&json).unwrap();
785
786 assert_eq!(deserialized.executable, "/bin/sh");
787 assert_eq!(deserialized.args, vec!["-c", "echo hello"]);
788 assert_eq!(deserialized.env.len(), 2);
789 }
790
791 #[test]
792 fn test_instance_spec_deserialize_missing_optional_fields() {
793 let json = r#"{
794 "box_id": "min",
795 "vcpus": 1,
796 "memory_mib": 256,
797 "rootfs_path": "/rootfs",
798 "exec_socket_path": "/exec.sock",
799 "fs_mounts": [],
800 "entrypoint": {"executable": "/bin/sh", "args": [], "env": []},
801 "console_output": null,
802 "workdir": "/"
803 }"#;
804
805 let spec: InstanceSpec = serde_json::from_str(json).unwrap();
806 assert_eq!(spec.box_id, "min");
807 assert!(spec.port_map.is_empty());
808 assert!(spec.user.is_none());
809 assert!(spec.network.is_none());
810 assert!(spec.tee_config.is_none());
811 }
812
813 #[test]
814 fn test_resource_limits_in_spec() {
815 let spec = InstanceSpec {
816 resource_limits: ResourceLimits {
817 pids_limit: Some(100),
818 cpuset_cpus: Some("0-3".to_string()),
819 ..Default::default()
820 },
821 ..Default::default()
822 };
823
824 let json = serde_json::to_string(&spec).unwrap();
825 let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
826
827 assert_eq!(deserialized.resource_limits.pids_limit, Some(100));
828 assert_eq!(
829 deserialized.resource_limits.cpuset_cpus,
830 Some("0-3".to_string())
831 );
832 }
833
834 #[test]
835 fn test_vm_metrics_default() {
836 let m = VmMetrics::default();
837 assert!(m.cpu_percent.is_none());
838 assert!(m.memory_bytes.is_none());
839 }
840
841 #[test]
842 fn test_vm_metrics_clone() {
843 let m = VmMetrics {
844 cpu_percent: Some(50.0),
845 memory_bytes: Some(1024 * 1024),
846 };
847 let cloned = m.clone();
848 assert_eq!(cloned.cpu_percent, Some(50.0));
849 assert_eq!(cloned.memory_bytes, Some(1024 * 1024));
850 }
851}