Skip to main content

vm_rs/
config.rs

1//! VM configuration types — everything needed to boot and manage a VM.
2
3#[cfg(unix)]
4use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
5use std::path::PathBuf;
6#[cfg(unix)]
7use std::sync::Arc;
8
9/// Readiness marker written to the serial console when the VM is ready.
10/// The full output is `VMRS_READY <ip_address>`.
11pub const READY_MARKER: &str = "VMRS_READY";
12
13/// Owned VM network endpoint backed by a Unix datagram socket file descriptor.
14#[cfg(unix)]
15#[derive(Debug, Clone)]
16pub struct VmSocketEndpoint(Arc<OwnedFd>);
17
18#[cfg(unix)]
19impl VmSocketEndpoint {
20    pub fn new(fd: OwnedFd) -> Self {
21        Self(Arc::new(fd))
22    }
23
24    pub fn try_clone_owned(&self) -> std::io::Result<OwnedFd> {
25        // SAFETY: `dup` duplicates a valid file descriptor we own through `OwnedFd`.
26        let duplicated = unsafe { libc::dup(self.as_raw_fd()) };
27        if duplicated < 0 {
28            return Err(std::io::Error::last_os_error());
29        }
30        // SAFETY: `dup` returned a new owned file descriptor.
31        Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
32    }
33}
34
35#[cfg(unix)]
36impl AsRawFd for VmSocketEndpoint {
37    fn as_raw_fd(&self) -> RawFd {
38        self.0.as_raw_fd()
39    }
40}
41
42/// Stable identity for a VM monitor process.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct VmmProcess {
45    pid: u32,
46    start_time_ticks: Option<u64>,
47}
48
49impl VmmProcess {
50    pub fn pid(&self) -> u32 {
51        self.pid
52    }
53
54    pub fn start_time_ticks(&self) -> Option<u64> {
55        self.start_time_ticks
56    }
57
58    /// Create a stable VM monitor process identity.
59    ///
60    /// `start_time_ticks` should come from the process start-time field in
61    /// `/proc/<pid>/stat` when available so the identity can detect PID reuse.
62    /// Pass `None` only when the platform cannot provide that information.
63    pub fn new(pid: u32, start_time_ticks: Option<u64>) -> Self {
64        Self {
65            pid,
66            start_time_ticks,
67        }
68    }
69}
70
71/// Everything needed to boot a VM.
72///
73/// Two boot modes are supported:
74///
75/// **Initramfs boot** (fast, stateless):
76///   Set `kernel` + `initramfs` + `cmdline` + `shared_dirs`. Leave `root_disk`
77///   and `seed_iso` as `None`. Config delivered via VirtioFS shared directories.
78///   The initramfs IS the root filesystem (unpacked into RAM by the kernel).
79///
80/// **Cloud-init boot** (traditional, disk-based):
81///   Set `kernel` + `root_disk` + `seed_iso`. Cloud-init reads its config from
82///   the seed ISO (NoCloud datasource). Requires a base disk image.
83#[derive(Debug, Clone)]
84pub struct VmConfig {
85    /// Unique name for this VM.
86    pub name: String,
87    /// Namespace (logical grouping, e.g., stack name).
88    pub namespace: String,
89    /// Path to the kernel image.
90    pub kernel: PathBuf,
91    /// Path to initramfs (required for initramfs boot, optional for cloud-init boot).
92    pub initramfs: Option<PathBuf>,
93    /// Path to the root disk image (None for stateless initramfs boot).
94    pub root_disk: Option<PathBuf>,
95    /// Path to additional data disk (optional).
96    pub data_disk: Option<PathBuf>,
97    /// Path to cloud-init seed ISO (None for initramfs boot with VirtioFS config).
98    pub seed_iso: Option<PathBuf>,
99    /// Number of vCPUs.
100    pub cpus: usize,
101    /// Memory in megabytes.
102    pub memory_mb: usize,
103    /// Network attachments (L2 switch ports or TAP devices).
104    pub networks: Vec<NetworkAttachment>,
105    /// Shared directories (host → guest via VirtioFS).
106    pub shared_dirs: Vec<SharedDir>,
107    /// Path to serial console log file.
108    pub serial_log: PathBuf,
109    /// Kernel command line arguments (optional — platform-specific defaults used if None).
110    pub cmdline: Option<String>,
111    /// Linux network namespace to run the VM in (optional).
112    /// When set, the VMM process is spawned inside `ip netns exec <netns>`.
113    pub netns: Option<String>,
114    /// Enable vsock device for host-guest communication.
115    pub vsock: bool,
116    /// Persistent machine identifier (opaque bytes, driver-specific).
117    pub machine_id: Option<Vec<u8>>,
118    /// Path to EFI variable store for UEFI boot (optional).
119    pub efi_variable_store: Option<PathBuf>,
120    /// Enable Rosetta translation layer (macOS only, Apple Silicon).
121    pub rosetta: bool,
122}
123
124impl VmConfig {
125    /// Validate configuration invariants.
126    pub fn validate(&self) -> Result<(), crate::driver::VmError> {
127        use crate::driver::VmError;
128        if self.name.is_empty() {
129            return Err(VmError::InvalidConfig("VM name must not be empty".into()));
130        }
131        if self.name.len() > 128 {
132            return Err(VmError::InvalidConfig(
133                "VM name must be 128 characters or fewer".into(),
134            ));
135        }
136        if !self
137            .name
138            .chars()
139            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
140        {
141            return Err(VmError::InvalidConfig(
142                "VM name must contain only alphanumeric characters, hyphens, underscores, and dots"
143                    .into(),
144            ));
145        }
146        if self.name.starts_with('.') || self.name.starts_with('-') {
147            return Err(VmError::InvalidConfig(
148                "VM name must not start with '.' or '-'".into(),
149            ));
150        }
151        if self.cpus == 0 {
152            return Err(VmError::InvalidConfig("cpus must be at least 1".into()));
153        }
154        if self.memory_mb == 0 {
155            return Err(VmError::InvalidConfig(
156                "memory_mb must be at least 1".into(),
157            ));
158        }
159        if self.kernel.as_os_str().is_empty() {
160            return Err(VmError::InvalidConfig(
161                "kernel path must not be empty".into(),
162            ));
163        }
164        Ok(())
165    }
166}
167
168/// Network attachment for a VM.
169#[derive(Debug, Clone)]
170pub enum NetworkAttachment {
171    /// Owned socket endpoint for an L2 switch port (macOS).
172    /// The endpoint is the VM's end of a socketpair — the switch holds the other end.
173    #[cfg(unix)]
174    SocketPairFd(VmSocketEndpoint),
175    /// TAP device name (Linux).
176    Tap { name: String, mac: Option<String> },
177}
178
179/// Host directory shared with guest via VirtioFS.
180#[derive(Debug, Clone)]
181pub struct SharedDir {
182    /// Path on the host.
183    pub host_path: PathBuf,
184    /// Mount tag inside the guest.
185    pub tag: String,
186    /// Read-only mount.
187    pub read_only: bool,
188}
189
190/// Handle to a running (or stopped) VM.
191#[derive(Debug, Clone)]
192pub struct VmHandle {
193    /// VM name.
194    pub name: String,
195    /// Namespace.
196    pub namespace: String,
197    /// Current state.
198    pub state: VmState,
199    /// Process identity of the VMM process (Linux: cloud-hypervisor, macOS: not applicable).
200    pub process: Option<VmmProcess>,
201    /// Serial console log path.
202    pub serial_log: PathBuf,
203    /// Persistent machine identifier (opaque bytes, driver-specific).
204    pub machine_id: Option<Vec<u8>>,
205}
206
207/// VM lifecycle state.
208#[derive(Debug, Clone, PartialEq, Eq)]
209#[must_use]
210pub enum VmState {
211    /// VM is being created / booting.
212    Starting,
213    /// VM is executing according to the hypervisor, but guest readiness is not confirmed yet.
214    Running,
215    /// VM is running and has reported readiness.
216    Ready {
217        /// IP address assigned to the VM.
218        ip: String,
219    },
220    /// VM is paused (execution suspended, state preserved).
221    Paused,
222    /// VM was stopped gracefully.
223    Stopped,
224    /// VM failed to boot or crashed.
225    Failed {
226        /// Human-readable failure reason.
227        reason: String,
228    },
229}
230
231impl VmState {
232    /// Returns true when the hypervisor reports the VM as executing.
233    pub fn is_running(&self) -> bool {
234        matches!(self, Self::Running | Self::Ready { .. })
235    }
236
237    /// Returns true when the guest has emitted the readiness marker.
238    pub fn is_ready(&self) -> bool {
239        matches!(self, Self::Ready { .. })
240    }
241
242    /// Returns the guest IP address once readiness has been confirmed.
243    pub fn ip(&self) -> Option<&str> {
244        match self {
245            Self::Ready { ip } => Some(ip),
246            _ => None,
247        }
248    }
249}
250
251impl std::fmt::Display for VmState {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        match self {
254            VmState::Starting => write!(f, "starting"),
255            VmState::Running => write!(f, "running"),
256            VmState::Ready { ip } => write!(f, "ready ({})", ip),
257            VmState::Paused => write!(f, "paused"),
258            VmState::Stopped => write!(f, "stopped"),
259            VmState::Failed { reason } => write!(f, "failed: {}", reason),
260        }
261    }
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn vm_state_display_starting() {
270        assert_eq!(VmState::Starting.to_string(), "starting");
271    }
272
273    #[test]
274    fn vm_state_display_running() {
275        let state = VmState::Ready {
276            ip: "10.0.1.2".into(),
277        };
278        assert_eq!(state.to_string(), "ready (10.0.1.2)");
279    }
280
281    #[test]
282    fn vm_state_display_running_without_ready_ip() {
283        assert_eq!(VmState::Running.to_string(), "running");
284    }
285
286    #[test]
287    fn vm_state_display_stopped() {
288        assert_eq!(VmState::Stopped.to_string(), "stopped");
289    }
290
291    #[test]
292    fn vm_state_display_failed() {
293        let state = VmState::Failed {
294            reason: "timeout".into(),
295        };
296        assert_eq!(state.to_string(), "failed: timeout");
297    }
298
299    #[test]
300    fn vm_state_equality() {
301        assert_eq!(VmState::Starting, VmState::Starting);
302        assert_eq!(VmState::Stopped, VmState::Stopped);
303        assert_ne!(VmState::Starting, VmState::Stopped);
304    }
305
306    #[test]
307    fn vm_state_helper_methods() {
308        let ready = VmState::Ready {
309            ip: "10.0.1.2".into(),
310        };
311        assert!(VmState::Running.is_running());
312        assert!(!VmState::Running.is_ready());
313        assert!(ready.is_running());
314        assert!(ready.is_ready());
315        assert_eq!(ready.ip(), Some("10.0.1.2"));
316        assert_eq!(VmState::Starting.ip(), None);
317    }
318
319    #[test]
320    fn ready_marker_value() {
321        assert_eq!(READY_MARKER, "VMRS_READY");
322    }
323
324    #[test]
325    fn vm_state_display_paused() {
326        assert_eq!(VmState::Paused.to_string(), "paused");
327    }
328
329    fn test_vm_config(name: &str) -> VmConfig {
330        VmConfig {
331            name: name.into(),
332            namespace: "test".into(),
333            kernel: std::path::PathBuf::from("/tmp/kernel"),
334            initramfs: None,
335            root_disk: None,
336            data_disk: None,
337            seed_iso: None,
338            cpus: 1,
339            memory_mb: 256,
340            networks: vec![],
341            shared_dirs: vec![],
342            serial_log: std::path::PathBuf::from("/tmp/serial.log"),
343            cmdline: None,
344            netns: None,
345            vsock: false,
346            machine_id: None,
347            efi_variable_store: None,
348            rosetta: false,
349        }
350    }
351
352    #[test]
353    fn validate_rejects_empty_name() {
354        let config = test_vm_config("");
355        let err = config
356            .validate()
357            .expect_err("empty VM name should fail validation")
358            .to_string();
359        assert!(err.contains("empty"), "expected 'empty' in error: {}", err);
360    }
361
362    #[test]
363    fn validate_rejects_path_traversal() {
364        let config = test_vm_config("../etc");
365        let err = config
366            .validate()
367            .expect_err("path traversal characters should fail validation")
368            .to_string();
369        assert!(
370            err.contains("alphanumeric") || err.contains("characters"),
371            "expected name validation error: {}",
372            err
373        );
374    }
375
376    #[test]
377    fn validate_rejects_zero_cpus() {
378        let mut config = test_vm_config("good-name");
379        config.cpus = 0;
380        let err = config
381            .validate()
382            .expect_err("zero CPUs should fail validation")
383            .to_string();
384        assert!(err.contains("cpus"), "expected 'cpus' in error: {}", err);
385    }
386
387    #[test]
388    fn validate_accepts_valid_config() {
389        let config = test_vm_config("my-vm.01");
390        config
391            .validate()
392            .expect("valid config should pass validation");
393    }
394}