1use anyhow::Result;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::path::PathBuf;
5
6#[derive(Debug, Clone, Default)]
30pub struct VmStartConfig {
31 pub name: String,
33 pub rootfs_path: String,
35 pub kernel_path: Option<String>,
37 pub initrd_path: Option<String>,
39 pub revision_hash: String,
41 pub flake_ref: String,
43 pub profile: Option<String>,
45 pub cpus: u32,
47 pub memory_mib: u32,
49 pub ports: Vec<VmPortMapping>,
51 pub volumes: Vec<VmVolume>,
53 pub config_files: Vec<VmFile>,
55 pub secret_files: Vec<VmFile>,
57 pub runner_dir: Option<String>,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct VmPortMapping {
64 pub host: u16,
65 pub guest: u16,
66}
67
68#[derive(Debug, Clone)]
70pub struct VmVolume {
71 pub host: String,
73 pub guest: String,
75 pub size: String,
77}
78
79#[derive(Debug, Clone)]
81pub struct VmFile {
82 pub name: String,
84 pub content: String,
86 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#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct VmNetworkInfo {
109 pub guest_ip: String,
111 pub gateway_ip: String,
113 pub subnet_cidr: String,
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
126pub enum GuestChannelInfo {
127 Vsock {
129 cid: u32,
131 port: u32,
133 },
134 UnixSocket {
136 path: PathBuf,
138 },
139}
140
141#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
165pub enum VmStatus {
166 Stopped,
168 Starting,
170 Running,
172 Paused,
174 Failed { reason: String },
176}
177
178#[derive(Debug, Clone, Default)]
183pub struct VmCapabilities {
184 pub pause_resume: bool,
186 pub snapshots: bool,
188 pub vsock: bool,
190 pub tap_networking: bool,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct VmInfo {
197 pub id: VmId,
199 pub name: String,
201 pub status: VmStatus,
203 #[serde(default)]
205 pub guest_ip: Option<String>,
206 pub cpus: u32,
208 pub memory_mib: u32,
210 #[serde(default)]
212 pub profile: Option<String>,
213 #[serde(default)]
215 pub revision: Option<String>,
216 #[serde(default)]
218 pub flake_ref: Option<String>,
219 #[serde(default)]
221 pub ports: Vec<VmPortMapping>,
222}
223
224pub trait VmBackend: Send + Sync {
247 fn name(&self) -> &str;
249
250 fn capabilities(&self) -> VmCapabilities;
252
253 fn start(&self, config: &VmStartConfig) -> Result<VmId>;
257
258 fn stop(&self, id: &VmId) -> Result<()>;
260
261 fn stop_all(&self) -> Result<()>;
263
264 fn status(&self, id: &VmId) -> Result<VmStatus>;
266
267 fn list(&self) -> Result<Vec<VmInfo>>;
269
270 fn logs(&self, id: &VmId, lines: u32, hypervisor: bool) -> Result<String>;
275
276 fn is_available(&self) -> Result<bool>;
278
279 fn install(&self) -> Result<()>;
281
282 fn network_info(&self, _id: &VmId) -> Result<VmNetworkInfo> {
286 anyhow::bail!("{} does not provide network info", self.name())
287 }
288
289 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}