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::fmt;
13use std::net::Ipv4Addr;
14#[cfg(unix)]
15use std::os::fd::RawFd;
16use std::path::{Path, PathBuf};
17
18use async_trait::async_trait;
19use serde::{Deserialize, Serialize};
20
21use crate::config::{ResourceLimits, DEFAULT_VCPUS};
22use crate::error::Result;
23
24// ── VM instance spec ──────────────────────────────────────────────────────────
25
26/// A filesystem mount from host to guest via virtio-fs.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct FsMount {
29    /// Virtiofs tag (guest uses this to identify the share)
30    pub tag: String,
31    /// Host directory to share
32    pub host_path: PathBuf,
33    /// Whether the share is read-only
34    pub read_only: bool,
35}
36
37/// Entrypoint configuration for the guest agent.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct Entrypoint {
40    /// Path to the executable inside the VM
41    pub executable: String,
42    /// Command-line arguments
43    pub args: Vec<String>,
44    /// Environment variables
45    pub env: Vec<(String, String)>,
46}
47
48/// TEE instance configuration for the shim.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct TeeInstanceConfig {
51    /// Path to TEE configuration JSON file
52    pub config_path: PathBuf,
53    /// TEE type identifier (e.g., "snp")
54    pub tee_type: String,
55}
56
57/// Network instance configuration for the network backend (passt on Linux, gvproxy on macOS).
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct NetworkInstanceConfig {
60    /// Path to the network backend Unix socket (passt on Linux, gvproxy on macOS).
61    pub net_socket_path: PathBuf,
62
63    /// Optional JSON stats file written by the userspace network backend.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub net_stats_path: Option<PathBuf>,
66
67    /// Pre-opened network socket fd inherited by the shim on Unix.
68    #[cfg(unix)]
69    #[serde(default)]
70    pub net_socket_fd: Option<RawFd>,
71
72    /// Proxy-side network socket fd inherited by the shim on Unix.
73    #[cfg(unix)]
74    #[serde(default)]
75    pub net_proxy_fd: Option<RawFd>,
76
77    /// Shared Unix-datagram Ethernet switch directory for this bridge network.
78    #[cfg(unix)]
79    #[serde(default, skip_serializing_if = "Option::is_none")]
80    pub bridge_socket_dir: Option<PathBuf>,
81
82    /// Assigned IPv4 address for this VM.
83    pub ip_address: Ipv4Addr,
84
85    /// Gateway IPv4 address.
86    pub gateway: Ipv4Addr,
87
88    /// Subnet prefix length (e.g., 24).
89    pub prefix_len: u8,
90
91    /// MAC address as 6 bytes.
92    pub mac_address: [u8; 6],
93
94    /// DNS servers to configure inside the guest.
95    #[serde(default)]
96    pub dns_servers: Vec<Ipv4Addr>,
97}
98
99/// Stable guest device path used for an A3S-managed ext4 root disk.
100pub const GUEST_EXT4_ROOT_DEVICE: &str = "/dev/vda";
101
102/// An explicitly raw auxiliary block device attached to a MicroVM.
103///
104/// The format is part of the type contract, so the shim never probes a path
105/// after guest access. Device order is stable: root block disks are attached
106/// first, followed by this list in order.
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
108#[serde(deny_unknown_fields)]
109pub struct RawBlockDevice {
110    pub id: String,
111    pub path: PathBuf,
112    #[serde(default)]
113    pub read_only: bool,
114}
115
116impl RawBlockDevice {
117    pub fn new(id: impl Into<String>, path: impl Into<PathBuf>, read_only: bool) -> Self {
118        Self {
119            id: id.into(),
120            path: path.into(),
121            read_only,
122        }
123    }
124}
125
126/// Root filesystem presented to a MicroVM.
127///
128/// Directory roots use libkrun's virtio-fs root transport. `Ext4Disk` is the
129/// guest-native path: an explicitly raw ext4 image is attached as the first
130/// virtio-blk device and becomes `/dev/vda` inside the guest. Keeping the disk
131/// format fixed in this typed variant prevents unsafe image-format probing
132/// after an untrusted guest has written to the image.
133#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
134#[serde(tag = "kind", rename_all = "snake_case")]
135pub enum RootfsSource {
136    /// Host directory exported to the guest through virtio-fs.
137    Directory { path: PathBuf },
138    /// Raw ext4 filesystem image opened directly by libkrun as virtio-blk.
139    Ext4Disk {
140        path: PathBuf,
141        #[serde(default)]
142        read_only: bool,
143    },
144}
145
146#[derive(Deserialize)]
147#[serde(tag = "kind", rename_all = "snake_case")]
148enum TaggedRootfsSource {
149    Directory {
150        path: PathBuf,
151    },
152    Ext4Disk {
153        path: PathBuf,
154        #[serde(default)]
155        read_only: bool,
156    },
157}
158
159#[derive(Deserialize)]
160#[serde(untagged)]
161enum RootfsSourceRepresentation {
162    LegacyDirectory(PathBuf),
163    Tagged(TaggedRootfsSource),
164}
165
166impl<'de> Deserialize<'de> for RootfsSource {
167    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
168    where
169        D: serde::Deserializer<'de>,
170    {
171        Ok(
172            match RootfsSourceRepresentation::deserialize(deserializer)? {
173                RootfsSourceRepresentation::LegacyDirectory(path) => Self::Directory { path },
174                RootfsSourceRepresentation::Tagged(TaggedRootfsSource::Directory { path }) => {
175                    Self::Directory { path }
176                }
177                RootfsSourceRepresentation::Tagged(TaggedRootfsSource::Ext4Disk {
178                    path,
179                    read_only,
180                }) => Self::Ext4Disk { path, read_only },
181            },
182        )
183    }
184}
185
186impl RootfsSource {
187    /// Construct a host-directory root exported through virtio-fs.
188    pub fn directory(path: impl Into<PathBuf>) -> Self {
189        Self::Directory { path: path.into() }
190    }
191
192    /// Construct an explicitly raw ext4 root disk.
193    pub fn ext4_disk(path: impl Into<PathBuf>, read_only: bool) -> Self {
194        Self::Ext4Disk {
195            path: path.into(),
196            read_only,
197        }
198    }
199
200    /// Host path backing this root filesystem.
201    pub fn path(&self) -> &Path {
202        match self {
203            Self::Directory { path } | Self::Ext4Disk { path, .. } => path,
204        }
205    }
206
207    /// Return the host directory for operations that require direct file access.
208    pub fn directory_path(&self) -> Option<&Path> {
209        match self {
210            Self::Directory { path } => Some(path),
211            Self::Ext4Disk { .. } => None,
212        }
213    }
214}
215
216impl Default for RootfsSource {
217    fn default() -> Self {
218        Self::directory(PathBuf::new())
219    }
220}
221
222impl fmt::Display for RootfsSource {
223    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
224        match self {
225            Self::Directory { path } => write!(formatter, "directory:{}", path.display()),
226            Self::Ext4Disk { path, .. } => write!(formatter, "ext4-disk:{}", path.display()),
227        }
228    }
229}
230
231/// Complete configuration for a VM instance.
232///
233/// Serialized and passed to the shim subprocess, which uses it to configure
234/// and start the VM via the underlying hypervisor.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct InstanceSpec {
237    /// Unique identifier for this box instance
238    pub box_id: String,
239
240    /// Number of vCPUs (platform default: 1 on Windows, 2 elsewhere)
241    pub vcpus: u8,
242
243    /// Memory in MiB (default: 512)
244    pub memory_mib: u32,
245
246    /// Root filesystem transport and backing path.
247    #[serde(alias = "rootfs_path")]
248    pub rootfs: RootfsSource,
249
250    /// Additional raw block devices, attached after any block-backed root.
251    #[serde(default, skip_serializing_if = "Vec::is_empty")]
252    pub block_devices: Vec<RawBlockDevice>,
253
254    /// Path to the Unix socket for exec communication
255    pub exec_socket_path: PathBuf,
256
257    /// Path to the Unix socket for PTY communication
258    #[serde(default)]
259    pub pty_socket_path: PathBuf,
260
261    /// Path to the Unix socket for TEE attestation communication
262    #[serde(default)]
263    pub attest_socket_path: PathBuf,
264
265    /// Path to the Unix socket for CRI port-forward control
266    #[serde(default)]
267    pub port_forward_socket_path: PathBuf,
268
269    /// Filesystem mounts (virtio-fs shares)
270    pub fs_mounts: Vec<FsMount>,
271
272    /// Guest agent entrypoint
273    pub entrypoint: Entrypoint,
274
275    /// Mark guest memory KSM-mergeable (host page dedup across same-image VMs;
276    /// Linux 6.4+, requires /sys/kernel/mm/ksm/run=1 on the host).
277    #[serde(default)]
278    pub ksm: bool,
279
280    /// Snapshot-fork (per-VM): file-backed guest RAM path. When set (with
281    /// `snapshot_sock`), this VM boots as a snapshot TEMPLATE — guest RAM is
282    /// file-backed so it can be snapshotted on demand.
283    #[serde(default)]
284    pub snapshot_mem_file: Option<String>,
285
286    /// Snapshot-fork (per-VM): unix socket on which libkrun serves snapshot
287    /// requests for this template VM.
288    #[serde(default)]
289    pub snapshot_sock: Option<String>,
290
291    /// Snapshot-fork (per-VM): when set (with `snapshot_mem_file`), this VM is a
292    /// RESTORE — it resumes the snapshotted template from this state file with
293    /// MAP_PRIVATE CoW of the RAM file, instead of cold-booting. This is the
294    /// per-VM seam that lets one process fork many VMs (the pool / fork daemon),
295    /// which a process-global `KRUN_RESTORE_FROM` env cannot express.
296    #[serde(default)]
297    pub restore_from: Option<String>,
298
299    /// Optional console output file path
300    pub console_output: Option<PathBuf>,
301
302    /// Working directory inside the VM
303    pub workdir: String,
304
305    /// TEE configuration (None for standard VM)
306    pub tee_config: Option<TeeInstanceConfig>,
307
308    /// TSI port mappings: ["host_port:guest_port", ...]
309    #[serde(default)]
310    pub port_map: Vec<String>,
311
312    /// User to run as inside the VM (from OCI USER directive).
313    /// Format: "uid", "uid:gid", "user", or "user:group"
314    #[serde(default)]
315    pub user: Option<String>,
316
317    /// Network configuration for virtio-net networking.
318    /// None = TSI mode (default), Some = virtio-net mode (passt on Linux, gvproxy on macOS).
319    #[serde(default)]
320    pub network: Option<NetworkInstanceConfig>,
321
322    /// Disable TSI socket interception while retaining explicit vsock IPC.
323    #[serde(default)]
324    pub disable_tsi: bool,
325
326    /// Resource limits (PID limits, CPU pinning, ulimits, cgroup controls).
327    #[serde(default)]
328    pub resource_limits: ResourceLimits,
329
330    /// Logging driver config. The shim runs the log processor for the box's
331    /// lifetime (so detached `run -d` logs aren't truncated when the CLI exits).
332    #[serde(default)]
333    pub log_config: crate::log::LogConfig,
334}
335
336impl Default for InstanceSpec {
337    fn default() -> Self {
338        Self {
339            box_id: String::new(),
340            vcpus: DEFAULT_VCPUS as u8,
341            memory_mib: 512,
342            rootfs: RootfsSource::default(),
343            block_devices: Vec::new(),
344            exec_socket_path: PathBuf::new(),
345            pty_socket_path: PathBuf::new(),
346            attest_socket_path: PathBuf::new(),
347            port_forward_socket_path: PathBuf::new(),
348            fs_mounts: Vec::new(),
349            entrypoint: Entrypoint {
350                executable: String::new(),
351                args: Vec::new(),
352                env: Vec::new(),
353            },
354            ksm: false,
355            snapshot_mem_file: None,
356            snapshot_sock: None,
357            restore_from: None,
358            console_output: None,
359            workdir: "/".to_string(),
360            tee_config: None,
361            port_map: Vec::new(),
362            user: None,
363            network: None,
364            disable_tsi: false,
365            resource_limits: ResourceLimits::default(),
366            log_config: crate::log::LogConfig::default(),
367        }
368    }
369}
370
371// ── VM handler and metrics ────────────────────────────────────────────────────
372
373/// VM resource metrics.
374#[derive(Debug, Clone, Default)]
375pub struct VmMetrics {
376    /// CPU usage percentage (0-100 per core)
377    pub cpu_percent: Option<f32>,
378    /// Memory usage in bytes
379    pub memory_bytes: Option<u64>,
380}
381
382/// Default shutdown timeout in milliseconds (10 seconds).
383pub const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 10_000;
384
385/// Parse a POSIX signal name or number string to a signal number.
386///
387/// Accepts "SIGTERM", "TERM", "15", "SIGQUIT", etc.
388/// Returns `SIGTERM` (15) for unrecognized names.
389pub fn parse_signal_name(name: &str) -> i32 {
390    let upper = name.trim().to_uppercase();
391    let short = upper.strip_prefix("SIG").unwrap_or(&upper);
392    match short {
393        "HUP" => 1,
394        "INT" => 2,
395        "QUIT" => 3,
396        "ILL" => 4,
397        "ABRT" => 6,
398        "FPE" => 8,
399        "KILL" => 9,
400        "USR1" => 10,
401        "SEGV" => 11,
402        "USR2" => 12,
403        "PIPE" => 13,
404        "ALRM" | "ALARM" => 14,
405        "TERM" => 15,
406        "CHLD" | "CLD" => 17,
407        "CONT" => 18,
408        "STOP" => 19,
409        "TSTP" => 20,
410        "WINCH" => 28,
411        _ => name.trim().parse::<i32>().unwrap_or(15),
412    }
413}
414
415/// Lifecycle operations on a running VM.
416///
417/// Separates runtime operations (stop, metrics) from spawning (VmmProvider).
418/// Allows reconnecting to existing VMs by constructing a handler from a PID.
419pub trait VmHandler: Send + Sync {
420    /// Stop the VM. Sends `signal` first, then SIGKILL after `timeout_ms`.
421    fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()>;
422
423    /// Get current CPU and memory metrics.
424    fn metrics(&self) -> VmMetrics;
425
426    /// Check if the VM process is still alive.
427    fn is_running(&self) -> bool;
428
429    /// Whether the VM process has exited, treating a zombie (an exited child not
430    /// yet reaped by its parent) as exited.
431    ///
432    /// Distinct from `!is_running()`: shim handlers implement `is_running` with
433    /// `kill(pid, 0)`, which still succeeds for a zombie, so a freshly-exited
434    /// shim looks alive until its parent reaps it. Boot-readiness waits use this
435    /// so a short-lived container's exit does not stall the wait for the full
436    /// timeout. On Linux it inspects `/proc/<pid>` process state; elsewhere it
437    /// falls back to `!is_running()`.
438    fn has_exited(&self) -> bool {
439        #[cfg(target_os = "linux")]
440        {
441            linux_process_exited(self.pid())
442        }
443        #[cfg(not(target_os = "linux"))]
444        {
445            !self.is_running()
446        }
447    }
448
449    /// Return the OS process ID of the VM.
450    fn pid(&self) -> u32;
451
452    /// Return the exit code of the VM process, if it has exited.
453    ///
454    /// Returns `None` until `stop()` has been called and the process has exited.
455    /// Backends that do not track exit codes may leave this as the default `None`.
456    fn exit_code(&self) -> Option<i32> {
457        None
458    }
459
460    /// Poll the VM process for natural exit without sending any signal.
461    ///
462    /// Implementations that own a child process handle can use this to reap
463    /// short-lived foreground workloads. Backends that cannot poll should
464    /// return `Ok(None)`.
465    fn try_wait_exit(&mut self) -> Result<Option<i32>> {
466        Ok(None)
467    }
468}
469
470/// Whether `pid` has exited, treating a zombie/dead process as exited.
471///
472/// Reads `/proc/<pid>/stat` and inspects the process state field. The `comm`
473/// field can contain spaces and parentheses (e.g. libkrun renames the shim to
474/// `(libkrun VM)`), so the state is located after the final `)`. A `Z` (zombie)
475/// or `X` (dead) state, or a missing `/proc` entry, means the process exited.
476#[cfg(target_os = "linux")]
477pub(crate) fn linux_process_exited(pid: u32) -> bool {
478    match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
479        Ok(stat) => match stat.rfind(')') {
480            Some(idx) => {
481                let state = stat[idx + 1..].trim_start().chars().next();
482                matches!(state, Some('Z') | Some('X'))
483            }
484            // Malformed stat — be conservative and treat as still running.
485            None => false,
486        },
487        // No /proc entry → the process is gone.
488        Err(_) => true,
489    }
490}
491
492// ── VMM provider ─────────────────────────────────────────────────────────────
493
494/// Trait for VMM backend implementations.
495///
496/// Implement this to plug in an alternative hypervisor (e.g., QEMU, Cloud
497/// Hypervisor) without changing any runtime code.
498#[async_trait]
499pub trait VmmProvider: Send + Sync {
500    /// Start a VM from the given spec. Returns a handler for its lifetime.
501    async fn start(&self, spec: &InstanceSpec) -> Result<Box<dyn VmHandler>>;
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::config::ResourceLimits;
508
509    #[cfg(target_os = "linux")]
510    #[test]
511    fn test_linux_process_exited_current_process_is_alive() {
512        // The test process itself is running (state R/S), not exited.
513        assert!(!linux_process_exited(std::process::id()));
514    }
515
516    #[cfg(target_os = "linux")]
517    #[test]
518    fn test_linux_process_exited_missing_pid_is_exited() {
519        // A PID with no /proc entry is treated as exited.
520        assert!(linux_process_exited(0x7fff_fffe));
521    }
522
523    #[test]
524    fn test_parse_signal_name_term() {
525        assert_eq!(parse_signal_name("SIGTERM"), 15);
526        assert_eq!(parse_signal_name("TERM"), 15);
527        assert_eq!(parse_signal_name("15"), 15);
528    }
529
530    #[test]
531    fn test_parse_signal_name_variants() {
532        assert_eq!(parse_signal_name("SIGKILL"), 9);
533        assert_eq!(parse_signal_name("KILL"), 9);
534        assert_eq!(parse_signal_name("SIGHUP"), 1);
535        assert_eq!(parse_signal_name("SIGQUIT"), 3);
536        assert_eq!(parse_signal_name("SIGINT"), 2);
537        assert_eq!(parse_signal_name("SIGUSR1"), 10);
538        assert_eq!(parse_signal_name("SIGUSR2"), 12);
539    }
540
541    #[test]
542    fn test_parse_signal_name_numeric() {
543        assert_eq!(parse_signal_name("9"), 9);
544        assert_eq!(parse_signal_name("1"), 1);
545    }
546
547    #[test]
548    fn test_parse_signal_name_unknown_defaults_to_sigterm() {
549        assert_eq!(parse_signal_name("SIGFOO"), 15);
550        assert_eq!(parse_signal_name(""), 15);
551        assert_eq!(parse_signal_name("notasignal"), 15);
552    }
553
554    #[test]
555    fn test_parse_signal_name_case_insensitive() {
556        assert_eq!(parse_signal_name("sigterm"), 15);
557        assert_eq!(parse_signal_name("Sigterm"), 15);
558    }
559
560    #[test]
561    fn test_instance_spec_default_values() {
562        let spec = InstanceSpec::default();
563        assert_eq!(spec.vcpus, DEFAULT_VCPUS as u8);
564        assert_eq!(spec.memory_mib, 512);
565        assert_eq!(spec.workdir, "/");
566        assert!(spec.box_id.is_empty());
567        assert!(spec.fs_mounts.is_empty());
568        assert!(spec.port_map.is_empty());
569        assert!(spec.tee_config.is_none());
570        assert!(spec.user.is_none());
571        assert!(spec.network.is_none());
572        assert!(!spec.disable_tsi);
573        assert!(spec.console_output.is_none());
574    }
575
576    #[test]
577    fn test_instance_spec_missing_disable_tsi_keeps_legacy_default() {
578        let mut value = serde_json::to_value(InstanceSpec::default()).unwrap();
579        value
580            .as_object_mut()
581            .unwrap()
582            .remove("disable_tsi")
583            .unwrap();
584
585        let spec: InstanceSpec = serde_json::from_value(value).unwrap();
586
587        assert!(!spec.disable_tsi);
588    }
589
590    #[test]
591    fn test_ext4_disk_rootfs_serde_roundtrip() {
592        let rootfs = RootfsSource::Ext4Disk {
593            path: PathBuf::from("/tmp/rootfs.ext4"),
594            read_only: true,
595        };
596
597        let json = serde_json::to_value(&rootfs).unwrap();
598        assert_eq!(json["kind"], "ext4_disk");
599        assert_eq!(json["path"], "/tmp/rootfs.ext4");
600        assert_eq!(json["read_only"], true);
601
602        let decoded: RootfsSource = serde_json::from_value(json).unwrap();
603        assert_eq!(decoded, rootfs);
604    }
605
606    #[test]
607    fn test_instance_spec_accepts_legacy_rootfs_path() {
608        let json = r#"{
609            "box_id": "legacy",
610            "vcpus": 1,
611            "memory_mib": 256,
612            "rootfs_path": "/legacy/rootfs",
613            "exec_socket_path": "/exec.sock",
614            "fs_mounts": [],
615            "entrypoint": {"executable": "/bin/sh", "args": [], "env": []},
616            "console_output": null,
617            "workdir": "/"
618        }"#;
619
620        let spec: InstanceSpec = serde_json::from_str(json).unwrap();
621        assert_eq!(
622            spec.rootfs,
623            RootfsSource::Directory {
624                path: PathBuf::from("/legacy/rootfs")
625            }
626        );
627    }
628
629    #[test]
630    fn test_instance_spec_serde_roundtrip() {
631        let spec = InstanceSpec {
632            box_id: "test-box-123".to_string(),
633            ksm: false,
634            snapshot_mem_file: None,
635            snapshot_sock: None,
636            restore_from: None,
637            vcpus: 4,
638            memory_mib: 2048,
639            rootfs: RootfsSource::directory("/tmp/rootfs"),
640            block_devices: vec![RawBlockDevice::new("data", "/tmp/data.ext4", true)],
641            exec_socket_path: PathBuf::from("/tmp/exec.sock"),
642            pty_socket_path: PathBuf::from("/tmp/pty.sock"),
643            attest_socket_path: PathBuf::from("/tmp/attest.sock"),
644            port_forward_socket_path: PathBuf::from("/tmp/portfwd.sock"),
645            fs_mounts: vec![FsMount {
646                tag: "workspace".to_string(),
647                host_path: PathBuf::from("/home/user/project"),
648                read_only: false,
649            }],
650            entrypoint: Entrypoint {
651                executable: "/usr/bin/agent".to_string(),
652                args: vec!["--port".to_string(), "8080".to_string()],
653                env: vec![("HOME".to_string(), "/root".to_string())],
654            },
655            console_output: Some(PathBuf::from("/tmp/console.log")),
656            workdir: "/app".to_string(),
657            tee_config: None,
658            port_map: vec!["8080:80".to_string()],
659            user: Some("1000:1000".to_string()),
660            network: None,
661            disable_tsi: true,
662            resource_limits: ResourceLimits::default(),
663            log_config: crate::log::LogConfig::default(),
664        };
665
666        let json = serde_json::to_string(&spec).unwrap();
667        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
668
669        assert_eq!(deserialized.box_id, "test-box-123");
670        assert_eq!(deserialized.vcpus, 4);
671        assert_eq!(deserialized.memory_mib, 2048);
672        assert_eq!(deserialized.rootfs, RootfsSource::directory("/tmp/rootfs"));
673        assert_eq!(deserialized.block_devices, spec.block_devices);
674        assert_eq!(deserialized.workdir, "/app");
675        assert_eq!(deserialized.fs_mounts.len(), 1);
676        assert_eq!(deserialized.fs_mounts[0].tag, "workspace");
677        assert!(!deserialized.fs_mounts[0].read_only);
678        assert_eq!(deserialized.entrypoint.executable, "/usr/bin/agent");
679        assert_eq!(deserialized.entrypoint.args.len(), 2);
680        assert_eq!(deserialized.entrypoint.env.len(), 1);
681        assert_eq!(
682            deserialized.port_forward_socket_path,
683            PathBuf::from("/tmp/portfwd.sock")
684        );
685        assert_eq!(deserialized.port_map, vec!["8080:80"]);
686        assert_eq!(deserialized.user, Some("1000:1000".to_string()));
687        assert!(deserialized.disable_tsi);
688    }
689
690    #[test]
691    fn instance_spec_omits_empty_auxiliary_block_device_list() {
692        let value = serde_json::to_value(InstanceSpec::default()).unwrap();
693        assert!(value.get("block_devices").is_none());
694
695        let decoded: InstanceSpec = serde_json::from_value(value).unwrap();
696        assert!(decoded.block_devices.is_empty());
697    }
698
699    #[test]
700    fn test_instance_spec_with_tee_config() {
701        let spec = InstanceSpec {
702            tee_config: Some(TeeInstanceConfig {
703                config_path: PathBuf::from("/etc/tee.json"),
704                tee_type: "snp".to_string(),
705            }),
706            ..Default::default()
707        };
708
709        let json = serde_json::to_string(&spec).unwrap();
710        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
711
712        let tee = deserialized.tee_config.unwrap();
713        assert_eq!(tee.tee_type, "snp");
714        assert_eq!(tee.config_path, PathBuf::from("/etc/tee.json"));
715    }
716
717    #[test]
718    fn test_instance_spec_with_network() {
719        let spec = InstanceSpec {
720            network: Some(NetworkInstanceConfig {
721                net_socket_path: PathBuf::from("/tmp/net.sock"),
722                net_stats_path: Some(PathBuf::from("/tmp/net.stats.json")),
723                #[cfg(unix)]
724                net_socket_fd: Some(42),
725                #[cfg(unix)]
726                net_proxy_fd: Some(43),
727                #[cfg(unix)]
728                bridge_socket_dir: Some(PathBuf::from("/tmp/a3s-switch")),
729                ip_address: "10.0.0.2".parse().unwrap(),
730                gateway: "10.0.0.1".parse().unwrap(),
731                prefix_len: 24,
732                mac_address: [0x02, 0x42, 0xac, 0x11, 0x00, 0x02],
733                dns_servers: vec!["8.8.8.8".parse().unwrap()],
734            }),
735            ..Default::default()
736        };
737
738        let json = serde_json::to_string(&spec).unwrap();
739        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
740
741        let net = deserialized.network.unwrap();
742        assert_eq!(
743            net.net_stats_path,
744            Some(PathBuf::from("/tmp/net.stats.json"))
745        );
746        #[cfg(unix)]
747        assert_eq!(net.net_socket_fd, Some(42));
748        #[cfg(unix)]
749        assert_eq!(net.net_proxy_fd, Some(43));
750        assert_eq!(net.ip_address, "10.0.0.2".parse::<Ipv4Addr>().unwrap());
751        assert_eq!(net.gateway, "10.0.0.1".parse::<Ipv4Addr>().unwrap());
752        assert_eq!(net.prefix_len, 24);
753        assert_eq!(net.dns_servers.len(), 1);
754    }
755
756    #[test]
757    fn test_fs_mount_serde() {
758        let mount = FsMount {
759            tag: "data".to_string(),
760            host_path: PathBuf::from("/mnt/data"),
761            read_only: true,
762        };
763
764        let json = serde_json::to_string(&mount).unwrap();
765        let deserialized: FsMount = serde_json::from_str(&json).unwrap();
766
767        assert_eq!(deserialized.tag, "data");
768        assert_eq!(deserialized.host_path, PathBuf::from("/mnt/data"));
769        assert!(deserialized.read_only);
770    }
771
772    #[test]
773    fn test_entrypoint_serde() {
774        let ep = Entrypoint {
775            executable: "/bin/sh".to_string(),
776            args: vec!["-c".to_string(), "echo hello".to_string()],
777            env: vec![
778                ("PATH".to_string(), "/usr/bin".to_string()),
779                ("HOME".to_string(), "/root".to_string()),
780            ],
781        };
782
783        let json = serde_json::to_string(&ep).unwrap();
784        let deserialized: Entrypoint = serde_json::from_str(&json).unwrap();
785
786        assert_eq!(deserialized.executable, "/bin/sh");
787        assert_eq!(deserialized.args, vec!["-c", "echo hello"]);
788        assert_eq!(deserialized.env.len(), 2);
789    }
790
791    #[test]
792    fn test_instance_spec_deserialize_missing_optional_fields() {
793        let json = r#"{
794            "box_id": "min",
795            "vcpus": 1,
796            "memory_mib": 256,
797            "rootfs_path": "/rootfs",
798            "exec_socket_path": "/exec.sock",
799            "fs_mounts": [],
800            "entrypoint": {"executable": "/bin/sh", "args": [], "env": []},
801            "console_output": null,
802            "workdir": "/"
803        }"#;
804
805        let spec: InstanceSpec = serde_json::from_str(json).unwrap();
806        assert_eq!(spec.box_id, "min");
807        assert!(spec.port_map.is_empty());
808        assert!(spec.user.is_none());
809        assert!(spec.network.is_none());
810        assert!(spec.tee_config.is_none());
811    }
812
813    #[test]
814    fn test_resource_limits_in_spec() {
815        let spec = InstanceSpec {
816            resource_limits: ResourceLimits {
817                pids_limit: Some(100),
818                cpuset_cpus: Some("0-3".to_string()),
819                ..Default::default()
820            },
821            ..Default::default()
822        };
823
824        let json = serde_json::to_string(&spec).unwrap();
825        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
826
827        assert_eq!(deserialized.resource_limits.pids_limit, Some(100));
828        assert_eq!(
829            deserialized.resource_limits.cpuset_cpus,
830            Some("0-3".to_string())
831        );
832    }
833
834    #[test]
835    fn test_vm_metrics_default() {
836        let m = VmMetrics::default();
837        assert!(m.cpu_percent.is_none());
838        assert!(m.memory_bytes.is_none());
839    }
840
841    #[test]
842    fn test_vm_metrics_clone() {
843        let m = VmMetrics {
844            cpu_percent: Some(50.0),
845            memory_bytes: Some(1024 * 1024),
846        };
847        let cloned = m.clone();
848        assert_eq!(cloned.cpu_percent, Some(50.0));
849        assert_eq!(cloned.memory_bytes, Some(1024 * 1024));
850    }
851}