use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
#[derive(Debug, Clone, Default)]
pub struct VmStartConfig {
pub name: String,
pub rootfs_path: String,
pub kernel_path: Option<String>,
pub initrd_path: Option<String>,
pub revision_hash: String,
pub flake_ref: String,
pub profile: Option<String>,
pub cpus: u32,
pub memory_mib: u32,
pub ports: Vec<VmPortMapping>,
pub volumes: Vec<VmVolume>,
pub config_files: Vec<VmFile>,
pub secret_files: Vec<VmFile>,
pub runner_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VmPortMapping {
pub host: u16,
pub guest: u16,
}
#[derive(Debug, Clone, Default)]
pub struct VmVolume {
pub host: String,
pub guest: String,
pub size: String,
pub read_only: bool,
}
#[derive(Debug, Clone)]
pub struct VmFile {
pub name: String,
pub content: String,
pub mode: u32,
}
impl Default for VmFile {
fn default() -> Self {
Self {
name: String::new(),
content: String::new(),
mode: 0o444,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VmNetworkInfo {
pub guest_ip: String,
pub gateway_ip: String,
pub subnet_cidr: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum GuestChannelInfo {
Vsock {
cid: u32,
port: u32,
},
UnixSocket {
path: PathBuf,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct VmId(pub String);
impl fmt::Display for VmId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<String> for VmId {
fn from(s: String) -> Self {
Self(s)
}
}
impl From<&str> for VmId {
fn from(s: &str) -> Self {
Self(s.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum VmStatus {
Stopped,
Starting,
Running,
Paused,
Failed { reason: String },
}
#[derive(Debug, Clone, Default)]
pub struct VmCapabilities {
pub pause_resume: bool,
pub snapshots: bool,
pub vsock: bool,
pub tap_networking: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VmInfo {
pub id: VmId,
pub name: String,
pub status: VmStatus,
#[serde(default)]
pub guest_ip: Option<String>,
pub cpus: u32,
pub memory_mib: u32,
#[serde(default)]
pub profile: Option<String>,
#[serde(default)]
pub revision: Option<String>,
#[serde(default)]
pub flake_ref: Option<String>,
#[serde(default)]
pub ports: Vec<VmPortMapping>,
}
pub trait VmBackend: Send + Sync {
fn name(&self) -> &str;
fn capabilities(&self) -> VmCapabilities;
fn start(&self, config: &VmStartConfig) -> Result<VmId>;
fn stop(&self, id: &VmId) -> Result<()>;
fn stop_all(&self) -> Result<()>;
fn status(&self, id: &VmId) -> Result<VmStatus>;
fn list(&self) -> Result<Vec<VmInfo>>;
fn logs(&self, id: &VmId, lines: u32, hypervisor: bool) -> Result<String>;
fn is_available(&self) -> Result<bool>;
fn install(&self) -> Result<()>;
fn network_info(&self, _id: &VmId) -> Result<VmNetworkInfo> {
anyhow::bail!("{} does not provide network info", self.name())
}
fn guest_channel_info(&self, _id: &VmId) -> Result<GuestChannelInfo> {
anyhow::bail!("{} does not provide guest channel info", self.name())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_vm_id_display() {
let id = VmId("my-vm".to_string());
assert_eq!(format!("{id}"), "my-vm");
}
#[test]
fn test_vm_id_from_str() {
let id: VmId = "test".into();
assert_eq!(id.0, "test");
}
#[test]
fn test_vm_id_from_string() {
let id: VmId = String::from("test").into();
assert_eq!(id.0, "test");
}
#[test]
fn test_vm_id_serde_roundtrip() {
let id = VmId("vm-001".to_string());
let json = serde_json::to_string(&id).unwrap();
let parsed: VmId = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, id);
}
#[test]
fn test_vm_status_serde_roundtrip() {
let statuses = vec![
VmStatus::Stopped,
VmStatus::Starting,
VmStatus::Running,
VmStatus::Paused,
VmStatus::Failed {
reason: "oom".to_string(),
},
];
for status in statuses {
let json = serde_json::to_string(&status).unwrap();
let parsed: VmStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, status);
}
}
#[test]
fn test_vm_capabilities_default() {
let caps = VmCapabilities::default();
assert!(!caps.pause_resume);
assert!(!caps.snapshots);
assert!(!caps.vsock);
assert!(!caps.tap_networking);
}
#[test]
fn test_vm_info_serde_roundtrip() {
let info = VmInfo {
id: VmId("vm-1".to_string()),
name: "worker-1".to_string(),
status: VmStatus::Running,
guest_ip: Some("172.16.0.2".to_string()),
cpus: 2,
memory_mib: 512,
profile: Some("worker".to_string()),
revision: Some("abc123".to_string()),
flake_ref: Some("/home/user/project".to_string()),
ports: vec![VmPortMapping {
host: 8888,
guest: 8080,
}],
};
let json = serde_json::to_string(&info).unwrap();
let parsed: VmInfo = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.id, info.id);
assert_eq!(parsed.name, "worker-1");
assert_eq!(parsed.cpus, 2);
assert_eq!(parsed.memory_mib, 512);
assert_eq!(parsed.guest_ip.as_deref(), Some("172.16.0.2"));
assert_eq!(parsed.profile.as_deref(), Some("worker"));
assert_eq!(parsed.revision.as_deref(), Some("abc123"));
assert_eq!(parsed.flake_ref.as_deref(), Some("/home/user/project"));
}
#[test]
fn test_vm_info_serde_without_optional_fields() {
let json = r#"{"id":"vm-1","name":"w","status":"Running","cpus":1,"memory_mib":256}"#;
let parsed: VmInfo = serde_json::from_str(json).unwrap();
assert_eq!(parsed.name, "w");
assert!(parsed.guest_ip.is_none());
assert!(parsed.profile.is_none());
assert!(parsed.revision.is_none());
assert!(parsed.flake_ref.is_none());
}
#[test]
fn test_vm_start_config_default() {
let config = VmStartConfig::default();
assert!(config.name.is_empty());
assert!(config.rootfs_path.is_empty());
assert!(config.kernel_path.is_none());
assert!(config.initrd_path.is_none());
assert_eq!(config.cpus, 0);
assert_eq!(config.memory_mib, 0);
assert!(config.ports.is_empty());
assert!(config.volumes.is_empty());
assert!(config.config_files.is_empty());
assert!(config.secret_files.is_empty());
}
#[test]
fn test_vm_port_mapping_serde_roundtrip() {
let mapping = VmPortMapping {
host: 8080,
guest: 80,
};
let json = serde_json::to_string(&mapping).unwrap();
let parsed: VmPortMapping = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.host, 8080);
assert_eq!(parsed.guest, 80);
}
#[test]
fn test_vm_file_default() {
let file = VmFile::default();
assert!(file.name.is_empty());
assert!(file.content.is_empty());
assert_eq!(file.mode, 0o444);
}
#[test]
fn test_vm_network_info_serde_roundtrip() {
let info = VmNetworkInfo {
guest_ip: "172.16.0.2".to_string(),
gateway_ip: "172.16.0.1".to_string(),
subnet_cidr: "172.16.0.0/24".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
let parsed: VmNetworkInfo = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.guest_ip, "172.16.0.2");
assert_eq!(parsed.gateway_ip, "172.16.0.1");
assert_eq!(parsed.subnet_cidr, "172.16.0.0/24");
}
#[test]
fn test_guest_channel_info_vsock_serde_roundtrip() {
let info = GuestChannelInfo::Vsock { cid: 3, port: 52 };
let json = serde_json::to_string(&info).unwrap();
let parsed: GuestChannelInfo = serde_json::from_str(&json).unwrap();
assert!(matches!(
parsed,
GuestChannelInfo::Vsock { cid: 3, port: 52 }
));
}
#[test]
fn test_guest_channel_info_unix_socket_serde_roundtrip() {
let info = GuestChannelInfo::UnixSocket {
path: PathBuf::from("/tmp/guest.sock"),
};
let json = serde_json::to_string(&info).unwrap();
let parsed: GuestChannelInfo = serde_json::from_str(&json).unwrap();
match parsed {
GuestChannelInfo::UnixSocket { path } => {
assert_eq!(path, PathBuf::from("/tmp/guest.sock"));
}
_ => panic!("Expected UnixSocket variant"),
}
}
}