mockforge_collab/
backup.rs

1//! Cloud backup and restore for workspaces
2
3use crate::core_bridge::CoreBridge;
4use crate::error::{CollabError, Result};
5use crate::history::VersionControl;
6use crate::workspace::WorkspaceService;
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use sqlx::{Pool, Sqlite};
10use std::path::Path;
11use std::sync::Arc;
12use uuid::Uuid;
13
14/// Storage backend type
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, sqlx::Type)]
16#[sqlx(type_name = "storage_backend", rename_all = "lowercase")]
17#[serde(rename_all = "lowercase")]
18pub enum StorageBackend {
19    /// Local filesystem
20    Local,
21    /// Amazon S3
22    S3,
23    /// Azure Blob Storage
24    Azure,
25    /// Google Cloud Storage
26    Gcs,
27    /// Custom storage backend
28    Custom,
29}
30
31/// Backup record
32#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
33pub struct WorkspaceBackup {
34    /// Unique backup ID
35    pub id: Uuid,
36    /// Workspace ID
37    pub workspace_id: Uuid,
38    /// Backup URL or path
39    pub backup_url: String,
40    /// Storage backend
41    pub storage_backend: StorageBackend,
42    /// Storage configuration (JSON)
43    pub storage_config: Option<serde_json::Value>,
44    /// Size in bytes
45    pub size_bytes: i64,
46    /// Backup format (yaml or json)
47    pub backup_format: String,
48    /// Whether backup is encrypted
49    pub encrypted: bool,
50    /// Commit ID this backup represents
51    pub commit_id: Option<Uuid>,
52    /// Created timestamp
53    pub created_at: DateTime<Utc>,
54    /// User who created the backup
55    pub created_by: Uuid,
56    /// Optional expiration date
57    pub expires_at: Option<DateTime<Utc>>,
58}
59
60impl WorkspaceBackup {
61    /// Create a new backup record
62    pub fn new(
63        workspace_id: Uuid,
64        backup_url: String,
65        storage_backend: StorageBackend,
66        size_bytes: i64,
67        created_by: Uuid,
68    ) -> Self {
69        Self {
70            id: Uuid::new_v4(),
71            workspace_id,
72            backup_url,
73            storage_backend,
74            storage_config: None,
75            size_bytes,
76            backup_format: "yaml".to_string(),
77            encrypted: false,
78            commit_id: None,
79            created_at: Utc::now(),
80            created_by,
81            expires_at: None,
82        }
83    }
84}
85
86/// Backup service for managing workspace backups
87pub struct BackupService {
88    db: Pool<Sqlite>,
89    version_control: VersionControl,
90    local_backup_dir: Option<String>,
91    core_bridge: Arc<CoreBridge>,
92    workspace_service: Arc<WorkspaceService>,
93}
94
95impl BackupService {
96    /// Create a new backup service
97    pub fn new(
98        db: Pool<Sqlite>,
99        local_backup_dir: Option<String>,
100        core_bridge: Arc<CoreBridge>,
101        workspace_service: Arc<WorkspaceService>,
102    ) -> Self {
103        Self {
104            db: db.clone(),
105            version_control: VersionControl::new(db),
106            local_backup_dir,
107            core_bridge,
108            workspace_service,
109        }
110    }
111
112    /// Create a backup of a workspace
113    ///
114    /// Exports the workspace to the specified storage backend.
115    /// For now, we support local filesystem backups. Cloud storage
116    /// backends (S3, Azure, GCS) can be added later.
117    pub async fn backup_workspace(
118        &self,
119        workspace_id: Uuid,
120        user_id: Uuid,
121        storage_backend: StorageBackend,
122        format: Option<String>,
123        commit_id: Option<Uuid>,
124    ) -> Result<WorkspaceBackup> {
125        // Get workspace data using CoreBridge to get full workspace state
126        let workspace = self
127            .workspace_service
128            .get_workspace(workspace_id)
129            .await
130            .map_err(|e| CollabError::Internal(format!("Failed to get workspace: {}", e)))?;
131
132        // Use CoreBridge to get full workspace state from mockforge-core
133        // This integrates with mockforge-core to get the complete workspace state
134        // including all mocks, folders, and configuration
135        let workspace_data = self
136            .core_bridge
137            .export_workspace_for_backup(&workspace)
138            .await
139            .map_err(|e| CollabError::Internal(format!("Failed to export workspace: {}", e)))?;
140
141        // Serialize workspace data
142        let backup_format = format.unwrap_or_else(|| "yaml".to_string());
143        let serialized = match backup_format.as_str() {
144            "yaml" => serde_yaml::to_string(&workspace_data).map_err(|e| {
145                CollabError::Internal(format!("Failed to serialize to YAML: {}", e))
146            })?,
147            "json" => serde_json::to_string_pretty(&workspace_data).map_err(|e| {
148                CollabError::Internal(format!("Failed to serialize to JSON: {}", e))
149            })?,
150            _ => {
151                return Err(CollabError::InvalidInput(format!(
152                    "Unsupported backup format: {}",
153                    backup_format
154                )));
155            }
156        };
157
158        let size_bytes = serialized.len() as i64;
159
160        // Save to storage backend
161        let backup_url = match storage_backend {
162            StorageBackend::Local => {
163                self.save_to_local(workspace_id, &serialized, &backup_format).await?
164            }
165            StorageBackend::S3 => {
166                return Err(CollabError::Internal("S3 backup not yet implemented".to_string()));
167            }
168            StorageBackend::Azure => {
169                return Err(CollabError::Internal("Azure backup not yet implemented".to_string()));
170            }
171            StorageBackend::Gcs => {
172                return Err(CollabError::Internal("GCS backup not yet implemented".to_string()));
173            }
174            StorageBackend::Custom => {
175                return Err(CollabError::Internal(
176                    "Custom storage backend not yet implemented".to_string(),
177                ));
178            }
179        };
180
181        // Create backup record
182        let mut backup =
183            WorkspaceBackup::new(workspace_id, backup_url, storage_backend, size_bytes, user_id);
184        backup.backup_format = backup_format;
185        backup.commit_id = commit_id;
186
187        // Save to database
188        sqlx::query!(
189            r#"
190            INSERT INTO workspace_backups (
191                id, workspace_id, backup_url, storage_backend, storage_config,
192                size_bytes, backup_format, encrypted, commit_id, created_at, created_by, expires_at
193            )
194            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
195            "#,
196            backup.id,
197            backup.workspace_id,
198            backup.backup_url,
199            backup.storage_backend,
200            backup.storage_config,
201            backup.size_bytes,
202            backup.backup_format,
203            backup.encrypted,
204            backup.commit_id,
205            backup.created_at,
206            backup.created_by,
207            backup.expires_at
208        )
209        .execute(&self.db)
210        .await?;
211
212        Ok(backup)
213    }
214
215    /// Restore a workspace from a backup
216    pub async fn restore_workspace(
217        &self,
218        backup_id: Uuid,
219        target_workspace_id: Option<Uuid>,
220        user_id: Uuid,
221    ) -> Result<Uuid> {
222        // Get backup record
223        let backup = self.get_backup(backup_id).await?;
224
225        // Load backup data
226        let backup_data = match backup.storage_backend {
227            StorageBackend::Local => self.load_from_local(&backup.backup_url).await?,
228            _ => {
229                return Err(CollabError::Internal(
230                    "Only local backups are supported for restore".to_string(),
231                ));
232            }
233        };
234
235        // Deserialize workspace data
236        let workspace_data: serde_json::Value = match backup.backup_format.as_str() {
237            "yaml" => serde_yaml::from_str(&backup_data)
238                .map_err(|e| CollabError::Internal(format!("Failed to deserialize YAML: {}", e)))?,
239            "json" => serde_json::from_str(&backup_data)
240                .map_err(|e| CollabError::Internal(format!("Failed to deserialize JSON: {}", e)))?,
241            _ => {
242                return Err(CollabError::Internal(format!(
243                    "Unsupported backup format: {}",
244                    backup.backup_format
245                )));
246            }
247        };
248
249        // Get the user who created the backup (or use a default - this should be passed in)
250        // For now, we'll need to get it from the backup record
251        let backup_record = self.get_backup(backup_id).await?;
252        let owner_id = backup_record.created_by;
253
254        // Import workspace from backup using CoreBridge
255        let restored_team_workspace = self
256            .core_bridge
257            .import_workspace_from_backup(&workspace_data, owner_id, None)
258            .await?;
259
260        // Determine target workspace ID
261        let restored_workspace_id = target_workspace_id.unwrap_or(backup.workspace_id);
262
263        // If restoring to a different workspace, update the ID
264        let mut team_workspace = if restored_workspace_id != backup.workspace_id {
265            // Create new workspace with the restored data
266            let mut new_workspace = restored_team_workspace;
267            new_workspace.id = restored_workspace_id;
268            new_workspace
269        } else {
270            // Update existing workspace
271            restored_team_workspace
272        };
273
274        // Update the workspace in the database
275        // This is a simplified version - in production, you'd want to use WorkspaceService
276        // For now, we'll save it to disk and let the system pick it up
277        self.core_bridge.save_workspace_to_disk(&team_workspace).await?;
278
279        // Create restore commit if specified
280        if let Some(commit_id) = backup.commit_id {
281            // Restore to specific commit
282            let _ =
283                self.version_control.restore_to_commit(restored_workspace_id, commit_id).await?;
284        }
285
286        Ok(restored_workspace_id)
287    }
288
289    /// List all backups for a workspace
290    pub async fn list_backups(
291        &self,
292        workspace_id: Uuid,
293        limit: Option<i32>,
294    ) -> Result<Vec<WorkspaceBackup>> {
295        let limit = limit.unwrap_or(100);
296        let workspace_id_str = workspace_id.to_string();
297
298        let rows = sqlx::query!(
299            r#"
300            SELECT
301                id,
302                workspace_id,
303                backup_url,
304                storage_backend,
305                storage_config,
306                size_bytes,
307                backup_format,
308                encrypted,
309                commit_id,
310                created_at,
311                created_by,
312                expires_at
313            FROM workspace_backups
314            WHERE workspace_id = ?
315            ORDER BY created_at DESC
316            LIMIT ?
317            "#,
318            workspace_id_str,
319            limit
320        )
321        .fetch_all(&self.db)
322        .await?;
323
324        let backups: Result<Vec<WorkspaceBackup>> = rows
325            .into_iter()
326            .map(|row| {
327                Ok(WorkspaceBackup {
328                    id: Uuid::parse_str(&row.id)
329                        .map_err(|e| CollabError::Internal(format!("Invalid UUID: {}", e)))?,
330                    workspace_id: Uuid::parse_str(&row.workspace_id)
331                        .map_err(|e| CollabError::Internal(format!("Invalid UUID: {}", e)))?,
332                    backup_url: row.backup_url,
333                    storage_backend: serde_json::from_str(&row.storage_backend).map_err(|e| {
334                        CollabError::Internal(format!("Invalid storage_backend: {}", e))
335                    })?,
336                    storage_config: row
337                        .storage_config
338                        .as_ref()
339                        .and_then(|s| serde_json::from_str(s).ok()),
340                    size_bytes: row.size_bytes,
341                    backup_format: row.backup_format,
342                    encrypted: row.encrypted != 0,
343                    commit_id: row.commit_id.as_ref().and_then(|s| Uuid::parse_str(s).ok()),
344                    created_at: chrono::DateTime::parse_from_rfc3339(&row.created_at)
345                        .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {}", e)))?
346                        .with_timezone(&chrono::Utc),
347                    created_by: Uuid::parse_str(&row.created_by)
348                        .map_err(|e| CollabError::Internal(format!("Invalid UUID: {}", e)))?,
349                    expires_at: row
350                        .expires_at
351                        .as_ref()
352                        .map(|s| {
353                            chrono::DateTime::parse_from_rfc3339(s)
354                                .map(|dt| dt.with_timezone(&chrono::Utc))
355                                .map_err(|e| {
356                                    CollabError::Internal(format!("Invalid timestamp: {}", e))
357                                })
358                        })
359                        .transpose()?,
360                })
361            })
362            .collect();
363        let backups = backups?;
364
365        Ok(backups)
366    }
367
368    /// Get a backup by ID
369    pub async fn get_backup(&self, backup_id: Uuid) -> Result<WorkspaceBackup> {
370        let backup_id_str = backup_id.to_string();
371        let row = sqlx::query!(
372            r#"
373            SELECT
374                id,
375                workspace_id,
376                backup_url,
377                storage_backend,
378                storage_config,
379                size_bytes,
380                backup_format,
381                encrypted,
382                commit_id,
383                created_at,
384                created_by,
385                expires_at
386            FROM workspace_backups
387            WHERE id = ?
388            "#,
389            backup_id_str
390        )
391        .fetch_optional(&self.db)
392        .await?
393        .ok_or_else(|| CollabError::Internal(format!("Backup not found: {}", backup_id)))?;
394
395        Ok(WorkspaceBackup {
396            id: Uuid::parse_str(&row.id)
397                .map_err(|e| CollabError::Internal(format!("Invalid UUID: {}", e)))?,
398            workspace_id: Uuid::parse_str(&row.workspace_id)
399                .map_err(|e| CollabError::Internal(format!("Invalid UUID: {}", e)))?,
400            backup_url: row.backup_url,
401            storage_backend: serde_json::from_str(&row.storage_backend)
402                .map_err(|e| CollabError::Internal(format!("Invalid storage_backend: {}", e)))?,
403            storage_config: row.storage_config.as_ref().and_then(|s| serde_json::from_str(s).ok()),
404            size_bytes: row.size_bytes,
405            backup_format: row.backup_format,
406            encrypted: row.encrypted != 0,
407            commit_id: row.commit_id.as_ref().and_then(|s| Uuid::parse_str(s).ok()),
408            created_at: chrono::DateTime::parse_from_rfc3339(&row.created_at)
409                .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {}", e)))?
410                .with_timezone(&chrono::Utc),
411            created_by: Uuid::parse_str(&row.created_by)
412                .map_err(|e| CollabError::Internal(format!("Invalid UUID: {}", e)))?,
413            expires_at: row
414                .expires_at
415                .as_ref()
416                .map(|s| {
417                    chrono::DateTime::parse_from_rfc3339(s)
418                        .map(|dt| dt.with_timezone(&chrono::Utc))
419                        .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {}", e)))
420                })
421                .transpose()?,
422        })
423    }
424
425    /// Delete a backup
426    pub async fn delete_backup(&self, backup_id: Uuid) -> Result<()> {
427        // Get backup record to get the URL
428        let backup = self.get_backup(backup_id).await?;
429
430        // Delete from storage
431        match backup.storage_backend {
432            StorageBackend::Local => {
433                if Path::new(&backup.backup_url).exists() {
434                    tokio::fs::remove_file(&backup.backup_url).await.map_err(|e| {
435                        CollabError::Internal(format!("Failed to delete backup file: {}", e))
436                    })?;
437                }
438            }
439            StorageBackend::S3 => {
440                self.delete_from_s3(&backup.backup_url, backup.storage_config.as_ref()).await?;
441            }
442            StorageBackend::Azure => {
443                self.delete_from_azure(&backup.backup_url, backup.storage_config.as_ref())
444                    .await?;
445            }
446            StorageBackend::Gcs => {
447                self.delete_from_gcs(&backup.backup_url, backup.storage_config.as_ref()).await?;
448            }
449            StorageBackend::Custom => {
450                return Err(CollabError::Internal(
451                    "Custom storage backend deletion not implemented".to_string(),
452                ));
453            }
454        }
455
456        // Delete from database
457        let backup_id_str = backup_id.to_string();
458        sqlx::query!(
459            r#"
460            DELETE FROM workspace_backups
461            WHERE id = ?
462            "#,
463            backup_id_str
464        )
465        .execute(&self.db)
466        .await?;
467
468        Ok(())
469    }
470
471    /// Save backup to local filesystem
472    async fn save_to_local(&self, workspace_id: Uuid, data: &str, format: &str) -> Result<String> {
473        let backup_dir = self.local_backup_dir.as_ref().ok_or_else(|| {
474            CollabError::Internal("Local backup directory not configured".to_string())
475        })?;
476
477        // Ensure backup directory exists
478        tokio::fs::create_dir_all(backup_dir).await.map_err(|e| {
479            CollabError::Internal(format!("Failed to create backup directory: {}", e))
480        })?;
481
482        // Create backup filename with timestamp
483        let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
484        let filename = format!("workspace_{}_{}.{}", workspace_id, timestamp, format);
485        let backup_path = Path::new(backup_dir).join(&filename);
486
487        // Write backup file
488        tokio::fs::write(&backup_path, data)
489            .await
490            .map_err(|e| CollabError::Internal(format!("Failed to write backup file: {}", e)))?;
491
492        Ok(backup_path.to_string_lossy().to_string())
493    }
494
495    /// Load backup from local filesystem
496    async fn load_from_local(&self, backup_url: &str) -> Result<String> {
497        tokio::fs::read_to_string(backup_url)
498            .await
499            .map_err(|e| CollabError::Internal(format!("Failed to read backup file: {}", e)))
500    }
501
502    /// Delete backup from S3
503    async fn delete_from_s3(
504        &self,
505        backup_url: &str,
506        storage_config: Option<&serde_json::Value>,
507    ) -> Result<()> {
508        // Parse S3 URL (format: s3://bucket-name/path/to/file)
509        if !backup_url.starts_with("s3://") {
510            return Err(CollabError::Internal(format!("Invalid S3 URL format: {}", backup_url)));
511        }
512
513        let url_parts: Vec<&str> =
514            backup_url.strip_prefix("s3://").unwrap().splitn(2, '/').collect();
515        if url_parts.len() != 2 {
516            return Err(CollabError::Internal(format!("Invalid S3 URL format: {}", backup_url)));
517        }
518
519        let bucket = url_parts[0];
520        let key = url_parts[1];
521
522        // Extract S3 credentials from storage_config
523        // Expected format: {"access_key_id": "...", "secret_access_key": "...", "region": "..."}
524        let _access_key = storage_config
525            .and_then(|c| c.get("access_key_id"))
526            .and_then(|v| v.as_str())
527            .ok_or_else(|| {
528                CollabError::Internal("S3 access_key_id not found in storage_config".to_string())
529            })?;
530
531        let _secret_key = storage_config
532            .and_then(|c| c.get("secret_access_key"))
533            .and_then(|v| v.as_str())
534            .ok_or_else(|| {
535                CollabError::Internal(
536                    "S3 secret_access_key not found in storage_config".to_string(),
537                )
538            })?;
539
540        // Note: In production, you would use the aws-sdk-s3 crate here
541        // For now, we'll return an error indicating S3 deletion requires the SDK
542        tracing::warn!(
543            "S3 deletion requires aws-sdk-s3. Backup URL: {}, Bucket: {}, Key: {}",
544            backup_url,
545            bucket,
546            key
547        );
548
549        // TODO: Implement actual S3 deletion using aws-sdk-s3
550        // Example:
551        // use aws_sdk_s3::Client;
552        // let client = Client::new(&aws_config).await;
553        // client.delete_object().bucket(bucket).key(key).send().await?;
554
555        Err(CollabError::Internal(
556            "S3 deletion requires aws-sdk-s3 dependency. Please install it to enable S3 support."
557                .to_string(),
558        ))
559    }
560
561    /// Delete backup from Azure Blob Storage
562    async fn delete_from_azure(
563        &self,
564        backup_url: &str,
565        storage_config: Option<&serde_json::Value>,
566    ) -> Result<()> {
567        // Parse Azure URL (format: https://account.blob.core.windows.net/container/path)
568        if !backup_url.contains("blob.core.windows.net") {
569            return Err(CollabError::Internal(format!(
570                "Invalid Azure Blob URL format: {}",
571                backup_url
572            )));
573        }
574
575        // Extract account, container, and blob name from URL
576        // Expected format: https://{account}.blob.core.windows.net/{container}/{blob}
577        let url_parts: Vec<&str> = backup_url.splitn(4, '/').collect();
578        if url_parts.len() < 4 {
579            return Err(CollabError::Internal(format!(
580                "Invalid Azure Blob URL format: {}",
581                backup_url
582            )));
583        }
584
585        // Extract account name from hostname
586        let hostname = url_parts[2];
587        let _account = hostname
588            .split('.')
589            .next()
590            .ok_or_else(|| CollabError::Internal("Invalid Azure hostname".to_string()))?;
591
592        let _container = url_parts[3]
593            .split('/')
594            .next()
595            .ok_or_else(|| CollabError::Internal("Invalid Azure container path".to_string()))?;
596
597        let _blob_name = backup_url
598            .splitn(5, '/')
599            .nth(4)
600            .ok_or_else(|| CollabError::Internal("Invalid Azure blob path".to_string()))?;
601
602        // Extract Azure credentials from storage_config
603        // Expected format: {"account_name": "...", "account_key": "..."}
604        let _account_name = storage_config
605            .and_then(|c| c.get("account_name"))
606            .and_then(|v| v.as_str())
607            .ok_or_else(|| {
608                CollabError::Internal("Azure account_name not found in storage_config".to_string())
609            })?;
610
611        // Note: In production, you would use the azure_storage_blobs crate here
612        tracing::warn!("Azure deletion requires azure-storage-blobs. Backup URL: {}", backup_url);
613
614        // TODO: Implement actual Azure deletion using azure-storage-blobs
615        Err(CollabError::Internal(
616            "Azure deletion requires azure-storage-blobs dependency. Please install it to enable Azure support.".to_string(),
617        ))
618    }
619
620    /// Delete backup from Google Cloud Storage
621    async fn delete_from_gcs(
622        &self,
623        backup_url: &str,
624        storage_config: Option<&serde_json::Value>,
625    ) -> Result<()> {
626        // Parse GCS URL (format: gs://bucket-name/path/to/file)
627        if !backup_url.starts_with("gs://") {
628            return Err(CollabError::Internal(format!("Invalid GCS URL format: {}", backup_url)));
629        }
630
631        let url_parts: Vec<&str> =
632            backup_url.strip_prefix("gs://").unwrap().splitn(2, '/').collect();
633        if url_parts.len() != 2 {
634            return Err(CollabError::Internal(format!("Invalid GCS URL format: {}", backup_url)));
635        }
636
637        let _bucket = url_parts[0];
638        let _object_name = url_parts[1];
639
640        // Extract GCS credentials from storage_config
641        // Expected format: {"service_account_key": "...", "project_id": "..."}
642        let _project_id = storage_config
643            .and_then(|c| c.get("project_id"))
644            .and_then(|v| v.as_str())
645            .ok_or_else(|| {
646                CollabError::Internal("GCS project_id not found in storage_config".to_string())
647            })?;
648
649        // Note: In production, you would use the google-cloud-storage crate here
650        tracing::warn!("GCS deletion requires google-cloud-storage. Backup URL: {}", backup_url);
651
652        // TODO: Implement actual GCS deletion using google-cloud-storage
653        Err(CollabError::Internal(
654            "GCS deletion requires google-cloud-storage dependency. Please install it to enable GCS support.".to_string(),
655        ))
656    }
657
658    /// Get workspace data for backup
659    ///
660    /// Gets the full workspace state from the TeamWorkspace and converts it to JSON.
661    async fn get_workspace_data(&self, workspace_id: Uuid) -> Result<serde_json::Value> {
662        // Get the TeamWorkspace
663        let team_workspace = self.workspace_service.get_workspace(workspace_id).await?;
664
665        // Use CoreBridge to get the full workspace state as JSON
666        self.core_bridge.get_workspace_state_json(&team_workspace)
667    }
668}