1pub mod backup;
2pub mod validator;
3
4use std::collections::{
5 BTreeMap,
6 HashMap,
7};
8use std::sync::Arc;
9
10pub use backup::{
11 BackupManager,
12 BackupPaths,
13};
14use chrono::{
15 DateTime,
16 Utc,
17};
18use serde::{
19 Deserialize,
20 Serialize,
21};
22pub use validator::{
23 PreMigrationValidationReport,
24 PreMigrationValidator,
25};
26
27use crate::domain::{
28 DomainError,
29 DomainResult,
30 MetricsQuery,
31 ProviderConfig,
32};
33use crate::event::EventBus;
34use crate::infrastructure::config::{
35 PipedashConfig,
36 StorageConfig,
37 StorageManager,
38};
39use crate::infrastructure::token_store::TokenStore;
40use crate::infrastructure::{
41 database::{
42 DatabasePool,
43 MetricsRepository,
44 PipelineViewRepository,
45 PushRepository,
46 RecordMetrics,
47 },
48 ConfigBackend,
49 StorageBackend,
50};
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
53pub enum MigrationStep {
54 ValidateTarget,
55 MigrateTokens,
56 MigrateConfigs,
57 MigrateCache,
58 VerifyMigration,
59 UpdateConfig,
60}
61
62impl MigrationStep {
63 pub fn description(&self) -> &str {
64 match self {
65 MigrationStep::ValidateTarget => "Validating target configuration",
66 MigrationStep::MigrateTokens => "Migrating tokens",
67 MigrationStep::MigrateConfigs => "Migrating provider configurations",
68 MigrationStep::MigrateCache => "Migrating cached data",
69 MigrationStep::VerifyMigration => "Verifying migration",
70 MigrationStep::UpdateConfig => "Updating storage configuration",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct MigrationPlan {
77 pub from: PipedashConfig,
78 pub to: PipedashConfig,
79 pub steps: Vec<MigrationStep>,
80 pub migrate_tokens: bool,
81 pub migrate_configs: bool,
82 pub migrate_cache: bool,
83 pub backend_changed: bool,
84 pub data_dir_changed: bool,
85 pub created_at: DateTime<Utc>,
86}
87
88impl MigrationPlan {
89 pub fn summary(&self) -> String {
90 format!(
91 "Migrate from {} to {} ({} steps)",
92 self.from.storage.summary(),
93 self.to.storage.summary(),
94 self.steps.len()
95 )
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct MigrationProgress {
101 pub current_step: MigrationStep,
102 pub step_index: usize,
103 pub total_steps: usize,
104 pub message: String,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct MigrationResult {
109 pub success: bool,
110 pub steps_completed: Vec<MigrationStep>,
111 pub errors: Vec<String>,
112 pub duration_ms: u64,
113 pub stats: MigrationStats,
114 #[serde(default)]
115 pub provider_id_mapping: std::collections::HashMap<i64, i64>,
116 #[serde(skip_serializing_if = "Option::is_none")]
117 pub backups: Option<BackupPaths>,
118}
119
120impl MigrationResult {
121 pub fn summary(&self) -> String {
122 if self.success {
123 format!(
124 "Migration successful: {} providers, {} cache entries migrated in {}ms",
125 self.stats.providers_migrated, self.stats.cache_entries_migrated, self.duration_ms
126 )
127 } else {
128 format!(
129 "Migration failed after {} steps: {}",
130 self.steps_completed.len(),
131 self.errors.join(", ")
132 )
133 }
134 }
135}
136
137#[derive(Debug, Clone, Default, Serialize, Deserialize)]
138pub struct MigrationStats {
139 pub providers_migrated: usize,
140 pub tokens_migrated: usize,
141 pub cache_entries_migrated: usize,
142 pub metrics_migrated: usize,
143 pub permissions_migrated: usize,
144 pub providers_cleaned: usize,
145 pub tokens_cleaned: usize,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct MigrationOptions {
150 pub migrate_tokens: bool,
151 pub migrate_cache: bool,
152 pub token_password: Option<String>,
153 pub dry_run: bool,
154 #[serde(default)]
155 pub allow_non_empty_target: bool,
156 #[serde(default)]
157 pub clean_target: bool,
158 #[serde(default)]
159 pub keep_backups: bool,
160 #[serde(default = "default_max_retries")]
161 pub max_retries: u32,
162}
163
164fn default_max_retries() -> u32 {
165 3
166}
167
168impl Default for MigrationOptions {
169 fn default() -> Self {
170 Self {
171 migrate_tokens: false,
172 migrate_cache: false,
173 token_password: None,
174 dry_run: false,
175 allow_non_empty_target: false,
176 clean_target: false,
177 keep_backups: false,
178 max_retries: 3,
179 }
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct ValidationReport {
185 pub errors: Vec<String>,
186 pub warnings: Vec<String>,
187 pub recommendations: Vec<String>,
188}
189
190impl ValidationReport {
191 pub fn is_valid(&self) -> bool {
192 self.errors.is_empty()
193 }
194}
195
196pub const VAULT_PASSWORD_ENV_VAR: &str = "PIPEDASH_VAULT_PASSWORD";
197
198fn get_vault_password(options: &MigrationOptions) -> DomainResult<String> {
199 if let Ok(password) = std::env::var(VAULT_PASSWORD_ENV_VAR) {
200 if !password.is_empty() {
201 return Ok(password);
202 }
203 }
204
205 options.token_password.clone().ok_or_else(|| {
206 DomainError::InvalidConfig(format!(
207 "Vault password required for token migration. Set {} environment variable or provide token_password in options.",
208 VAULT_PASSWORD_ENV_VAR
209 ))
210 })
211}
212
213fn remap_pipeline_id(pipeline_id: &str, provider_ids: &HashMap<i64, i64>) -> Option<String> {
214 let mut parts = pipeline_id.splitn(3, "__");
215 let (Some(provider_type), Some(provider_id), Some(rest)) =
216 (parts.next(), parts.next(), parts.next())
217 else {
218 return None;
219 };
220 let new_id = provider_ids.get(&provider_id.parse().ok()?)?;
221
222 Some(format!("{provider_type}__{new_id}__{rest}"))
223}
224
225fn same_database(from: &PipedashConfig, to: &PipedashConfig) -> bool {
226 from.storage.backend == to.storage.backend
227 && if to.storage.backend.is_sqlite() {
228 from.db_path() == to.db_path()
229 } else {
230 from.storage.postgres.connection_string == to.storage.postgres.connection_string
231 }
232}
233
234pub struct MigrationOrchestrator {
235 source_config: PipedashConfig,
236 source_token_store: Arc<dyn TokenStore>,
237 source_config_backend: Arc<dyn ConfigBackend>,
238 source_cache_backend: Arc<dyn StorageBackend>,
239 source_metrics: MetricsRepository,
240 source_pool: DatabasePool,
241 event_bus: Option<Arc<dyn EventBus>>,
242 target_token_store: Option<Arc<dyn TokenStore>>,
243}
244
245impl MigrationOrchestrator {
246 pub async fn from_manager(
247 manager: &StorageManager, event_bus: Option<Arc<dyn EventBus>>,
248 target_token_store: Option<Arc<dyn TokenStore>>,
249 ) -> Self {
250 Self {
251 source_config: manager.config().clone(),
252 source_token_store: manager.token_store().await,
253 source_config_backend: manager.config_backend(),
254 source_cache_backend: manager.cache_backend(),
255 source_metrics: MetricsRepository::new_from_pool(manager.database_pool()),
256 source_pool: manager.database_pool(),
257 event_bus,
258 target_token_store,
259 }
260 }
261
262 pub fn plan_migration(
263 &self, mut target_config: PipedashConfig, options: &MigrationOptions,
264 ) -> DomainResult<MigrationPlan> {
265 use crate::infrastructure::config::StorageBackend as ConfigStorageBackend;
266
267 let source_data_dir = self.source_config.data_dir();
268 let target_data_dir = target_config.data_dir();
269 let data_dir_changed = source_data_dir != target_data_dir;
270
271 let source_storage = &self.source_config.storage;
272 let target_storage = &mut target_config.storage;
273
274 if data_dir_changed {
275 tracing::info!(
276 "[Migration Planning] Data directory change detected: {:?} → {:?}",
277 source_data_dir,
278 target_data_dir
279 );
280 }
281
282 let backend_changed = source_storage.backend != target_storage.backend;
283
284 let token_backend_changing = {
285 let source_uses_keyring = source_storage.backend.is_sqlite()
286 && std::env::var(VAULT_PASSWORD_ENV_VAR).is_err();
287 let target_uses_encrypted = target_storage.backend.is_sqlite()
288 && (options.token_password.is_some()
289 || std::env::var(VAULT_PASSWORD_ENV_VAR).is_ok());
290
291 source_uses_keyring && target_uses_encrypted
292 };
293
294 if token_backend_changing {
295 tracing::info!(
296 "[Migration Planning] Token backend change detected: keyring → encrypted storage"
297 );
298 }
299
300 let needs_data_migration = backend_changed || data_dir_changed || token_backend_changing;
301
302 if target_storage.backend == ConfigStorageBackend::Postgres {
303 if target_storage.data_dir.starts_with("postgresql://")
304 || target_storage.data_dir.starts_with("postgres://")
305 {
306 tracing::warn!(
307 "Target data_dir contains PostgreSQL connection string: {}. \
308 Resetting to empty (will use default) - connection string should be in postgres.connection_string field.",
309 target_storage.data_dir
310 );
311 target_storage.data_dir = String::new(); } else if target_storage.data_dir.is_empty() {
313 tracing::debug!("Target uses PostgreSQL - data_dir is empty (will use default for cache/local files)");
314 }
315 }
316
317 let mut steps = Vec::new();
318
319 steps.push(MigrationStep::ValidateTarget);
320
321 if needs_data_migration {
322 steps.push(MigrationStep::MigrateConfigs);
323 }
324
325 if needs_data_migration && options.migrate_tokens {
326 steps.push(MigrationStep::MigrateTokens);
327 }
328
329 if needs_data_migration && options.migrate_cache {
330 steps.push(MigrationStep::MigrateCache);
331 }
332
333 if !steps.is_empty() {
334 steps.push(MigrationStep::VerifyMigration);
335 steps.push(MigrationStep::UpdateConfig);
336 }
337
338 Ok(MigrationPlan {
339 from: self.source_config.clone(),
340 to: target_config,
341 steps,
342 migrate_tokens: needs_data_migration && options.migrate_tokens,
343 migrate_configs: needs_data_migration,
344 migrate_cache: needs_data_migration && options.migrate_cache,
345 backend_changed,
346 data_dir_changed,
347 created_at: Utc::now(),
348 })
349 }
350
351 pub async fn validate_target_config(
352 &self, config: &StorageConfig,
353 ) -> DomainResult<ValidationReport> {
354 use crate::infrastructure::config::StorageBackend as ConfigStorageBackend;
355
356 let mut report = ValidationReport {
357 errors: Vec::new(),
358 warnings: Vec::new(),
359 recommendations: Vec::new(),
360 };
361
362 if config.backend == ConfigStorageBackend::Postgres {
363 if config.postgres.connection_string.is_empty() {
364 report.errors.push(
365 "PostgreSQL connection string required when using postgres backend".into(),
366 );
367 }
368
369 let has_settings_password = config.vault_password.is_some();
370 let has_env_password = std::env::var(VAULT_PASSWORD_ENV_VAR).is_ok();
371
372 if !has_settings_password && !has_env_password {
373 report.warnings.push(format!(
374 "Vault password not provided. Set {} environment variable or provide password during setup.",
375 VAULT_PASSWORD_ENV_VAR
376 ));
377 }
378 }
379
380 Ok(report)
381 }
382
383 pub async fn execute_migration(
384 &self, plan: MigrationPlan, options: MigrationOptions, is_desktop: bool,
385 ) -> MigrationResult {
386 let start_time = std::time::Instant::now();
387 let mut result = MigrationResult {
388 success: false,
389 steps_completed: Vec::new(),
390 errors: Vec::new(),
391 duration_ms: 0,
392 stats: MigrationStats::default(),
393 provider_id_mapping: std::collections::HashMap::new(),
394 backups: None,
395 };
396
397 if options.dry_run {
398 result.success = true;
399 result.duration_ms = start_time.elapsed().as_millis() as u64;
400 return result;
401 }
402
403 let backup_manager = BackupManager::new(plan.from.data_dir());
404 let backups = match backup_manager
405 .create_backups(&plan.from, &plan.to, Some(&*self.source_config_backend))
406 .await
407 {
408 Ok(backups) => backups,
409 Err(e) => {
410 result.errors.push(format!(
411 "Failed to create backups: {}\n\n\
412 WARNING: Migration aborted - backup creation is required for safe migration.\n\
413 INFO: Your data is safe - no modifications were performed.\n\n\
414 Please ensure:\n\
415 1. Sufficient disk space (need ~2x source database size)\n\
416 2. Write permissions to data directory\n\
417 3. Source database is not locked by another process",
418 e
419 ));
420 result.duration_ms = start_time.elapsed().as_millis() as u64;
421 return result;
422 }
423 };
424 result.backups = Some(backups.clone());
425
426 let target_manager = if let Some(token_store) = &self.target_token_store {
427 match StorageManager::with_token_store(plan.to.clone(), token_store.clone(), is_desktop)
428 .await
429 {
430 Ok(m) => m,
431 Err(e) => {
432 result.errors.push(format!(
433 "Failed to create target manager with token store: {}\n\n\
434 INFO: Migration aborted before any changes were made.\n\
435 INFO: Your data is safe - no modifications were performed.",
436 e
437 ));
438 backup_manager.cleanup_backups(&backups, false).await.ok();
439 result.duration_ms = start_time.elapsed().as_millis() as u64;
440 return result;
441 }
442 }
443 } else {
444 match StorageManager::from_config(plan.to.clone(), false).await {
445 Ok(m) => m,
446 Err(e) => {
447 result.errors.push(format!(
448 "Failed to create target manager: {}\n\n\
449 INFO: Migration aborted before any changes were made.\n\
450 INFO: Your data is safe - no modifications were performed.",
451 e
452 ));
453 backup_manager.cleanup_backups(&backups, false).await.ok();
454 result.duration_ms = start_time.elapsed().as_millis() as u64;
455 return result;
456 }
457 }
458 };
459
460 for (idx, step) in plan.steps.iter().enumerate() {
461 self.emit_progress(*step, idx, plan.steps.len()).await;
462
463 let step_result = self
464 .execute_step(*step, &plan, &options, &target_manager, &mut result)
465 .await;
466
467 match step_result {
468 Ok(()) => {
469 result.steps_completed.push(*step);
470 }
471 Err(e) => {
472 let step_name = format!("{:?}", step);
473
474 tracing::error!(
475 "Migration failed at step {} - restoring from backups",
476 step_name
477 );
478
479 let recovery_instructions = match backup_manager.restore_backups(&backups).await
480 {
481 Ok(()) => {
482 tracing::info!("Successfully restored from backups");
483 format!(
484 "✓ Your data has been restored from backups.\n\
485 ✓ Config file: {}\n\
486 ✓ Database: {}\n\n\
487 The migration failed but your original data is intact.\n\
488 Please review the error below and try again after fixing the issue:\n\n\
489 Error: {}",
490 backups.source_config.display(),
491 backups.source_db.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "N/A".to_string()),
492 e
493 )
494 }
495 Err(restore_err) => {
496 format!(
497 "CRITICAL ERROR: Migration failed AND backup restoration failed!\n\n\
498 Original error: {}\n\
499 Restore error: {}\n\n\
500 MANUAL RECOVERY REQUIRED:\n\
501 1. Restore config file from: {}\n\
502 2. Restore database from: {}\n\
503 3. Review logs for details\n\
504 4. Contact support if needed",
505 e,
506 restore_err,
507 backups.source_config.display(),
508 backups.source_db.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "N/A".to_string())
509 )
510 }
511 };
512
513 result.errors.push(format!(
514 "Migration failed at step '{}':\n{}\n\n{}",
515 step_name, e, recovery_instructions
516 ));
517
518 self.rollback(&result).await;
519 result.duration_ms = start_time.elapsed().as_millis() as u64;
520 return result;
521 }
522 }
523 }
524
525 result.success = true;
526 result.duration_ms = start_time.elapsed().as_millis() as u64;
527
528 if let Err(e) = backup_manager
529 .cleanup_backups(&backups, options.keep_backups)
530 .await
531 {
532 tracing::warn!("Failed to cleanup backups: {}", e);
533 }
534
535 result
536 }
537
538 async fn execute_step(
539 &self, step: MigrationStep, plan: &MigrationPlan, options: &MigrationOptions,
540 target_manager: &StorageManager, result: &mut MigrationResult,
541 ) -> DomainResult<()> {
542 match step {
543 MigrationStep::ValidateTarget => {
544 if !target_manager.cache_backend().is_available().await {
545 return Err(DomainError::NetworkError(
546 "Target cache backend not available".into(),
547 ));
548 }
549
550 target_manager.config_backend().list_providers().await?;
551 }
552
553 MigrationStep::MigrateTokens => {
554 tracing::info!("[Migration] Starting token migration...");
555 let password = get_vault_password(options)?;
556 tracing::info!(
557 "[Migration] Vault password retrieved: {} chars",
558 password.len()
559 );
560
561 tracing::info!("[Migration] Exporting tokens from source token store...");
562 let encrypted_blob = self.source_token_store.export_encrypted(&password).await?;
563 tracing::info!(
564 "[Migration] Exported encrypted blob: {} bytes",
565 encrypted_blob.len()
566 );
567
568 if !result.provider_id_mapping.is_empty() {
569 tracing::info!(
570 "[Migration] Using provider ID remapping path ({} mappings)",
571 result.provider_id_mapping.len()
572 );
573
574 let source_tokens = self.source_token_store.get_all_tokens().await?;
575 tracing::info!(
576 "[Migration] Retrieved {} tokens from source",
577 source_tokens.len()
578 );
579
580 let mut migrated_count = 0;
581 for (old_id, token) in source_tokens {
582 if let Some(&new_id) = result.provider_id_mapping.get(&old_id) {
583 tracing::debug!(
584 "[Migration] Storing token for provider {} → {} (token length: {})",
585 old_id,
586 new_id,
587 token.len()
588 );
589 target_manager
590 .token_store()
591 .await
592 .store_token(new_id, &token)
593 .await?;
594 migrated_count += 1;
595 tracing::info!(
596 "[Migration] Successfully stored token for provider {} (remapped to {})",
597 old_id,
598 new_id
599 );
600 } else {
601 let source_providers =
602 self.source_config_backend.list_providers().await?;
603 return Err(DomainError::DataConsistency(format!(
604 "Cannot migrate token for provider ID {} - provider not found in remapping.\n\n\
605 This indicates data corruption or inconsistency.\n\n\
606 Source provider IDs: {:?}\n\
607 Remapping table: {:?}\n\n\
608 This token would be lost if migration continued.\n\
609 Please investigate why this provider's configuration wasn't migrated.",
610 old_id,
611 source_providers.iter().filter_map(|p| p.id).collect::<Vec<_>>(),
612 result.provider_id_mapping
613 )));
614 }
615 }
616 tracing::info!(
617 "[Migration] Migrated {} tokens via ID remapping",
618 migrated_count
619 );
620 } else {
621 tracing::info!("[Migration] Using direct import path (no ID remapping)");
622 tracing::info!(
623 "[Migration] Importing encrypted blob ({} bytes) to target token store...",
624 encrypted_blob.len()
625 );
626 target_manager
627 .token_store()
628 .await
629 .import_encrypted(&encrypted_blob, &password)
630 .await?;
631 tracing::info!("[Migration] Direct import completed");
632 }
633
634 let count: usize = target_manager
635 .token_store()
636 .await
637 .get_all_tokens()
638 .await?
639 .len();
640 tracing::info!(
641 "[Migration] Verification: {} tokens now in target database",
642 count
643 );
644 result.stats.tokens_migrated = count;
645 }
646
647 MigrationStep::MigrateConfigs => {
648 tracing::info!(
649 "[Migration] Exporting providers from source database: {:?}",
650 self.source_config.db_path()
651 );
652 let export = self.source_config_backend.export_all().await?;
653 result.stats.providers_migrated = export.providers.len();
654 result.stats.permissions_migrated = export.permissions.len();
655
656 tracing::info!(
657 "[Migration] Importing {} providers to target database: {:?}",
658 export.providers.len(),
659 plan.to.db_path()
660 );
661 target_manager.config_backend().import_all(&export).await?;
662
663 let source_providers = self.source_config_backend.list_providers().await?;
664 let target_providers: Vec<ProviderConfig> =
665 target_manager.config_backend().list_providers().await?;
666
667 for source_provider in &source_providers {
668 if let Some(source_id) = source_provider.id {
669 if let Some(target_provider) = target_providers
670 .iter()
671 .find(|p| p.name == source_provider.name)
672 {
673 if let Some(target_id) = target_provider.id {
674 result.provider_id_mapping.insert(source_id, target_id);
675 }
676 }
677 }
678 }
679
680 if !same_database(&plan.from, &plan.to) {
681 let mut metrics_config = self.source_metrics.export_config().await?;
682 metrics_config.pipeline_configs.retain_mut(|config| {
683 match remap_pipeline_id(&config.pipeline_id, &result.provider_id_mapping) {
684 Some(pipeline_id) => {
685 config.pipeline_id = pipeline_id;
686 true
687 }
688 None => false,
689 }
690 });
691 MetricsRepository::new_from_pool(target_manager.database_pool())
692 .import_config(&metrics_config)
693 .await?;
694 }
695
696 if !same_database(&plan.from, &plan.to) {
697 let subscriptions =
698 PushRepository::new(self.source_pool.clone()).list().await?;
699 let target_push = PushRepository::new(target_manager.database_pool());
700 target_push.import(&subscriptions).await?;
701 if options.clean_target {
702 for stale in target_push.list().await? {
703 if !subscriptions.iter().any(|s| s.id == stale.id) {
704 target_push.delete(&stale.id).await?;
705 }
706 }
707 }
708 tracing::info!(
709 "[Migration] Migrated {} push subscriptions",
710 subscriptions.len()
711 );
712 }
713
714 if !same_database(&plan.from, &plan.to) {
715 let mapping = &result.provider_id_mapping;
716 let mut export = PipelineViewRepository::new(self.source_pool.clone())
717 .export()
718 .await?;
719 let remap = |pipeline_id: &mut String, provider_id: &mut i64| match (
720 remap_pipeline_id(pipeline_id, mapping),
721 mapping.get(provider_id),
722 ) {
723 (Some(next), Some(target)) => {
724 *pipeline_id = next;
725 *provider_id = *target;
726 true
727 }
728 _ => false,
729 };
730 export
731 .members
732 .retain_mut(|m| remap(&mut m.pipeline_id, &mut m.provider_id));
733 for view in &mut export.views {
734 view.rules.provider_ids = view
735 .rules
736 .provider_ids
737 .iter()
738 .filter_map(|id| mapping.get(id).copied())
739 .collect();
740 }
741 PipelineViewRepository::new(target_manager.database_pool())
742 .import(&export, options.clean_target)
743 .await?;
744 tracing::info!("[Migration] Migrated {} pipeline views", export.views.len());
745 }
746
747 if options.clean_target {
748 let source_names: std::collections::HashSet<String> =
749 source_providers.iter().map(|p| p.name.clone()).collect();
750
751 let orphaned: Vec<_> = target_providers
752 .iter()
753 .filter(|p| !source_names.contains(&p.name))
754 .collect();
755
756 for orphan in &orphaned {
757 if let Some(id) = orphan.id {
758 tracing::info!(
759 "[Migration] Removing orphaned provider: {} (id={})",
760 orphan.name,
761 id
762 );
763 target_manager.config_backend().delete_provider(id).await?;
764 let _ = target_manager.token_store().await.delete_token(id).await;
765 result.stats.tokens_cleaned += 1;
766 }
767 }
768
769 if !orphaned.is_empty() {
770 result.stats.providers_cleaned = orphaned.len();
771 tracing::info!(
772 "[Migration] Cleaned {} orphaned providers from target",
773 orphaned.len()
774 );
775 }
776 }
777 }
778
779 MigrationStep::MigrateCache => {
780 let objects = self.source_cache_backend.list(None).await?;
781 tracing::info!(
782 "[Migration] Migrating {} cache entries from {:?} to {:?}",
783 objects.len(),
784 self.source_config.data_dir().join("cache"),
785 plan.to.data_dir().join("cache")
786 );
787 for obj in objects {
788 let data = self.source_cache_backend.get(&obj.key).await?;
789 target_manager
790 .cache_backend()
791 .put(&obj.key, &data, obj.content_type.as_deref())
792 .await?;
793 result.stats.cache_entries_migrated += 1;
794 }
795
796 if !same_database(&plan.from, &plan.to) {
797 let target_metrics =
798 MetricsRepository::new_from_pool(target_manager.database_pool());
799 let pipeline_query = |pipeline_id: &str| MetricsQuery {
800 pipeline_id: Some(pipeline_id.to_string()),
801 limit: None,
802 ..Default::default()
803 };
804 let mut pipelines = 0;
805 for source_id in self.source_metrics.list_pipeline_ids().await? {
806 let Some(target_id) =
807 remap_pipeline_id(&source_id, &result.provider_id_mapping)
808 else {
809 continue;
810 };
811 let last_run = self
812 .source_metrics
813 .get_last_processed_run(&source_id)
814 .await?;
815 let mut runs: BTreeMap<i64, RecordMetrics> = BTreeMap::new();
816 for entry in self
817 .source_metrics
818 .query_metrics(pipeline_query(&source_id))
819 .await?
820 {
821 let slot = entry.metric_type as usize;
822 let record =
823 runs.entry(entry.run_number)
824 .or_insert_with(|| RecordMetrics {
825 run_number: entry.run_number,
826 run_hash: entry.run_hash.clone().unwrap_or_default(),
827 entries: [None, None, None],
828 });
829 record.entries[slot] = Some(entry);
830 }
831 let records: Vec<_> = runs.into_values().collect();
832 target_metrics
833 .refresh_record_metrics(&target_id, &records)
834 .await?;
835 result.stats.metrics_migrated += records
836 .iter()
837 .map(|record| record.entries.iter().flatten().count())
838 .sum::<usize>();
839 pipelines += 1;
840
841 let Some(run) = last_run else {
842 continue;
843 };
844 if target_metrics
845 .get_last_processed_run(&target_id)
846 .await?
847 .is_none_or(|target_run| target_run < run)
848 {
849 target_metrics
850 .update_last_processed_run(&target_id, run)
851 .await?;
852 }
853 }
854 tracing::info!(
855 "[Migration] Migrated {} metric entries for {} pipelines",
856 result.stats.metrics_migrated,
857 pipelines
858 );
859 }
860 }
861
862 MigrationStep::VerifyMigration => {
863 let source_providers = self.source_config_backend.list_providers().await?;
864
865 if plan.migrate_configs && !source_providers.is_empty() {
866 let target_providers: Vec<ProviderConfig> =
867 target_manager.config_backend().list_providers().await?;
868
869 for source_provider in &source_providers {
870 let found = target_providers
871 .iter()
872 .any(|t| t.name == source_provider.name);
873 if !found {
874 return Err(DomainError::DatabaseError(format!(
875 "Provider '{}' from source was not found in target after migration",
876 source_provider.name
877 )));
878 }
879 }
880
881 if options.clean_target {
882 if source_providers.len() != target_providers.len() {
883 return Err(DomainError::DatabaseError(format!(
884 "Provider count mismatch after cleanup: source={}, target={}",
885 source_providers.len(),
886 target_providers.len()
887 )));
888 }
889 tracing::info!(
890 "[Migration] Verified exact match: {} providers",
891 source_providers.len()
892 );
893 } else if target_providers.len() > source_providers.len() {
894 tracing::info!(
895 "[Migration] Merge complete: {} migrated, {} pre-existing kept (total: {})",
896 source_providers.len(),
897 target_providers.len() - source_providers.len(),
898 target_providers.len()
899 );
900 } else {
901 tracing::info!(
902 "[Migration] Verified {} providers migrated successfully",
903 source_providers.len()
904 );
905 }
906 } else if plan.migrate_configs {
907 tracing::info!("Skipping provider verification: source database is empty");
908 }
909
910 if plan.migrate_tokens {
911 let source_tokens = self.source_token_store.get_all_tokens().await?;
912
913 if !source_tokens.is_empty() {
914 let target_tokens: HashMap<i64, String> =
915 target_manager.token_store().await.get_all_tokens().await?;
916
917 if options.clean_target {
918 if source_tokens.len() != target_tokens.len() {
919 return Err(DomainError::DatabaseError(format!(
920 "Token count mismatch after cleanup: source={}, target={}",
921 source_tokens.len(),
922 target_tokens.len()
923 )));
924 }
925 tracing::info!(
926 "[Migration] Verified exact match: {} tokens",
927 source_tokens.len()
928 );
929 } else {
930 if target_tokens.len() < source_tokens.len() {
931 return Err(DomainError::DatabaseError(format!(
932 "Token count too low after migration: source={}, target={}",
933 source_tokens.len(),
934 target_tokens.len()
935 )));
936 }
937 tracing::info!(
938 "[Migration] Token migration complete: {} migrated (target has {} total)",
939 source_tokens.len(),
940 target_tokens.len()
941 );
942 }
943 } else {
944 tracing::info!("Skipping token verification: source has no tokens");
945 }
946 }
947 }
948
949 MigrationStep::UpdateConfig => {
950 use crate::infrastructure::config::ConfigLoader;
951
952 let config_path = ConfigLoader::discover_config_path();
953
954 tracing::info!(
955 "Saving migrated config to: {} (data_dir: {:?}, backend: {})",
956 config_path.display(),
957 plan.to.data_dir(),
958 plan.to.storage.backend
959 );
960
961 ConfigLoader::save(&plan.to, &config_path).map_err(|e| {
962 DomainError::DatabaseError(format!("Failed to save config: {}", e))
963 })?;
964 }
965 }
966
967 Ok(())
968 }
969
970 async fn emit_progress(&self, step: MigrationStep, step_index: usize, total_steps: usize) {
971 if let Some(ref event_bus) = self.event_bus {
972 event_bus
973 .emit(crate::event::CoreEvent::MigrationProgress {
974 step: format!("{:?}", step),
975 step_index,
976 total_steps,
977 message: step.description().to_string(),
978 })
979 .await;
980 }
981 }
982
983 async fn rollback(&self, _result: &MigrationResult) {
984 tracing::warn!("Migration failed. Manual intervention may be required.");
985 }
986}
987
988#[cfg(test)]
989mod tests {
990 use tempfile::TempDir;
991
992 use super::*;
993 use crate::{
994 infrastructure::token_store::MemoryTokenStore,
995 test_env::EnvGuard,
996 };
997
998 async fn create_test_manager(temp_dir: &TempDir) -> StorageManager {
999 let mut config = PipedashConfig::default();
1000 config.storage.data_dir = temp_dir.path().to_string_lossy().to_string();
1001
1002 let token_store: Arc<dyn TokenStore> = Arc::new(MemoryTokenStore::new());
1003 StorageManager::with_token_store(config, token_store, true)
1004 .await
1005 .unwrap()
1006 }
1007
1008 #[tokio::test]
1009 async fn test_plan_migration_no_changes() {
1010 let temp_dir = TempDir::new().unwrap();
1011 let manager = create_test_manager(&temp_dir).await;
1012
1013 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1014
1015 let target_config = manager.config().clone();
1016 let plan = orchestrator
1017 .plan_migration(target_config, &MigrationOptions::default())
1018 .unwrap();
1019
1020 assert!(!plan.steps.contains(&MigrationStep::MigrateConfigs));
1021 assert!(!plan.steps.contains(&MigrationStep::MigrateTokens));
1022 }
1023
1024 #[tokio::test]
1025 async fn test_migration_dry_run() {
1026 let temp_dir = TempDir::new().unwrap();
1027 let manager = create_test_manager(&temp_dir).await;
1028
1029 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1030
1031 let target_config = manager.config().clone();
1032 let plan = MigrationPlan {
1033 from: manager.config().clone(),
1034 to: target_config,
1035 steps: vec![MigrationStep::ValidateTarget],
1036 migrate_tokens: false,
1037 migrate_configs: false,
1038 migrate_cache: false,
1039 backend_changed: false,
1040 data_dir_changed: false,
1041 created_at: Utc::now(),
1042 };
1043
1044 let result = orchestrator
1045 .execute_migration(
1046 plan,
1047 MigrationOptions {
1048 dry_run: true,
1049 ..Default::default()
1050 },
1051 true, )
1053 .await;
1054
1055 assert!(result.success);
1056 }
1057
1058 #[test]
1059 fn test_migration_step_descriptions() {
1060 assert!(!MigrationStep::ValidateTarget.description().is_empty());
1061 assert!(!MigrationStep::MigrateTokens.description().is_empty());
1062 assert!(!MigrationStep::MigrateConfigs.description().is_empty());
1063 }
1064
1065 #[test]
1066 fn test_migration_result_summary() {
1067 let result = MigrationResult {
1068 success: true,
1069 steps_completed: vec![MigrationStep::MigrateConfigs],
1070 errors: vec![],
1071 duration_ms: 100,
1072 stats: MigrationStats {
1073 providers_migrated: 5,
1074 tokens_migrated: 3,
1075 cache_entries_migrated: 10,
1076 metrics_migrated: 0,
1077 permissions_migrated: 2,
1078 providers_cleaned: 0,
1079 tokens_cleaned: 0,
1080 },
1081 provider_id_mapping: std::collections::HashMap::new(),
1082 backups: None,
1083 };
1084
1085 let summary = result.summary();
1086 assert!(summary.contains("5 providers"));
1087 assert!(summary.contains("10 cache entries"));
1088 }
1089
1090 #[tokio::test]
1091 async fn migrate_tokens_after_a_provider_was_deleted() {
1092 let source_dir = TempDir::new().unwrap();
1093 let target_dir = TempDir::new().unwrap();
1094 let config_path = source_dir.path().join("config.toml");
1095 std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1096 let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1097
1098 let mut source = PipedashConfig::default();
1099 source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1100 source.storage.vault_password = Some("test-password".to_string());
1101 let mut target = source.clone();
1102 target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1103
1104 let manager = StorageManager::from_config(source, false).await.unwrap();
1105 let backend = manager.config_backend();
1106 let tokens = manager.token_store().await;
1107 let mut ids = Vec::new();
1108 for name in ["a", "b", "c"] {
1109 let provider = ProviderConfig {
1110 id: None,
1111 name: name.to_string(),
1112 provider_type: "github".to_string(),
1113 token: String::new(),
1114 config: HashMap::new(),
1115 refresh_interval: 30,
1116 version: None,
1117 };
1118 ids.push(backend.create_provider(&provider).await.unwrap());
1119 }
1120 backend.delete_provider(ids[1]).await.unwrap();
1121 tokens.store_token(ids[0], "tok-a").await.unwrap();
1122 tokens.store_token(ids[2], "tok-c").await.unwrap();
1123
1124 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1125 let options = MigrationOptions {
1126 migrate_tokens: true,
1127 token_password: Some("test-password".to_string()),
1128 ..Default::default()
1129 };
1130 let plan = orchestrator
1131 .plan_migration(target.clone(), &options)
1132 .unwrap();
1133 let result = orchestrator.execute_migration(plan, options, false).await;
1134 assert!(result.success, "{:?}", result.errors);
1135
1136 let target_manager = StorageManager::from_config(target, false).await.unwrap();
1137 let mut migrated = HashMap::new();
1138 for provider in target_manager
1139 .config_backend()
1140 .list_providers()
1141 .await
1142 .unwrap()
1143 {
1144 let token = target_manager
1145 .token_store()
1146 .await
1147 .get_token(provider.id.unwrap())
1148 .await
1149 .unwrap();
1150 migrated.insert(provider.name, token);
1151 }
1152 assert_eq!(
1153 migrated,
1154 HashMap::from([
1155 ("a".to_string(), "tok-a".to_string()),
1156 ("c".to_string(), "tok-c".to_string()),
1157 ])
1158 );
1159 }
1160
1161 #[tokio::test]
1162 async fn migration_keeps_pipeline_metrics() {
1163 use crate::domain::{
1164 MetricEntry,
1165 MetricType,
1166 };
1167 use crate::infrastructure::database::RecordMetrics;
1168
1169 let source_dir = TempDir::new().unwrap();
1170 let target_dir = TempDir::new().unwrap();
1171 let config_path = source_dir.path().join("config.toml");
1172 std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1173 let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1174
1175 let mut source = PipedashConfig::default();
1176 source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1177 source.storage.vault_password = Some("test-password".to_string());
1178 let mut target = source.clone();
1179 target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1180
1181 let manager = StorageManager::from_config(source, false).await.unwrap();
1182 let backend = manager.config_backend();
1183 let mut ids = Vec::new();
1184 for name in ["deleted", "gh"] {
1185 let provider = ProviderConfig {
1186 id: None,
1187 name: name.to_string(),
1188 provider_type: "github".to_string(),
1189 token: String::new(),
1190 config: HashMap::new(),
1191 refresh_interval: 30,
1192 version: None,
1193 };
1194 ids.push(backend.create_provider(&provider).await.unwrap());
1195 }
1196 backend.delete_provider(ids[0]).await.unwrap();
1197 let orphan_pipeline = format!("github__{}__o__r__1", ids[0]);
1198 let source_pipeline = format!("github__{}__o__r__1", ids[1]);
1199 let expired_pipeline = format!("github__{}__o__r__2", ids[1]);
1200
1201 let metrics = MetricsRepository::new_from_pool(manager.database_pool());
1202 metrics.update_global_config(true, 30).await.unwrap();
1203 metrics
1204 .upsert_pipeline_config(&source_pipeline, true, 14)
1205 .await
1206 .unwrap();
1207 metrics
1208 .upsert_pipeline_config(&orphan_pipeline, false, 3)
1209 .await
1210 .unwrap();
1211 let entry = |pipeline_id: &str, n: i64| MetricEntry {
1212 id: 0,
1213 pipeline_id: pipeline_id.to_string(),
1214 run_number: n,
1215 timestamp: Utc::now(),
1216 metric_type: MetricType::RunDuration,
1217 value: 60.0 * n as f64,
1218 metadata: None,
1219 created_at: Utc::now(),
1220 run_hash: Some(format!("hash-{n}")),
1221 };
1222 let mut entries: Vec<MetricEntry> = (1..=5).map(|n| entry(&source_pipeline, n)).collect();
1223 entries.push(entry(&orphan_pipeline, 1));
1224 metrics.insert_metrics_batch(entries).await.unwrap();
1225 metrics
1226 .update_last_processed_run(&source_pipeline, 5)
1227 .await
1228 .unwrap();
1229 metrics
1230 .update_last_processed_run(&expired_pipeline, 9)
1231 .await
1232 .unwrap();
1233
1234 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1235 let options = MigrationOptions {
1236 migrate_tokens: true,
1237 migrate_cache: true,
1238 token_password: Some("test-password".to_string()),
1239 ..Default::default()
1240 };
1241 let plan = orchestrator
1242 .plan_migration(target.clone(), &options)
1243 .unwrap();
1244 let migrate = || orchestrator.execute_migration(plan.clone(), options.clone(), false);
1245 let result = migrate().await;
1246 assert!(result.success, "{:?}", result.errors);
1247 assert_eq!(result.stats.metrics_migrated, 5);
1248 let refreshed = MetricEntry {
1249 run_hash: Some("hash-4b".to_string()),
1250 value: 999.0,
1251 ..entry(&source_pipeline, 4)
1252 };
1253 metrics
1254 .refresh_record_metrics(
1255 &source_pipeline,
1256 &[RecordMetrics {
1257 run_number: 4,
1258 run_hash: "hash-4b".to_string(),
1259 entries: [Some(refreshed), None, None],
1260 }],
1261 )
1262 .await
1263 .unwrap();
1264 let result = migrate().await;
1265 assert!(result.success, "{:?}", result.errors);
1266
1267 let target_manager = StorageManager::from_config(target, false).await.unwrap();
1268 let target_id = target_manager
1269 .config_backend()
1270 .list_providers()
1271 .await
1272 .unwrap()[0]
1273 .id
1274 .unwrap();
1275 assert_ne!(target_id, ids[1]);
1276 let target_pipeline = format!("github__{target_id}__o__r__1");
1277 let target_metrics = MetricsRepository::new_from_pool(target_manager.database_pool());
1278
1279 let copied = target_metrics
1280 .query_metrics(MetricsQuery {
1281 pipeline_id: Some(target_pipeline.clone()),
1282 ..Default::default()
1283 })
1284 .await
1285 .unwrap();
1286 assert_eq!(copied.len(), 5);
1287 let run_4: Vec<_> = copied
1288 .into_iter()
1289 .filter(|entry| entry.run_number == 4)
1290 .map(|entry| (entry.run_hash, entry.value))
1291 .collect();
1292 assert_eq!(run_4, [(Some("hash-4b".to_string()), 999.0)]);
1293 let config = target_metrics
1294 .get_pipeline_config(&target_pipeline)
1295 .await
1296 .unwrap()
1297 .unwrap();
1298 assert!(config.enabled);
1299 assert_eq!(config.retention_days, 14);
1300 let global = target_metrics.get_global_config().await.unwrap();
1301 assert!(global.enabled);
1302 assert_eq!(global.default_retention_days, 30);
1303 assert_eq!(
1304 target_metrics
1305 .get_last_processed_run(&target_pipeline)
1306 .await
1307 .unwrap(),
1308 Some(5)
1309 );
1310 assert_eq!(
1311 target_metrics
1312 .get_last_processed_run(&format!("github__{target_id}__o__r__2"))
1313 .await
1314 .unwrap(),
1315 Some(9)
1316 );
1317 assert_eq!(
1318 target_metrics
1319 .export_config()
1320 .await
1321 .unwrap()
1322 .pipeline_configs
1323 .len(),
1324 1
1325 );
1326 assert_eq!(
1327 target_metrics
1328 .calculate_storage_stats()
1329 .await
1330 .unwrap()
1331 .total_metrics_count,
1332 5
1333 );
1334 }
1335
1336 #[tokio::test]
1337 async fn push_subscriptions_follow_a_storage_migration_without_the_cache() {
1338 let source_dir = TempDir::new().unwrap();
1339 let target_dir = TempDir::new().unwrap();
1340 let config_path = source_dir.path().join("config.toml");
1341 std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1342 let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1343
1344 let mut source = PipedashConfig::default();
1345 source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1346 source.storage.vault_password = Some("test-password".to_string());
1347 let mut target = source.clone();
1348 target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1349
1350 let manager = StorageManager::from_config(source, false).await.unwrap();
1351 let push = PushRepository::new(manager.database_pool());
1352 push.register("relay-a", "iPhone", "ios").await.unwrap();
1353 let paired = push.register("relay-b", "iPad", "ios").await.unwrap();
1354 push.mark_success(&paired.id).await.unwrap();
1355 let expected = push.list().await.unwrap();
1356
1357 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1358 let options = MigrationOptions {
1359 migrate_tokens: false,
1360 migrate_cache: false,
1361 ..Default::default()
1362 };
1363 let plan = orchestrator
1364 .plan_migration(target.clone(), &options)
1365 .unwrap();
1366 let result = orchestrator.execute_migration(plan, options, false).await;
1367 assert!(result.success, "{:?}", result.errors);
1368
1369 let target_manager = StorageManager::from_config(target, false).await.unwrap();
1370 let migrated = PushRepository::new(target_manager.database_pool())
1371 .list()
1372 .await
1373 .unwrap();
1374 assert_eq!(migrated, expected);
1375 }
1376
1377 #[tokio::test]
1378 async fn views_and_pins_survive_a_restart_and_a_storage_migration() {
1379 use crate::domain::{
1380 PinKind,
1381 SectionMode,
1382 ViewDisplay,
1383 ViewKind,
1384 ViewLayout,
1385 ViewRules,
1386 };
1387 use crate::infrastructure::database::ViewExport;
1388
1389 let source_dir = TempDir::new().unwrap();
1390 let target_dir = TempDir::new().unwrap();
1391 let config_path = source_dir.path().join("config.toml");
1392 std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1393 let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1394
1395 let mut source = PipedashConfig::default();
1396 source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1397 source.storage.vault_password = Some("test-password".to_string());
1398 let mut target = source.clone();
1399 target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1400
1401 let manager = StorageManager::from_config(source.clone(), false)
1402 .await
1403 .unwrap();
1404 let backend = manager.config_backend();
1405 let mut ids = Vec::new();
1406 for name in ["deleted", "gh"] {
1407 let provider = ProviderConfig {
1408 id: None,
1409 name: name.to_string(),
1410 provider_type: "github".to_string(),
1411 token: String::new(),
1412 config: HashMap::new(),
1413 refresh_interval: 30,
1414 version: None,
1415 };
1416 ids.push(backend.create_provider(&provider).await.unwrap());
1417 }
1418 backend.delete_provider(ids[0]).await.unwrap();
1419 let provider = ids[1];
1420 let pipeline = |n: u32| format!("github__{provider}__o__r__{n}");
1421
1422 let repo = PipelineViewRepository::new(manager.database_pool());
1423 let rules = ViewRules {
1424 provider_ids: vec![provider],
1425 repositories: vec!["o/*".to_string()],
1426 name_contains: Some("deploy".to_string()),
1427 statuses: Vec::new(),
1428 };
1429 let cards = ViewDisplay {
1430 layout: ViewLayout::Cards,
1431 sections: SectionMode::Provider,
1432 ..ViewDisplay::default()
1433 };
1434 let release = repo
1435 .insert_custom("Release", None, &rules, &cards)
1436 .await
1437 .unwrap();
1438 let prod = repo
1439 .insert_custom(
1440 "Prod",
1441 Some(release),
1442 &ViewRules::default(),
1443 &ViewDisplay::default(),
1444 )
1445 .await
1446 .unwrap();
1447 let eu = repo
1448 .insert_custom(
1449 "EU",
1450 Some(prod),
1451 &ViewRules::default(),
1452 &ViewDisplay::default(),
1453 )
1454 .await
1455 .unwrap();
1456 repo.upsert_member(eu, provider, &pipeline(1), false)
1457 .await
1458 .unwrap();
1459 repo.upsert_member(release, provider, &pipeline(2), true)
1460 .await
1461 .unwrap();
1462 let pinned = repo
1463 .list_views()
1464 .await
1465 .unwrap()
1466 .into_iter()
1467 .find(|v| v.kind == ViewKind::Pinned)
1468 .unwrap();
1469 repo.update_view(
1470 pinned.id,
1471 &pinned.name,
1472 None,
1473 &pinned.rules,
1474 &ViewDisplay {
1475 layout: ViewLayout::Table,
1476 ..pinned.display
1477 },
1478 )
1479 .await
1480 .unwrap();
1481 repo.insert_pin(PinKind::View, &eu.to_string())
1482 .await
1483 .unwrap();
1484 repo.insert_pin(PinKind::Repository, "o/r").await.unwrap();
1485 repo.insert_pin(PinKind::View, &release.to_string())
1486 .await
1487 .unwrap();
1488
1489 let snapshot = |export: ViewExport, provider: i64| {
1490 let pid = |id: &str| id.replace(&format!("__{provider}__"), "__P__");
1491 let view_name = |id: i64| {
1492 export
1493 .views
1494 .iter()
1495 .find(|v| v.id == id)
1496 .unwrap()
1497 .name
1498 .clone()
1499 };
1500 let views: Vec<_> = export
1501 .views
1502 .iter()
1503 .map(|v| {
1504 let mut rules = v.rules.clone();
1505 rules.provider_ids = rules
1506 .provider_ids
1507 .iter()
1508 .map(|id| i64::from(*id == provider))
1509 .collect();
1510 (
1511 v.kind,
1512 v.name.clone(),
1513 v.parent_id.map(view_name),
1514 v.position,
1515 rules,
1516 v.display,
1517 )
1518 })
1519 .collect();
1520 let members: Vec<_> = export
1521 .members
1522 .iter()
1523 .map(|m| {
1524 (
1525 view_name(m.view_id),
1526 pid(&m.pipeline_id),
1527 m.excluded,
1528 m.provider_id == provider,
1529 )
1530 })
1531 .collect();
1532 let pins: Vec<_> = export
1533 .pins
1534 .iter()
1535 .map(|p| match p.kind {
1536 PinKind::View => view_name(p.reference.parse().unwrap()),
1537 PinKind::Repository => p.reference.clone(),
1538 })
1539 .collect();
1540 format!("{views:?}\n{members:?}\n{pins:?}")
1541 };
1542 let before = snapshot(repo.export().await.unwrap(), provider);
1543 drop(repo);
1544 drop(manager);
1545
1546 let manager = StorageManager::from_config(source, false).await.unwrap();
1547 let reopened = PipelineViewRepository::new(manager.database_pool())
1548 .export()
1549 .await
1550 .unwrap();
1551 assert_eq!(snapshot(reopened, provider), before);
1552
1553 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1554 let options = MigrationOptions {
1555 migrate_tokens: false,
1556 migrate_cache: false,
1557 ..Default::default()
1558 };
1559 let plan = orchestrator
1560 .plan_migration(target.clone(), &options)
1561 .unwrap();
1562 let result = orchestrator.execute_migration(plan, options, false).await;
1563 assert!(result.success, "{:?}", result.errors);
1564
1565 let target_manager = StorageManager::from_config(target, false).await.unwrap();
1566 let target_provider = target_manager
1567 .config_backend()
1568 .list_providers()
1569 .await
1570 .unwrap()[0]
1571 .id
1572 .unwrap();
1573 assert_ne!(target_provider, provider);
1574 let migrated = PipelineViewRepository::new(target_manager.database_pool())
1575 .export()
1576 .await
1577 .unwrap();
1578 assert_eq!(snapshot(migrated, target_provider), before);
1579 }
1580}