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