use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tracing::info;
use uuid::Uuid;
use crate::config::SnapshotType;
use crate::error::{Result, VmmError};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotMeta {
pub id: String,
pub vm_id: String,
pub name: Option<String>,
pub snapshot_type: SnapshotType,
pub vmstate_path: PathBuf,
pub mem_path: Option<PathBuf>,
pub created_at: DateTime<Utc>,
pub parent_id: Option<String>,
#[serde(default)]
pub kernel_path: Option<String>,
#[serde(default)]
pub rootfs_path: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SnapshotInfo {
pub id: String,
pub vm_id: String,
pub name: Option<String>,
pub snapshot_type: SnapshotType,
pub vmstate_path: PathBuf,
pub mem_path: Option<PathBuf>,
pub created_at: DateTime<Utc>,
}
impl From<&SnapshotMeta> for SnapshotInfo {
fn from(m: &SnapshotMeta) -> Self {
Self {
id: m.id.clone(),
vm_id: m.vm_id.clone(),
name: m.name.clone(),
snapshot_type: m.snapshot_type,
vmstate_path: m.vmstate_path.clone(),
mem_path: m.mem_path.clone(),
created_at: m.created_at,
}
}
}
pub struct SnapshotCatalog {
root: PathBuf,
}
impl SnapshotCatalog {
pub fn new(data_dir: &str) -> Self {
Self {
root: PathBuf::from(data_dir).join("snapshots"),
}
}
#[allow(clippy::too_many_arguments)]
pub fn register(
&self,
vm_id: &str,
name: Option<String>,
snapshot_type: SnapshotType,
vmstate_path: PathBuf,
mem_path: Option<PathBuf>,
parent_id: Option<String>,
kernel_path: Option<String>,
rootfs_path: Option<String>,
) -> Result<SnapshotMeta> {
let id = Uuid::new_v4().to_string();
let meta = SnapshotMeta {
id: id.clone(),
vm_id: vm_id.to_owned(),
name,
snapshot_type,
vmstate_path,
mem_path,
created_at: Utc::now(),
parent_id,
kernel_path,
rootfs_path,
};
self.write_meta(&meta)?;
info!(snapshot_id = %id, vm_id, "snapshot registered");
Ok(meta)
}
pub fn list(&self, vm_id: &str) -> Result<Vec<SnapshotInfo>> {
let dir = self.vm_dir(vm_id);
if !dir.exists() {
return Ok(vec![]);
}
let mut entries: Vec<SnapshotMeta> = std::fs::read_dir(&dir)
.map_err(VmmError::Io)?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.filter_map(|e| self.read_meta(&e.path()).ok())
.collect();
entries.sort_by_key(|m| m.created_at);
Ok(entries.iter().map(SnapshotInfo::from).collect())
}
pub fn get(&self, vm_id: &str, snapshot_id: &str) -> Result<SnapshotMeta> {
let path = self.snapshot_dir(vm_id, snapshot_id);
self.read_meta(&path)
}
pub fn delete(&self, vm_id: &str, snapshot_id: &str) -> Result<()> {
let path = self.snapshot_dir(vm_id, snapshot_id);
if !path.exists() {
return Err(VmmError::Snapshot(format!(
"snapshot {snapshot_id} not found for VM {vm_id}"
)));
}
std::fs::remove_dir_all(&path).map_err(VmmError::Io)?;
info!(snapshot_id, vm_id, "snapshot deleted");
Ok(())
}
pub fn prepare_dir(&self, vm_id: &str, snapshot_id: &str) -> Result<PathBuf> {
let dir = self.snapshot_dir(vm_id, snapshot_id);
std::fs::create_dir_all(&dir).map_err(VmmError::Io)?;
Ok(dir)
}
pub fn find_by_id(&self, snapshot_id: &str) -> Result<SnapshotMeta> {
if !self.root.exists() {
return Err(VmmError::Snapshot(format!(
"snapshot {snapshot_id} not found"
)));
}
for entry in std::fs::read_dir(&self.root).map_err(VmmError::Io)? {
let entry = entry.map_err(VmmError::Io)?;
if entry.path().is_dir() {
let snap_path = entry.path().join(snapshot_id);
if snap_path.is_dir()
&& let Ok(meta) = self.read_meta(&snap_path)
{
return Ok(meta);
}
}
}
Err(VmmError::Snapshot(format!(
"snapshot {snapshot_id} not found"
)))
}
pub fn list_all(&self) -> Result<Vec<SnapshotInfo>> {
if !self.root.exists() {
return Ok(vec![]);
}
let mut all: Vec<SnapshotInfo> = vec![];
for entry in std::fs::read_dir(&self.root).map_err(VmmError::Io)? {
let entry = entry.map_err(VmmError::Io)?;
if entry.path().is_dir() {
let owner_id = entry.file_name().to_string_lossy().into_owned();
let mut infos = self.list(&owner_id)?;
all.append(&mut infos);
}
}
all.sort_by_key(|s| s.created_at);
Ok(all)
}
pub fn delete_by_id(&self, snapshot_id: &str) -> Result<()> {
let meta = self.find_by_id(snapshot_id)?;
self.delete(&meta.vm_id, snapshot_id)
}
fn vm_dir(&self, vm_id: &str) -> PathBuf {
self.root.join(vm_id)
}
fn snapshot_dir(&self, vm_id: &str, snapshot_id: &str) -> PathBuf {
self.vm_dir(vm_id).join(snapshot_id)
}
fn meta_path(dir: &Path) -> PathBuf {
dir.join("meta.json")
}
fn write_meta(&self, meta: &SnapshotMeta) -> Result<()> {
let dir = self.snapshot_dir(&meta.vm_id, &meta.id);
std::fs::create_dir_all(&dir).map_err(VmmError::Io)?;
let json = serde_json::to_string_pretty(meta)?;
std::fs::write(Self::meta_path(&dir), json).map_err(VmmError::Io)?;
Ok(())
}
fn read_meta(&self, dir: &Path) -> Result<SnapshotMeta> {
let json = std::fs::read_to_string(Self::meta_path(dir)).map_err(VmmError::Io)?;
let meta = serde_json::from_str(&json)?;
Ok(meta)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::SnapshotType;
fn register_one(catalog: &SnapshotCatalog, vm_id: &str) -> SnapshotMeta {
catalog
.register(
vm_id,
None,
SnapshotType::Full,
PathBuf::from("/tmp/vmstate"),
None,
None,
None,
None,
)
.unwrap()
}
#[test]
fn test_list_empty() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
assert!(catalog.list("vm-1").unwrap().is_empty());
assert!(catalog.list_all().unwrap().is_empty());
}
#[test]
fn test_register_and_list() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let meta = register_one(&catalog, "vm-1");
let snapshots = catalog.list("vm-1").unwrap();
assert_eq!(snapshots.len(), 1);
assert_eq!(snapshots[0].id, meta.id);
assert_eq!(snapshots[0].vm_id, "vm-1");
}
#[test]
fn test_register_and_get() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let meta = catalog
.register(
"vm-2",
Some("my-snap".into()),
SnapshotType::Diff,
PathBuf::from("/tmp/vmstate"),
None,
None,
None,
None,
)
.unwrap();
let loaded = catalog.get("vm-2", &meta.id).unwrap();
assert_eq!(loaded.id, meta.id);
assert_eq!(loaded.snapshot_type, SnapshotType::Diff);
assert_eq!(loaded.name.as_deref(), Some("my-snap"));
}
#[test]
fn test_delete_removes_snapshot() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let meta = register_one(&catalog, "vm-1");
catalog.delete("vm-1", &meta.id).unwrap();
assert!(catalog.list("vm-1").unwrap().is_empty());
}
#[test]
fn test_find_by_id_across_vms() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let meta = register_one(&catalog, "vm-42");
let found = catalog.find_by_id(&meta.id).unwrap();
assert_eq!(found.vm_id, "vm-42");
}
#[test]
fn test_list_all_across_multiple_vms() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
register_one(&catalog, "vm-a");
register_one(&catalog, "vm-a");
register_one(&catalog, "vm-b");
let all = catalog.list_all().unwrap();
assert_eq!(all.len(), 3);
}
#[test]
fn test_delete_by_id() {
let dir = tempfile::tempdir().unwrap();
let catalog = SnapshotCatalog::new(dir.path().to_str().unwrap());
let meta = register_one(&catalog, "vm-1");
catalog.delete_by_id(&meta.id).unwrap();
assert!(catalog.list_all().unwrap().is_empty());
}
}