use anyhow::{Context, Result}; use chrono::{DateTime, Utc}; use serde::Serialize; use uuid::Uuid; use super::panel_adapter::PanelAdapter; /// Metadata for a stored backup. #[derive(Debug, Clone, Serialize, sqlx::FromRow)] pub struct BackupInfo { pub id: Uuid, pub license_id: Uuid, pub wipe_history_id: Option, pub storage_path: String, pub size_bytes: i64, pub created_at: DateTime, } /// Pre-wipe backup and rollback management. /// /// Creates snapshots of server state (map, save data, plugin data, /// config files) before a wipe executes. Backups are used by the /// wipe engine's rollback mechanism if post-wipe verification fails. pub struct BackupManager { db: sqlx::PgPool, storage_base_path: String, } impl BackupManager { pub fn new(db: sqlx::PgPool, storage_base_path: String) -> Self { Self { db, storage_base_path, } } /// Create a pre-wipe backup of the server's current state. /// /// Captures: map/save files, plugin data directories, server.cfg, /// and any other files configured for backup in the wipe profile. /// Returns a backup reference ID stored in wipe_history. pub async fn create_backup( &self, adapter: &dyn PanelAdapter, server_id: &str, license_id: Uuid, wipe_history_id: Uuid, ) -> Result { // Generate backup ID let backup_id = Uuid::new_v4(); // Define files to backup let files_to_backup = vec![ "/server/rust/server.cfg", "/server/rust/server/procedural/", "/server/rust/oxide/data/", "/server/rust/oxide/config/", ]; // Create temporary directory for collecting files let temp_dir = std::env::temp_dir().join(format!("backup_{}", backup_id)); tokio::fs::create_dir_all(&temp_dir) .await .context("Failed to create temporary backup directory")?; // Download files from server via panel adapter for file_path in &files_to_backup { match adapter.get_file(server_id, file_path).await { Ok(data) => { // Create local path preserving structure let local_path = temp_dir.join(file_path.trim_start_matches('/')); if let Some(parent) = local_path.parent() { tokio::fs::create_dir_all(parent) .await .context("Failed to create backup directory structure")?; } tokio::fs::write(&local_path, data) .await .with_context(|| format!("Failed to write backup file: {}", file_path))?; } Err(e) => { tracing::warn!("Failed to backup file {}: {}", file_path, e); // Continue with other files } } } // Create tar.gz archive let archive_path = format!( "{}/{}/backup_{}.tar.gz", self.storage_base_path, license_id, backup_id ); // Ensure parent directory exists if let Some(parent) = std::path::Path::new(&archive_path).parent() { tokio::fs::create_dir_all(parent) .await .context("Failed to create backup storage directory")?; } // Create archive using tar command let output = tokio::process::Command::new("tar") .arg("-czf") .arg(&archive_path) .arg("-C") .arg(&temp_dir) .arg(".") .output() .await .context("Failed to execute tar command")?; if !output.status.success() { anyhow::bail!( "tar failed: {}", String::from_utf8_lossy(&output.stderr) ); } // Get archive size let metadata = tokio::fs::metadata(&archive_path) .await .context("Failed to read archive metadata")?; let size_bytes = metadata.len() as i64; // Clean up temp directory let _ = tokio::fs::remove_dir_all(&temp_dir).await; // Insert backup record in DB sqlx::query( "INSERT INTO backups (id, license_id, wipe_history_id, storage_path, size_bytes, created_at) VALUES ($1, $2, $3, $4, $5, NOW())", ) .bind(backup_id) .bind(license_id) .bind(wipe_history_id) .bind(&archive_path) .bind(size_bytes) .execute(&self.db) .await .context("Failed to insert backup record")?; tracing::info!( "Backup created: {} ({} bytes) for wipe {}", backup_id, size_bytes, wipe_history_id ); Ok(backup_id.to_string()) } /// Restore a previously created backup to the server. /// /// Used during rollback when post-wipe verification fails. pub async fn restore_backup( &self, adapter: &dyn PanelAdapter, server_id: &str, backup_reference: &str, ) -> Result<()> { // Parse backup ID let backup_id = Uuid::parse_str(backup_reference) .context("Invalid backup reference format")?; // Load backup record from DB let backup: Option<(String,)> = sqlx::query_as( "SELECT storage_path FROM backups WHERE id = $1", ) .bind(backup_id) .fetch_optional(&self.db) .await .context("Failed to query backup")?; if let Some((storage_path,)) = backup { // Create temporary directory for extraction let temp_dir = std::env::temp_dir().join(format!("restore_{}", backup_id)); tokio::fs::create_dir_all(&temp_dir) .await .context("Failed to create temporary restore directory")?; // Extract archive let output = tokio::process::Command::new("tar") .arg("-xzf") .arg(&storage_path) .arg("-C") .arg(&temp_dir) .output() .await .context("Failed to execute tar extraction")?; if !output.status.success() { anyhow::bail!( "tar extraction failed: {}", String::from_utf8_lossy(&output.stderr) ); } // Upload extracted files back to server self.upload_directory_recursive(adapter, server_id, &temp_dir, "/") .await?; // Clean up temp directory let _ = tokio::fs::remove_dir_all(&temp_dir).await; tracing::info!("Backup restored: {}", backup_reference); } else { anyhow::bail!("Backup not found: {}", backup_reference); } Ok(()) } /// Recursively upload directory contents to server. fn upload_directory_recursive<'a>( &'a self, adapter: &'a dyn PanelAdapter, server_id: &'a str, local_dir: &'a std::path::Path, remote_base: &'a str, ) -> std::pin::Pin> + 'a>> { Box::pin(async move { let mut entries = tokio::fs::read_dir(local_dir) .await .context("Failed to read local directory")?; while let Some(entry) = entries.next_entry().await? { let path = entry.path(); let file_name = entry.file_name(); let remote_path = format!( "{}/{}", remote_base.trim_end_matches('/'), file_name.to_string_lossy() ); if path.is_dir() { // Recurse into subdirectory self.upload_directory_recursive(adapter, server_id, &path, &remote_path) .await?; } else { // Upload file let data = tokio::fs::read(&path) .await .with_context(|| format!("Failed to read file: {:?}", path))?; adapter .put_file(server_id, &remote_path, &data) .await .with_context(|| format!("Failed to upload file: {}", remote_path))?; tracing::debug!("Restored file: {}", remote_path); } } Ok(()) }) } /// List all backups for a license, ordered by creation date (newest first). pub async fn list_backups(&self, license_id: Uuid) -> Result> { let backups: Vec = sqlx::query_as( "SELECT id, license_id, wipe_history_id, storage_path, size_bytes, created_at FROM backups WHERE license_id = $1 ORDER BY created_at DESC", ) .bind(license_id) .fetch_all(&self.db) .await .context("Failed to query backups")?; Ok(backups) } /// Clean up old backups beyond the configured retention period/count. pub async fn cleanup_old_backups(&self, license_id: Uuid) -> Result { // Load retention config (default: keep last 10 backups) let retention_count = 10; // Query backups older than retention threshold let old_backups: Vec<(Uuid, String)> = sqlx::query_as( "SELECT id, storage_path FROM backups WHERE license_id = $1 ORDER BY created_at DESC OFFSET $2", ) .bind(license_id) .bind(retention_count) .fetch_all(&self.db) .await .context("Failed to query old backups")?; let mut deleted_count = 0; for (backup_id, storage_path) in old_backups { // Delete backup file from storage if tokio::fs::remove_file(&storage_path).await.is_err() { tracing::warn!("Failed to delete backup file: {}", storage_path); } // Delete backup record from DB sqlx::query("DELETE FROM backups WHERE id = $1") .bind(backup_id) .execute(&self.db) .await .context("Failed to delete backup record")?; deleted_count += 1; } if deleted_count > 0 { tracing::info!( "Cleaned up {} old backups for license {}", deleted_count, license_id ); } Ok(deleted_count) } }