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(unix)]
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 network socket fd inherited by the shim on Unix.
67    #[cfg(unix)]
68    #[serde(default)]
69    pub net_socket_fd: Option<RawFd>,
70
71    /// Proxy-side network socket fd inherited by the shim on Unix.
72    #[cfg(unix)]
73    #[serde(default)]
74    pub net_proxy_fd: Option<RawFd>,
75
76    /// Shared Unix-datagram Ethernet switch directory for this bridge network.
77    #[cfg(unix)]
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    /// Disable TSI socket interception while retaining explicit vsock IPC.
185    #[serde(default)]
186    pub disable_tsi: bool,
187
188    /// Resource limits (PID limits, CPU pinning, ulimits, cgroup controls).
189    #[serde(default)]
190    pub resource_limits: ResourceLimits,
191
192    /// Logging driver config. The shim runs the log processor for the box's
193    /// lifetime (so detached `run -d` logs aren't truncated when the CLI exits).
194    #[serde(default)]
195    pub log_config: crate::log::LogConfig,
196}
197
198impl Default for InstanceSpec {
199    fn default() -> Self {
200        Self {
201            box_id: String::new(),
202            vcpus: DEFAULT_VCPUS as u8,
203            memory_mib: 512,
204            rootfs_path: PathBuf::new(),
205            exec_socket_path: PathBuf::new(),
206            pty_socket_path: PathBuf::new(),
207            attest_socket_path: PathBuf::new(),
208            port_forward_socket_path: PathBuf::new(),
209            fs_mounts: Vec::new(),
210            entrypoint: Entrypoint {
211                executable: String::new(),
212                args: Vec::new(),
213                env: Vec::new(),
214            },
215            ksm: false,
216            snapshot_mem_file: None,
217            snapshot_sock: None,
218            restore_from: None,
219            console_output: None,
220            workdir: "/".to_string(),
221            tee_config: None,
222            port_map: Vec::new(),
223            user: None,
224            network: None,
225            disable_tsi: false,
226            resource_limits: ResourceLimits::default(),
227            log_config: crate::log::LogConfig::default(),
228        }
229    }
230}
231
232// ── VM handler and metrics ────────────────────────────────────────────────────
233
234/// VM resource metrics.
235#[derive(Debug, Clone, Default)]
236pub struct VmMetrics {
237    /// CPU usage percentage (0-100 per core)
238    pub cpu_percent: Option<f32>,
239    /// Memory usage in bytes
240    pub memory_bytes: Option<u64>,
241}
242
243/// Default shutdown timeout in milliseconds (10 seconds).
244pub const DEFAULT_SHUTDOWN_TIMEOUT_MS: u64 = 10_000;
245
246/// Parse a POSIX signal name or number string to a signal number.
247///
248/// Accepts "SIGTERM", "TERM", "15", "SIGQUIT", etc.
249/// Returns `SIGTERM` (15) for unrecognized names.
250pub fn parse_signal_name(name: &str) -> i32 {
251    let upper = name.trim().to_uppercase();
252    let short = upper.strip_prefix("SIG").unwrap_or(&upper);
253    match short {
254        "HUP" => 1,
255        "INT" => 2,
256        "QUIT" => 3,
257        "ILL" => 4,
258        "ABRT" => 6,
259        "FPE" => 8,
260        "KILL" => 9,
261        "USR1" => 10,
262        "SEGV" => 11,
263        "USR2" => 12,
264        "PIPE" => 13,
265        "ALRM" | "ALARM" => 14,
266        "TERM" => 15,
267        "CHLD" | "CLD" => 17,
268        "CONT" => 18,
269        "STOP" => 19,
270        "TSTP" => 20,
271        "WINCH" => 28,
272        _ => name.trim().parse::<i32>().unwrap_or(15),
273    }
274}
275
276/// Lifecycle operations on a running VM.
277///
278/// Separates runtime operations (stop, metrics) from spawning (VmmProvider).
279/// Allows reconnecting to existing VMs by constructing a handler from a PID.
280pub trait VmHandler: Send + Sync {
281    /// Stop the VM. Sends `signal` first, then SIGKILL after `timeout_ms`.
282    fn stop(&mut self, signal: i32, timeout_ms: u64) -> Result<()>;
283
284    /// Get current CPU and memory metrics.
285    fn metrics(&self) -> VmMetrics;
286
287    /// Check if the VM process is still alive.
288    fn is_running(&self) -> bool;
289
290    /// Whether the VM process has exited, treating a zombie (an exited child not
291    /// yet reaped by its parent) as exited.
292    ///
293    /// Distinct from `!is_running()`: shim handlers implement `is_running` with
294    /// `kill(pid, 0)`, which still succeeds for a zombie, so a freshly-exited
295    /// shim looks alive until its parent reaps it. Boot-readiness waits use this
296    /// so a short-lived container's exit does not stall the wait for the full
297    /// timeout. On Linux it inspects `/proc/<pid>` process state; elsewhere it
298    /// falls back to `!is_running()`.
299    fn has_exited(&self) -> bool {
300        #[cfg(target_os = "linux")]
301        {
302            linux_process_exited(self.pid())
303        }
304        #[cfg(not(target_os = "linux"))]
305        {
306            !self.is_running()
307        }
308    }
309
310    /// Return the OS process ID of the VM.
311    fn pid(&self) -> u32;
312
313    /// Return the exit code of the VM process, if it has exited.
314    ///
315    /// Returns `None` until `stop()` has been called and the process has exited.
316    /// Backends that do not track exit codes may leave this as the default `None`.
317    fn exit_code(&self) -> Option<i32> {
318        None
319    }
320
321    /// Poll the VM process for natural exit without sending any signal.
322    ///
323    /// Implementations that own a child process handle can use this to reap
324    /// short-lived foreground workloads. Backends that cannot poll should
325    /// return `Ok(None)`.
326    fn try_wait_exit(&mut self) -> Result<Option<i32>> {
327        Ok(None)
328    }
329}
330
331/// Whether `pid` has exited, treating a zombie/dead process as exited.
332///
333/// Reads `/proc/<pid>/stat` and inspects the process state field. The `comm`
334/// field can contain spaces and parentheses (e.g. libkrun renames the shim to
335/// `(libkrun VM)`), so the state is located after the final `)`. A `Z` (zombie)
336/// or `X` (dead) state, or a missing `/proc` entry, means the process exited.
337#[cfg(target_os = "linux")]
338pub(crate) fn linux_process_exited(pid: u32) -> bool {
339    match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
340        Ok(stat) => match stat.rfind(')') {
341            Some(idx) => {
342                let state = stat[idx + 1..].trim_start().chars().next();
343                matches!(state, Some('Z') | Some('X'))
344            }
345            // Malformed stat — be conservative and treat as still running.
346            None => false,
347        },
348        // No /proc entry → the process is gone.
349        Err(_) => true,
350    }
351}
352
353// ── VMM provider ─────────────────────────────────────────────────────────────
354
355/// Trait for VMM backend implementations.
356///
357/// Implement this to plug in an alternative hypervisor (e.g., QEMU, Cloud
358/// Hypervisor) without changing any runtime code.
359#[async_trait]
360pub trait VmmProvider: Send + Sync {
361    /// Start a VM from the given spec. Returns a handler for its lifetime.
362    async fn start(&self, spec: &InstanceSpec) -> Result<Box<dyn VmHandler>>;
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::config::ResourceLimits;
369
370    #[cfg(target_os = "linux")]
371    #[test]
372    fn test_linux_process_exited_current_process_is_alive() {
373        // The test process itself is running (state R/S), not exited.
374        assert!(!linux_process_exited(std::process::id()));
375    }
376
377    #[cfg(target_os = "linux")]
378    #[test]
379    fn test_linux_process_exited_missing_pid_is_exited() {
380        // A PID with no /proc entry is treated as exited.
381        assert!(linux_process_exited(0x7fff_fffe));
382    }
383
384    #[test]
385    fn test_parse_signal_name_term() {
386        assert_eq!(parse_signal_name("SIGTERM"), 15);
387        assert_eq!(parse_signal_name("TERM"), 15);
388        assert_eq!(parse_signal_name("15"), 15);
389    }
390
391    #[test]
392    fn test_parse_signal_name_variants() {
393        assert_eq!(parse_signal_name("SIGKILL"), 9);
394        assert_eq!(parse_signal_name("KILL"), 9);
395        assert_eq!(parse_signal_name("SIGHUP"), 1);
396        assert_eq!(parse_signal_name("SIGQUIT"), 3);
397        assert_eq!(parse_signal_name("SIGINT"), 2);
398        assert_eq!(parse_signal_name("SIGUSR1"), 10);
399        assert_eq!(parse_signal_name("SIGUSR2"), 12);
400    }
401
402    #[test]
403    fn test_parse_signal_name_numeric() {
404        assert_eq!(parse_signal_name("9"), 9);
405        assert_eq!(parse_signal_name("1"), 1);
406    }
407
408    #[test]
409    fn test_parse_signal_name_unknown_defaults_to_sigterm() {
410        assert_eq!(parse_signal_name("SIGFOO"), 15);
411        assert_eq!(parse_signal_name(""), 15);
412        assert_eq!(parse_signal_name("notasignal"), 15);
413    }
414
415    #[test]
416    fn test_parse_signal_name_case_insensitive() {
417        assert_eq!(parse_signal_name("sigterm"), 15);
418        assert_eq!(parse_signal_name("Sigterm"), 15);
419    }
420
421    #[test]
422    fn test_instance_spec_default_values() {
423        let spec = InstanceSpec::default();
424        assert_eq!(spec.vcpus, DEFAULT_VCPUS as u8);
425        assert_eq!(spec.memory_mib, 512);
426        assert_eq!(spec.workdir, "/");
427        assert!(spec.box_id.is_empty());
428        assert!(spec.fs_mounts.is_empty());
429        assert!(spec.port_map.is_empty());
430        assert!(spec.tee_config.is_none());
431        assert!(spec.user.is_none());
432        assert!(spec.network.is_none());
433        assert!(!spec.disable_tsi);
434        assert!(spec.console_output.is_none());
435    }
436
437    #[test]
438    fn test_instance_spec_missing_disable_tsi_keeps_legacy_default() {
439        let mut value = serde_json::to_value(InstanceSpec::default()).unwrap();
440        value
441            .as_object_mut()
442            .unwrap()
443            .remove("disable_tsi")
444            .unwrap();
445
446        let spec: InstanceSpec = serde_json::from_value(value).unwrap();
447
448        assert!(!spec.disable_tsi);
449    }
450
451    #[test]
452    fn test_instance_spec_serde_roundtrip() {
453        let spec = InstanceSpec {
454            box_id: "test-box-123".to_string(),
455            ksm: false,
456            snapshot_mem_file: None,
457            snapshot_sock: None,
458            restore_from: None,
459            vcpus: 4,
460            memory_mib: 2048,
461            rootfs_path: PathBuf::from("/tmp/rootfs"),
462            exec_socket_path: PathBuf::from("/tmp/exec.sock"),
463            pty_socket_path: PathBuf::from("/tmp/pty.sock"),
464            attest_socket_path: PathBuf::from("/tmp/attest.sock"),
465            port_forward_socket_path: PathBuf::from("/tmp/portfwd.sock"),
466            fs_mounts: vec![FsMount {
467                tag: "workspace".to_string(),
468                host_path: PathBuf::from("/home/user/project"),
469                read_only: false,
470            }],
471            entrypoint: Entrypoint {
472                executable: "/usr/bin/agent".to_string(),
473                args: vec!["--port".to_string(), "8080".to_string()],
474                env: vec![("HOME".to_string(), "/root".to_string())],
475            },
476            console_output: Some(PathBuf::from("/tmp/console.log")),
477            workdir: "/app".to_string(),
478            tee_config: None,
479            port_map: vec!["8080:80".to_string()],
480            user: Some("1000:1000".to_string()),
481            network: None,
482            disable_tsi: true,
483            resource_limits: ResourceLimits::default(),
484            log_config: crate::log::LogConfig::default(),
485        };
486
487        let json = serde_json::to_string(&spec).unwrap();
488        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
489
490        assert_eq!(deserialized.box_id, "test-box-123");
491        assert_eq!(deserialized.vcpus, 4);
492        assert_eq!(deserialized.memory_mib, 2048);
493        assert_eq!(deserialized.workdir, "/app");
494        assert_eq!(deserialized.fs_mounts.len(), 1);
495        assert_eq!(deserialized.fs_mounts[0].tag, "workspace");
496        assert!(!deserialized.fs_mounts[0].read_only);
497        assert_eq!(deserialized.entrypoint.executable, "/usr/bin/agent");
498        assert_eq!(deserialized.entrypoint.args.len(), 2);
499        assert_eq!(deserialized.entrypoint.env.len(), 1);
500        assert_eq!(
501            deserialized.port_forward_socket_path,
502            PathBuf::from("/tmp/portfwd.sock")
503        );
504        assert_eq!(deserialized.port_map, vec!["8080:80"]);
505        assert_eq!(deserialized.user, Some("1000:1000".to_string()));
506        assert!(deserialized.disable_tsi);
507    }
508
509    #[test]
510    fn test_instance_spec_with_tee_config() {
511        let spec = InstanceSpec {
512            tee_config: Some(TeeInstanceConfig {
513                config_path: PathBuf::from("/etc/tee.json"),
514                tee_type: "snp".to_string(),
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 tee = deserialized.tee_config.unwrap();
523        assert_eq!(tee.tee_type, "snp");
524        assert_eq!(tee.config_path, PathBuf::from("/etc/tee.json"));
525    }
526
527    #[test]
528    fn test_instance_spec_with_network() {
529        let spec = InstanceSpec {
530            network: Some(NetworkInstanceConfig {
531                net_socket_path: PathBuf::from("/tmp/net.sock"),
532                net_stats_path: Some(PathBuf::from("/tmp/net.stats.json")),
533                #[cfg(unix)]
534                net_socket_fd: Some(42),
535                #[cfg(unix)]
536                net_proxy_fd: Some(43),
537                #[cfg(unix)]
538                bridge_socket_dir: Some(PathBuf::from("/tmp/a3s-switch")),
539                ip_address: "10.0.0.2".parse().unwrap(),
540                gateway: "10.0.0.1".parse().unwrap(),
541                prefix_len: 24,
542                mac_address: [0x02, 0x42, 0xac, 0x11, 0x00, 0x02],
543                dns_servers: vec!["8.8.8.8".parse().unwrap()],
544            }),
545            ..Default::default()
546        };
547
548        let json = serde_json::to_string(&spec).unwrap();
549        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
550
551        let net = deserialized.network.unwrap();
552        assert_eq!(
553            net.net_stats_path,
554            Some(PathBuf::from("/tmp/net.stats.json"))
555        );
556        #[cfg(unix)]
557        assert_eq!(net.net_socket_fd, Some(42));
558        #[cfg(unix)]
559        assert_eq!(net.net_proxy_fd, Some(43));
560        assert_eq!(net.ip_address, "10.0.0.2".parse::<Ipv4Addr>().unwrap());
561        assert_eq!(net.gateway, "10.0.0.1".parse::<Ipv4Addr>().unwrap());
562        assert_eq!(net.prefix_len, 24);
563        assert_eq!(net.dns_servers.len(), 1);
564    }
565
566    #[test]
567    fn test_fs_mount_serde() {
568        let mount = FsMount {
569            tag: "data".to_string(),
570            host_path: PathBuf::from("/mnt/data"),
571            read_only: true,
572        };
573
574        let json = serde_json::to_string(&mount).unwrap();
575        let deserialized: FsMount = serde_json::from_str(&json).unwrap();
576
577        assert_eq!(deserialized.tag, "data");
578        assert_eq!(deserialized.host_path, PathBuf::from("/mnt/data"));
579        assert!(deserialized.read_only);
580    }
581
582    #[test]
583    fn test_entrypoint_serde() {
584        let ep = Entrypoint {
585            executable: "/bin/sh".to_string(),
586            args: vec!["-c".to_string(), "echo hello".to_string()],
587            env: vec![
588                ("PATH".to_string(), "/usr/bin".to_string()),
589                ("HOME".to_string(), "/root".to_string()),
590            ],
591        };
592
593        let json = serde_json::to_string(&ep).unwrap();
594        let deserialized: Entrypoint = serde_json::from_str(&json).unwrap();
595
596        assert_eq!(deserialized.executable, "/bin/sh");
597        assert_eq!(deserialized.args, vec!["-c", "echo hello"]);
598        assert_eq!(deserialized.env.len(), 2);
599    }
600
601    #[test]
602    fn test_instance_spec_deserialize_missing_optional_fields() {
603        let json = r#"{
604            "box_id": "min",
605            "vcpus": 1,
606            "memory_mib": 256,
607            "rootfs_path": "/rootfs",
608            "exec_socket_path": "/exec.sock",
609            "fs_mounts": [],
610            "entrypoint": {"executable": "/bin/sh", "args": [], "env": []},
611            "console_output": null,
612            "workdir": "/"
613        }"#;
614
615        let spec: InstanceSpec = serde_json::from_str(json).unwrap();
616        assert_eq!(spec.box_id, "min");
617        assert!(spec.port_map.is_empty());
618        assert!(spec.user.is_none());
619        assert!(spec.network.is_none());
620        assert!(spec.tee_config.is_none());
621    }
622
623    #[test]
624    fn test_resource_limits_in_spec() {
625        let spec = InstanceSpec {
626            resource_limits: ResourceLimits {
627                pids_limit: Some(100),
628                cpuset_cpus: Some("0-3".to_string()),
629                ..Default::default()
630            },
631            ..Default::default()
632        };
633
634        let json = serde_json::to_string(&spec).unwrap();
635        let deserialized: InstanceSpec = serde_json::from_str(&json).unwrap();
636
637        assert_eq!(deserialized.resource_limits.pids_limit, Some(100));
638        assert_eq!(
639            deserialized.resource_limits.cpuset_cpus,
640            Some("0-3".to_string())
641        );
642    }
643
644    #[test]
645    fn test_vm_metrics_default() {
646        let m = VmMetrics::default();
647        assert!(m.cpu_percent.is_none());
648        assert!(m.memory_bytes.is_none());
649    }
650
651    #[test]
652    fn test_vm_metrics_clone() {
653        let m = VmMetrics {
654            cpu_percent: Some(50.0),
655            memory_bytes: Some(1024 * 1024),
656        };
657        let cloned = m.clone();
658        assert_eq!(cloned.cpu_percent, Some(50.0));
659        assert_eq!(cloned.memory_bytes, Some(1024 * 1024));
660    }
661}