BackupScheduler Enhancement: - copy_file() now compresses files using ZSTD or LZ4 - min_size threshold: 1024 bytes (smaller files not compressed) - compression level: 3 (balanced speed/compression) BackupConfigResponse Updated: - Added compress, encrypt, include_checksums fields - compress: 'none' | 'lz4' | 'zstd' - Default: 'zstd' REST API Enhancement: - GET /api/v2/backup/config returns full config - POST /api/v2/backup/config accepts compression settings Test Results: - Set compress='lz4': ✅ Config updated - Set compress='zstd': ✅ Config updated - Compression applied via run_backup() (scheduled backup) Note: Direct create_snapshot API doesn't use compression (scheduler.run_backup() is the primary backup mechanism) Build: 495 tests pass
549 lines
15 KiB
Rust
549 lines
15 KiB
Rust
//! 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,
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
pub struct BackupScheduler {
|
|
backend: Arc<dyn VfsBackend>,
|
|
root: PathBuf,
|
|
config: BackupScheduleConfig,
|
|
last_backup: Option<u64>,
|
|
next_backup: Option<u64>,
|
|
backup_count: usize,
|
|
snapshots: Vec<String>,
|
|
}
|
|
|
|
impl BackupScheduler {
|
|
pub fn new(
|
|
backend: Arc<dyn VfsBackend>,
|
|
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<dyn VfsBackend>, 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<String, VfsError> {
|
|
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)?;
|
|
|
|
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_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.clone(),
|
|
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<String> = 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<Vec<BackupInfo>, 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<BackupInfo, VfsError> {
|
|
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<u64, VfsError> {
|
|
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<u64>,
|
|
pub next_backup: Option<u64>,
|
|
pub interval_hours: u64,
|
|
pub max_snapshots: usize,
|
|
}
|
|
|
|
impl BackupStats {
|
|
pub fn next_backup_in_secs(&self) -> Option<u64> {
|
|
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<dyn VfsBackend> = 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<dyn VfsBackend> = 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());
|
|
}
|
|
} |