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