//! Backup Scheduler - Automated snapshot creation //! //! Similar to Proxmox Backup Server scheduling use std::sync::Arc; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use chrono::TimeZone; use super::{VfsBackend, VfsError, VfsCompression}; pub struct BackupScheduleConfig { pub enabled: bool, pub interval_hours: u64, pub max_snapshots: usize, pub auto_cleanup: bool, pub compress: VfsCompression, pub encrypt: bool, pub include_checksums: bool, pub incremental: bool, } impl Default for BackupScheduleConfig { fn default() -> Self { Self { enabled: true, interval_hours: 24, max_snapshots: 7, auto_cleanup: true, compress: VfsCompression::Zstd, encrypt: false, include_checksums: true, incremental: true, } } } pub struct BackupScheduler { backend: Arc, root: PathBuf, config: BackupScheduleConfig, last_backup: Option, next_backup: Option, backup_count: usize, snapshots: Vec, } impl BackupScheduler { pub fn new( backend: Arc, root: PathBuf, config: BackupScheduleConfig, ) -> Self { Self { backend, root, config, last_backup: None, next_backup: None, backup_count: 0, snapshots: Vec::new(), } } pub fn with_defaults(backend: Arc, root: PathBuf) -> Self { Self::new(backend, root, BackupScheduleConfig::default()) } pub fn start(&mut self) { self.config.enabled = true; self.schedule_next(); } pub fn stop(&mut self) { self.config.enabled = false; } pub fn is_enabled(&self) -> bool { self.config.enabled } pub fn get_config(&self) -> &BackupScheduleConfig { &self.config } pub fn set_config(&mut self, config: BackupScheduleConfig) { self.config = config; if self.config.enabled { self.schedule_next(); } } pub fn schedule_next(&mut self) { let now = current_time_secs(); let interval_secs = self.config.interval_hours * 3600; if let Some(last) = self.last_backup { self.next_backup = Some(last + interval_secs); } else { self.next_backup = Some(now + interval_secs); } } pub fn should_run(&self) -> bool { if !self.config.enabled { return false; } let now = current_time_secs(); match self.next_backup { None => true, Some(next) => now >= next, } } pub fn run_backup(&mut self) -> Result { if !self.config.enabled { return Err(VfsError::Io("Backup scheduler is disabled".to_string())); } let name = generate_snapshot_name(); let snapshot_dir = self.root.join(".snapshots").join(&name); self.backend.create_dir(&snapshot_dir, 0o755)?; if self.config.incremental && !self.snapshots.is_empty() { let base_snapshot = self.snapshots.last().unwrap(); self.copy_incremental_to_snapshot(base_snapshot, &snapshot_dir)?; } else { self.copy_root_to_snapshot(&snapshot_dir)?; } if self.config.include_checksums { self.generate_checksums(&snapshot_dir)?; } if self.config.auto_cleanup { self.cleanup_old_snapshots()?; } self.last_backup = Some(current_time_secs()); self.backup_count += 1; self.snapshots.push(name.clone()); self.schedule_next(); Ok(name) } fn copy_incremental_to_snapshot(&self, base: &str, snapshot_dir: &PathBuf) -> Result<(), VfsError> { let base_dir = self.root.join(".snapshots").join(base); if !self.backend.exists(&base_dir) { return self.copy_root_to_snapshot(snapshot_dir); } let entries = self.backend.read_dir(&self.root)?; for entry in entries { if entry.name == ".snapshots" || entry.name == ".checksums" { continue; } let src_path = self.root.join(&entry.name); let dst_path = snapshot_dir.join(&entry.name); let base_path = base_dir.join(&entry.name); if entry.stat.is_dir { self.copy_directory_incremental(&src_path, &dst_path, &base_path)?; } else { let needs_copy = !self.backend.exists(&base_path) || self.file_changed(&src_path, &base_path)?; if needs_copy { self.copy_file(&src_path, &dst_path)?; } else { self.create_hard_link(&base_path, &dst_path)?; } } } Ok(()) } fn file_changed(&self, src: &PathBuf, base: &PathBuf) -> Result { let src_stat = self.backend.stat(src)?; let base_stat = self.backend.stat(base)?; Ok(src_stat.size != base_stat.size || src_stat.mtime != base_stat.mtime) } fn create_hard_link(&self, src: &PathBuf, dst: &PathBuf) -> Result<(), VfsError> { self.backend.hard_link(src, dst) } fn copy_directory_incremental(&self, src: &PathBuf, dst: &PathBuf, base: &PathBuf) -> Result<(), VfsError> { self.backend.create_dir(dst, 0o755)?; let entries = self.backend.read_dir(src)?; for entry in entries { let child_src = src.join(&entry.name); let child_dst = dst.join(&entry.name); let child_base = base.join(&entry.name); if entry.stat.is_dir { self.copy_directory_incremental(&child_src, &child_dst, &child_base)?; } else { let needs_copy = !self.backend.exists(&child_base) || self.file_changed(&child_src, &child_base)?; if needs_copy { self.copy_file(&child_src, &child_dst)?; } else { self.create_hard_link(&child_base, &child_dst)?; } } } Ok(()) } fn copy_root_to_snapshot(&self, snapshot_dir: &PathBuf) -> Result<(), VfsError> { let entries = self.backend.read_dir(&self.root)?; for entry in entries { if entry.name == ".snapshots" || entry.name == ".checksums" { continue; } let src_path = self.root.join(&entry.name); let dst_path = snapshot_dir.join(&entry.name); if entry.stat.is_dir { self.copy_directory(&src_path, &dst_path)?; } else { self.copy_file(&src_path, &dst_path)?; } } Ok(()) } fn copy_directory(&self, src: &PathBuf, dst: &PathBuf) -> Result<(), VfsError> { self.backend.create_dir(dst, 0o755)?; let entries = self.backend.read_dir(src)?; for entry in entries { let src_path = src.join(&entry.name); let dst_path = dst.join(&entry.name); if entry.stat.is_dir { self.copy_directory(&src_path, &dst_path)?; } else { self.copy_file(&src_path, &dst_path)?; } } Ok(()) } fn copy_file(&self, src: &PathBuf, dst: &PathBuf) -> Result<(), VfsError> { use super::compression::Compressor; use super::VfsCompressionConfig; let mut src_file = self.backend.open_file(src, &super::open_flags::OpenFlags::new().read())?; let data = src_file.read_all()?; let final_data = if self.config.compress != super::VfsCompression::None { let compressor = Compressor::new(VfsCompressionConfig { algorithm: self.config.compress, min_size: 1024, level: 3, }); compressor.compress(&data)? } else { data }; let mut dst_file = self.backend.open_file( dst, &super::open_flags::OpenFlags::new().write().create().truncate(), )?; dst_file.write_all(&final_data)?; dst_file.flush()?; Ok(()) } fn generate_checksums(&self, snapshot_dir: &PathBuf) -> Result<(), VfsError> { use super::checksum::create_checksums_for_file; let entries = self.backend.read_dir(snapshot_dir)?; for entry in entries { if entry.name == ".manifest.json" || entry.name == ".meta" || entry.name == ".checksums" { continue; } let file_path = snapshot_dir.join(&entry.name); if entry.stat.is_dir { self.generate_checksums_recursive(&file_path, snapshot_dir)?; } else { create_checksums_for_file(self.backend.as_ref(), &file_path, snapshot_dir)?; } } Ok(()) } fn generate_checksums_recursive( &self, dir: &PathBuf, snapshot_dir: &PathBuf, ) -> Result<(), VfsError> { use super::checksum::create_checksums_for_file; let entries = self.backend.read_dir(dir)?; for entry in entries { let file_path = dir.join(&entry.name); if entry.stat.is_dir { self.generate_checksums_recursive(&file_path, snapshot_dir)?; } else { create_checksums_for_file(self.backend.as_ref(), &file_path, snapshot_dir)?; } } Ok(()) } fn cleanup_old_snapshots(&mut self) -> Result<(), VfsError> { let snapshots_dir = self.root.join(".snapshots"); if !self.backend.exists(&snapshots_dir) { return Ok(()); } let entries = self.backend.read_dir(&snapshots_dir)?; let mut snapshot_names: Vec = entries .iter() .filter(|e| e.stat.is_dir && e.name != ".checksums") .map(|e| e.name.clone()) .collect(); snapshot_names.sort(); while snapshot_names.len() > self.config.max_snapshots { let oldest = snapshot_names.remove(0); let oldest_dir = snapshots_dir.join(&oldest); self.remove_directory_recursive(&oldest_dir)?; self.snapshots.retain(|s| s != &oldest); } Ok(()) } fn remove_directory_recursive(&self, dir: &PathBuf) -> Result<(), VfsError> { if !self.backend.exists(dir) { return Ok(()); } let entries = self.backend.read_dir(dir)?; for entry in entries { let path = dir.join(&entry.name); if entry.stat.is_dir { self.remove_directory_recursive(&path)?; } else { self.backend.remove_file(&path)?; } } self.backend.remove_dir(dir)?; Ok(()) } pub fn list_backups(&self) -> Result, VfsError> { let snapshots_dir = self.root.join(".snapshots"); if !self.backend.exists(&snapshots_dir) { return Ok(Vec::new()); } let entries = self.backend.read_dir(&snapshots_dir)?; let mut backups = Vec::new(); for entry in entries { if !entry.stat.is_dir || entry.name == ".checksums" { continue; } let snapshot_dir = snapshots_dir.join(&entry.name); let info = self.get_backup_info(&entry.name, &snapshot_dir)?; backups.push(info); } backups.sort_by(|a, b| b.created_at.cmp(&a.created_at)); Ok(backups) } fn get_backup_info(&self, name: &str, snapshot_dir: &PathBuf) -> Result { let manifest_path = snapshot_dir.join(".manifest.json"); let created_at = if self.backend.exists(&manifest_path) { let mut file = self.backend.open_file(&manifest_path, &super::open_flags::OpenFlags::new().read())?; let data = file.read_all()?; if let Ok(manifest) = super::backup_manifest::BackupManifest::from_bytes(&data) { manifest.created_at } else { current_time_secs() } } else { current_time_secs() }; let size = self.calculate_snapshot_size(snapshot_dir)?; Ok(BackupInfo { name: name.to_string(), created_at, size, checksum_verified: false, compressed: self.config.compress != VfsCompression::None, encrypted: self.config.encrypt, }) } fn calculate_snapshot_size(&self, dir: &PathBuf) -> Result { let mut total_size = 0u64; let entries = self.backend.read_dir(dir)?; for entry in entries { let path = dir.join(&entry.name); if entry.stat.is_dir { total_size += self.calculate_snapshot_size(&path)?; } else { total_size += entry.stat.size; } } Ok(total_size) } pub fn get_stats(&self) -> BackupStats { BackupStats { enabled: self.config.enabled, backup_count: self.backup_count, last_backup: self.last_backup, next_backup: self.next_backup, interval_hours: self.config.interval_hours, max_snapshots: self.config.max_snapshots, } } } fn generate_snapshot_name() -> String { let now = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let datetime = chrono::Utc.timestamp_opt(now as i64, 0) .single() .map(|dt| dt.format("%Y-%m-%d_%H%M%S").to_string()) .unwrap_or_else(|| format!("{}", now)); format!("snap_{}", datetime) } fn current_time_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0) } #[derive(Debug, Clone)] pub struct BackupInfo { pub name: String, pub created_at: u64, pub size: u64, pub checksum_verified: bool, pub compressed: bool, pub encrypted: bool, } impl BackupInfo { pub fn format_created(&self) -> String { chrono::Utc.timestamp_opt(self.created_at as i64, 0) .single() .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) .unwrap_or_else(|| format!("{} seconds since epoch", self.created_at)) } pub fn format_size(&self) -> String { if self.size < 1024 { format!("{} B", self.size) } else if self.size < 1024 * 1024 { format!("{:.2} KB", self.size as f64 / 1024.0) } else if self.size < 1024 * 1024 * 1024 { format!("{:.2} MB", self.size as f64 / (1024.0 * 1024.0)) } else { format!("{:.2} GB", self.size as f64 / (1024.0 * 1024.0 * 1024.0)) } } } #[derive(Debug, Clone)] pub struct BackupStats { pub enabled: bool, pub backup_count: usize, pub last_backup: Option, pub next_backup: Option, pub interval_hours: u64, pub max_snapshots: usize, } impl BackupStats { pub fn next_backup_in_secs(&self) -> Option { if !self.enabled { return None; } let now = current_time_secs(); let next = self.next_backup?; if next > now { Some(next - now) } else { Some(0) } } pub fn format_last_backup(&self) -> String { match self.last_backup { None => "Never".to_string(), Some(t) => chrono::Utc.timestamp_opt(t as i64, 0) .single() .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) .unwrap_or_else(|| format!("{} seconds since epoch", t)), } } pub fn format_next_backup(&self) -> String { match self.next_backup { None => "Not scheduled".to_string(), Some(t) => chrono::Utc.timestamp_opt(t as i64, 0) .single() .map(|dt| dt.format("%Y-%m-%d %H:%M:%S UTC").to_string()) .unwrap_or_else(|| format!("{} seconds since epoch", t)), } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_config() { let config = BackupScheduleConfig::default(); assert!(config.enabled); assert_eq!(config.interval_hours, 24); assert_eq!(config.max_snapshots, 7); assert!(config.auto_cleanup); } #[test] fn test_scheduler_creation() { let backend: Arc = Arc::new(super::super::local_fs::LocalFs::new()); let scheduler = BackupScheduler::with_defaults(backend, PathBuf::from("/tmp")); assert!(scheduler.is_enabled()); } #[test] fn test_schedule_next() { let backend: Arc = Arc::new(super::super::local_fs::LocalFs::new()); let mut scheduler = BackupScheduler::with_defaults(backend, PathBuf::from("/tmp")); scheduler.schedule_next(); assert!(scheduler.next_backup.is_some()); } #[test] fn test_backup_info_format() { let info = BackupInfo { name: "snap_test".to_string(), created_at: 1719234567, size: 1536, checksum_verified: true, compressed: true, encrypted: false, }; assert!(info.format_created().contains("2024")); assert!(info.format_size().contains("KB")); } #[test] fn test_backup_stats() { let now = current_time_secs(); let stats = BackupStats { enabled: true, backup_count: 5, last_backup: Some(now - 3600), next_backup: Some(now + 3600), interval_hours: 24, max_snapshots: 7, }; assert!(stats.enabled); assert_eq!(stats.backup_count, 5); assert!(stats.next_backup_in_secs().unwrap_or(0) > 0); } #[test] fn test_snapshot_name_generation() { let name = generate_snapshot_name(); assert!(name.starts_with("snap_")); assert!(name.len() > "snap_".len()); } }