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, Default)]
70pub struct VmVolume {
71 pub host: String,
73 pub guest: String,
75 pub size: String,
77 pub read_only: bool,
79}
80
81#[derive(Debug, Clone)]
83pub struct VmFile {
84 pub name: String,
86 pub content: String,
88 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#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct VmNetworkInfo {
111 pub guest_ip: String,
113 pub gateway_ip: String,
115 pub subnet_cidr: String,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
128pub enum GuestChannelInfo {
129 Vsock {
131 cid: u32,
133 port: u32,
135 },
136 UnixSocket {
138 path: PathBuf,
140 },
141}
142
143#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
167pub enum VmStatus {
168 Stopped,
170 Starting,
172 Running,
174 Paused,
176 Failed { reason: String },
178}
179
180#[derive(Debug, Clone, Default)]
185pub struct VmCapabilities {
186 pub pause_resume: bool,
188 pub snapshots: bool,
190 pub vsock: bool,
192 pub tap_networking: bool,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct VmInfo {
199 pub id: VmId,
201 pub name: String,
203 pub status: VmStatus,
205 #[serde(default)]
207 pub guest_ip: Option<String>,
208 pub cpus: u32,
210 pub memory_mib: u32,
212 #[serde(default)]
214 pub profile: Option<String>,
215 #[serde(default)]
217 pub revision: Option<String>,
218 #[serde(default)]
220 pub flake_ref: Option<String>,
221 #[serde(default)]
223 pub ports: Vec<VmPortMapping>,
224}
225
226pub trait VmBackend: Send + Sync {
249 fn name(&self) -> &str;
251
252 fn capabilities(&self) -> VmCapabilities;
254
255 fn start(&self, config: &VmStartConfig) -> Result<VmId>;
259
260 fn stop(&self, id: &VmId) -> Result<()>;
262
263 fn stop_all(&self) -> Result<()>;
265
266 fn status(&self, id: &VmId) -> Result<VmStatus>;
268
269 fn list(&self) -> Result<Vec<VmInfo>>;
271
272 fn logs(&self, id: &VmId, lines: u32, hypervisor: bool) -> Result<String>;
277
278 fn is_available(&self) -> Result<bool>;
280
281 fn install(&self) -> Result<()>;
283
284 fn network_info(&self, _id: &VmId) -> Result<VmNetworkInfo> {
288 anyhow::bail!("{} does not provide network info", self.name())
289 }
290
291 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}