Skip to main content

mvm_core/
vm_backend.rs

1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::path::PathBuf;
5
6// ---------------------------------------------------------------------------
7// VmStartConfig — backend-agnostic VM launch configuration
8// ---------------------------------------------------------------------------
9
10/// Backend-agnostic configuration describing *what* to run.
11///
12/// Callers build a `VmStartConfig` from CLI arguments and build output.
13/// Each backend converts this into its own internal config type, filling
14/// in backend-specific details (Firecracker: kernel path, TAP slot;
15/// Apple Container: VZ block attachment; Docker: container image).
16///
17/// # Examples
18///
19/// ```ignore
20/// let config = VmStartConfig {
21///     name: "my-vm".into(),
22///     rootfs_path: "/nix/store/.../rootfs.ext4".into(),
23///     cpus: 2,
24///     memory_mib: 512,
25///     ..Default::default()
26/// };
27/// backend.start(&config)?;
28/// ```
29#[derive(Debug, Clone, Default)]
30pub struct VmStartConfig {
31    /// VM name (user-provided or auto-generated).
32    pub name: String,
33    /// Absolute path to the root filesystem (ext4 image).
34    pub rootfs_path: String,
35    /// Absolute path to the kernel image (Firecracker needs this; others may ignore).
36    pub kernel_path: Option<String>,
37    /// Absolute path to the initial ramdisk (NixOS stage-1), if present.
38    pub initrd_path: Option<String>,
39    /// Nix store revision hash.
40    pub revision_hash: String,
41    /// Original flake reference (for display / status).
42    pub flake_ref: String,
43    /// Flake profile name (e.g. "worker", "gateway").
44    pub profile: Option<String>,
45    /// Number of vCPUs.
46    pub cpus: u32,
47    /// Memory in MiB.
48    pub memory_mib: u32,
49    /// Declared port mappings (host:guest) for forwarding and guest config.
50    pub ports: Vec<VmPortMapping>,
51    /// Extra volumes to mount in the guest.
52    pub volumes: Vec<VmVolume>,
53    /// Extra config files to make available to the guest.
54    pub config_files: Vec<VmFile>,
55    /// Secret files (written with restricted permissions).
56    pub secret_files: Vec<VmFile>,
57    /// Directory containing microvm.nix runner scripts (microvm.nix backend only).
58    pub runner_dir: Option<String>,
59}
60
61/// A host:guest port mapping, backend-agnostic.
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct VmPortMapping {
64    pub host: u16,
65    pub guest: u16,
66}
67
68/// A volume to mount in the guest, backend-agnostic.
69#[derive(Debug, Clone)]
70pub struct VmVolume {
71    /// Host-side path or identifier.
72    pub host: String,
73    /// Mount point inside the guest.
74    pub guest: String,
75    /// Size hint (e.g. "1G"). Backend may ignore.
76    pub size: String,
77}
78
79/// A file to inject into the guest (config or secret).
80#[derive(Debug, Clone)]
81pub struct VmFile {
82    /// Filename inside the guest.
83    pub name: String,
84    /// File contents (inline).
85    pub content: String,
86    /// Unix permissions (octal). Config: 0o444, secrets: 0o400.
87    pub mode: u32,
88}
89
90impl Default for VmFile {
91    fn default() -> Self {
92        Self {
93            name: String::new(),
94            content: String::new(),
95            mode: 0o444,
96        }
97    }
98}
99
100// ---------------------------------------------------------------------------
101// VmNetworkInfo — backend-reported network state
102// ---------------------------------------------------------------------------
103
104/// Network information for a running VM, reported by the backend.
105///
106/// Replaces hardcoded IPs (e.g. `172.16.0.2`) with backend-provided values.
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct VmNetworkInfo {
109    /// IP address assigned to the guest.
110    pub guest_ip: String,
111    /// Gateway IP (host-side endpoint).
112    pub gateway_ip: String,
113    /// Subnet in CIDR notation (e.g. "172.16.0.0/24").
114    pub subnet_cidr: String,
115}
116
117// ---------------------------------------------------------------------------
118// GuestChannel — backend-agnostic guest communication
119// ---------------------------------------------------------------------------
120
121/// Describes how to connect to the guest agent for a given VM.
122///
123/// Firecracker and Apple Containers use vsock; Docker uses a unix socket
124/// mounted as a volume.
125#[derive(Debug, Clone, Serialize, Deserialize)]
126pub enum GuestChannelInfo {
127    /// Vsock connection (Firecracker, Apple Container).
128    Vsock {
129        /// Context ID (Firecracker assigns per-VM; Apple Container auto-assigns).
130        cid: u32,
131        /// Port the guest agent listens on.
132        port: u32,
133    },
134    /// Unix socket path (Docker — mounted as a volume in the container).
135    UnixSocket {
136        /// Path to the socket on the host.
137        path: PathBuf,
138    },
139}
140
141/// Unique identifier for a VM managed by a backend.
142#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
143pub struct VmId(pub String);
144
145impl fmt::Display for VmId {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.write_str(&self.0)
148    }
149}
150
151impl From<String> for VmId {
152    fn from(s: String) -> Self {
153        Self(s)
154    }
155}
156
157impl From<&str> for VmId {
158    fn from(s: &str) -> Self {
159        Self(s.to_string())
160    }
161}
162
163/// Runtime status of a VM.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub enum VmStatus {
166    /// VM exists but is not running.
167    Stopped,
168    /// VM is booting / initializing.
169    Starting,
170    /// VM is running and accepting work.
171    Running,
172    /// VM vCPUs are paused (Firecracker warm state).
173    Paused,
174    /// VM is in an error state.
175    Failed { reason: String },
176}
177
178/// Capabilities that a backend may or may not support.
179///
180/// Used by consumers to check what operations are available before
181/// attempting them. For example, WASM backends won't support snapshots.
182#[derive(Debug, Clone, Default)]
183pub struct VmCapabilities {
184    /// Can pause/resume vCPUs (Firecracker: yes, WASM: no).
185    pub pause_resume: bool,
186    /// Can create/restore memory snapshots (Firecracker: yes, Docker: checkpoints, WASM: no).
187    pub snapshots: bool,
188    /// Supports vsock guest communication (Firecracker: yes, others: typically no).
189    pub vsock: bool,
190    /// Supports TAP-based networking (Firecracker/Docker: yes, WASM: no).
191    pub tap_networking: bool,
192}
193
194/// Summary info for a managed VM, returned by [`VmBackend::list`].
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct VmInfo {
197    /// Backend-assigned VM identifier.
198    pub id: VmId,
199    /// Human-readable name.
200    pub name: String,
201    /// Current status.
202    pub status: VmStatus,
203    /// Guest IP address, if networking is configured.
204    #[serde(default)]
205    pub guest_ip: Option<String>,
206    /// Number of vCPUs.
207    pub cpus: u32,
208    /// Memory in MiB.
209    pub memory_mib: u32,
210    /// Flake profile name (e.g. "worker", "gateway").
211    #[serde(default)]
212    pub profile: Option<String>,
213    /// Nix store revision hash.
214    #[serde(default)]
215    pub revision: Option<String>,
216    /// Original flake reference.
217    #[serde(default)]
218    pub flake_ref: Option<String>,
219    /// Active port forwardings (host:guest).
220    #[serde(default)]
221    pub ports: Vec<VmPortMapping>,
222}
223
224/// Backend-agnostic VM lifecycle trait.
225///
226/// Defines the minimal interface for starting, stopping, inspecting, and
227/// listing VMs. All backends accept [`VmStartConfig`] which describes
228/// *what* to run; each backend translates it into backend-specific actions.
229///
230/// This trait lives in `mvm-core` so it has no runtime dependencies.
231/// Implementations live in `mvm-runtime` (Firecracker, Apple Container)
232/// or future crates (Docker).
233///
234/// # Examples
235///
236/// ```ignore
237/// use mvm_core::vm_backend::{VmBackend, VmStartConfig};
238///
239/// fn run_vm(backend: &impl VmBackend, config: &VmStartConfig) -> anyhow::Result<()> {
240///     let id = backend.start(config)?;
241///     println!("Started VM: {}", id);
242///     backend.stop(&id)?;
243///     Ok(())
244/// }
245/// ```
246pub trait VmBackend: Send + Sync {
247    /// Human-readable backend name (e.g., "firecracker", "apple-container", "docker").
248    fn name(&self) -> &str;
249
250    /// Capabilities supported by this backend.
251    fn capabilities(&self) -> VmCapabilities;
252
253    /// Start a new VM from the given configuration.
254    ///
255    /// Returns the [`VmId`] assigned to the running VM.
256    fn start(&self, config: &VmStartConfig) -> Result<VmId>;
257
258    /// Stop a running VM.
259    fn stop(&self, id: &VmId) -> Result<()>;
260
261    /// Stop all VMs managed by this backend.
262    fn stop_all(&self) -> Result<()>;
263
264    /// Query the status of a specific VM.
265    fn status(&self, id: &VmId) -> Result<VmStatus>;
266
267    /// List all VMs managed by this backend.
268    fn list(&self) -> Result<Vec<VmInfo>>;
269
270    /// Retrieve log output from a VM.
271    ///
272    /// `lines` controls how many recent lines to return.
273    /// `hypervisor` selects hypervisor logs vs guest console logs.
274    fn logs(&self, id: &VmId, lines: u32, hypervisor: bool) -> Result<String>;
275
276    /// Check whether the backend runtime is installed and available.
277    fn is_available(&self) -> Result<bool>;
278
279    /// Install or download the backend runtime (if supported).
280    fn install(&self) -> Result<()>;
281
282    /// Return network information for a running VM.
283    ///
284    /// Backends that don't support networking may return an error.
285    fn network_info(&self, _id: &VmId) -> Result<VmNetworkInfo> {
286        anyhow::bail!("{} does not provide network info", self.name())
287    }
288
289    /// Return guest communication channel info for a running VM.
290    ///
291    /// Backends that don't support guest communication may return an error.
292    fn guest_channel_info(&self, _id: &VmId) -> Result<GuestChannelInfo> {
293        anyhow::bail!("{} does not provide guest channel info", self.name())
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[test]
302    fn test_vm_id_display() {
303        let id = VmId("my-vm".to_string());
304        assert_eq!(format!("{id}"), "my-vm");
305    }
306
307    #[test]
308    fn test_vm_id_from_str() {
309        let id: VmId = "test".into();
310        assert_eq!(id.0, "test");
311    }
312
313    #[test]
314    fn test_vm_id_from_string() {
315        let id: VmId = String::from("test").into();
316        assert_eq!(id.0, "test");
317    }
318
319    #[test]
320    fn test_vm_id_serde_roundtrip() {
321        let id = VmId("vm-001".to_string());
322        let json = serde_json::to_string(&id).unwrap();
323        let parsed: VmId = serde_json::from_str(&json).unwrap();
324        assert_eq!(parsed, id);
325    }
326
327    #[test]
328    fn test_vm_status_serde_roundtrip() {
329        let statuses = vec![
330            VmStatus::Stopped,
331            VmStatus::Starting,
332            VmStatus::Running,
333            VmStatus::Paused,
334            VmStatus::Failed {
335                reason: "oom".to_string(),
336            },
337        ];
338        for status in statuses {
339            let json = serde_json::to_string(&status).unwrap();
340            let parsed: VmStatus = serde_json::from_str(&json).unwrap();
341            assert_eq!(parsed, status);
342        }
343    }
344
345    #[test]
346    fn test_vm_capabilities_default() {
347        let caps = VmCapabilities::default();
348        assert!(!caps.pause_resume);
349        assert!(!caps.snapshots);
350        assert!(!caps.vsock);
351        assert!(!caps.tap_networking);
352    }
353
354    #[test]
355    fn test_vm_info_serde_roundtrip() {
356        let info = VmInfo {
357            id: VmId("vm-1".to_string()),
358            name: "worker-1".to_string(),
359            status: VmStatus::Running,
360            guest_ip: Some("172.16.0.2".to_string()),
361            cpus: 2,
362            memory_mib: 512,
363            profile: Some("worker".to_string()),
364            revision: Some("abc123".to_string()),
365            flake_ref: Some("/home/user/project".to_string()),
366            ports: vec![VmPortMapping {
367                host: 8888,
368                guest: 8080,
369            }],
370        };
371        let json = serde_json::to_string(&info).unwrap();
372        let parsed: VmInfo = serde_json::from_str(&json).unwrap();
373        assert_eq!(parsed.id, info.id);
374        assert_eq!(parsed.name, "worker-1");
375        assert_eq!(parsed.cpus, 2);
376        assert_eq!(parsed.memory_mib, 512);
377        assert_eq!(parsed.guest_ip.as_deref(), Some("172.16.0.2"));
378        assert_eq!(parsed.profile.as_deref(), Some("worker"));
379        assert_eq!(parsed.revision.as_deref(), Some("abc123"));
380        assert_eq!(parsed.flake_ref.as_deref(), Some("/home/user/project"));
381    }
382
383    #[test]
384    fn test_vm_info_serde_without_optional_fields() {
385        let json = r#"{"id":"vm-1","name":"w","status":"Running","cpus":1,"memory_mib":256}"#;
386        let parsed: VmInfo = serde_json::from_str(json).unwrap();
387        assert_eq!(parsed.name, "w");
388        assert!(parsed.guest_ip.is_none());
389        assert!(parsed.profile.is_none());
390        assert!(parsed.revision.is_none());
391        assert!(parsed.flake_ref.is_none());
392    }
393
394    #[test]
395    fn test_vm_start_config_default() {
396        let config = VmStartConfig::default();
397        assert!(config.name.is_empty());
398        assert!(config.rootfs_path.is_empty());
399        assert!(config.kernel_path.is_none());
400        assert!(config.initrd_path.is_none());
401        assert_eq!(config.cpus, 0);
402        assert_eq!(config.memory_mib, 0);
403        assert!(config.ports.is_empty());
404        assert!(config.volumes.is_empty());
405        assert!(config.config_files.is_empty());
406        assert!(config.secret_files.is_empty());
407    }
408
409    #[test]
410    fn test_vm_port_mapping_serde_roundtrip() {
411        let mapping = VmPortMapping {
412            host: 8080,
413            guest: 80,
414        };
415        let json = serde_json::to_string(&mapping).unwrap();
416        let parsed: VmPortMapping = serde_json::from_str(&json).unwrap();
417        assert_eq!(parsed.host, 8080);
418        assert_eq!(parsed.guest, 80);
419    }
420
421    #[test]
422    fn test_vm_file_default() {
423        let file = VmFile::default();
424        assert!(file.name.is_empty());
425        assert!(file.content.is_empty());
426        assert_eq!(file.mode, 0o444);
427    }
428
429    #[test]
430    fn test_vm_network_info_serde_roundtrip() {
431        let info = VmNetworkInfo {
432            guest_ip: "172.16.0.2".to_string(),
433            gateway_ip: "172.16.0.1".to_string(),
434            subnet_cidr: "172.16.0.0/24".to_string(),
435        };
436        let json = serde_json::to_string(&info).unwrap();
437        let parsed: VmNetworkInfo = serde_json::from_str(&json).unwrap();
438        assert_eq!(parsed.guest_ip, "172.16.0.2");
439        assert_eq!(parsed.gateway_ip, "172.16.0.1");
440        assert_eq!(parsed.subnet_cidr, "172.16.0.0/24");
441    }
442
443    #[test]
444    fn test_guest_channel_info_vsock_serde_roundtrip() {
445        let info = GuestChannelInfo::Vsock { cid: 3, port: 52 };
446        let json = serde_json::to_string(&info).unwrap();
447        let parsed: GuestChannelInfo = serde_json::from_str(&json).unwrap();
448        assert!(matches!(
449            parsed,
450            GuestChannelInfo::Vsock { cid: 3, port: 52 }
451        ));
452    }
453
454    #[test]
455    fn test_guest_channel_info_unix_socket_serde_roundtrip() {
456        let info = GuestChannelInfo::UnixSocket {
457            path: PathBuf::from("/tmp/guest.sock"),
458        };
459        let json = serde_json::to_string(&info).unwrap();
460        let parsed: GuestChannelInfo = serde_json::from_str(&json).unwrap();
461        match parsed {
462            GuestChannelInfo::UnixSocket { path } => {
463                assert_eq!(path, PathBuf::from("/tmp/guest.sock"));
464            }
465            _ => panic!("Expected UnixSocket variant"),
466        }
467    }
468}