use std::net::Ipv4Addr;
#[cfg(target_os = "macos")]
use std::os::fd::RawFd;
use std::path::PathBuf;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::config::ResourceLimits;
use crate::error::Result;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FsMount {
pub tag: String,
pub host_path: PathBuf,
pub read_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Entrypoint {
pub executable: String,
pub args: Vec<String>,
pub env: Vec<(String, String)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TeeInstanceConfig {
pub config_path: PathBuf,
pub tee_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkInstanceConfig {
pub net_socket_path: PathBuf,
#[cfg(target_os = "macos")]
#[serde(default)]
pub net_socket_fd: Option<RawFd>,
#[cfg(target_os = "macos")]
#[serde(default)]
pub net_proxy_fd: Option<RawFd>,
pub ip_address: Ipv4Addr,
pub gateway: Ipv4Addr,
pub prefix_len: u8,
pub mac_address: [u8; 6],
#[serde(default)]
pub dns_servers: Vec<Ipv4Addr>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstanceSpec {
pub box_id: String,
pub vcpus: u8,
pub memory_mib: u32,
pub rootfs_path: PathBuf,
pub exec_socket_path: PathBuf,
#[serde(default)]
pub pty_socket_path: PathBuf,
#[serde(default)]
pub attest_socket_path: PathBuf,
#[serde(default)]
pub port_forward_socket_path: PathBuf,
pub fs_mounts: Vec<FsMount>,
pub entrypoint: Entrypoint,
#[serde(default)]
pub ksm: bool,
#[serde(default)]
pub snapshot_mem_file: Option<String>,
#[serde(default)]
pub snapshot_sock: Option<String>,
#[serde(default)]
pub restore_from: Option<String>,
pub console_output: Option<PathBuf>,
pub workdir: String,
pub tee_config: Option<TeeInstanceConfig>,
#[serde(default)]
pub port_map: Vec<String>,
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub network: Option<NetworkInstanceConfig>,
#[serde(default)]
pub resource_limits: ResourceLimits,
#[serde(default)]
pub log_config: crate::log::LogConfig,
}
impl Default for InstanceSpec {
fn default() -> Self {
Self {
box_id: String::new(),
vcpus: 2,
memory_mib: 512,
rootfs_path: PathBuf::new(),
exec_socket_path: PathBuf::new(),
pty_socket_path: PathBuf::new(),
attest_socket_path: PathBuf::new(),
port_forward_socket_path: PathBuf::new(),
fs_mounts: Vec::new(),
entrypoint: Entrypoint {
executable: String::new(),
args: Vec::new(),
env: Vec::new(),
},
ksm: false,
snapshot_mem_file: None,
snapshot_sock: None,
restore_from: None,
console_output: None,
workdir: "/".to_string(),
tee_config: None,
port_map: Vec::new(),
user: None,
network: None,
resource_limits: ResourceLimits::default(),
log_config: crate::log::LogConfig::default(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct VmMetrics {
pub cpu_percent: Option<f32>,
pub memory_bytes: Option<u64>,
}
pub const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 10_000;
pub fn parse_signal_name(name: &str) -> i32 {
let upper = name.trim().to_uppercase();
let short = upper.strip_prefix("SIG").unwrap_or(&upper);
match short {
"HUP" => 1,
"INT" => 2,
"QUIT" => 3,
"ILL" => 4,
"ABRT" => 6,
"FPE" => 8,
"KILL" => 9,
"USR1" => 10,
"SEGV" => 11,
"USR2" => 12,
"PIPE" => 13,
"ALRM" | "ALARM" => 14,
"TERM" => 15,
"CHLD" | "CLD" => 17,
"CONT" => 18,
"STOP" => 19,
"TSTP" => 20,
"WINCH" => 28,
_ => name.trim().parse::<i32>().unwrap_or(15),
}
}
pub trait VmHandler: Send + Sync {
fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()>;
fn metrics(&self) -> VmMetrics;
fn is_running(&self) -> bool;
fn has_exited(&self) -> bool {
#[cfg(target_os = "linux")]
{
linux_process_exited(self.pid())
}
#[cfg(not(target_os = "linux"))]
{
!self.is_running()
}
}
fn pid(&self) -> u32;
fn exit_code(&self) -> Option<i32> {
None
}
fn try_wait_exit(&mut self) -> Result<Option<i32>> {
Ok(None)
}
}
#[cfg(target_os = "linux")]
pub(crate) fn linux_process_exited(pid: u32) -> bool {
match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(stat) => match stat.rfind(')') {
Some(idx) => {
let state = stat[idx + 1..].trim_start().chars().next();
matches!(state, Some('Z') | Some('X'))
}
None => false,
},
Err(_) => true,
}
}
#[async_trait]
pub trait VmmProvider: Send + Sync {
async fn start(&self, spec: &InstanceSpec) -> Result<Box<dyn VmHandler>>;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ResourceLimits;
#[cfg(target_os = "linux")]
#[test]
fn test_linux_process_exited_current_process_is_alive() {
assert!(!linux_process_exited(std::process::id()));
}
#[cfg(target_os = "linux")]
#[test]
fn test_linux_process_exited_missing_pid_is_exited() {
assert!(linux_process_exited(0x7fff_fffe));
}
#[test]
fn test_parse_signal_name_term() {
assert_eq!(parse_signal_name("SIGTERM"), 15);
assert_eq!(parse_signal_name("TERM"), 15);
assert_eq!(parse_signal_name("15"), 15);
}
#[test]
fn test_parse_signal_name_variants() {
assert_eq!(parse_signal_name("SIGKILL"), 9);
assert_eq!(parse_signal_name("KILL"), 9);
assert_eq!(parse_signal_name("SIGHUP"), 1);
assert_eq!(parse_signal_name("SIGQUIT"), 3);
assert_eq!(parse_signal_name("SIGINT"), 2);
assert_eq!(parse_signal_name("SIGUSR1"), 10);
assert_eq!(parse_signal_name("SIGUSR2"), 12);
}
#[test]
fn test_parse_signal_name_numeric() {
assert_eq!(parse_signal_name("9"), 9);
assert_eq!(parse_signal_name("1"), 1);
}
#[test]
fn test_parse_signal_name_unknown_defaults_to_sigterm() {
assert_eq!(parse_signal_name("SIGFOO"), 15);
assert_eq!(parse_signal_name(""), 15);
assert_eq!(parse_signal_name("notasignal"), 15);
}
#[test]
fn test_parse_signal_name_case_insensitive() {
assert_eq!(parse_signal_name("sigterm"), 15);
assert_eq!(parse_signal_name("Sigterm"), 15);
}
#[test]
fn test_instance_spec_default_values() {
let spec = InstanceSpec::default();
assert_eq!(spec.vcpus, 2);
assert_eq!(spec.memory_mib, 512);
assert_eq!(spec.workdir, "/");
assert!(spec.box_id.is_empty());
assert!(spec.fs_mounts.is_empty());
assert!(spec.port_map.is_empty());
assert!(spec.tee_config.is_none());
assert!(spec.user.is_none());
assert!(spec.network.is_none());
assert!(spec.console_output.is_none());
}
#[test]
fn test_instance_spec_serde_roundtrip() {
let spec = InstanceSpec {
box_id: "test-box-123".to_string(),
ksm: false,
snapshot_mem_file: None,
snapshot_sock: None,
restore_from: None,
vcpus: 4,
memory_mib: 2048,
rootfs_path: PathBuf::from("/tmp/rootfs"),
exec_socket_path: PathBuf::from("/tmp/exec.sock"),
pty_socket_path: PathBuf::from("/tmp/pty.sock"),
attest_socket_path: PathBuf::from("/tmp/attest.sock"),
port_forward_socket_path: PathBuf::from("/tmp/portfwd.sock"),
fs_mounts: vec![FsMount {
tag: "workspace".to_string(),
host_path: PathBuf::from("/home/user/project"),
read_only: false,
}],
entrypoint: Entrypoint {
executable: "/usr/bin/agent".to_string(),
args: vec!["--port".to_string(), "8080".to_string()],
env: vec![("HOME".to_string(), "/root".to_string())],
},
console_output: Some(PathBuf::from("/tmp/console.log")),
workdir: "/app".to_string(),
tee_config: None,
port_map: vec!["8080:80".to_string()],
user: Some("1000:1000".to_string()),
network: None,
resource_limits: ResourceLimits::default(),
log_config: crate::log::LogConfig::default(),
};
let json = serde_json::to_string(&spec).unwrap();
let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.box_id, "test-box-123");
assert_eq!(deserialized.vcpus, 4);
assert_eq!(deserialized.memory_mib, 2048);
assert_eq!(deserialized.workdir, "/app");
assert_eq!(deserialized.fs_mounts.len(), 1);
assert_eq!(deserialized.fs_mounts[0].tag, "workspace");
assert!(!deserialized.fs_mounts[0].read_only);
assert_eq!(deserialized.entrypoint.executable, "/usr/bin/agent");
assert_eq!(deserialized.entrypoint.args.len(), 2);
assert_eq!(deserialized.entrypoint.env.len(), 1);
assert_eq!(
deserialized.port_forward_socket_path,
PathBuf::from("/tmp/portfwd.sock")
);
assert_eq!(deserialized.port_map, vec!["8080:80"]);
assert_eq!(deserialized.user, Some("1000:1000".to_string()));
}
#[test]
fn test_instance_spec_with_tee_config() {
let spec = InstanceSpec {
tee_config: Some(TeeInstanceConfig {
config_path: PathBuf::from("/etc/tee.json"),
tee_type: "snp".to_string(),
}),
..Default::default()
};
let json = serde_json::to_string(&spec).unwrap();
let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
let tee = deserialized.tee_config.unwrap();
assert_eq!(tee.tee_type, "snp");
assert_eq!(tee.config_path, PathBuf::from("/etc/tee.json"));
}
#[test]
fn test_instance_spec_with_network() {
let spec = InstanceSpec {
network: Some(NetworkInstanceConfig {
net_socket_path: PathBuf::from("/tmp/net.sock"),
#[cfg(target_os = "macos")]
net_socket_fd: Some(42),
#[cfg(target_os = "macos")]
net_proxy_fd: Some(43),
ip_address: "10.0.0.2".parse().unwrap(),
gateway: "10.0.0.1".parse().unwrap(),
prefix_len: 24,
mac_address: [0x02, 0x42, 0xac, 0x11, 0x00, 0x02],
dns_servers: vec!["8.8.8.8".parse().unwrap()],
}),
..Default::default()
};
let json = serde_json::to_string(&spec).unwrap();
let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
let net = deserialized.network.unwrap();
#[cfg(target_os = "macos")]
assert_eq!(net.net_socket_fd, Some(42));
#[cfg(target_os = "macos")]
assert_eq!(net.net_proxy_fd, Some(43));
assert_eq!(net.ip_address, "10.0.0.2".parse::<Ipv4Addr>().unwrap());
assert_eq!(net.gateway, "10.0.0.1".parse::<Ipv4Addr>().unwrap());
assert_eq!(net.prefix_len, 24);
assert_eq!(net.dns_servers.len(), 1);
}
#[test]
fn test_fs_mount_serde() {
let mount = FsMount {
tag: "data".to_string(),
host_path: PathBuf::from("/mnt/data"),
read_only: true,
};
let json = serde_json::to_string(&mount).unwrap();
let deserialized: FsMount = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.tag, "data");
assert_eq!(deserialized.host_path, PathBuf::from("/mnt/data"));
assert!(deserialized.read_only);
}
#[test]
fn test_entrypoint_serde() {
let ep = Entrypoint {
executable: "/bin/sh".to_string(),
args: vec!["-c".to_string(), "echo hello".to_string()],
env: vec![
("PATH".to_string(), "/usr/bin".to_string()),
("HOME".to_string(), "/root".to_string()),
],
};
let json = serde_json::to_string(&ep).unwrap();
let deserialized: Entrypoint = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.executable, "/bin/sh");
assert_eq!(deserialized.args, vec!["-c", "echo hello"]);
assert_eq!(deserialized.env.len(), 2);
}
#[test]
fn test_instance_spec_deserialize_missing_optional_fields() {
let json = r#"{
"box_id": "min",
"vcpus": 1,
"memory_mib": 256,
"rootfs_path": "/rootfs",
"exec_socket_path": "/exec.sock",
"fs_mounts": [],
"entrypoint": {"executable": "/bin/sh", "args": [], "env": []},
"console_output": null,
"workdir": "/"
}"#;
let spec: InstanceSpec = serde_json::from_str(json).unwrap();
assert_eq!(spec.box_id, "min");
assert!(spec.port_map.is_empty());
assert!(spec.user.is_none());
assert!(spec.network.is_none());
assert!(spec.tee_config.is_none());
}
#[test]
fn test_resource_limits_in_spec() {
let spec = InstanceSpec {
resource_limits: ResourceLimits {
pids_limit: Some(100),
cpuset_cpus: Some("0-3".to_string()),
..Default::default()
},
..Default::default()
};
let json = serde_json::to_string(&spec).unwrap();
let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.resource_limits.pids_limit, Some(100));
assert_eq!(
deserialized.resource_limits.cpuset_cpus,
Some("0-3".to_string())
);
}
#[test]
fn test_vm_metrics_default() {
let m = VmMetrics::default();
assert!(m.cpu_percent.is_none());
assert!(m.memory_bytes.is_none());
}
#[test]
fn test_vm_metrics_clone() {
let m = VmMetrics {
cpu_percent: Some(50.0),
memory_bytes: Some(1024 * 1024),
};
let cloned = m.clone();
assert_eq!(cloned.cpu_percent, Some(50.0));
assert_eq!(cloned.memory_bytes, Some(1024 * 1024));
}
}