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