use crate::error::{CoreError, Result};
use crate::machine::{MachineInfo, MachineState};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::io::Write;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistedMachine {
pub name: String,
pub cpus: u32,
pub memory_mb: u64,
pub disk_gb: u64,
#[serde(default)]
pub kernel: Option<String>,
#[serde(default)]
pub cmdline: Option<String>,
#[serde(default)]
pub block_devices: Vec<crate::vm::BlockDeviceConfig>,
#[serde(default)]
pub distro: Option<String>,
#[serde(default)]
pub distro_version: Option<String>,
#[serde(default)]
pub disk_path: Option<String>,
#[serde(default)]
pub ssh_key_path: Option<String>,
#[serde(default)]
pub ip_address: Option<String>,
pub state: PersistedState,
pub vm_id: String,
#[serde(default = "default_created_at")]
pub created_at: DateTime<Utc>,
}
fn default_created_at() -> DateTime<Utc> {
Utc::now()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum PersistedState {
#[default]
Created,
Running,
Stopped,
}
impl PersistedState {
#[must_use]
pub const fn needs_recovery(self) -> bool {
matches!(self, Self::Running)
}
}
impl From<MachineState> for PersistedState {
fn from(state: MachineState) -> Self {
match state {
MachineState::Created => Self::Created,
MachineState::Starting | MachineState::Running => Self::Running,
MachineState::Stopping | MachineState::Stopped => Self::Stopped,
}
}
}
impl From<PersistedState> for MachineState {
fn from(state: PersistedState) -> Self {
match state {
PersistedState::Created => Self::Created,
PersistedState::Running => Self::Stopped,
PersistedState::Stopped => Self::Stopped,
}
}
}
impl From<&MachineInfo> for PersistedMachine {
fn from(info: &MachineInfo) -> Self {
Self {
name: info.name.clone(),
cpus: info.cpus,
memory_mb: info.memory_mb,
disk_gb: info.disk_gb,
kernel: info.kernel.clone(),
cmdline: info.cmdline.clone(),
block_devices: info.block_devices.clone(),
distro: info.distro.clone(),
distro_version: info.distro_version.clone(),
disk_path: info
.disk_path
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
ssh_key_path: info
.ssh_key_path
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
ip_address: info.ip_address.clone(),
state: info.state.into(),
vm_id: info.vm_id.to_string(),
created_at: info.created_at,
}
}
}
fn atomic_write(path: &std::path::Path, data: &[u8]) -> Result<()> {
let dir = path
.parent()
.ok_or_else(|| CoreError::config("config path has no parent directory"))?;
let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
tmp.write_all(data)?;
tmp.flush()?;
tmp.persist(path).map_err(|e| {
std::io::Error::new(
e.error.kind(),
format!("failed to persist to '{}': {}", path.display(), e.error),
)
})?;
Ok(())
}
pub struct MachinePersistence {
base_dir: PathBuf,
}
impl MachinePersistence {
pub fn new(base_dir: impl Into<PathBuf>) -> Self {
Self {
base_dir: base_dir.into(),
}
}
fn config_path(&self, name: &str) -> PathBuf {
self.base_dir.join(name).join("config.toml")
}
fn machine_dir(&self, name: &str) -> PathBuf {
self.base_dir.join(name)
}
pub fn save(&self, machine: &MachineInfo) -> Result<()> {
let dir = self.machine_dir(&machine.name);
fs::create_dir_all(&dir)?;
let persisted = PersistedMachine::from(machine);
let content = toml::to_string_pretty(&persisted)
.map_err(|e| CoreError::config(format!("failed to serialize config: {e}")))?;
atomic_write(&self.config_path(&machine.name), content.as_bytes())?;
tracing::debug!("Saved machine config: {}", machine.name);
Ok(())
}
pub fn load(&self, name: &str) -> Result<PersistedMachine> {
let path = self.config_path(name);
let content = fs::read_to_string(&path)
.map_err(|e| CoreError::not_found(format!("Machine config not found: {e}")))?;
toml::from_str(&content).map_err(CoreError::from)
}
#[must_use]
pub fn list(&self) -> Vec<String> {
let Ok(entries) = fs::read_dir(&self.base_dir) else {
return Vec::new();
};
entries
.filter_map(std::result::Result::ok)
.filter(|e| e.path().is_dir())
.filter(|e| e.path().join("config.toml").exists())
.filter_map(|e| e.file_name().into_string().ok())
.collect()
}
#[must_use]
pub fn load_all(&self) -> Vec<PersistedMachine> {
self.list()
.iter()
.filter_map(|name| self.load(name).ok())
.collect()
}
pub fn remove(&self, name: &str) -> Result<()> {
let dir = self.machine_dir(name);
if dir.exists() {
fs::remove_dir_all(&dir)?;
tracing::debug!("Removed machine config: {}", name);
}
Ok(())
}
pub fn update_state(&self, name: &str, state: MachineState) -> Result<()> {
self.update(name, |machine| {
machine.state = state.into();
})
}
pub fn update_ip(&self, name: &str, ip: Option<&str>) -> Result<()> {
self.update(name, |machine| {
machine.ip_address = ip.map(ToString::to_string);
})
}
pub fn update(&self, name: &str, mutate: impl FnOnce(&mut PersistedMachine)) -> Result<()> {
let mut machine = self.load(name)?;
mutate(&mut machine);
let content = toml::to_string_pretty(&machine)
.map_err(|e| CoreError::config(format!("failed to serialize config: {e}")))?;
atomic_write(&self.config_path(name), content.as_bytes())?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vm::VmId;
use tempfile::TempDir;
#[test]
fn test_save_and_load() {
let temp = TempDir::new().unwrap();
let persistence = MachinePersistence::new(temp.path());
let created_at = Utc::now();
let info = MachineInfo {
name: "test-vm".to_string(),
state: MachineState::Created,
vm_id: VmId::new(),
cpus: 4,
memory_mb: 4096,
disk_gb: 50,
kernel: Some("/path/to/kernel".to_string()),
cmdline: Some("console=ttyS0".to_string()),
block_devices: Vec::new(),
distro: None,
distro_version: None,
disk_path: None,
ssh_key_path: None,
ip_address: None,
cid: None,
created_at,
};
persistence.save(&info).unwrap();
let loaded = persistence.load("test-vm").unwrap();
assert_eq!(loaded.name, "test-vm");
assert_eq!(loaded.cpus, 4);
assert_eq!(loaded.memory_mb, 4096);
assert_eq!(loaded.kernel, Some("/path/to/kernel".to_string()));
assert_eq!(loaded.cmdline, Some("console=ttyS0".to_string()));
assert!((loaded.created_at - created_at).num_seconds().abs() < 1);
}
#[test]
fn test_list() {
let temp = TempDir::new().unwrap();
let persistence = MachinePersistence::new(temp.path());
for name in ["vm1", "vm2", "vm3"] {
let info = MachineInfo {
name: name.to_string(),
state: MachineState::Created,
vm_id: VmId::new(),
cpus: 2,
memory_mb: 2048,
disk_gb: 20,
kernel: None,
cmdline: None,
block_devices: Vec::new(),
distro: None,
distro_version: None,
disk_path: None,
ssh_key_path: None,
ip_address: None,
cid: None,
created_at: Utc::now(),
};
persistence.save(&info).unwrap();
}
let machines = persistence.list();
assert_eq!(machines.len(), 3);
assert!(machines.contains(&"vm1".to_string()));
assert!(machines.contains(&"vm2".to_string()));
assert!(machines.contains(&"vm3".to_string()));
}
#[test]
fn test_remove() {
let temp = TempDir::new().unwrap();
let persistence = MachinePersistence::new(temp.path());
let info = MachineInfo {
name: "test-vm".to_string(),
state: MachineState::Created,
vm_id: VmId::new(),
cpus: 2,
memory_mb: 2048,
disk_gb: 20,
kernel: None,
cmdline: None,
block_devices: Vec::new(),
distro: None,
distro_version: None,
disk_path: None,
ssh_key_path: None,
ip_address: None,
cid: None,
created_at: Utc::now(),
};
persistence.save(&info).unwrap();
assert!(persistence.load("test-vm").is_ok());
persistence.remove("test-vm").unwrap();
assert!(persistence.load("test-vm").is_err());
}
#[test]
fn test_update_batches_mutations() {
let temp = TempDir::new().unwrap();
let persistence = MachinePersistence::new(temp.path());
let info = MachineInfo {
name: "test-vm".to_string(),
state: MachineState::Created,
vm_id: VmId::new(),
cpus: 2,
memory_mb: 2048,
disk_gb: 20,
kernel: None,
cmdline: None,
block_devices: Vec::new(),
distro: None,
distro_version: None,
disk_path: None,
ssh_key_path: None,
ip_address: None,
cid: None,
created_at: Utc::now(),
};
persistence.save(&info).unwrap();
persistence
.update("test-vm", |m| {
m.state = PersistedState::Running;
m.ip_address = Some("10.0.2.15".to_string());
})
.unwrap();
let loaded = persistence.load("test-vm").unwrap();
assert_eq!(loaded.state, PersistedState::Running);
assert_eq!(loaded.ip_address.as_deref(), Some("10.0.2.15"));
}
#[test]
fn test_needs_recovery_detects_interrupted_running() {
assert!(PersistedState::Running.needs_recovery());
assert!(!PersistedState::Stopped.needs_recovery());
assert!(!PersistedState::Created.needs_recovery());
}
#[test]
fn test_persisted_running_maps_to_stopped_on_reload() {
let temp = TempDir::new().unwrap();
let persistence = MachinePersistence::new(temp.path());
let info = MachineInfo {
name: "crash-vm".to_string(),
state: MachineState::Running,
vm_id: VmId::new(),
cpus: 2,
memory_mb: 2048,
disk_gb: 20,
kernel: None,
cmdline: None,
block_devices: Vec::new(),
distro: None,
distro_version: None,
disk_path: None,
ssh_key_path: None,
ip_address: Some("10.0.2.15".to_string()),
cid: None,
created_at: Utc::now(),
};
persistence.save(&info).unwrap();
let loaded = persistence.load("crash-vm").unwrap();
assert_eq!(loaded.state, PersistedState::Running);
assert!(loaded.state.needs_recovery());
let state: MachineState = loaded.state.into();
assert_eq!(state, MachineState::Stopped);
persistence
.update_state("crash-vm", MachineState::Stopped)
.unwrap();
let reloaded = persistence.load("crash-vm").unwrap();
assert_eq!(reloaded.state, PersistedState::Stopped);
assert!(!reloaded.state.needs_recovery());
}
}