1use 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#[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,
21 S3,
23 Azure,
25 Gcs,
27 Custom,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
33pub struct WorkspaceBackup {
34 pub id: Uuid,
36 pub workspace_id: Uuid,
38 pub backup_url: String,
40 pub storage_backend: StorageBackend,
42 pub storage_config: Option<serde_json::Value>,
44 pub size_bytes: i64,
46 pub backup_format: String,
48 pub encrypted: bool,
50 pub commit_id: Option<Uuid>,
52 pub created_at: DateTime<Utc>,
54 pub created_by: Uuid,
56 pub expires_at: Option<DateTime<Utc>>,
58}
59
60impl WorkspaceBackup {
61 #[must_use]
63 pub fn new(
64 workspace_id: Uuid,
65 backup_url: String,
66 storage_backend: StorageBackend,
67 size_bytes: i64,
68 created_by: Uuid,
69 ) -> Self {
70 Self {
71 id: Uuid::new_v4(),
72 workspace_id,
73 backup_url,
74 storage_backend,
75 storage_config: None,
76 size_bytes,
77 backup_format: "yaml".to_string(),
78 encrypted: false,
79 commit_id: None,
80 created_at: Utc::now(),
81 created_by,
82 expires_at: None,
83 }
84 }
85}
86
87pub struct BackupService {
89 db: Pool<Sqlite>,
90 version_control: VersionControl,
91 local_backup_dir: Option<String>,
92 client: reqwest::Client,
93 core_bridge: Arc<CoreBridge>,
94 workspace_service: Arc<WorkspaceService>,
95}
96
97impl BackupService {
98 #[must_use]
100 pub fn new(
101 db: Pool<Sqlite>,
102 local_backup_dir: Option<String>,
103 core_bridge: Arc<CoreBridge>,
104 workspace_service: Arc<WorkspaceService>,
105 ) -> Self {
106 Self {
107 db: db.clone(),
108 version_control: VersionControl::new(db),
109 local_backup_dir,
110 client: reqwest::Client::new(),
111 core_bridge,
112 workspace_service,
113 }
114 }
115
116 pub async fn backup_workspace(
122 &self,
123 workspace_id: Uuid,
124 user_id: Uuid,
125 storage_backend: StorageBackend,
126 format: Option<String>,
127 commit_id: Option<Uuid>,
128 ) -> Result<WorkspaceBackup> {
129 self.backup_workspace_with_config(
130 workspace_id,
131 user_id,
132 storage_backend,
133 format,
134 commit_id,
135 None,
136 )
137 .await
138 }
139
140 pub async fn backup_workspace_with_config(
153 &self,
154 workspace_id: Uuid,
155 user_id: Uuid,
156 storage_backend: StorageBackend,
157 format: Option<String>,
158 commit_id: Option<Uuid>,
159 storage_config: Option<serde_json::Value>,
160 ) -> Result<WorkspaceBackup> {
161 let workspace = self
163 .workspace_service
164 .get_workspace(workspace_id)
165 .await
166 .map_err(|e| CollabError::Internal(format!("Failed to get workspace: {e}")))?;
167
168 let workspace_data = self
172 .core_bridge
173 .export_workspace_for_backup(&workspace)
174 .await
175 .map_err(|e| CollabError::Internal(format!("Failed to export workspace: {e}")))?;
176
177 let backup_format = format.unwrap_or_else(|| "yaml".to_string());
179 let serialized = match backup_format.as_str() {
180 "yaml" => serde_yaml::to_string(&workspace_data)
181 .map_err(|e| CollabError::Internal(format!("Failed to serialize to YAML: {e}")))?,
182 "json" => serde_json::to_string_pretty(&workspace_data)
183 .map_err(|e| CollabError::Internal(format!("Failed to serialize to JSON: {e}")))?,
184 _ => {
185 return Err(CollabError::InvalidInput(format!(
186 "Unsupported backup format: {backup_format}"
187 )));
188 }
189 };
190
191 let size_bytes = serialized.len() as i64;
192
193 let backup_url = match storage_backend {
195 StorageBackend::Local => {
196 self.save_to_local(workspace_id, &serialized, &backup_format).await?
197 }
198 StorageBackend::S3 => {
199 self.save_to_s3(workspace_id, &serialized, &backup_format, storage_config.as_ref())
200 .await?
201 }
202 StorageBackend::Azure => {
203 self.save_to_azure(
204 workspace_id,
205 &serialized,
206 &backup_format,
207 storage_config.as_ref(),
208 )
209 .await?
210 }
211 StorageBackend::Gcs => {
212 self.save_to_gcs(workspace_id, &serialized, &backup_format, storage_config.as_ref())
213 .await?
214 }
215 StorageBackend::Custom => {
216 self.save_to_custom(
217 workspace_id,
218 &serialized,
219 &backup_format,
220 storage_config.as_ref(),
221 )
222 .await?
223 }
224 };
225
226 let mut backup =
228 WorkspaceBackup::new(workspace_id, backup_url, storage_backend, size_bytes, user_id);
229 backup.backup_format = backup_format;
230 backup.storage_config = storage_config;
231 backup.commit_id = commit_id;
232
233 let storage_backend_str = match backup.storage_backend {
235 StorageBackend::Local => "local",
236 StorageBackend::S3 => "s3",
237 StorageBackend::Azure => "azure",
238 StorageBackend::Gcs => "gcs",
239 StorageBackend::Custom => "custom",
240 };
241 let storage_config_str = backup.storage_config.as_ref().map(|v| v.to_string());
242 let created_at_str = backup.created_at.to_rfc3339();
243 let expires_at_str = backup.expires_at.map(|dt| dt.to_rfc3339());
244
245 sqlx::query!(
247 r#"
248 INSERT INTO workspace_backups (
249 id, workspace_id, backup_url, storage_backend, storage_config,
250 size_bytes, backup_format, encrypted, commit_id, created_at, created_by, expires_at
251 )
252 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
253 "#,
254 backup.id,
255 backup.workspace_id,
256 backup.backup_url,
257 storage_backend_str,
258 storage_config_str,
259 backup.size_bytes,
260 backup.backup_format,
261 backup.encrypted,
262 backup.commit_id,
263 created_at_str,
264 backup.created_by,
265 expires_at_str
266 )
267 .execute(&self.db)
268 .await?;
269
270 Ok(backup)
271 }
272
273 pub async fn restore_workspace(
275 &self,
276 backup_id: Uuid,
277 target_workspace_id: Option<Uuid>,
278 _user_id: Uuid,
279 ) -> Result<Uuid> {
280 let backup = self.get_backup(backup_id).await?;
282
283 let backup_data = match backup.storage_backend {
285 StorageBackend::Local => self.load_from_local(&backup.backup_url).await?,
286 StorageBackend::Custom => {
287 self.load_from_custom(&backup.backup_url, backup.storage_config.as_ref())
288 .await?
289 }
290 _ => {
291 return Err(CollabError::Internal(
292 "Only local and custom backups are supported for restore".to_string(),
293 ));
294 }
295 };
296
297 let workspace_data: serde_json::Value = match backup.backup_format.as_str() {
299 "yaml" => serde_yaml::from_str(&backup_data)
300 .map_err(|e| CollabError::Internal(format!("Failed to deserialize YAML: {e}")))?,
301 "json" => serde_json::from_str(&backup_data)
302 .map_err(|e| CollabError::Internal(format!("Failed to deserialize JSON: {e}")))?,
303 _ => {
304 return Err(CollabError::Internal(format!(
305 "Unsupported backup format: {}",
306 backup.backup_format
307 )));
308 }
309 };
310
311 let backup_record = self.get_backup(backup_id).await?;
314 let owner_id = backup_record.created_by;
315
316 let restored_team_workspace = self
318 .core_bridge
319 .import_workspace_from_backup(&workspace_data, owner_id, None)
320 .await?;
321
322 let restored_workspace_id = target_workspace_id.unwrap_or(backup.workspace_id);
324
325 let team_workspace = if restored_workspace_id == backup.workspace_id {
327 restored_team_workspace
329 } else {
330 let mut new_workspace = restored_team_workspace;
332 new_workspace.id = restored_workspace_id;
333 new_workspace
334 };
335
336 self.core_bridge.save_workspace_to_disk(&team_workspace).await?;
340
341 if let Some(commit_id) = backup.commit_id {
343 let _ =
345 self.version_control.restore_to_commit(restored_workspace_id, commit_id).await?;
346 }
347
348 Ok(restored_workspace_id)
349 }
350
351 pub async fn list_backups(
353 &self,
354 workspace_id: Uuid,
355 limit: Option<i32>,
356 ) -> Result<Vec<WorkspaceBackup>> {
357 let limit = limit.unwrap_or(100);
358
359 let rows = sqlx::query!(
360 r#"
361 SELECT
362 id as "id: Uuid",
363 workspace_id as "workspace_id: Uuid",
364 backup_url,
365 storage_backend,
366 storage_config,
367 size_bytes,
368 backup_format,
369 encrypted,
370 commit_id as "commit_id: Uuid",
371 created_at,
372 created_by as "created_by: Uuid",
373 expires_at
374 FROM workspace_backups
375 WHERE workspace_id = ?
376 ORDER BY created_at DESC
377 LIMIT ?
378 "#,
379 workspace_id,
380 limit
381 )
382 .fetch_all(&self.db)
383 .await?;
384
385 let backups: Result<Vec<WorkspaceBackup>> = rows
386 .into_iter()
387 .map(|row| {
388 let storage_backend = match row.storage_backend.as_str() {
389 "local" => StorageBackend::Local,
390 "s3" => StorageBackend::S3,
391 "azure" => StorageBackend::Azure,
392 "gcs" => StorageBackend::Gcs,
393 "custom" => StorageBackend::Custom,
394 other => {
395 return Err(CollabError::Internal(format!(
396 "Invalid storage_backend: {other}"
397 )))
398 }
399 };
400 Ok(WorkspaceBackup {
401 id: row.id,
402 workspace_id: row.workspace_id,
403 backup_url: row.backup_url,
404 storage_backend,
405 storage_config: row
406 .storage_config
407 .as_ref()
408 .and_then(|s| serde_json::from_str(s).ok()),
409 size_bytes: row.size_bytes,
410 backup_format: row.backup_format,
411 encrypted: row.encrypted != 0,
412 commit_id: row.commit_id,
413 created_at: DateTime::parse_from_rfc3339(&row.created_at)
414 .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))?
415 .with_timezone(&Utc),
416 created_by: row.created_by,
417 expires_at: row
418 .expires_at
419 .as_ref()
420 .map(|s| {
421 DateTime::parse_from_rfc3339(s)
422 .map(|dt| dt.with_timezone(&Utc))
423 .map_err(|e| {
424 CollabError::Internal(format!("Invalid timestamp: {e}"))
425 })
426 })
427 .transpose()?,
428 })
429 })
430 .collect();
431 let backups = backups?;
432
433 Ok(backups)
434 }
435
436 pub async fn get_backup(&self, backup_id: Uuid) -> Result<WorkspaceBackup> {
438 let row = sqlx::query!(
439 r#"
440 SELECT
441 id as "id: Uuid",
442 workspace_id as "workspace_id: Uuid",
443 backup_url,
444 storage_backend,
445 storage_config,
446 size_bytes,
447 backup_format,
448 encrypted,
449 commit_id as "commit_id: Uuid",
450 created_at,
451 created_by as "created_by: Uuid",
452 expires_at
453 FROM workspace_backups
454 WHERE id = ?
455 "#,
456 backup_id
457 )
458 .fetch_optional(&self.db)
459 .await?
460 .ok_or_else(|| CollabError::Internal(format!("Backup not found: {backup_id}")))?;
461
462 let storage_backend = match row.storage_backend.as_str() {
463 "local" => StorageBackend::Local,
464 "s3" => StorageBackend::S3,
465 "azure" => StorageBackend::Azure,
466 "gcs" => StorageBackend::Gcs,
467 "custom" => StorageBackend::Custom,
468 other => {
469 return Err(CollabError::Internal(format!("Invalid storage_backend: {other}")))
470 }
471 };
472
473 Ok(WorkspaceBackup {
474 id: row.id,
475 workspace_id: row.workspace_id,
476 backup_url: row.backup_url,
477 storage_backend,
478 storage_config: row.storage_config.as_ref().and_then(|s| serde_json::from_str(s).ok()),
479 size_bytes: row.size_bytes,
480 backup_format: row.backup_format,
481 encrypted: row.encrypted != 0,
482 commit_id: row.commit_id,
483 created_at: DateTime::parse_from_rfc3339(&row.created_at)
484 .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))?
485 .with_timezone(&Utc),
486 created_by: row.created_by,
487 expires_at: row
488 .expires_at
489 .as_ref()
490 .map(|s| {
491 DateTime::parse_from_rfc3339(s)
492 .map(|dt| dt.with_timezone(&Utc))
493 .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))
494 })
495 .transpose()?,
496 })
497 }
498
499 pub async fn delete_backup(&self, backup_id: Uuid) -> Result<()> {
501 let backup = self.get_backup(backup_id).await?;
503
504 match backup.storage_backend {
506 StorageBackend::Local => {
507 if Path::new(&backup.backup_url).exists() {
508 tokio::fs::remove_file(&backup.backup_url).await.map_err(|e| {
509 CollabError::Internal(format!("Failed to delete backup file: {e}"))
510 })?;
511 }
512 }
513 StorageBackend::S3 => {
514 self.delete_from_s3(&backup.backup_url, backup.storage_config.as_ref()).await?;
515 }
516 StorageBackend::Azure => {
517 self.delete_from_azure(&backup.backup_url, backup.storage_config.as_ref())
518 .await?;
519 }
520 StorageBackend::Gcs => {
521 self.delete_from_gcs(&backup.backup_url, backup.storage_config.as_ref()).await?;
522 }
523 StorageBackend::Custom => {
524 self.delete_from_custom(&backup.backup_url, backup.storage_config.as_ref())
525 .await?;
526 }
527 }
528
529 sqlx::query!(
531 r#"
532 DELETE FROM workspace_backups
533 WHERE id = ?
534 "#,
535 backup_id
536 )
537 .execute(&self.db)
538 .await?;
539
540 Ok(())
541 }
542
543 async fn save_to_local(&self, workspace_id: Uuid, data: &str, format: &str) -> Result<String> {
545 let backup_dir = self.local_backup_dir.as_ref().ok_or_else(|| {
546 CollabError::Internal("Local backup directory not configured".to_string())
547 })?;
548
549 tokio::fs::create_dir_all(backup_dir).await.map_err(|e| {
551 CollabError::Internal(format!("Failed to create backup directory: {e}"))
552 })?;
553
554 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
556 let filename = format!("workspace_{workspace_id}_{timestamp}.{format}");
557 let backup_path = Path::new(backup_dir).join(&filename);
558
559 tokio::fs::write(&backup_path, data)
561 .await
562 .map_err(|e| CollabError::Internal(format!("Failed to write backup file: {e}")))?;
563
564 Ok(backup_path.to_string_lossy().to_string())
565 }
566
567 async fn load_from_local(&self, backup_url: &str) -> Result<String> {
569 tokio::fs::read_to_string(backup_url)
570 .await
571 .map_err(|e| CollabError::Internal(format!("Failed to read backup file: {e}")))
572 }
573
574 async fn save_to_custom(
582 &self,
583 workspace_id: Uuid,
584 data: &str,
585 format: &str,
586 storage_config: Option<&serde_json::Value>,
587 ) -> Result<String> {
588 let config = storage_config.ok_or_else(|| {
589 CollabError::Internal("Custom storage configuration required".to_string())
590 })?;
591
592 let upload_url = config.get("upload_url").and_then(|v| v.as_str()).ok_or_else(|| {
593 CollabError::Internal("Custom storage config must include 'upload_url'".to_string())
594 })?;
595
596 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
597 let filename = format!("workspace_{workspace_id}_{timestamp}.{format}");
598 let resolved_upload_url = upload_url.replace("{filename}", &filename);
599
600 let mut request = self.client.put(&resolved_upload_url).body(data.to_string()).header(
601 "content-type",
602 match format {
603 "yaml" => "application/x-yaml",
604 "json" => "application/json",
605 _ => "application/octet-stream",
606 },
607 );
608
609 if let Some(headers) = config.get("headers").and_then(|h| h.as_object()) {
610 for (key, value) in headers {
611 if let Some(value) = value.as_str() {
612 request = request.header(key, value);
613 }
614 }
615 }
616
617 let response = request
618 .send()
619 .await
620 .map_err(|e| CollabError::Internal(format!("Custom upload request failed: {e}")))?;
621
622 if !response.status().is_success() {
623 return Err(CollabError::Internal(format!(
624 "Custom upload failed with status {}",
625 response.status()
626 )));
627 }
628
629 if let Some(location) = response.headers().get("location").and_then(|v| v.to_str().ok()) {
630 return Ok(location.to_string());
631 }
632
633 if let Ok(body_json) = response.json::<serde_json::Value>().await {
634 if let Some(url) = body_json
635 .get("backup_url")
636 .or_else(|| body_json.get("url"))
637 .and_then(|v| v.as_str())
638 {
639 return Ok(url.to_string());
640 }
641 }
642
643 if let Some(base) = config.get("backup_url_base").and_then(|v| v.as_str()) {
644 return Ok(format!("{}/{}", base.trim_end_matches('/'), filename));
645 }
646
647 Ok(resolved_upload_url)
648 }
649
650 async fn load_from_custom(
652 &self,
653 backup_url: &str,
654 storage_config: Option<&serde_json::Value>,
655 ) -> Result<String> {
656 let mut request = self.client.get(backup_url);
657 if let Some(config) = storage_config {
658 if let Some(headers) = config.get("headers").and_then(|h| h.as_object()) {
659 for (key, value) in headers {
660 if let Some(value) = value.as_str() {
661 request = request.header(key, value);
662 }
663 }
664 }
665 }
666
667 let response = request
668 .send()
669 .await
670 .map_err(|e| CollabError::Internal(format!("Custom download request failed: {e}")))?;
671 if !response.status().is_success() {
672 return Err(CollabError::Internal(format!(
673 "Custom download failed with status {}",
674 response.status()
675 )));
676 }
677
678 response
679 .text()
680 .await
681 .map_err(|e| CollabError::Internal(format!("Failed to read custom backup body: {e}")))
682 }
683
684 #[allow(unused_variables)]
686 async fn save_to_s3(
687 &self,
688 workspace_id: Uuid,
689 data: &str,
690 format: &str,
691 storage_config: Option<&serde_json::Value>,
692 ) -> Result<String> {
693 #[cfg(feature = "s3")]
694 {
695 use aws_config::SdkConfig;
696 use aws_sdk_s3::config::{Credentials, Region};
697 use aws_sdk_s3::primitives::ByteStream;
698 use aws_sdk_s3::Client as S3Client;
699
700 let config = storage_config.ok_or_else(|| {
701 CollabError::Internal("S3 storage configuration required".to_string())
702 })?;
703
704 let bucket_name =
705 config.get("bucket_name").and_then(|v| v.as_str()).ok_or_else(|| {
706 CollabError::Internal("S3 bucket_name not found in storage_config".to_string())
707 })?;
708
709 let prefix = config.get("prefix").and_then(|v| v.as_str()).unwrap_or("backups");
710 let region_str = config.get("region").and_then(|v| v.as_str()).unwrap_or("us-east-1");
711
712 let aws_config: SdkConfig = if let (Some(access_key_id), Some(secret_access_key)) = (
713 config.get("access_key_id").and_then(|v| v.as_str()),
714 config.get("secret_access_key").and_then(|v| v.as_str()),
715 ) {
716 let credentials =
717 Credentials::new(access_key_id, secret_access_key, None, None, "mockforge");
718 aws_config::ConfigLoader::default()
719 .credentials_provider(credentials)
720 .region(Region::new(region_str.to_string()))
721 .load()
722 .await
723 } else {
724 aws_config::load_from_env().await
725 };
726
727 let client = S3Client::new(&aws_config);
728
729 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
730 let object_key =
731 format!("{}/workspace_{}_{}.{}", prefix, workspace_id, timestamp, format);
732 let content_type = match format {
733 "yaml" => "application/x-yaml",
734 "json" => "application/json",
735 _ => "application/octet-stream",
736 };
737
738 client
739 .put_object()
740 .bucket(bucket_name)
741 .key(&object_key)
742 .content_type(content_type)
743 .body(ByteStream::from(data.as_bytes().to_vec()))
744 .send()
745 .await
746 .map_err(|e| CollabError::Internal(format!("Failed to upload to S3: {}", e)))?;
747
748 let backup_url = format!("s3://{}/{}", bucket_name, object_key);
749 tracing::info!("Successfully uploaded backup to S3: {}", backup_url);
750 Ok(backup_url)
751 }
752
753 #[cfg(not(feature = "s3"))]
754 {
755 Err(CollabError::Internal(
756 "S3 backup requires 's3' feature to be enabled. Add 's3' feature to mockforge-collab in Cargo.toml.".to_string(),
757 ))
758 }
759 }
760
761 #[allow(unused_variables)]
763 async fn save_to_azure(
764 &self,
765 workspace_id: Uuid,
766 data: &str,
767 format: &str,
768 storage_config: Option<&serde_json::Value>,
769 ) -> Result<String> {
770 #[cfg(feature = "azure")]
771 {
772 use azure_identity::{DefaultAzureCredential, TokenCredentialOptions};
773 use azure_storage::StorageCredentials;
774 use azure_storage_blobs::prelude::*;
775 use std::sync::Arc;
776
777 let config = storage_config.ok_or_else(|| {
779 CollabError::Internal("Azure storage configuration required".to_string())
780 })?;
781
782 let account_name = config
783 .get("account_name")
784 .and_then(|v| v.as_str())
785 .map(|s| s.to_string())
786 .ok_or_else(|| {
787 CollabError::Internal(
788 "Azure account_name required in storage config".to_string(),
789 )
790 })?;
791
792 let container_name = config
793 .get("container_name")
794 .and_then(|v| v.as_str())
795 .map(|s| s.to_string())
796 .unwrap_or_else(|| "mockforge-backups".to_string());
797
798 let storage_credentials = if let Some(account_key) =
800 config.get("account_key").and_then(|v| v.as_str()).map(|s| s.to_string())
801 {
802 StorageCredentials::access_key(account_name.clone(), account_key)
803 } else if let Some(sas_token) =
804 config.get("sas_token").and_then(|v| v.as_str()).map(|s| s.to_string())
805 {
806 StorageCredentials::sas_token(sas_token)
807 .map_err(|e| CollabError::Internal(format!("Invalid SAS token: {}", e)))?
808 } else {
809 let credential = Arc::new(
810 DefaultAzureCredential::create(TokenCredentialOptions::default()).map_err(
811 |e| {
812 CollabError::Internal(format!(
813 "Failed to create Azure credentials: {}",
814 e
815 ))
816 },
817 )?,
818 );
819 StorageCredentials::token_credential(credential)
820 };
821
822 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
824 let blob_name = format!("workspace_{workspace_id}_{timestamp}.{format}");
825
826 let blob_client = ClientBuilder::new(account_name.clone(), storage_credentials)
828 .blob_client(&container_name, &blob_name);
829
830 blob_client
831 .put_block_blob(data.as_bytes().to_vec())
832 .content_type(match format {
833 "yaml" => "application/x-yaml",
834 "json" => "application/json",
835 _ => "application/octet-stream",
836 })
837 .await
838 .map_err(|e| CollabError::Internal(format!("Failed to upload to Azure: {}", e)))?;
839
840 let backup_url = format!(
841 "https://{}.blob.core.windows.net/{}/{}",
842 account_name, container_name, blob_name
843 );
844 tracing::info!("Successfully uploaded backup to Azure: {}", backup_url);
845 Ok(backup_url)
846 }
847
848 #[cfg(not(feature = "azure"))]
849 {
850 Err(CollabError::Internal(
851 "Azure backup requires 'azure' feature to be enabled. Add 'azure' feature to mockforge-collab in Cargo.toml.".to_string(),
852 ))
853 }
854 }
855
856 #[allow(unused_variables)]
858 async fn save_to_gcs(
859 &self,
860 workspace_id: Uuid,
861 data: &str,
862 format: &str,
863 storage_config: Option<&serde_json::Value>,
864 ) -> Result<String> {
865 #[cfg(feature = "gcs")]
866 {
867 use bytes::Bytes;
868 use google_cloud_storage::client::Storage;
869
870 let config = storage_config.ok_or_else(|| {
872 CollabError::Internal("GCS storage configuration required".to_string())
873 })?;
874
875 let bucket_name = config
876 .get("bucket_name")
877 .and_then(|v| v.as_str())
878 .unwrap_or("mockforge-backups");
879
880 let client = Storage::builder().build().await.map_err(|e| {
882 CollabError::Internal(format!("Failed to create GCS client: {}", e))
883 })?;
884
885 let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
887 let object_name = format!("workspace_{workspace_id}_{timestamp}.{format}");
888
889 let payload = Bytes::from(data.as_bytes().to_vec());
892 client
893 .write_object(bucket_name, &object_name, payload)
894 .send_unbuffered()
895 .await
896 .map_err(|e| CollabError::Internal(format!("Failed to upload to GCS: {}", e)))?;
897
898 let backup_url = format!("gs://{}/{}", bucket_name, object_name);
899 tracing::info!("Successfully uploaded backup to GCS: {}", backup_url);
900 Ok(backup_url)
901 }
902
903 #[cfg(not(feature = "gcs"))]
904 {
905 Err(CollabError::Internal(
906 "GCS backup requires 'gcs' feature to be enabled. Add 'gcs' feature to mockforge-collab in Cargo.toml.".to_string(),
907 ))
908 }
909 }
910
911 async fn delete_from_s3(
913 &self,
914 backup_url: &str,
915 storage_config: Option<&serde_json::Value>,
916 ) -> Result<()> {
917 #[cfg(not(feature = "s3"))]
918 let _ = (backup_url, storage_config);
919 #[cfg(feature = "s3")]
920 {
921 use aws_config::SdkConfig;
922 use aws_sdk_s3::config::{Credentials, Region};
923 use aws_sdk_s3::Client as S3Client;
924
925 if !backup_url.starts_with("s3://") {
927 return Err(CollabError::Internal(format!(
928 "Invalid S3 URL format: {}",
929 backup_url
930 )));
931 }
932
933 let url_parts: Vec<&str> = backup_url
934 .strip_prefix("s3://")
935 .ok_or_else(|| {
936 CollabError::Internal(format!("Invalid S3 URL format: {}", backup_url))
937 })?
938 .splitn(2, '/')
939 .collect();
940 if url_parts.len() != 2 {
941 return Err(CollabError::Internal(format!(
942 "Invalid S3 URL format: {}",
943 backup_url
944 )));
945 }
946
947 let bucket = url_parts[0];
948 let key = url_parts[1];
949
950 let aws_config: SdkConfig = if let Some(config) = storage_config {
952 let access_key_id =
955 config.get("access_key_id").and_then(|v| v.as_str()).ok_or_else(|| {
956 CollabError::Internal(
957 "S3 access_key_id not found in storage_config".to_string(),
958 )
959 })?;
960
961 let secret_access_key =
962 config.get("secret_access_key").and_then(|v| v.as_str()).ok_or_else(|| {
963 CollabError::Internal(
964 "S3 secret_access_key not found in storage_config".to_string(),
965 )
966 })?;
967
968 let region_str =
969 config.get("region").and_then(|v| v.as_str()).unwrap_or("us-east-1");
970
971 let credentials = Credentials::new(
973 access_key_id,
974 secret_access_key,
975 None, None, "mockforge",
978 );
979
980 aws_config::ConfigLoader::default()
982 .credentials_provider(credentials)
983 .region(Region::new(region_str.to_string()))
984 .load()
985 .await
986 } else {
987 aws_config::load_from_env().await
989 };
990
991 let client = S3Client::new(&aws_config);
993
994 client
996 .delete_object()
997 .bucket(bucket)
998 .key(key)
999 .send()
1000 .await
1001 .map_err(|e| CollabError::Internal(format!("Failed to delete S3 object: {}", e)))?;
1002
1003 tracing::info!("Successfully deleted S3 object: {}", backup_url);
1004 Ok(())
1005 }
1006
1007 #[cfg(not(feature = "s3"))]
1008 {
1009 Err(CollabError::Internal(
1010 "S3 deletion requires 's3' feature to be enabled. Add 's3' feature to mockforge-collab in Cargo.toml.".to_string(),
1011 ))
1012 }
1013 }
1014
1015 async fn delete_from_azure(
1017 &self,
1018 backup_url: &str,
1019 storage_config: Option<&serde_json::Value>,
1020 ) -> Result<()> {
1021 #[cfg(feature = "azure")]
1022 {
1023 use azure_identity::{DefaultAzureCredential, TokenCredentialOptions};
1024 use azure_storage::StorageCredentials;
1025 use azure_storage_blobs::prelude::*;
1026 use std::sync::Arc;
1027
1028 if !backup_url.contains("blob.core.windows.net") {
1030 return Err(CollabError::Internal(format!(
1031 "Invalid Azure Blob URL format: {}",
1032 backup_url
1033 )));
1034 }
1035
1036 let url = url::Url::parse(backup_url)
1038 .map_err(|e| CollabError::Internal(format!("Invalid Azure URL: {}", e)))?;
1039
1040 let hostname = url
1042 .host_str()
1043 .ok_or_else(|| CollabError::Internal("Invalid Azure hostname".to_string()))?;
1044 let account_name = hostname.split('.').next().ok_or_else(|| {
1045 CollabError::Internal("Invalid Azure hostname format".to_string())
1046 })?;
1047
1048 let path = url.path();
1050 let path_parts: Vec<&str> = path.splitn(3, '/').filter(|s| !s.is_empty()).collect();
1051 if path_parts.len() < 2 {
1052 return Err(CollabError::Internal(format!("Invalid Azure blob path: {}", path)));
1053 }
1054
1055 let container_name = path_parts[0].to_string();
1056 let blob_name = path_parts[1..].join("/");
1057 let account_name = account_name.to_string();
1058
1059 let create_default_creds = || -> Result<StorageCredentials> {
1061 let credential = Arc::new(
1062 DefaultAzureCredential::create(TokenCredentialOptions::default()).map_err(
1063 |e| {
1064 CollabError::Internal(format!(
1065 "Failed to create Azure credentials: {}",
1066 e
1067 ))
1068 },
1069 )?,
1070 );
1071 Ok(StorageCredentials::token_credential(credential))
1072 };
1073
1074 let storage_credentials = if let Some(config) = storage_config {
1076 if let Some(account_key) =
1077 config.get("account_key").and_then(|v| v.as_str()).map(|s| s.to_string())
1078 {
1079 StorageCredentials::access_key(account_name.clone(), account_key)
1081 } else if let Some(sas_token) =
1082 config.get("sas_token").and_then(|v| v.as_str()).map(|s| s.to_string())
1083 {
1084 StorageCredentials::sas_token(sas_token)
1086 .map_err(|e| CollabError::Internal(format!("Invalid SAS token: {}", e)))?
1087 } else {
1088 create_default_creds()?
1090 }
1091 } else {
1092 create_default_creds()?
1094 };
1095
1096 let blob_client = ClientBuilder::new(account_name, storage_credentials)
1098 .blob_client(&container_name, &blob_name);
1099
1100 blob_client.delete().await.map_err(|e| {
1101 CollabError::Internal(format!("Failed to delete Azure blob: {}", e))
1102 })?;
1103
1104 tracing::info!("Successfully deleted Azure blob: {}", backup_url);
1105 Ok(())
1106 }
1107
1108 #[cfg(not(feature = "azure"))]
1109 {
1110 let _ = (backup_url, storage_config); Err(CollabError::Internal(
1112 "Azure deletion requires 'azure' feature to be enabled. Add 'azure' feature to mockforge-collab in Cargo.toml.".to_string(),
1113 ))
1114 }
1115 }
1116
1117 async fn delete_from_gcs(
1119 &self,
1120 backup_url: &str,
1121 _storage_config: Option<&serde_json::Value>,
1122 ) -> Result<()> {
1123 #[cfg(feature = "gcs")]
1124 {
1125 use google_cloud_storage::client::StorageControl;
1126
1127 if !backup_url.starts_with("gs://") {
1129 return Err(CollabError::Internal(format!(
1130 "Invalid GCS URL format: {}",
1131 backup_url
1132 )));
1133 }
1134
1135 let url_parts: Vec<&str> = backup_url
1136 .strip_prefix("gs://")
1137 .ok_or_else(|| {
1138 CollabError::Internal(format!("Invalid GCS URL format: {}", backup_url))
1139 })?
1140 .splitn(2, '/')
1141 .collect();
1142 if url_parts.len() != 2 {
1143 return Err(CollabError::Internal(format!(
1144 "Invalid GCS URL format (expected gs://bucket/object): {}",
1145 backup_url
1146 )));
1147 }
1148
1149 let bucket_name = url_parts[0];
1150 let object_name = url_parts[1];
1151
1152 let client = StorageControl::builder().build().await.map_err(|e| {
1156 CollabError::Internal(format!("Failed to create GCS client: {}", e))
1157 })?;
1158
1159 client
1161 .delete_object()
1162 .set_bucket(format!("projects/_/buckets/{}", bucket_name))
1163 .set_object(object_name)
1164 .send()
1165 .await
1166 .map_err(|e| {
1167 CollabError::Internal(format!("Failed to delete GCS object: {}", e))
1168 })?;
1169
1170 tracing::info!("Successfully deleted GCS object: {}", backup_url);
1171 Ok(())
1172 }
1173
1174 #[cfg(not(feature = "gcs"))]
1175 {
1176 let _ = backup_url; Err(CollabError::Internal(
1178 "GCS deletion requires 'gcs' feature to be enabled. Add 'gcs' feature to mockforge-collab in Cargo.toml.".to_string(),
1179 ))
1180 }
1181 }
1182
1183 async fn delete_from_custom(
1185 &self,
1186 backup_url: &str,
1187 storage_config: Option<&serde_json::Value>,
1188 ) -> Result<()> {
1189 let mut request = self.client.delete(backup_url);
1190 if let Some(config) = storage_config {
1191 if let Some(headers) = config.get("headers").and_then(|h| h.as_object()) {
1192 for (key, value) in headers {
1193 if let Some(value) = value.as_str() {
1194 request = request.header(key, value);
1195 }
1196 }
1197 }
1198 }
1199
1200 let response = request
1201 .send()
1202 .await
1203 .map_err(|e| CollabError::Internal(format!("Custom delete request failed: {e}")))?;
1204 if !response.status().is_success() {
1205 return Err(CollabError::Internal(format!(
1206 "Custom delete failed with status {}",
1207 response.status()
1208 )));
1209 }
1210 Ok(())
1211 }
1212
1213 async fn get_workspace_data(&self, workspace_id: Uuid) -> Result<serde_json::Value> {
1217 let team_workspace = self.workspace_service.get_workspace(workspace_id).await?;
1219
1220 self.core_bridge.get_workspace_state_json(&team_workspace)
1222 }
1223}
1224
1225#[cfg(test)]
1226mod tests {
1227 use super::*;
1228
1229 #[test]
1230 fn test_storage_backend_equality() {
1231 assert_eq!(StorageBackend::Local, StorageBackend::Local);
1232 assert_eq!(StorageBackend::S3, StorageBackend::S3);
1233 assert_eq!(StorageBackend::Azure, StorageBackend::Azure);
1234 assert_eq!(StorageBackend::Gcs, StorageBackend::Gcs);
1235 assert_eq!(StorageBackend::Custom, StorageBackend::Custom);
1236
1237 assert_ne!(StorageBackend::Local, StorageBackend::S3);
1238 }
1239
1240 #[test]
1241 fn test_storage_backend_serialization() {
1242 let backend = StorageBackend::S3;
1243 let json = serde_json::to_string(&backend).unwrap();
1244 let deserialized: StorageBackend = serde_json::from_str(&json).unwrap();
1245
1246 assert_eq!(backend, deserialized);
1247 }
1248
1249 #[test]
1250 fn test_storage_backend_all_variants() {
1251 let backends = vec![
1252 StorageBackend::Local,
1253 StorageBackend::S3,
1254 StorageBackend::Azure,
1255 StorageBackend::Gcs,
1256 StorageBackend::Custom,
1257 ];
1258
1259 for backend in backends {
1260 let json = serde_json::to_string(&backend).unwrap();
1261 let deserialized: StorageBackend = serde_json::from_str(&json).unwrap();
1262 assert_eq!(backend, deserialized);
1263 }
1264 }
1265
1266 #[test]
1267 fn test_workspace_backup_new() {
1268 let workspace_id = Uuid::new_v4();
1269 let created_by = Uuid::new_v4();
1270 let backup_url = "s3://bucket/backup.yaml".to_string();
1271 let size_bytes = 1024;
1272
1273 let backup = WorkspaceBackup::new(
1274 workspace_id,
1275 backup_url.clone(),
1276 StorageBackend::S3,
1277 size_bytes,
1278 created_by,
1279 );
1280
1281 assert_eq!(backup.workspace_id, workspace_id);
1282 assert_eq!(backup.backup_url, backup_url);
1283 assert_eq!(backup.storage_backend, StorageBackend::S3);
1284 assert_eq!(backup.size_bytes, size_bytes);
1285 assert_eq!(backup.created_by, created_by);
1286 assert_eq!(backup.backup_format, "yaml");
1287 assert!(!backup.encrypted);
1288 assert!(backup.commit_id.is_none());
1289 assert!(backup.expires_at.is_none());
1290 assert!(backup.storage_config.is_none());
1291 }
1292
1293 #[test]
1294 fn test_workspace_backup_clone() {
1295 let backup = WorkspaceBackup::new(
1296 Uuid::new_v4(),
1297 "backup.yaml".to_string(),
1298 StorageBackend::Local,
1299 512,
1300 Uuid::new_v4(),
1301 );
1302
1303 let cloned = backup.clone();
1304
1305 assert_eq!(backup.id, cloned.id);
1306 assert_eq!(backup.workspace_id, cloned.workspace_id);
1307 assert_eq!(backup.backup_url, cloned.backup_url);
1308 assert_eq!(backup.size_bytes, cloned.size_bytes);
1309 }
1310
1311 #[test]
1312 fn test_workspace_backup_serialization() {
1313 let backup = WorkspaceBackup::new(
1314 Uuid::new_v4(),
1315 "backup.yaml".to_string(),
1316 StorageBackend::Local,
1317 256,
1318 Uuid::new_v4(),
1319 );
1320
1321 let json = serde_json::to_string(&backup).unwrap();
1322 let deserialized: WorkspaceBackup = serde_json::from_str(&json).unwrap();
1323
1324 assert_eq!(backup.id, deserialized.id);
1325 assert_eq!(backup.workspace_id, deserialized.workspace_id);
1326 assert_eq!(backup.storage_backend, deserialized.storage_backend);
1327 }
1328
1329 #[test]
1330 fn test_workspace_backup_with_commit() {
1331 let mut backup = WorkspaceBackup::new(
1332 Uuid::new_v4(),
1333 "backup.yaml".to_string(),
1334 StorageBackend::Local,
1335 128,
1336 Uuid::new_v4(),
1337 );
1338
1339 let commit_id = Uuid::new_v4();
1340 backup.commit_id = Some(commit_id);
1341
1342 assert_eq!(backup.commit_id, Some(commit_id));
1343 }
1344
1345 #[test]
1346 fn test_workspace_backup_with_encryption() {
1347 let mut backup = WorkspaceBackup::new(
1348 Uuid::new_v4(),
1349 "backup.yaml".to_string(),
1350 StorageBackend::S3,
1351 2048,
1352 Uuid::new_v4(),
1353 );
1354
1355 backup.encrypted = true;
1356
1357 assert!(backup.encrypted);
1358 }
1359
1360 #[test]
1361 fn test_workspace_backup_with_expiration() {
1362 let mut backup = WorkspaceBackup::new(
1363 Uuid::new_v4(),
1364 "backup.yaml".to_string(),
1365 StorageBackend::Azure,
1366 512,
1367 Uuid::new_v4(),
1368 );
1369
1370 let expires_at = Utc::now() + chrono::Duration::days(30);
1371 backup.expires_at = Some(expires_at);
1372
1373 assert!(backup.expires_at.is_some());
1374 }
1375
1376 #[test]
1377 fn test_workspace_backup_with_storage_config() {
1378 let mut backup = WorkspaceBackup::new(
1379 Uuid::new_v4(),
1380 "backup.yaml".to_string(),
1381 StorageBackend::S3,
1382 1024,
1383 Uuid::new_v4(),
1384 );
1385
1386 let config = serde_json::json!({
1387 "region": "us-east-1",
1388 "bucket": "my-bucket"
1389 });
1390 backup.storage_config = Some(config.clone());
1391
1392 assert_eq!(backup.storage_config, Some(config));
1393 }
1394
1395 #[test]
1396 fn test_workspace_backup_different_formats() {
1397 let mut backup = WorkspaceBackup::new(
1398 Uuid::new_v4(),
1399 "backup.json".to_string(),
1400 StorageBackend::Local,
1401 256,
1402 Uuid::new_v4(),
1403 );
1404
1405 assert_eq!(backup.backup_format, "yaml"); backup.backup_format = "json".to_string();
1408 assert_eq!(backup.backup_format, "json");
1409 }
1410
1411 #[test]
1412 fn test_storage_backend_debug() {
1413 let backend = StorageBackend::S3;
1414 let debug_str = format!("{:?}", backend);
1415 assert!(debug_str.contains("S3"));
1416 }
1417
1418 #[test]
1419 fn test_workspace_backup_debug() {
1420 let backup = WorkspaceBackup::new(
1421 Uuid::new_v4(),
1422 "backup.yaml".to_string(),
1423 StorageBackend::Local,
1424 100,
1425 Uuid::new_v4(),
1426 );
1427
1428 let debug_str = format!("{:?}", backup);
1429 assert!(debug_str.contains("WorkspaceBackup"));
1430 }
1431}