Skip to main content

a3s_box_core/
vmm.rs

1//! VMM contract — types and traits for pluggable VM backends.
2//!
3//! All types here are pure data (no runtime dependencies). This lets
4//! third-party VMM implementors depend only on `a3s-box-core` rather
5//! than pulling in the full `a3s-box-runtime`.
6//!
7//! # Extension points
8//!
9//! - [`VmmProvider`] — start VMs from an [`InstanceSpec`]
10//! - [`VmHandler`] — lifecycle operations on a running VM
11
12use std::net::Ipv4Addr;
13use std::path::PathBuf;
14
15use async_trait::async_trait;
16use serde::{Deserialize, Serialize};
17
18use crate::config::ResourceLimits;
19use crate::error::Result;
20
21// ── VM instance spec ──────────────────────────────────────────────────────────
22
23/// A filesystem mount from host to guest via virtio-fs.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct FsMount {
26    /// Virtiofs tag (guest uses this to identify the share)
27    pub tag: String,
28    /// Host directory to share
29    pub host_path: PathBuf,
30    /// Whether the share is read-only
31    pub read_only: bool,
32}
33
34/// Entrypoint configuration for the guest agent.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Entrypoint {
37    /// Path to the executable inside the VM
38    pub executable: String,
39    /// Command-line arguments
40    pub args: Vec<String>,
41    /// Environment variables
42    pub env: Vec<(String, String)>,
43}
44
45/// TEE instance configuration for the shim.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct TeeInstanceConfig {
48    /// Path to TEE configuration JSON file
49    pub config_path: PathBuf,
50    /// TEE type identifier (e.g., "snp")
51    pub tee_type: String,
52}
53
54/// Network instance configuration for passt-based networking.
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct NetworkInstanceConfig {
57    /// Path to the passt Unix socket.
58    pub passt_socket_path: PathBuf,
59
60    /// Assigned IPv4 address for this VM.
61    pub ip_address: Ipv4Addr,
62
63    /// Gateway IPv4 address.
64    pub gateway: Ipv4Addr,
65
66    /// Subnet prefix length (e.g., 24).
67    pub prefix_len: u8,
68
69    /// MAC address as 6 bytes.
70    pub mac_address: [u8; 6],
71
72    /// DNS servers to configure inside the guest.
73    #[serde(default)]
74    pub dns_servers: Vec<Ipv4Addr>,
75}
76
77/// Complete configuration for a VM instance.
78///
79/// Serialized and passed to the shim subprocess, which uses it to configure
80/// and start the VM via the underlying hypervisor.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct InstanceSpec {
83    /// Unique identifier for this box instance
84    pub box_id: String,
85
86    /// Number of vCPUs (default: 2)
87    pub vcpus: u8,
88
89    /// Memory in MiB (default: 512)
90    pub memory_mib: u32,
91
92    /// Path to the root filesystem
93    pub rootfs_path: PathBuf,
94
95    /// Path to the Unix socket for exec communication
96    pub exec_socket_path: PathBuf,
97
98    /// Path to the Unix socket for PTY communication
99    #[serde(default)]
100    pub pty_socket_path: PathBuf,
101
102    /// Path to the Unix socket for TEE attestation communication
103    #[serde(default)]
104    pub attest_socket_path: PathBuf,
105
106    /// Filesystem mounts (virtio-fs shares)
107    pub fs_mounts: Vec<FsMount>,
108
109    /// Guest agent entrypoint
110    pub entrypoint: Entrypoint,
111
112    /// Optional console output file path
113    pub console_output: Option<PathBuf>,
114
115    /// Working directory inside the VM
116    pub workdir: String,
117
118    /// TEE configuration (None for standard VM)
119    pub tee_config: Option<TeeInstanceConfig>,
120
121    /// TSI port mappings: ["host_port:guest_port", ...]
122    #[serde(default)]
123    pub port_map: Vec<String>,
124
125    /// User to run as inside the VM (from OCI USER directive).
126    /// Format: "uid", "uid:gid", "user", or "user:group"
127    #[serde(default)]
128    pub user: Option<String>,
129
130    /// Network configuration for passt-based networking.
131    /// None = TSI mode (default), Some = passt virtio-net mode.
132    #[serde(default)]
133    pub network: Option<NetworkInstanceConfig>,
134
135    /// Resource limits (PID limits, CPU pinning, ulimits, cgroup controls).
136    #[serde(default)]
137    pub resource_limits: ResourceLimits,
138}
139
140impl Default for InstanceSpec {
141    fn default() -> Self {
142        Self {
143            box_id: String::new(),
144            vcpus: 2,
145            memory_mib: 512,
146            rootfs_path: PathBuf::new(),
147            exec_socket_path: PathBuf::new(),
148            pty_socket_path: PathBuf::new(),
149            attest_socket_path: PathBuf::new(),
150            fs_mounts: Vec::new(),
151            entrypoint: Entrypoint {
152                executable: String::new(),
153                args: Vec::new(),
154                env: Vec::new(),
155            },
156            console_output: None,
157            workdir: "/".to_string(),
158            tee_config: None,
159            port_map: Vec::new(),
160            user: None,
161            network: None,
162            resource_limits: ResourceLimits::default(),
163        }
164    }
165}
166
167// ── VM handler and metrics ────────────────────────────────────────────────────
168
169/// VM resource metrics.
170#[derive(Debug, Clone, Default)]
171pub struct VmMetrics {
172    /// CPU usage percentage (0-100 per core)
173    pub cpu_percent: Option<f32>,
174    /// Memory usage in bytes
175    pub memory_bytes: Option<u64>,
176}
177
178/// Default shutdown timeout in milliseconds (10 seconds).
179pub const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 10_000;
180
181/// Parse a POSIX signal name or number string to a signal number.
182///
183/// Accepts "SIGTERM", "TERM", "15", "SIGQUIT", etc.
184/// Returns `SIGTERM` (15) for unrecognized names.
185pub fn parse_signal_name(name: &str) -> i32 {
186    let upper = name.trim().to_uppercase();
187    let short = upper.strip_prefix("SIG").unwrap_or(&upper);
188    match short {
189        "HUP" => 1,
190        "INT" => 2,
191        "QUIT" => 3,
192        "ILL" => 4,
193        "ABRT" => 6,
194        "FPE" => 8,
195        "KILL" => 9,
196        "USR1" => 10,
197        "SEGV" => 11,
198        "USR2" => 12,
199        "PIPE" => 13,
200        "ALRM" | "ALARM" => 14,
201        "TERM" => 15,
202        "CHLD" | "CLD" => 17,
203        "CONT" => 18,
204        "STOP" => 19,
205        "TSTP" => 20,
206        "WINCH" => 28,
207        _ => name.trim().parse::<i32>().unwrap_or(15),
208    }
209}
210
211/// Lifecycle operations on a running VM.
212///
213/// Separates runtime operations (stop, metrics) from spawning (VmmProvider).
214/// Allows reconnecting to existing VMs by constructing a handler from a PID.
215pub trait VmHandler: Send + Sync {
216    /// Stop the VM. Sends `signal` first, then SIGKILL after `timeout_ms`.
217    fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()>;
218
219    /// Get current CPU and memory metrics.
220    fn metrics(&self) -> VmMetrics;
221
222    /// Check if the VM process is still alive.
223    fn is_running(&self) -> bool;
224
225    /// Return the OS process ID of the VM.
226    fn pid(&self) -> u32;
227
228    /// Return the exit code of the VM process, if it has exited.
229    ///
230    /// Returns `None` until `stop()` has been called and the process has exited.
231    /// Backends that do not track exit codes may leave this as the default `None`.
232    fn exit_code(&self) -> Option<i32> {
233        None
234    }
235}
236
237// ── VMM provider ─────────────────────────────────────────────────────────────
238
239/// Trait for VMM backend implementations.
240///
241/// Implement this to plug in an alternative hypervisor (e.g., QEMU, Cloud
242/// Hypervisor) without changing any runtime code.
243#[async_trait]
244pub trait VmmProvider: Send + Sync {
245    /// Start a VM from the given spec. Returns a handler for its lifetime.
246    async fn start(&self, spec: &InstanceSpec) -> Result<Box<dyn VmHandler>>;
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use crate::config::ResourceLimits;
253
254    #[test]
255    fn test_parse_signal_name_term() {
256        assert_eq!(parse_signal_name("SIGTERM"), 15);
257        assert_eq!(parse_signal_name("TERM"), 15);
258        assert_eq!(parse_signal_name("15"), 15);
259    }
260
261    #[test]
262    fn test_parse_signal_name_variants() {
263        assert_eq!(parse_signal_name("SIGKILL"), 9);
264        assert_eq!(parse_signal_name("KILL"), 9);
265        assert_eq!(parse_signal_name("SIGHUP"), 1);
266        assert_eq!(parse_signal_name("SIGQUIT"), 3);
267        assert_eq!(parse_signal_name("SIGINT"), 2);
268        assert_eq!(parse_signal_name("SIGUSR1"), 10);
269        assert_eq!(parse_signal_name("SIGUSR2"), 12);
270    }
271
272    #[test]
273    fn test_parse_signal_name_numeric() {
274        assert_eq!(parse_signal_name("9"), 9);
275        assert_eq!(parse_signal_name("1"), 1);
276    }
277
278    #[test]
279    fn test_parse_signal_name_unknown_defaults_to_sigterm() {
280        assert_eq!(parse_signal_name("SIGFOO"), 15);
281        assert_eq!(parse_signal_name(""), 15);
282        assert_eq!(parse_signal_name("notasignal"), 15);
283    }
284
285    #[test]
286    fn test_parse_signal_name_case_insensitive() {
287        assert_eq!(parse_signal_name("sigterm"), 15);
288        assert_eq!(parse_signal_name("Sigterm"), 15);
289    }
290
291    #[test]
292    fn test_instance_spec_default_values() {
293        let spec = InstanceSpec::default();
294        assert_eq!(spec.vcpus, 2);
295        assert_eq!(spec.memory_mib, 512);
296        assert_eq!(spec.workdir, "/");
297        assert!(spec.box_id.is_empty());
298        assert!(spec.fs_mounts.is_empty());
299        assert!(spec.port_map.is_empty());
300        assert!(spec.tee_config.is_none());
301        assert!(spec.user.is_none());
302        assert!(spec.network.is_none());
303        assert!(spec.console_output.is_none());
304    }
305
306    #[test]
307    fn test_instance_spec_serde_roundtrip() {
308        let spec = InstanceSpec {
309            box_id: "test-box-123".to_string(),
310            vcpus: 4,
311            memory_mib: 2048,
312            rootfs_path: PathBuf::from("/tmp/rootfs"),
313            exec_socket_path: PathBuf::from("/tmp/exec.sock"),
314            pty_socket_path: PathBuf::from("/tmp/pty.sock"),
315            attest_socket_path: PathBuf::from("/tmp/attest.sock"),
316            fs_mounts: vec![FsMount {
317                tag: "workspace".to_string(),
318                host_path: PathBuf::from("/home/user/project"),
319                read_only: false,
320            }],
321            entrypoint: Entrypoint {
322                executable: "/usr/bin/agent".to_string(),
323                args: vec!["--port".to_string(), "8080".to_string()],
324                env: vec![("HOME".to_string(), "/root".to_string())],
325            },
326            console_output: Some(PathBuf::from("/tmp/console.log")),
327            workdir: "/app".to_string(),
328            tee_config: None,
329            port_map: vec!["8080:80".to_string()],
330            user: Some("1000:1000".to_string()),
331            network: None,
332            resource_limits: ResourceLimits::default(),
333        };
334
335        let json = serde_json::to_string(&spec).unwrap();
336        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
337
338        assert_eq!(deserialized.box_id, "test-box-123");
339        assert_eq!(deserialized.vcpus, 4);
340        assert_eq!(deserialized.memory_mib, 2048);
341        assert_eq!(deserialized.workdir, "/app");
342        assert_eq!(deserialized.fs_mounts.len(), 1);
343        assert_eq!(deserialized.fs_mounts[0].tag, "workspace");
344        assert!(!deserialized.fs_mounts[0].read_only);
345        assert_eq!(deserialized.entrypoint.executable, "/usr/bin/agent");
346        assert_eq!(deserialized.entrypoint.args.len(), 2);
347        assert_eq!(deserialized.entrypoint.env.len(), 1);
348        assert_eq!(deserialized.port_map, vec!["8080:80"]);
349        assert_eq!(deserialized.user, Some("1000:1000".to_string()));
350    }
351
352    #[test]
353    fn test_instance_spec_with_tee_config() {
354        let spec = InstanceSpec {
355            tee_config: Some(TeeInstanceConfig {
356                config_path: PathBuf::from("/etc/tee.json"),
357                tee_type: "snp".to_string(),
358            }),
359            ..Default::default()
360        };
361
362        let json = serde_json::to_string(&spec).unwrap();
363        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
364
365        let tee = deserialized.tee_config.unwrap();
366        assert_eq!(tee.tee_type, "snp");
367        assert_eq!(tee.config_path, PathBuf::from("/etc/tee.json"));
368    }
369
370    #[test]
371    fn test_instance_spec_with_network() {
372        let spec = InstanceSpec {
373            network: Some(NetworkInstanceConfig {
374                passt_socket_path: PathBuf::from("/tmp/passt.sock"),
375                ip_address: "10.0.0.2".parse().unwrap(),
376                gateway: "10.0.0.1".parse().unwrap(),
377                prefix_len: 24,
378                mac_address: [0x02, 0x42, 0xac, 0x11, 0x00, 0x02],
379                dns_servers: vec!["8.8.8.8".parse().unwrap()],
380            }),
381            ..Default::default()
382        };
383
384        let json = serde_json::to_string(&spec).unwrap();
385        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
386
387        let net = deserialized.network.unwrap();
388        assert_eq!(net.ip_address, "10.0.0.2".parse::<Ipv4Addr>().unwrap());
389        assert_eq!(net.gateway, "10.0.0.1".parse::<Ipv4Addr>().unwrap());
390        assert_eq!(net.prefix_len, 24);
391        assert_eq!(net.dns_servers.len(), 1);
392    }
393
394    #[test]
395    fn test_fs_mount_serde() {
396        let mount = FsMount {
397            tag: "data".to_string(),
398            host_path: PathBuf::from("/mnt/data"),
399            read_only: true,
400        };
401
402        let json = serde_json::to_string(&mount).unwrap();
403        let deserialized: FsMount = serde_json::from_str(&json).unwrap();
404
405        assert_eq!(deserialized.tag, "data");
406        assert_eq!(deserialized.host_path, PathBuf::from("/mnt/data"));
407        assert!(deserialized.read_only);
408    }
409
410    #[test]
411    fn test_entrypoint_serde() {
412        let ep = Entrypoint {
413            executable: "/bin/sh".to_string(),
414            args: vec!["-c".to_string(), "echo hello".to_string()],
415            env: vec![
416                ("PATH".to_string(), "/usr/bin".to_string()),
417                ("HOME".to_string(), "/root".to_string()),
418            ],
419        };
420
421        let json = serde_json::to_string(&ep).unwrap();
422        let deserialized: Entrypoint = serde_json::from_str(&json).unwrap();
423
424        assert_eq!(deserialized.executable, "/bin/sh");
425        assert_eq!(deserialized.args, vec!["-c", "echo hello"]);
426        assert_eq!(deserialized.env.len(), 2);
427    }
428
429    #[test]
430    fn test_instance_spec_deserialize_missing_optional_fields() {
431        let json = r#"{
432            "box_id": "min",
433            "vcpus": 1,
434            "memory_mib": 256,
435            "rootfs_path": "/rootfs",
436            "exec_socket_path": "/exec.sock",
437            "fs_mounts": [],
438            "entrypoint": {"executable": "/bin/sh", "args": [], "env": []},
439            "console_output": null,
440            "workdir": "/"
441        }"#;
442
443        let spec: InstanceSpec = serde_json::from_str(json).unwrap();
444        assert_eq!(spec.box_id, "min");
445        assert!(spec.port_map.is_empty());
446        assert!(spec.user.is_none());
447        assert!(spec.network.is_none());
448        assert!(spec.tee_config.is_none());
449    }
450
451    #[test]
452    fn test_resource_limits_in_spec() {
453        let spec = InstanceSpec {
454            resource_limits: ResourceLimits {
455                pids_limit: Some(100),
456                cpuset_cpus: Some("0-3".to_string()),
457                ..Default::default()
458            },
459            ..Default::default()
460        };
461
462        let json = serde_json::to_string(&spec).unwrap();
463        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
464
465        assert_eq!(deserialized.resource_limits.pids_limit, Some(100));
466        assert_eq!(
467            deserialized.resource_limits.cpuset_cpus,
468            Some("0-3".to_string())
469        );
470    }
471
472    #[test]
473    fn test_vm_metrics_default() {
474        let m = VmMetrics::default();
475        assert!(m.cpu_percent.is_none());
476        assert!(m.memory_bytes.is_none());
477    }
478
479    #[test]
480    fn test_vm_metrics_clone() {
481        let m = VmMetrics {
482            cpu_percent: Some(50.0),
483            memory_bytes: Some(1024 * 1024),
484        };
485        let cloned = m.clone();
486        assert_eq!(cloned.cpu_percent, Some(50.0));
487        assert_eq!(cloned.memory_bytes, Some(1024 * 1024));
488    }
489}