use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::{BufReader, BufWriter, Read, Write};
use std::path::{Path, PathBuf};
use arcbox_hypervisor::{DeviceSnapshot, VcpuSnapshot, VmSnapshot};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SnapshotInfo {
pub id: String,
pub name: String,
pub target_id: String,
pub target_type: SnapshotTargetType,
pub created: DateTime<Utc>,
pub size: u64,
pub parent: Option<String>,
pub description: Option<String>,
pub labels: HashMap<String, String>,
pub state: SnapshotState,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SnapshotTargetType {
Vm,
Container,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SnapshotState {
Creating,
Ready,
Invalid,
}
#[derive(Debug, Clone, Default)]
pub struct SnapshotCreateOptions {
pub name: Option<String>,
pub description: Option<String>,
pub labels: HashMap<String, String>,
pub parent: Option<String>,
pub pause_vm: bool,
pub compress: bool,
}
pub struct VmSnapshotContext {
pub vcpu_snapshots: Vec<VcpuSnapshot>,
pub device_snapshots: Vec<DeviceSnapshot>,
pub memory_size: u64,
pub memory_reader: Box<dyn FnOnce(&mut [u8]) -> Result<(), SnapshotError> + Send>,
}
pub struct VmRestoreData {
pub vm_snapshot: VmSnapshot,
pub memory: Vec<u8>,
}
impl VmRestoreData {
#[must_use]
pub fn vcpu_snapshots(&self) -> &[VcpuSnapshot] {
&self.vm_snapshot.vcpus
}
#[must_use]
pub fn device_snapshots(&self) -> &[arcbox_hypervisor::DeviceSnapshot] {
&self.vm_snapshot.devices
}
#[must_use]
pub const fn memory_size(&self) -> u64 {
self.vm_snapshot.total_memory
}
#[must_use]
pub fn memory(&self) -> &[u8] {
&self.memory
}
#[must_use]
pub const fn was_compressed(&self) -> bool {
self.vm_snapshot.compressed
}
}
pub struct SnapshotManager {
base_dir: PathBuf,
snapshots: std::sync::RwLock<HashMap<String, SnapshotInfo>>,
restore_cache: std::sync::RwLock<HashMap<String, VmRestoreData>>,
}
impl SnapshotManager {
#[must_use]
pub fn new(base_dir: PathBuf) -> Self {
let manager = Self {
base_dir,
snapshots: std::sync::RwLock::new(HashMap::new()),
restore_cache: std::sync::RwLock::new(HashMap::new()),
};
if let Err(e) = manager.load_snapshots() {
tracing::warn!("Failed to load existing snapshots: {}", e);
}
manager
}
fn load_snapshots(&self) -> Result<(), SnapshotError> {
if !self.base_dir.exists() {
return Ok(());
}
let mut snapshots = self
.snapshots
.write()
.map_err(|_| SnapshotError::Internal("lock poisoned".to_string()))?;
for entry in fs::read_dir(&self.base_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let metadata_path = path.join("snapshot.json");
if !metadata_path.exists() {
continue;
}
match fs::read_to_string(&metadata_path) {
Ok(content) => match serde_json::from_str::<SnapshotInfo>(&content) {
Ok(info) => {
snapshots.insert(info.id.clone(), info);
}
Err(e) => {
tracing::warn!("Failed to parse snapshot metadata: {}", e);
}
},
Err(e) => {
tracing::warn!("Failed to read snapshot metadata: {}", e);
}
}
}
tracing::debug!("Loaded {} snapshots from disk", snapshots.len());
Ok(())
}
pub async fn create(
&self,
target_id: &str,
target_type: SnapshotTargetType,
options: SnapshotCreateOptions,
) -> Result<SnapshotInfo, SnapshotError> {
let snapshot_id = generate_snapshot_id();
let name = options
.name
.clone()
.unwrap_or_else(|| format!("snapshot-{}", &snapshot_id[..8]));
tracing::info!(
"Creating snapshot '{}' ({}) for {:?} {}",
name,
snapshot_id,
target_type,
target_id
);
let snapshot_dir = self.base_dir.join(&snapshot_id);
fs::create_dir_all(&snapshot_dir)?;
let mut info = SnapshotInfo {
id: snapshot_id.clone(),
name: name.clone(),
target_id: target_id.to_string(),
target_type,
created: Utc::now(),
size: 0,
parent: options.parent.clone(),
description: options.description.clone(),
labels: options.labels.clone(),
state: SnapshotState::Creating,
};
self.save_metadata(&info)?;
match target_type {
SnapshotTargetType::Vm => {
self.capture_vm_snapshot(&snapshot_dir, target_id, &options)
.await?;
}
SnapshotTargetType::Container => {
self.capture_container_snapshot(&snapshot_dir, target_id)
.await?;
}
}
info.size = calculate_dir_size(&snapshot_dir);
info.state = SnapshotState::Ready;
self.save_metadata(&info)?;
{
let mut snapshots = self
.snapshots
.write()
.map_err(|_| SnapshotError::Internal("lock poisoned".to_string()))?;
snapshots.insert(snapshot_id.clone(), info.clone());
}
tracing::info!("Created snapshot '{}' (size: {} bytes)", name, info.size);
Ok(info)
}
pub async fn create_vm_with_context(
&self,
target_id: &str,
options: SnapshotCreateOptions,
context: VmSnapshotContext,
) -> Result<SnapshotInfo, SnapshotError> {
let snapshot_id = generate_snapshot_id();
let name = options
.name
.clone()
.unwrap_or_else(|| format!("snapshot-{}", &snapshot_id[..8]));
tracing::info!(
"Creating VM snapshot '{}' ({}) for {} with explicit context",
name,
snapshot_id,
target_id
);
let snapshot_dir = self.base_dir.join(&snapshot_id);
fs::create_dir_all(&snapshot_dir)?;
let mut info = SnapshotInfo {
id: snapshot_id.clone(),
name: name.clone(),
target_id: target_id.to_string(),
target_type: SnapshotTargetType::Vm,
created: Utc::now(),
size: 0,
parent: options.parent.clone(),
description: options.description.clone(),
labels: options.labels.clone(),
state: SnapshotState::Creating,
};
self.save_metadata(&info)?;
self.capture_vm_snapshot_with_context(&snapshot_dir, target_id, &options, context)
.await?;
info.size = calculate_dir_size(&snapshot_dir);
info.state = SnapshotState::Ready;
self.save_metadata(&info)?;
{
let mut snapshots = self
.snapshots
.write()
.map_err(|_| SnapshotError::Internal("lock poisoned".to_string()))?;
snapshots.insert(snapshot_id, info.clone());
}
tracing::info!("Created VM snapshot '{}' (size: {} bytes)", name, info.size);
Ok(info)
}
pub async fn capture_vm_snapshot_with_context(
&self,
snapshot_dir: &Path,
target_id: &str,
options: &SnapshotCreateOptions,
context: VmSnapshotContext,
) -> Result<(), SnapshotError> {
tracing::debug!(
"Capturing VM snapshot for {} (vcpus={}, memory={}MB, compress={})",
target_id,
context.vcpu_snapshots.len(),
context.memory_size / (1024 * 1024),
options.compress
);
let vm_snapshot = VmSnapshot {
version: 1,
arch: context
.vcpu_snapshots
.first()
.map(|v| v.arch)
.unwrap_or(arcbox_hypervisor::CpuArch::native()),
vcpus: context.vcpu_snapshots.clone(),
devices: context
.device_snapshots
.iter()
.map(|d| arcbox_hypervisor::DeviceSnapshot {
device_type: d.device_type,
name: d.name.clone(),
state: d.state.clone(),
})
.collect(),
memory_regions: vec![arcbox_hypervisor::MemoryRegionSnapshot {
guest_addr: 0,
size: context.memory_size,
read_only: false,
file_offset: 0,
}],
total_memory: context.memory_size,
compressed: options.compress,
compression: if options.compress {
Some("lz4".to_string())
} else {
None
},
parent_id: options.parent.clone(),
};
let state_file = snapshot_dir.join("vm_state.json");
let state_json = serde_json::to_string_pretty(&vm_snapshot)
.map_err(|e| SnapshotError::Internal(e.to_string()))?;
fs::write(&state_file, state_json)?;
let memory_file = if options.compress {
snapshot_dir.join("memory.bin.lz4")
} else {
snapshot_dir.join("memory.bin")
};
let mut memory_buffer = vec![0u8; context.memory_size as usize];
(context.memory_reader)(&mut memory_buffer)?;
if options.compress {
compress_and_write(&memory_file, &memory_buffer)?;
tracing::debug!(
"Wrote compressed memory dump ({} bytes -> {} bytes)",
memory_buffer.len(),
fs::metadata(&memory_file).map(|m| m.len()).unwrap_or(0)
);
} else {
fs::write(&memory_file, &memory_buffer)?;
tracing::debug!("Wrote raw memory dump ({} bytes)", memory_buffer.len());
}
tracing::info!(
"Captured VM snapshot for {}: {} vCPUs, {} devices, {} memory",
target_id,
vm_snapshot.vcpus.len(),
vm_snapshot.devices.len(),
format_size(context.memory_size)
);
Ok(())
}
async fn capture_vm_snapshot(
&self,
snapshot_dir: &Path,
target_id: &str,
options: &SnapshotCreateOptions,
) -> Result<(), SnapshotError> {
let state_file = snapshot_dir.join("vm_state.json");
let vm_state = VmSnapshotState {
target_id: target_id.to_string(),
captured_at: Utc::now(),
paused: options.pause_vm,
vcpu_count: 0,
memory_size: 0,
devices: Vec::new(),
};
let state_json = serde_json::to_string_pretty(&vm_state)
.map_err(|e| SnapshotError::Internal(e.to_string()))?;
fs::write(state_file, state_json)?;
let memory_file = snapshot_dir.join("memory.bin");
fs::write(&memory_file, b"")?;
tracing::warn!(
"Captured placeholder VM snapshot for {} (use capture_vm_snapshot_with_context for real snapshots)",
target_id
);
Ok(())
}
#[cfg(all(target_os = "linux", feature = "criu"))]
pub async fn capture_container_with_criu(
&self,
snapshot_dir: &Path,
target_id: &str,
container_pid: u32,
options: &CriuCheckpointOptions,
) -> Result<(), SnapshotError> {
use std::process::Command;
tracing::info!(
"Capturing container checkpoint for {} (pid={}) with CRIU",
target_id,
container_pid
);
let criu_dir = snapshot_dir.join("criu");
fs::create_dir_all(&criu_dir)?;
let mut cmd = Command::new("criu");
cmd.arg("dump")
.arg("-t")
.arg(container_pid.to_string())
.arg("-D")
.arg(&criu_dir)
.arg("--shell-job");
if options.leave_running {
cmd.arg("--leave-running");
}
if options.file_locks {
cmd.arg("--file-locks");
}
if options.tcp_established {
cmd.arg("--tcp-established");
}
let output = cmd
.output()
.map_err(|e| SnapshotError::CriuError(format!("Failed to execute CRIU: {}", e)))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SnapshotError::CriuError(format!(
"CRIU dump failed (exit code {:?}): {}",
output.status.code(),
stderr
)));
}
let checkpoint = ContainerCheckpoint {
target_id: target_id.to_string(),
captured_at: Utc::now(),
process_tree: vec![container_pid],
open_files: Vec::new(), network_state: None, };
let checkpoint_file = snapshot_dir.join("container_checkpoint.json");
let checkpoint_json = serde_json::to_string_pretty(&checkpoint)
.map_err(|e| SnapshotError::Internal(e.to_string()))?;
fs::write(&checkpoint_file, checkpoint_json)?;
tracing::info!(
"Container checkpoint created for {} in {:?}",
target_id,
criu_dir
);
Ok(())
}
async fn capture_container_snapshot(
&self,
snapshot_dir: &Path,
target_id: &str,
) -> Result<(), SnapshotError> {
let checkpoint_file = snapshot_dir.join("container_checkpoint.json");
let checkpoint = ContainerCheckpoint {
target_id: target_id.to_string(),
captured_at: Utc::now(),
process_tree: Vec::new(),
open_files: Vec::new(),
network_state: None,
};
let checkpoint_json = serde_json::to_string_pretty(&checkpoint)
.map_err(|e| SnapshotError::Internal(e.to_string()))?;
fs::write(checkpoint_file, checkpoint_json)?;
tracing::warn!(
"Captured placeholder container checkpoint for {} (enable CRIU for real checkpoints)",
target_id
);
Ok(())
}
fn save_metadata(&self, info: &SnapshotInfo) -> Result<(), SnapshotError> {
let snapshot_dir = self.base_dir.join(&info.id);
let metadata_path = snapshot_dir.join("snapshot.json");
let json = serde_json::to_string_pretty(info)
.map_err(|e| SnapshotError::Internal(e.to_string()))?;
fs::write(metadata_path, json)?;
Ok(())
}
pub async fn restore(&self, snapshot_id: &str) -> Result<(), SnapshotError> {
let info = self
.get(snapshot_id)
.ok_or_else(|| SnapshotError::NotFound(snapshot_id.to_string()))?;
if info.state != SnapshotState::Ready {
return Err(SnapshotError::InvalidState(format!(
"snapshot {} is not ready (state: {:?})",
snapshot_id, info.state
)));
}
tracing::info!(
"Restoring {:?} {} from snapshot '{}'",
info.target_type,
info.target_id,
info.name
);
let snapshot_dir = self.base_dir.join(snapshot_id);
match info.target_type {
SnapshotTargetType::Vm => {
self.restore_vm_snapshot(&snapshot_dir, &info).await?;
}
SnapshotTargetType::Container => {
self.restore_container_snapshot(&snapshot_dir, &info)
.await?;
}
}
tracing::info!("Restored from snapshot '{}'", info.name);
Ok(())
}
async fn restore_vm_snapshot(
&self,
snapshot_dir: &Path,
info: &SnapshotInfo,
) -> Result<(), SnapshotError> {
let state_file = snapshot_dir.join("vm_state.json");
if !state_file.exists() {
return Err(SnapshotError::Corrupted(
"vm_state.json not found".to_string(),
));
}
let state_json = fs::read_to_string(&state_file)?;
if let Ok(vm_snapshot) = serde_json::from_str::<VmSnapshot>(&state_json) {
tracing::debug!(
"Loading VM snapshot for {}: {} vCPUs, {} devices, {} memory",
info.target_id,
vm_snapshot.vcpus.len(),
vm_snapshot.devices.len(),
format_size(vm_snapshot.total_memory)
);
let memory_file = if vm_snapshot.compressed {
snapshot_dir.join("memory.bin.lz4")
} else {
snapshot_dir.join("memory.bin")
};
if memory_file.exists() {
let memory_data = if vm_snapshot.compressed {
read_and_decompress(&memory_file)?
} else {
fs::read(&memory_file)?
};
tracing::info!(
"Loaded memory dump for {}: {} bytes",
info.target_id,
memory_data.len()
);
let restore_data = VmRestoreData {
vm_snapshot,
memory: memory_data,
};
if let Ok(mut cache) = self.restore_cache.write() {
cache.insert(info.id.clone(), restore_data);
}
} else {
tracing::warn!("Memory dump file not found for {}", info.target_id);
}
} else {
let _state: VmSnapshotState = serde_json::from_str(&state_json)
.map_err(|e| SnapshotError::Corrupted(e.to_string()))?;
tracing::debug!("Loaded legacy VM state for {}", info.target_id);
}
tracing::info!("Restored VM state for {}", info.target_id);
Ok(())
}
pub fn take_restore_data(&self, snapshot_id: &str) -> Option<VmRestoreData> {
self.restore_cache.write().ok()?.remove(snapshot_id)
}
async fn restore_container_snapshot(
&self,
snapshot_dir: &Path,
info: &SnapshotInfo,
) -> Result<(), SnapshotError> {
let checkpoint_file = snapshot_dir.join("container_checkpoint.json");
if !checkpoint_file.exists() {
return Err(SnapshotError::Corrupted(
"container_checkpoint.json not found".to_string(),
));
}
let checkpoint_json = fs::read_to_string(&checkpoint_file)?;
let _checkpoint: ContainerCheckpoint = serde_json::from_str(&checkpoint_json)
.map_err(|e| SnapshotError::Corrupted(e.to_string()))?;
tracing::debug!("Restored container checkpoint for {}", info.target_id);
Ok(())
}
#[must_use]
pub fn get(&self, snapshot_id: &str) -> Option<SnapshotInfo> {
self.snapshots.read().ok()?.get(snapshot_id).cloned()
}
#[must_use]
pub fn list_all(&self) -> Vec<SnapshotInfo> {
self.snapshots
.read()
.map(|s| s.values().cloned().collect())
.unwrap_or_default()
}
#[must_use]
pub fn list(&self, target_id: &str) -> Vec<SnapshotInfo> {
self.snapshots
.read()
.map(|s| {
s.values()
.filter(|info| info.target_id == target_id)
.cloned()
.collect()
})
.unwrap_or_default()
}
#[must_use]
pub fn list_by_type(&self, target_type: SnapshotTargetType) -> Vec<SnapshotInfo> {
self.snapshots
.read()
.map(|s| {
s.values()
.filter(|info| info.target_type == target_type)
.cloned()
.collect()
})
.unwrap_or_default()
}
pub async fn delete(&self, snapshot_id: &str) -> Result<(), SnapshotError> {
let info = self
.get(snapshot_id)
.ok_or_else(|| SnapshotError::NotFound(snapshot_id.to_string()))?;
{
let snapshots = self
.snapshots
.read()
.map_err(|_| SnapshotError::Internal("lock poisoned".to_string()))?;
for other in snapshots.values() {
if other.parent.as_deref() == Some(snapshot_id) {
return Err(SnapshotError::InUse(format!(
"snapshot {} is parent of {}",
snapshot_id, other.id
)));
}
}
}
tracing::info!("Deleting snapshot '{}' ({})", info.name, snapshot_id);
let snapshot_dir = self.base_dir.join(snapshot_id);
if snapshot_dir.exists() {
fs::remove_dir_all(&snapshot_dir)?;
}
{
let mut snapshots = self
.snapshots
.write()
.map_err(|_| SnapshotError::Internal("lock poisoned".to_string()))?;
snapshots.remove(snapshot_id);
}
tracing::info!("Deleted snapshot '{}'", info.name);
Ok(())
}
pub async fn prune(&self, keep: usize) -> Result<Vec<String>, SnapshotError> {
let mut deleted = Vec::new();
let by_target: HashMap<String, Vec<SnapshotInfo>> = {
let snapshots = self
.snapshots
.read()
.map_err(|_| SnapshotError::Internal("lock poisoned".to_string()))?;
let mut map: HashMap<String, Vec<SnapshotInfo>> = HashMap::new();
for info in snapshots.values() {
map.entry(info.target_id.clone())
.or_default()
.push(info.clone());
}
map
};
for (target_id, mut snapshots) in by_target {
if snapshots.len() <= keep {
continue;
}
snapshots.sort_by(|a, b| b.created.cmp(&a.created));
for info in snapshots.into_iter().skip(keep) {
match self.delete(&info.id).await {
Ok(()) => {
deleted.push(info.id);
}
Err(e) => {
tracing::warn!(
"Failed to prune snapshot {} for {}: {}",
info.id,
target_id,
e
);
}
}
}
}
Ok(deleted)
}
#[must_use]
pub fn total_size(&self) -> u64 {
self.snapshots
.read()
.map(|s| s.values().map(|info| info.size).sum())
.unwrap_or(0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct VmSnapshotState {
target_id: String,
captured_at: DateTime<Utc>,
paused: bool,
vcpu_count: u32,
memory_size: u64,
devices: Vec<DeviceState>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct DeviceState {
device_type: String,
data: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ContainerCheckpoint {
target_id: String,
captured_at: DateTime<Utc>,
process_tree: Vec<u32>,
open_files: Vec<String>,
network_state: Option<serde_json::Value>,
}
fn generate_snapshot_id() -> String {
uuid::Uuid::new_v4().to_string().replace('-', "")
}
fn calculate_dir_size(path: &Path) -> u64 {
if !path.exists() {
return 0;
}
let mut size: u64 = 0;
if let Ok(entries) = fs::read_dir(path) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Ok(metadata) = fs::metadata(&path) {
size += metadata.len();
}
} else if path.is_dir() {
size += calculate_dir_size(&path);
}
}
}
size
}
#[cfg(all(target_os = "linux", feature = "criu"))]
#[derive(Debug, Clone, Default)]
pub struct CriuCheckpointOptions {
pub leave_running: bool,
pub file_locks: bool,
pub tcp_established: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum SnapshotError {
#[error("snapshot not found: {0}")]
NotFound(String),
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("snapshot corrupted: {0}")]
Corrupted(String),
#[error("invalid state: {0}")]
InvalidState(String),
#[error("snapshot in use: {0}")]
InUse(String),
#[error("internal error: {0}")]
Internal(String),
#[error("CRIU error: {0}")]
CriuError(String),
#[error("compression error: {0}")]
CompressionError(String),
}
fn compress_and_write(path: &Path, data: &[u8]) -> Result<(), SnapshotError> {
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
let compressed = lz4_flex::compress_prepend_size(data);
writer
.write_all(&compressed)
.map_err(|e| SnapshotError::CompressionError(e.to_string()))?;
writer.flush()?;
Ok(())
}
#[allow(dead_code)] fn read_and_decompress(path: &Path) -> Result<Vec<u8>, SnapshotError> {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut compressed = Vec::new();
reader.read_to_end(&mut compressed)?;
lz4_flex::decompress_size_prepended(&compressed)
.map_err(|e| SnapshotError::CompressionError(e.to_string()))
}
fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{bytes} bytes")
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestDir {
path: PathBuf,
}
impl TestDir {
fn new() -> Self {
let id = uuid::Uuid::new_v4().to_string().replace('-', "");
let path = std::env::temp_dir().join(format!("arcbox-snapshot-test-{}", id));
fs::create_dir_all(&path).unwrap();
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[tokio::test]
async fn test_create_snapshot() {
let temp_dir = TestDir::new();
let manager = SnapshotManager::new(temp_dir.path().to_path_buf());
let info = manager
.create(
"test-vm",
SnapshotTargetType::Vm,
SnapshotCreateOptions {
name: Some("test-snapshot".to_string()),
..Default::default()
},
)
.await
.unwrap();
assert_eq!(info.name, "test-snapshot");
assert_eq!(info.target_id, "test-vm");
assert_eq!(info.target_type, SnapshotTargetType::Vm);
assert_eq!(info.state, SnapshotState::Ready);
}
#[tokio::test]
async fn test_list_snapshots() {
let temp_dir = TestDir::new();
let manager = SnapshotManager::new(temp_dir.path().to_path_buf());
manager
.create("vm-1", SnapshotTargetType::Vm, Default::default())
.await
.unwrap();
manager
.create("vm-1", SnapshotTargetType::Vm, Default::default())
.await
.unwrap();
manager
.create("vm-2", SnapshotTargetType::Vm, Default::default())
.await
.unwrap();
assert_eq!(manager.list_all().len(), 3);
assert_eq!(manager.list("vm-1").len(), 2);
assert_eq!(manager.list("vm-2").len(), 1);
}
#[tokio::test]
async fn test_delete_snapshot() {
let temp_dir = TestDir::new();
let manager = SnapshotManager::new(temp_dir.path().to_path_buf());
let info = manager
.create("test-vm", SnapshotTargetType::Vm, Default::default())
.await
.unwrap();
assert!(manager.get(&info.id).is_some());
manager.delete(&info.id).await.unwrap();
assert!(manager.get(&info.id).is_none());
}
#[tokio::test]
async fn test_prune_snapshots() {
let temp_dir = TestDir::new();
let manager = SnapshotManager::new(temp_dir.path().to_path_buf());
for i in 0..5 {
manager
.create(
"test-vm",
SnapshotTargetType::Vm,
SnapshotCreateOptions {
name: Some(format!("snapshot-{}", i)),
..Default::default()
},
)
.await
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(manager.list("test-vm").len(), 5);
let deleted = manager.prune(2).await.unwrap();
assert_eq!(deleted.len(), 3);
assert_eq!(manager.list("test-vm").len(), 2);
}
#[tokio::test]
async fn test_persistence() {
let temp_dir = TestDir::new();
let path = temp_dir.path().to_path_buf();
let snapshot_id = {
let manager = SnapshotManager::new(path.clone());
let info = manager
.create("test-vm", SnapshotTargetType::Vm, Default::default())
.await
.unwrap();
info.id
};
let manager = SnapshotManager::new(path);
assert!(manager.get(&snapshot_id).is_some());
}
}