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