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;
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// ── VM instance spec ──────────────────────────────────────────────────────────
24
25/// A filesystem mount from host to guest via virtio-fs.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct FsMount {
28    /// Virtiofs tag (guest uses this to identify the share)
29    pub tag: String,
30    /// Host directory to share
31    pub host_path: PathBuf,
32    /// Whether the share is read-only
33    pub read_only: bool,
34}
35
36/// Entrypoint configuration for the guest agent.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Entrypoint {
39    /// Path to the executable inside the VM
40    pub executable: String,
41    /// Command-line arguments
42    pub args: Vec<String>,
43    /// Environment variables
44    pub env: Vec<(String, String)>,
45}
46
47/// TEE instance configuration for the shim.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct TeeInstanceConfig {
50    /// Path to TEE configuration JSON file
51    pub config_path: PathBuf,
52    /// TEE type identifier (e.g., "snp")
53    pub tee_type: String,
54}
55
56/// Network instance configuration for the network backend (passt on Linux, gvproxy on macOS).
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct NetworkInstanceConfig {
59    /// Path to the network backend Unix socket (passt on Linux, gvproxy on macOS).
60    pub net_socket_path: PathBuf,
61
62    /// Pre-opened Unix datagram socket fd inherited by the shim on macOS.
63    #[cfg(target_os = "macos")]
64    #[serde(default)]
65    pub net_socket_fd: Option<RawFd>,
66
67    /// Proxy-side Unix datagram socket fd inherited by the shim on macOS.
68    #[cfg(target_os = "macos")]
69    #[serde(default)]
70    pub net_proxy_fd: Option<RawFd>,
71
72    /// Assigned IPv4 address for this VM.
73    pub ip_address: Ipv4Addr,
74
75    /// Gateway IPv4 address.
76    pub gateway: Ipv4Addr,
77
78    /// Subnet prefix length (e.g., 24).
79    pub prefix_len: u8,
80
81    /// MAC address as 6 bytes.
82    pub mac_address: [u8; 6],
83
84    /// DNS servers to configure inside the guest.
85    #[serde(default)]
86    pub dns_servers: Vec<Ipv4Addr>,
87}
88
89/// Complete configuration for a VM instance.
90///
91/// Serialized and passed to the shim subprocess, which uses it to configure
92/// and start the VM via the underlying hypervisor.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct InstanceSpec {
95    /// Unique identifier for this box instance
96    pub box_id: String,
97
98    /// Number of vCPUs (default: 2)
99    pub vcpus: u8,
100
101    /// Memory in MiB (default: 512)
102    pub memory_mib: u32,
103
104    /// Path to the root filesystem
105    pub rootfs_path: PathBuf,
106
107    /// Path to the Unix socket for exec communication
108    pub exec_socket_path: PathBuf,
109
110    /// Path to the Unix socket for PTY communication
111    #[serde(default)]
112    pub pty_socket_path: PathBuf,
113
114    /// Path to the Unix socket for TEE attestation communication
115    #[serde(default)]
116    pub attest_socket_path: PathBuf,
117
118    /// Path to the Unix socket for CRI port-forward control
119    #[serde(default)]
120    pub port_forward_socket_path: PathBuf,
121
122    /// Filesystem mounts (virtio-fs shares)
123    pub fs_mounts: Vec<FsMount>,
124
125    /// Guest agent entrypoint
126    pub entrypoint: Entrypoint,
127
128    /// Mark guest memory KSM-mergeable (host page dedup across same-image VMs;
129    /// Linux 6.4+, requires /sys/kernel/mm/ksm/run=1 on the host).
130    #[serde(default)]
131    pub ksm: bool,
132
133    /// Snapshot-fork (per-VM): file-backed guest RAM path. When set (with
134    /// `snapshot_sock`), this VM boots as a snapshot TEMPLATE — guest RAM is
135    /// file-backed so it can be snapshotted on demand.
136    #[serde(default)]
137    pub snapshot_mem_file: Option<String>,
138
139    /// Snapshot-fork (per-VM): unix socket on which libkrun serves snapshot
140    /// requests for this template VM.
141    #[serde(default)]
142    pub snapshot_sock: Option<String>,
143
144    /// Snapshot-fork (per-VM): when set (with `snapshot_mem_file`), this VM is a
145    /// RESTORE — it resumes the snapshotted template from this state file with
146    /// MAP_PRIVATE CoW of the RAM file, instead of cold-booting. This is the
147    /// per-VM seam that lets one process fork many VMs (the pool / fork daemon),
148    /// which a process-global `KRUN_RESTORE_FROM` env cannot express.
149    #[serde(default)]
150    pub restore_from: Option<String>,
151
152    /// Optional console output file path
153    pub console_output: Option<PathBuf>,
154
155    /// Working directory inside the VM
156    pub workdir: String,
157
158    /// TEE configuration (None for standard VM)
159    pub tee_config: Option<TeeInstanceConfig>,
160
161    /// TSI port mappings: ["host_port:guest_port", ...]
162    #[serde(default)]
163    pub port_map: Vec<String>,
164
165    /// User to run as inside the VM (from OCI USER directive).
166    /// Format: "uid", "uid:gid", "user", or "user:group"
167    #[serde(default)]
168    pub user: Option<String>,
169
170    /// Network configuration for virtio-net networking.
171    /// None = TSI mode (default), Some = virtio-net mode (passt on Linux, gvproxy on macOS).
172    #[serde(default)]
173    pub network: Option<NetworkInstanceConfig>,
174
175    /// Resource limits (PID limits, CPU pinning, ulimits, cgroup controls).
176    #[serde(default)]
177    pub resource_limits: ResourceLimits,
178
179    /// Logging driver config. The shim runs the log processor for the box's
180    /// lifetime (so detached `run -d` logs aren't truncated when the CLI exits).
181    #[serde(default)]
182    pub log_config: crate::log::LogConfig,
183}
184
185impl Default for InstanceSpec {
186    fn default() -> Self {
187        Self {
188            box_id: String::new(),
189            vcpus: 2,
190            memory_mib: 512,
191            rootfs_path: PathBuf::new(),
192            exec_socket_path: PathBuf::new(),
193            pty_socket_path: PathBuf::new(),
194            attest_socket_path: PathBuf::new(),
195            port_forward_socket_path: PathBuf::new(),
196            fs_mounts: Vec::new(),
197            entrypoint: Entrypoint {
198                executable: String::new(),
199                args: Vec::new(),
200                env: Vec::new(),
201            },
202            ksm: false,
203            snapshot_mem_file: None,
204            snapshot_sock: None,
205            restore_from: None,
206            console_output: None,
207            workdir: "/".to_string(),
208            tee_config: None,
209            port_map: Vec::new(),
210            user: None,
211            network: None,
212            resource_limits: ResourceLimits::default(),
213            log_config: crate::log::LogConfig::default(),
214        }
215    }
216}
217
218// ── VM handler and metrics ────────────────────────────────────────────────────
219
220/// VM resource metrics.
221#[derive(Debug, Clone, Default)]
222pub struct VmMetrics {
223    /// CPU usage percentage (0-100 per core)
224    pub cpu_percent: Option<f32>,
225    /// Memory usage in bytes
226    pub memory_bytes: Option<u64>,
227}
228
229/// Default shutdown timeout in milliseconds (10 seconds).
230pub const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 10_000;
231
232/// Parse a POSIX signal name or number string to a signal number.
233///
234/// Accepts "SIGTERM", "TERM", "15", "SIGQUIT", etc.
235/// Returns `SIGTERM` (15) for unrecognized names.
236pub fn parse_signal_name(name: &str) -> i32 {
237    let upper = name.trim().to_uppercase();
238    let short = upper.strip_prefix("SIG").unwrap_or(&upper);
239    match short {
240        "HUP" => 1,
241        "INT" => 2,
242        "QUIT" => 3,
243        "ILL" => 4,
244        "ABRT" => 6,
245        "FPE" => 8,
246        "KILL" => 9,
247        "USR1" => 10,
248        "SEGV" => 11,
249        "USR2" => 12,
250        "PIPE" => 13,
251        "ALRM" | "ALARM" => 14,
252        "TERM" => 15,
253        "CHLD" | "CLD" => 17,
254        "CONT" => 18,
255        "STOP" => 19,
256        "TSTP" => 20,
257        "WINCH" => 28,
258        _ => name.trim().parse::<i32>().unwrap_or(15),
259    }
260}
261
262/// Lifecycle operations on a running VM.
263///
264/// Separates runtime operations (stop, metrics) from spawning (VmmProvider).
265/// Allows reconnecting to existing VMs by constructing a handler from a PID.
266pub trait VmHandler: Send + Sync {
267    /// Stop the VM. Sends `signal` first, then SIGKILL after `timeout_ms`.
268    fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()>;
269
270    /// Get current CPU and memory metrics.
271    fn metrics(&self) -> VmMetrics;
272
273    /// Check if the VM process is still alive.
274    fn is_running(&self) -> bool;
275
276    /// Whether the VM process has exited, treating a zombie (an exited child not
277    /// yet reaped by its parent) as exited.
278    ///
279    /// Distinct from `!is_running()`: shim handlers implement `is_running` with
280    /// `kill(pid, 0)`, which still succeeds for a zombie, so a freshly-exited
281    /// shim looks alive until its parent reaps it. Boot-readiness waits use this
282    /// so a short-lived container's exit does not stall the wait for the full
283    /// timeout. On Linux it inspects `/proc/<pid>` process state; elsewhere it
284    /// falls back to `!is_running()`.
285    fn has_exited(&self) -> bool {
286        #[cfg(target_os = "linux")]
287        {
288            linux_process_exited(self.pid())
289        }
290        #[cfg(not(target_os = "linux"))]
291        {
292            !self.is_running()
293        }
294    }
295
296    /// Return the OS process ID of the VM.
297    fn pid(&self) -> u32;
298
299    /// Return the exit code of the VM process, if it has exited.
300    ///
301    /// Returns `None` until `stop()` has been called and the process has exited.
302    /// Backends that do not track exit codes may leave this as the default `None`.
303    fn exit_code(&self) -> Option<i32> {
304        None
305    }
306
307    /// Poll the VM process for natural exit without sending any signal.
308    ///
309    /// Implementations that own a child process handle can use this to reap
310    /// short-lived foreground workloads. Backends that cannot poll should
311    /// return `Ok(None)`.
312    fn try_wait_exit(&mut self) -> Result<Option<i32>> {
313        Ok(None)
314    }
315}
316
317/// Whether `pid` has exited, treating a zombie/dead process as exited.
318///
319/// Reads `/proc/<pid>/stat` and inspects the process state field. The `comm`
320/// field can contain spaces and parentheses (e.g. libkrun renames the shim to
321/// `(libkrun VM)`), so the state is located after the final `)`. A `Z` (zombie)
322/// or `X` (dead) state, or a missing `/proc` entry, means the process exited.
323#[cfg(target_os = "linux")]
324pub(crate) fn linux_process_exited(pid: u32) -> bool {
325    match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
326        Ok(stat) => match stat.rfind(')') {
327            Some(idx) => {
328                let state = stat[idx + 1..].trim_start().chars().next();
329                matches!(state, Some('Z') | Some('X'))
330            }
331            // Malformed stat — be conservative and treat as still running.
332            None => false,
333        },
334        // No /proc entry → the process is gone.
335        Err(_) => true,
336    }
337}
338
339// ── VMM provider ─────────────────────────────────────────────────────────────
340
341/// Trait for VMM backend implementations.
342///
343/// Implement this to plug in an alternative hypervisor (e.g., QEMU, Cloud
344/// Hypervisor) without changing any runtime code.
345#[async_trait]
346pub trait VmmProvider: Send + Sync {
347    /// Start a VM from the given spec. Returns a handler for its lifetime.
348    async fn start(&self, spec: &InstanceSpec) -> Result<Box<dyn VmHandler>>;
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use crate::config::ResourceLimits;
355
356    #[cfg(target_os = "linux")]
357    #[test]
358    fn test_linux_process_exited_current_process_is_alive() {
359        // The test process itself is running (state R/S), not exited.
360        assert!(!linux_process_exited(std::process::id()));
361    }
362
363    #[cfg(target_os = "linux")]
364    #[test]
365    fn test_linux_process_exited_missing_pid_is_exited() {
366        // A PID with no /proc entry is treated as exited.
367        assert!(linux_process_exited(0x7fff_fffe));
368    }
369
370    #[test]
371    fn test_parse_signal_name_term() {
372        assert_eq!(parse_signal_name("SIGTERM"), 15);
373        assert_eq!(parse_signal_name("TERM"), 15);
374        assert_eq!(parse_signal_name("15"), 15);
375    }
376
377    #[test]
378    fn test_parse_signal_name_variants() {
379        assert_eq!(parse_signal_name("SIGKILL"), 9);
380        assert_eq!(parse_signal_name("KILL"), 9);
381        assert_eq!(parse_signal_name("SIGHUP"), 1);
382        assert_eq!(parse_signal_name("SIGQUIT"), 3);
383        assert_eq!(parse_signal_name("SIGINT"), 2);
384        assert_eq!(parse_signal_name("SIGUSR1"), 10);
385        assert_eq!(parse_signal_name("SIGUSR2"), 12);
386    }
387
388    #[test]
389    fn test_parse_signal_name_numeric() {
390        assert_eq!(parse_signal_name("9"), 9);
391        assert_eq!(parse_signal_name("1"), 1);
392    }
393
394    #[test]
395    fn test_parse_signal_name_unknown_defaults_to_sigterm() {
396        assert_eq!(parse_signal_name("SIGFOO"), 15);
397        assert_eq!(parse_signal_name(""), 15);
398        assert_eq!(parse_signal_name("notasignal"), 15);
399    }
400
401    #[test]
402    fn test_parse_signal_name_case_insensitive() {
403        assert_eq!(parse_signal_name("sigterm"), 15);
404        assert_eq!(parse_signal_name("Sigterm"), 15);
405    }
406
407    #[test]
408    fn test_instance_spec_default_values() {
409        let spec = InstanceSpec::default();
410        assert_eq!(spec.vcpus, 2);
411        assert_eq!(spec.memory_mib, 512);
412        assert_eq!(spec.workdir, "/");
413        assert!(spec.box_id.is_empty());
414        assert!(spec.fs_mounts.is_empty());
415        assert!(spec.port_map.is_empty());
416        assert!(spec.tee_config.is_none());
417        assert!(spec.user.is_none());
418        assert!(spec.network.is_none());
419        assert!(spec.console_output.is_none());
420    }
421
422    #[test]
423    fn test_instance_spec_serde_roundtrip() {
424        let spec = InstanceSpec {
425            box_id: "test-box-123".to_string(),
426            ksm: false,
427            snapshot_mem_file: None,
428            snapshot_sock: None,
429            restore_from: None,
430            vcpus: 4,
431            memory_mib: 2048,
432            rootfs_path: PathBuf::from("/tmp/rootfs"),
433            exec_socket_path: PathBuf::from("/tmp/exec.sock"),
434            pty_socket_path: PathBuf::from("/tmp/pty.sock"),
435            attest_socket_path: PathBuf::from("/tmp/attest.sock"),
436            port_forward_socket_path: PathBuf::from("/tmp/portfwd.sock"),
437            fs_mounts: vec![FsMount {
438                tag: "workspace".to_string(),
439                host_path: PathBuf::from("/home/user/project"),
440                read_only: false,
441            }],
442            entrypoint: Entrypoint {
443                executable: "/usr/bin/agent".to_string(),
444                args: vec!["--port".to_string(), "8080".to_string()],
445                env: vec![("HOME".to_string(), "/root".to_string())],
446            },
447            console_output: Some(PathBuf::from("/tmp/console.log")),
448            workdir: "/app".to_string(),
449            tee_config: None,
450            port_map: vec!["8080:80".to_string()],
451            user: Some("1000:1000".to_string()),
452            network: None,
453            resource_limits: ResourceLimits::default(),
454            log_config: crate::log::LogConfig::default(),
455        };
456
457        let json = serde_json::to_string(&spec).unwrap();
458        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
459
460        assert_eq!(deserialized.box_id, "test-box-123");
461        assert_eq!(deserialized.vcpus, 4);
462        assert_eq!(deserialized.memory_mib, 2048);
463        assert_eq!(deserialized.workdir, "/app");
464        assert_eq!(deserialized.fs_mounts.len(), 1);
465        assert_eq!(deserialized.fs_mounts[0].tag, "workspace");
466        assert!(!deserialized.fs_mounts[0].read_only);
467        assert_eq!(deserialized.entrypoint.executable, "/usr/bin/agent");
468        assert_eq!(deserialized.entrypoint.args.len(), 2);
469        assert_eq!(deserialized.entrypoint.env.len(), 1);
470        assert_eq!(
471            deserialized.port_forward_socket_path,
472            PathBuf::from("/tmp/portfwd.sock")
473        );
474        assert_eq!(deserialized.port_map, vec!["8080:80"]);
475        assert_eq!(deserialized.user, Some("1000:1000".to_string()));
476    }
477
478    #[test]
479    fn test_instance_spec_with_tee_config() {
480        let spec = InstanceSpec {
481            tee_config: Some(TeeInstanceConfig {
482                config_path: PathBuf::from("/etc/tee.json"),
483                tee_type: "snp".to_string(),
484            }),
485            ..Default::default()
486        };
487
488        let json = serde_json::to_string(&spec).unwrap();
489        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
490
491        let tee = deserialized.tee_config.unwrap();
492        assert_eq!(tee.tee_type, "snp");
493        assert_eq!(tee.config_path, PathBuf::from("/etc/tee.json"));
494    }
495
496    #[test]
497    fn test_instance_spec_with_network() {
498        let spec = InstanceSpec {
499            network: Some(NetworkInstanceConfig {
500                net_socket_path: PathBuf::from("/tmp/net.sock"),
501                #[cfg(target_os = "macos")]
502                net_socket_fd: Some(42),
503                #[cfg(target_os = "macos")]
504                net_proxy_fd: Some(43),
505                ip_address: "10.0.0.2".parse().unwrap(),
506                gateway: "10.0.0.1".parse().unwrap(),
507                prefix_len: 24,
508                mac_address: [0x02, 0x42, 0xac, 0x11, 0x00, 0x02],
509                dns_servers: vec!["8.8.8.8".parse().unwrap()],
510            }),
511            ..Default::default()
512        };
513
514        let json = serde_json::to_string(&spec).unwrap();
515        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
516
517        let net = deserialized.network.unwrap();
518        #[cfg(target_os = "macos")]
519        assert_eq!(net.net_socket_fd, Some(42));
520        #[cfg(target_os = "macos")]
521        assert_eq!(net.net_proxy_fd, Some(43));
522        assert_eq!(net.ip_address, "10.0.0.2".parse::<Ipv4Addr>().unwrap());
523        assert_eq!(net.gateway, "10.0.0.1".parse::<Ipv4Addr>().unwrap());
524        assert_eq!(net.prefix_len, 24);
525        assert_eq!(net.dns_servers.len(), 1);
526    }
527
528    #[test]
529    fn test_fs_mount_serde() {
530        let mount = FsMount {
531            tag: "data".to_string(),
532            host_path: PathBuf::from("/mnt/data"),
533            read_only: true,
534        };
535
536        let json = serde_json::to_string(&mount).unwrap();
537        let deserialized: FsMount = serde_json::from_str(&json).unwrap();
538
539        assert_eq!(deserialized.tag, "data");
540        assert_eq!(deserialized.host_path, PathBuf::from("/mnt/data"));
541        assert!(deserialized.read_only);
542    }
543
544    #[test]
545    fn test_entrypoint_serde() {
546        let ep = Entrypoint {
547            executable: "/bin/sh".to_string(),
548            args: vec!["-c".to_string(), "echo hello".to_string()],
549            env: vec![
550                ("PATH".to_string(), "/usr/bin".to_string()),
551                ("HOME".to_string(), "/root".to_string()),
552            ],
553        };
554
555        let json = serde_json::to_string(&ep).unwrap();
556        let deserialized: Entrypoint = serde_json::from_str(&json).unwrap();
557
558        assert_eq!(deserialized.executable, "/bin/sh");
559        assert_eq!(deserialized.args, vec!["-c", "echo hello"]);
560        assert_eq!(deserialized.env.len(), 2);
561    }
562
563    #[test]
564    fn test_instance_spec_deserialize_missing_optional_fields() {
565        let json = r#"{
566            "box_id": "min",
567            "vcpus": 1,
568            "memory_mib": 256,
569            "rootfs_path": "/rootfs",
570            "exec_socket_path": "/exec.sock",
571            "fs_mounts": [],
572            "entrypoint": {"executable": "/bin/sh", "args": [], "env": []},
573            "console_output": null,
574            "workdir": "/"
575        }"#;
576
577        let spec: InstanceSpec = serde_json::from_str(json).unwrap();
578        assert_eq!(spec.box_id, "min");
579        assert!(spec.port_map.is_empty());
580        assert!(spec.user.is_none());
581        assert!(spec.network.is_none());
582        assert!(spec.tee_config.is_none());
583    }
584
585    #[test]
586    fn test_resource_limits_in_spec() {
587        let spec = InstanceSpec {
588            resource_limits: ResourceLimits {
589                pids_limit: Some(100),
590                cpuset_cpus: Some("0-3".to_string()),
591                ..Default::default()
592            },
593            ..Default::default()
594        };
595
596        let json = serde_json::to_string(&spec).unwrap();
597        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
598
599        assert_eq!(deserialized.resource_limits.pids_limit, Some(100));
600        assert_eq!(
601            deserialized.resource_limits.cpuset_cpus,
602            Some("0-3".to_string())
603        );
604    }
605
606    #[test]
607    fn test_vm_metrics_default() {
608        let m = VmMetrics::default();
609        assert!(m.cpu_percent.is_none());
610        assert!(m.memory_bytes.is_none());
611    }
612
613    #[test]
614    fn test_vm_metrics_clone() {
615        let m = VmMetrics {
616            cpu_percent: Some(50.0),
617            memory_bytes: Some(1024 * 1024),
618        };
619        let cloned = m.clone();
620        assert_eq!(cloned.cpu_percent, Some(50.0));
621        assert_eq!(cloned.memory_bytes, Some(1024 * 1024));
622    }
623}