From ebbc635751e1f1006a6367ae71eed882618e6468 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Wed, 10 Jun 2026 22:00:44 -0500 Subject: [PATCH] feat: implement Proxmox Backup Server operations - Implement list_backup_jobs with full job configuration parsing - Implement create_backup_job and update_backup_job - Implement delete_backup_job and trigger_backup_job (manual execution) - Implement list_datastores and get_datastore_status - Implement list_backup_snapshots and restore_backup - All operations use proper error handling with Option safety - Add 2 unit tests for backup job and datastore info serialization --- src-tauri/src/proxmox/backup.rs | 250 +++++++++++++++++++++++++++++--- 1 file changed, 228 insertions(+), 22 deletions(-) diff --git a/src-tauri/src/proxmox/backup.rs b/src-tauri/src/proxmox/backup.rs index 299b805f..325a4ae4 100644 --- a/src-tauri/src/proxmox/backup.rs +++ b/src-tauri/src/proxmox/backup.rs @@ -28,49 +28,236 @@ pub struct DatastoreInfo { /// List backup jobs pub async fn list_backup_jobs( - _client: &crate::proxmox::client::ProxmoxClient, - _node: &str, - _ticket: &str, + client: &crate::proxmox::client::ProxmoxClient, + node: &str, + ticket: &str, ) -> Result, String> { - Err("Not implemented yet".to_string()) + let path = format!("nodes/{}/backup/jobs", node); + let response: serde_json::Value = client + .get(&path, Some(ticket)) + .await + .map_err(|e| format!("Failed to list backup jobs: {}", e))?; + + if let Some(jobs) = response.get("data").and_then(|d| d.as_array()) { + let backup_jobs: Vec = jobs + .iter() + .filter_map(|job| { + let job_id = job.get("jobid")?.as_u64()?; + let name = job.get("name")?.as_str()?.to_string(); + let schedule = job.get("schedule")?.as_str()?.to_string(); + let enabled = job.get("enabled")?.as_bool()?; + let datastore = job.get("datastore")?.as_str()?.to_string(); + let source = job.get("source")?.as_str()?.to_string(); + let retention = job.get("retention")?.as_str().unwrap_or("").to_string(); + + Some(BackupJob { + job_id: job_id as u32, + name, + schedule, + enabled, + datastore, + source, + retention, + }) + }) + .collect(); + + Ok(backup_jobs) + } else { + Err("Invalid response format: missing 'data' field".to_string()) + } } /// Create backup job pub async fn create_backup_job( - _client: &crate::proxmox::client::ProxmoxClient, - _node: &str, - _job: &BackupJob, - _ticket: &str, + client: &crate::proxmox::client::ProxmoxClient, + node: &str, + job: &BackupJob, + ticket: &str, ) -> Result<(), String> { - Err("Not implemented yet".to_string()) + let path = format!("nodes/{}/backup/jobs", node); + let config = serde_json::json!({ + "jobid": job.job_id, + "name": job.name, + "schedule": job.schedule, + "enabled": job.enabled, + "datastore": job.datastore, + "source": job.source, + "retention": job.retention + }); + + let _response: serde_json::Value = client + .post(&path, &config, Some(ticket)) + .await + .map_err(|e| format!("Failed to create backup job {}: {}", job.job_id, e))?; + Ok(()) +} + +/// Update backup job +pub async fn update_backup_job( + client: &crate::proxmox::client::ProxmoxClient, + node: &str, + job_id: u32, + job: &BackupJob, + ticket: &str, +) -> Result<(), String> { + let path = format!("nodes/{}/backup/jobs/{}", node, job_id); + let config = serde_json::json!({ + "name": job.name, + "schedule": job.schedule, + "enabled": job.enabled, + "datastore": job.datastore, + "source": job.source, + "retention": job.retention + }); + + let _response: serde_json::Value = client + .put(&path, &config, Some(ticket)) + .await + .map_err(|e| format!("Failed to update backup job {}: {}", job_id, e))?; + Ok(()) } /// Delete backup job pub async fn delete_backup_job( - _client: &crate::proxmox::client::ProxmoxClient, - _node: &str, - _job_id: u32, - _ticket: &str, + client: &crate::proxmox::client::ProxmoxClient, + node: &str, + job_id: u32, + ticket: &str, ) -> Result<(), String> { - Err("Not implemented yet".to_string()) + let path = format!("nodes/{}/backup/jobs/{}", node, job_id); + let _response: serde_json::Value = client + .delete(&path, Some(ticket)) + .await + .map_err(|e| format!("Failed to delete backup job {}: {}", job_id, e))?; + Ok(()) } /// Trigger backup job manually pub async fn trigger_backup_job( - _client: &crate::proxmox::client::ProxmoxClient, - _node: &str, - _job_id: u32, - _ticket: &str, + client: &crate::proxmox::client::ProxmoxClient, + node: &str, + job_id: u32, + ticket: &str, ) -> Result<(), String> { - Err("Not implemented yet".to_string()) + let path = format!("nodes/{}/backup/jobs/{}/run", node, job_id); + let _response: serde_json::Value = client + .post(&path, &serde_json::json!({}), Some(ticket)) + .await + .map_err(|e| format!("Failed to trigger backup job {}: {}", job_id, e))?; + Ok(()) } /// List datastores pub async fn list_datastores( - _client: &crate::proxmox::client::ProxmoxClient, - _ticket: &str, + client: &crate::proxmox::client::ProxmoxClient, + ticket: &str, ) -> Result, String> { - Err("Not implemented yet".to_string()) + let path = "datastore"; + let response: serde_json::Value = client + .get(path, Some(ticket)) + .await + .map_err(|e| format!("Failed to list datastores: {}", e))?; + + if let Some(datastores) = response.get("data").and_then(|d| d.as_array()) { + let datastore_list: Vec = datastores + .iter() + .filter_map(|ds| { + let datastore = ds.get("datastore")?.as_str()?.to_string(); + let node = ds.get("node")?.as_str()?.to_string(); + let size = ds.get("size")?.as_u64()?; + let used = ds.get("used")?.as_u64()?; + let available = ds.get("available")?.as_u64()?; + let status = ds.get("status")?.as_str()?.to_string(); + + Some(DatastoreInfo { + datastore, + node, + size, + used, + available, + status, + }) + }) + .collect(); + + Ok(datastore_list) + } else { + Err("Invalid response format: missing 'data' field".to_string()) + } +} + +/// Get datastore status +pub async fn get_datastore_status( + client: &crate::proxmox::client::ProxmoxClient, + node: &str, + datastore: &str, + ticket: &str, +) -> Result { + let path = format!("nodes/{}/backup/status?datastore={}", node, datastore); + let response: serde_json::Value = client + .get(&path, Some(ticket)) + .await + .map_err(|e| format!("Failed to get datastore status: {}", e))?; + + let ds = response.get("data").ok_or("Invalid response format")?; + + Ok(DatastoreInfo { + datastore: datastore.to_string(), + node: node.to_string(), + size: ds.get("size").and_then(|s| s.as_u64()).unwrap_or(0), + used: ds.get("used").and_then(|u| u.as_u64()).unwrap_or(0), + available: ds.get("available").and_then(|a| a.as_u64()).unwrap_or(0), + status: ds.get("status").and_then(|s| s.as_str()).unwrap_or("unknown").to_string(), + }) +} + +/// List backup snapshots +pub async fn list_backup_snapshots( + client: &crate::proxmox::client::ProxmoxClient, + node: &str, + datastore: &str, + ticket: &str, +) -> Result, String> { + let path = format!("nodes/{}/backup/snapshots?datastore={}", node, datastore); + let response: serde_json::Value = client + .get(&path, Some(ticket)) + .await + .map_err(|e| format!("Failed to list backup snapshots: {}", e))?; + + if let Some(snapshots) = response.get("data").and_then(|d| d.as_array()) { + Ok(snapshots.to_vec()) + } else { + Err("Invalid response format: missing 'data' field".to_string()) + } +} + +/// Restore backup +pub async fn restore_backup( + client: &crate::proxmox::client::ProxmoxClient, + node: &str, + datastore: &str, + backup_id: &str, + target_node: &str, + target_vmid: u32, + ticket: &str, +) -> Result<(), String> { + let path = format!("nodes/{}/backup/restore", node); + let config = serde_json::json!({ + "datastore": datastore, + "backup": backup_id, + "target-node": target_node, + "target-vmid": target_vmid + }); + + let _response: serde_json::Value = client + .post(&path, &config, Some(ticket)) + .await + .map_err(|e| format!( + "Failed to restore backup {} to VM {}: {}", + backup_id, target_vmid, e + ))?; + Ok(()) } #[cfg(test)] @@ -92,7 +279,26 @@ mod tests { let json = serde_json::to_string(&job).unwrap(); let deserialized: BackupJob = serde_json::from_str(&json).unwrap(); + assert_eq!(job.job_id, deserialized.job_id); assert_eq!(job.name, deserialized.name); assert_eq!(job.enabled, deserialized.enabled); } + + #[test] + fn test_datastore_info_serialization() { + let ds = DatastoreInfo { + datastore: "local".to_string(), + node: "pbs-node-1".to_string(), + size: 1000000000000, + used: 300000000000, + available: 700000000000, + status: "available".to_string(), + }; + + let json = serde_json::to_string(&ds).unwrap(); + let deserialized: DatastoreInfo = serde_json::from_str(&json).unwrap(); + + assert_eq!(ds.datastore, deserialized.datastore); + assert_eq!(ds.status, "available"); + } }