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 DeviceAlert,
29 DomainError,
30 DomainResult,
31 MetricsQuery,
32 ProviderConfig,
33};
34use crate::event::EventBus;
35use crate::infrastructure::config::{
36 PipedashConfig,
37 StorageConfig,
38 StorageManager,
39};
40use crate::infrastructure::token_store::TokenStore;
41use crate::infrastructure::{
42 database::{
43 DatabasePool,
44 DeviceAlertRepository,
45 DevicePushRepository,
46 DeviceRepository,
47 MetricsRepository,
48 PipelineViewRepository,
49 PushRepository,
50 RecordMetrics,
51 Repository,
52 },
53 ConfigBackend,
54 StorageBackend,
55};
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58pub enum MigrationStep {
59 ValidateTarget,
60 MigrateTokens,
61 MigrateConfigs,
62 MigrateCache,
63 VerifyMigration,
64 UpdateConfig,
65}
66
67impl MigrationStep {
68 pub fn description(&self) -> &str {
69 match self {
70 MigrationStep::ValidateTarget => "Validating target configuration",
71 MigrationStep::MigrateTokens => "Migrating tokens",
72 MigrationStep::MigrateConfigs => "Migrating provider configurations",
73 MigrationStep::MigrateCache => "Migrating cached data",
74 MigrationStep::VerifyMigration => "Verifying migration",
75 MigrationStep::UpdateConfig => "Updating storage configuration",
76 }
77 }
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct MigrationPlan {
82 pub from: PipedashConfig,
83 pub to: PipedashConfig,
84 pub steps: Vec<MigrationStep>,
85 pub migrate_tokens: bool,
86 pub migrate_configs: bool,
87 pub migrate_cache: bool,
88 pub backend_changed: bool,
89 pub data_dir_changed: bool,
90 pub created_at: DateTime<Utc>,
91}
92
93impl MigrationPlan {
94 pub fn summary(&self) -> String {
95 format!(
96 "Migrate from {} to {} ({} steps)",
97 self.from.storage.summary(),
98 self.to.storage.summary(),
99 self.steps.len()
100 )
101 }
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct MigrationProgress {
106 pub current_step: MigrationStep,
107 pub step_index: usize,
108 pub total_steps: usize,
109 pub message: String,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct MigrationResult {
114 pub success: bool,
115 pub steps_completed: Vec<MigrationStep>,
116 pub errors: Vec<String>,
117 pub duration_ms: u64,
118 pub stats: MigrationStats,
119 #[serde(default)]
120 pub provider_id_mapping: std::collections::HashMap<i64, i64>,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub backups: Option<BackupPaths>,
123}
124
125impl MigrationResult {
126 pub fn summary(&self) -> String {
127 if self.success {
128 format!(
129 "Migration successful: {} providers, {} cache entries migrated in {}ms",
130 self.stats.providers_migrated, self.stats.cache_entries_migrated, self.duration_ms
131 )
132 } else {
133 format!(
134 "Migration failed after {} steps: {}",
135 self.steps_completed.len(),
136 self.errors.join(", ")
137 )
138 }
139 }
140}
141
142#[derive(Debug, Clone, Default, Serialize, Deserialize)]
143pub struct MigrationStats {
144 pub providers_migrated: usize,
145 pub tokens_migrated: usize,
146 pub cache_entries_migrated: usize,
147 pub metrics_migrated: usize,
148 pub permissions_migrated: usize,
149 pub providers_cleaned: usize,
150 pub tokens_cleaned: usize,
151}
152
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct MigrationOptions {
155 pub migrate_tokens: bool,
156 pub migrate_cache: bool,
157 pub token_password: Option<String>,
158 pub dry_run: bool,
159 #[serde(default)]
160 pub allow_non_empty_target: bool,
161 #[serde(default)]
162 pub clean_target: bool,
163 #[serde(default)]
164 pub keep_backups: bool,
165 #[serde(default = "default_max_retries")]
166 pub max_retries: u32,
167}
168
169fn default_max_retries() -> u32 {
170 3
171}
172
173impl Default for MigrationOptions {
174 fn default() -> Self {
175 Self {
176 migrate_tokens: false,
177 migrate_cache: false,
178 token_password: None,
179 dry_run: false,
180 allow_non_empty_target: false,
181 clean_target: false,
182 keep_backups: false,
183 max_retries: 3,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct ValidationReport {
190 pub errors: Vec<String>,
191 pub warnings: Vec<String>,
192 pub recommendations: Vec<String>,
193}
194
195impl ValidationReport {
196 pub fn is_valid(&self) -> bool {
197 self.errors.is_empty()
198 }
199}
200
201pub const VAULT_PASSWORD_ENV_VAR: &str = "PIPEDASH_VAULT_PASSWORD";
202
203fn get_vault_password(options: &MigrationOptions) -> DomainResult<String> {
204 if let Ok(password) = std::env::var(VAULT_PASSWORD_ENV_VAR) {
205 if !password.is_empty() {
206 return Ok(password);
207 }
208 }
209
210 options.token_password.clone().ok_or_else(|| {
211 DomainError::InvalidConfig(format!(
212 "Vault password required for token migration. Set {} environment variable or provide token_password in options.",
213 VAULT_PASSWORD_ENV_VAR
214 ))
215 })
216}
217
218fn remap_pipeline_id(pipeline_id: &str, provider_ids: &HashMap<i64, i64>) -> Option<String> {
219 let mut parts = pipeline_id.splitn(3, "__");
220 let (Some(provider_type), Some(provider_id), Some(rest)) =
221 (parts.next(), parts.next(), parts.next())
222 else {
223 return None;
224 };
225 let new_id = provider_ids.get(&provider_id.parse().ok()?)?;
226
227 Some(format!("{provider_type}__{new_id}__{rest}"))
228}
229
230fn remap_alert(mut alert: DeviceAlert, providers: &HashMap<i64, i64>) -> Option<DeviceAlert> {
231 if let Some(provider_id) = alert.provider_id {
232 alert.provider_id = Some(*providers.get(&provider_id)?);
233 }
234 if let Some(pipeline_id) = alert.pipeline_id.take() {
235 let next = remap_pipeline_id(&pipeline_id, providers)?;
236 if alert.key == format!("pipeline:{pipeline_id}") {
237 alert.key = format!("pipeline:{next}");
238 }
239 alert.pipeline_id = Some(next);
240 }
241 for prefix in ["connection:", "instance:"] {
242 if let Some(id) = alert.key.strip_prefix(prefix) {
243 let next = providers.get(&id.parse().ok()?)?;
244 alert.key = format!("{prefix}{next}");
245 break;
246 }
247 }
248
249 Some(alert)
250}
251
252fn same_database(from: &PipedashConfig, to: &PipedashConfig) -> bool {
253 from.storage.backend == to.storage.backend
254 && if to.storage.backend.is_sqlite() {
255 from.db_path() == to.db_path()
256 } else {
257 from.storage.postgres.connection_string == to.storage.postgres.connection_string
258 }
259}
260
261pub struct MigrationOrchestrator {
262 source_config: PipedashConfig,
263 source_token_store: Arc<dyn TokenStore>,
264 source_config_backend: Arc<dyn ConfigBackend>,
265 source_cache_backend: Arc<dyn StorageBackend>,
266 source_metrics: MetricsRepository,
267 source_pool: DatabasePool,
268 event_bus: Option<Arc<dyn EventBus>>,
269 target_token_store: Option<Arc<dyn TokenStore>>,
270}
271
272impl MigrationOrchestrator {
273 pub async fn from_manager(
274 manager: &StorageManager, event_bus: Option<Arc<dyn EventBus>>,
275 target_token_store: Option<Arc<dyn TokenStore>>,
276 ) -> Self {
277 Self {
278 source_config: manager.config().clone(),
279 source_token_store: manager.token_store().await,
280 source_config_backend: manager.config_backend(),
281 source_cache_backend: manager.cache_backend(),
282 source_metrics: MetricsRepository::new_from_pool(manager.database_pool()),
283 source_pool: manager.database_pool(),
284 event_bus,
285 target_token_store,
286 }
287 }
288
289 pub fn plan_migration(
290 &self, mut target_config: PipedashConfig, options: &MigrationOptions,
291 ) -> DomainResult<MigrationPlan> {
292 use crate::infrastructure::config::StorageBackend as ConfigStorageBackend;
293
294 let source_data_dir = self.source_config.data_dir();
295 let target_data_dir = target_config.data_dir();
296 let data_dir_changed = source_data_dir != target_data_dir;
297
298 let source_storage = &self.source_config.storage;
299 let target_storage = &mut target_config.storage;
300
301 if data_dir_changed {
302 tracing::info!(
303 "[Migration Planning] Data directory change detected: {:?} → {:?}",
304 source_data_dir,
305 target_data_dir
306 );
307 }
308
309 let backend_changed = source_storage.backend != target_storage.backend;
310
311 let token_backend_changing = {
312 let source_uses_keyring = source_storage.backend.is_sqlite()
313 && std::env::var(VAULT_PASSWORD_ENV_VAR).is_err();
314 let target_uses_encrypted = target_storage.backend.is_sqlite()
315 && (options.token_password.is_some()
316 || std::env::var(VAULT_PASSWORD_ENV_VAR).is_ok());
317
318 source_uses_keyring && target_uses_encrypted
319 };
320
321 if token_backend_changing {
322 tracing::info!(
323 "[Migration Planning] Token backend change detected: keyring → encrypted storage"
324 );
325 }
326
327 let needs_data_migration = backend_changed || data_dir_changed || token_backend_changing;
328
329 if target_storage.backend == ConfigStorageBackend::Postgres {
330 if target_storage.data_dir.starts_with("postgresql://")
331 || target_storage.data_dir.starts_with("postgres://")
332 {
333 tracing::warn!(
334 "Target data_dir contains PostgreSQL connection string: {}. \
335 Resetting to empty (will use default) - connection string should be in postgres.connection_string field.",
336 target_storage.data_dir
337 );
338 target_storage.data_dir = String::new(); } else if target_storage.data_dir.is_empty() {
340 tracing::debug!("Target uses PostgreSQL - data_dir is empty (will use default for cache/local files)");
341 }
342 }
343
344 let mut steps = Vec::new();
345
346 steps.push(MigrationStep::ValidateTarget);
347
348 if needs_data_migration {
349 steps.push(MigrationStep::MigrateConfigs);
350 }
351
352 if needs_data_migration && options.migrate_tokens {
353 steps.push(MigrationStep::MigrateTokens);
354 }
355
356 if needs_data_migration && options.migrate_cache {
357 steps.push(MigrationStep::MigrateCache);
358 }
359
360 if !steps.is_empty() {
361 steps.push(MigrationStep::VerifyMigration);
362 steps.push(MigrationStep::UpdateConfig);
363 }
364
365 Ok(MigrationPlan {
366 from: self.source_config.clone(),
367 to: target_config,
368 steps,
369 migrate_tokens: needs_data_migration && options.migrate_tokens,
370 migrate_configs: needs_data_migration,
371 migrate_cache: needs_data_migration && options.migrate_cache,
372 backend_changed,
373 data_dir_changed,
374 created_at: Utc::now(),
375 })
376 }
377
378 pub async fn validate_target_config(
379 &self, config: &StorageConfig,
380 ) -> DomainResult<ValidationReport> {
381 use crate::infrastructure::config::StorageBackend as ConfigStorageBackend;
382
383 let mut report = ValidationReport {
384 errors: Vec::new(),
385 warnings: Vec::new(),
386 recommendations: Vec::new(),
387 };
388
389 if config.backend == ConfigStorageBackend::Postgres {
390 if config.postgres.connection_string.is_empty() {
391 report.errors.push(
392 "PostgreSQL connection string required when using postgres backend".into(),
393 );
394 }
395
396 let has_settings_password = config.vault_password.is_some();
397 let has_env_password = std::env::var(VAULT_PASSWORD_ENV_VAR).is_ok();
398
399 if !has_settings_password && !has_env_password {
400 report.warnings.push(format!(
401 "Vault password not provided. Set {} environment variable or provide password during setup.",
402 VAULT_PASSWORD_ENV_VAR
403 ));
404 }
405 }
406
407 Ok(report)
408 }
409
410 pub async fn execute_migration(
411 &self, plan: MigrationPlan, options: MigrationOptions, is_desktop: bool,
412 ) -> MigrationResult {
413 let start_time = std::time::Instant::now();
414 let mut result = MigrationResult {
415 success: false,
416 steps_completed: Vec::new(),
417 errors: Vec::new(),
418 duration_ms: 0,
419 stats: MigrationStats::default(),
420 provider_id_mapping: std::collections::HashMap::new(),
421 backups: None,
422 };
423
424 if options.dry_run {
425 result.success = true;
426 result.duration_ms = start_time.elapsed().as_millis() as u64;
427 return result;
428 }
429
430 let backup_manager = BackupManager::new(plan.from.data_dir());
431 let backups = match backup_manager
432 .create_backups(&plan.from, &plan.to, Some(&*self.source_config_backend))
433 .await
434 {
435 Ok(backups) => backups,
436 Err(e) => {
437 result.errors.push(format!(
438 "Failed to create backups: {}\n\n\
439 WARNING: Migration aborted - backup creation is required for safe migration.\n\
440 INFO: Your data is safe - no modifications were performed.\n\n\
441 Please ensure:\n\
442 1. Sufficient disk space (need ~2x source database size)\n\
443 2. Write permissions to data directory\n\
444 3. Source database is not locked by another process",
445 e
446 ));
447 result.duration_ms = start_time.elapsed().as_millis() as u64;
448 return result;
449 }
450 };
451 result.backups = Some(backups.clone());
452
453 let target_manager = if let Some(token_store) = &self.target_token_store {
454 match StorageManager::with_token_store(plan.to.clone(), token_store.clone(), is_desktop)
455 .await
456 {
457 Ok(m) => m,
458 Err(e) => {
459 result.errors.push(format!(
460 "Failed to create target manager with token store: {}\n\n\
461 INFO: Migration aborted before any changes were made.\n\
462 INFO: Your data is safe - no modifications were performed.",
463 e
464 ));
465 backup_manager.cleanup_backups(&backups, false).await.ok();
466 result.duration_ms = start_time.elapsed().as_millis() as u64;
467 return result;
468 }
469 }
470 } else {
471 match StorageManager::from_config(plan.to.clone(), false).await {
472 Ok(m) => m,
473 Err(e) => {
474 result.errors.push(format!(
475 "Failed to create target manager: {}\n\n\
476 INFO: Migration aborted before any changes were made.\n\
477 INFO: Your data is safe - no modifications were performed.",
478 e
479 ));
480 backup_manager.cleanup_backups(&backups, false).await.ok();
481 result.duration_ms = start_time.elapsed().as_millis() as u64;
482 return result;
483 }
484 }
485 };
486
487 for (idx, step) in plan.steps.iter().enumerate() {
488 self.emit_progress(*step, idx, plan.steps.len()).await;
489
490 let step_result = self
491 .execute_step(*step, &plan, &options, &target_manager, &mut result)
492 .await;
493
494 match step_result {
495 Ok(()) => {
496 result.steps_completed.push(*step);
497 }
498 Err(e) => {
499 let step_name = format!("{:?}", step);
500
501 tracing::error!(
502 "Migration failed at step {} - restoring from backups",
503 step_name
504 );
505
506 let recovery_instructions = match backup_manager.restore_backups(&backups).await
507 {
508 Ok(()) => {
509 tracing::info!("Successfully restored from backups");
510 format!(
511 "✓ Your data has been restored from backups.\n\
512 ✓ Config file: {}\n\
513 ✓ Database: {}\n\n\
514 The migration failed but your original data is intact.\n\
515 Please review the error below and try again after fixing the issue:\n\n\
516 Error: {}",
517 backups.source_config.display(),
518 backups.source_db.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "N/A".to_string()),
519 e
520 )
521 }
522 Err(restore_err) => {
523 format!(
524 "CRITICAL ERROR: Migration failed AND backup restoration failed!\n\n\
525 Original error: {}\n\
526 Restore error: {}\n\n\
527 MANUAL RECOVERY REQUIRED:\n\
528 1. Restore config file from: {}\n\
529 2. Restore database from: {}\n\
530 3. Review logs for details\n\
531 4. Contact support if needed",
532 e,
533 restore_err,
534 backups.source_config.display(),
535 backups.source_db.as_ref().map(|p| p.display().to_string()).unwrap_or_else(|| "N/A".to_string())
536 )
537 }
538 };
539
540 result.errors.push(format!(
541 "Migration failed at step '{}':\n{}\n\n{}",
542 step_name, e, recovery_instructions
543 ));
544
545 self.rollback(&result).await;
546 result.duration_ms = start_time.elapsed().as_millis() as u64;
547 return result;
548 }
549 }
550 }
551
552 result.success = true;
553 result.duration_ms = start_time.elapsed().as_millis() as u64;
554
555 if let Err(e) = backup_manager
556 .cleanup_backups(&backups, options.keep_backups)
557 .await
558 {
559 tracing::warn!("Failed to cleanup backups: {}", e);
560 }
561
562 result
563 }
564
565 async fn execute_step(
566 &self, step: MigrationStep, plan: &MigrationPlan, options: &MigrationOptions,
567 target_manager: &StorageManager, result: &mut MigrationResult,
568 ) -> DomainResult<()> {
569 match step {
570 MigrationStep::ValidateTarget => {
571 if !target_manager.cache_backend().is_available().await {
572 return Err(DomainError::NetworkError(
573 "Target cache backend not available".into(),
574 ));
575 }
576
577 target_manager.config_backend().list_providers().await?;
578 }
579
580 MigrationStep::MigrateTokens => {
581 tracing::info!("[Migration] Starting token migration...");
582 let password = get_vault_password(options)?;
583 tracing::info!(
584 "[Migration] Vault password retrieved: {} chars",
585 password.len()
586 );
587
588 tracing::info!("[Migration] Exporting tokens from source token store...");
589 let encrypted_blob = self.source_token_store.export_encrypted(&password).await?;
590 tracing::info!(
591 "[Migration] Exported encrypted blob: {} bytes",
592 encrypted_blob.len()
593 );
594
595 if !result.provider_id_mapping.is_empty() {
596 tracing::info!(
597 "[Migration] Using provider ID remapping path ({} mappings)",
598 result.provider_id_mapping.len()
599 );
600
601 let source_tokens = self.source_token_store.get_all_tokens().await?;
602 tracing::info!(
603 "[Migration] Retrieved {} tokens from source",
604 source_tokens.len()
605 );
606
607 let mut migrated_count = 0;
608 for (old_id, token) in source_tokens {
609 if let Some(&new_id) = result.provider_id_mapping.get(&old_id) {
610 tracing::debug!(
611 "[Migration] Storing token for provider {} → {} (token length: {})",
612 old_id,
613 new_id,
614 token.len()
615 );
616 target_manager
617 .token_store()
618 .await
619 .store_token(new_id, &token)
620 .await?;
621 migrated_count += 1;
622 tracing::info!(
623 "[Migration] Successfully stored token for provider {} (remapped to {})",
624 old_id,
625 new_id
626 );
627 } else {
628 let source_providers =
629 self.source_config_backend.list_providers().await?;
630 return Err(DomainError::DataConsistency(format!(
631 "Cannot migrate token for provider ID {} - provider not found in remapping.\n\n\
632 This indicates data corruption or inconsistency.\n\n\
633 Source provider IDs: {:?}\n\
634 Remapping table: {:?}\n\n\
635 This token would be lost if migration continued.\n\
636 Please investigate why this provider's configuration wasn't migrated.",
637 old_id,
638 source_providers.iter().filter_map(|p| p.id).collect::<Vec<_>>(),
639 result.provider_id_mapping
640 )));
641 }
642 }
643 tracing::info!(
644 "[Migration] Migrated {} tokens via ID remapping",
645 migrated_count
646 );
647 } else {
648 tracing::info!("[Migration] Using direct import path (no ID remapping)");
649 tracing::info!(
650 "[Migration] Importing encrypted blob ({} bytes) to target token store...",
651 encrypted_blob.len()
652 );
653 target_manager
654 .token_store()
655 .await
656 .import_encrypted(&encrypted_blob, &password)
657 .await?;
658 tracing::info!("[Migration] Direct import completed");
659 }
660
661 let count: usize = target_manager
662 .token_store()
663 .await
664 .get_all_tokens()
665 .await?
666 .len();
667 tracing::info!(
668 "[Migration] Verification: {} tokens now in target database",
669 count
670 );
671 result.stats.tokens_migrated = count;
672 }
673
674 MigrationStep::MigrateConfigs => {
675 tracing::info!(
676 "[Migration] Exporting providers from source database: {:?}",
677 self.source_config.db_path()
678 );
679 let export = self.source_config_backend.export_all().await?;
680 result.stats.providers_migrated = export.providers.len();
681 result.stats.permissions_migrated = export.permissions.len();
682
683 tracing::info!(
684 "[Migration] Importing {} providers to target database: {:?}",
685 export.providers.len(),
686 plan.to.db_path()
687 );
688 target_manager.config_backend().import_all(&export).await?;
689
690 let source_providers = self.source_config_backend.list_providers().await?;
691 let target_providers: Vec<ProviderConfig> =
692 target_manager.config_backend().list_providers().await?;
693
694 for source_provider in &source_providers {
695 if let Some(source_id) = source_provider.id {
696 if let Some(target_provider) = target_providers
697 .iter()
698 .find(|p| p.name == source_provider.name)
699 {
700 if let Some(target_id) = target_provider.id {
701 result.provider_id_mapping.insert(source_id, target_id);
702 }
703 }
704 }
705 }
706
707 if !same_database(&plan.from, &plan.to) {
708 let mut metrics_config = self.source_metrics.export_config().await?;
709 metrics_config.pipeline_configs.retain_mut(|config| {
710 match remap_pipeline_id(&config.pipeline_id, &result.provider_id_mapping) {
711 Some(pipeline_id) => {
712 config.pipeline_id = pipeline_id;
713 true
714 }
715 None => false,
716 }
717 });
718 MetricsRepository::new_from_pool(target_manager.database_pool())
719 .import_config(&metrics_config)
720 .await?;
721 }
722
723 if !same_database(&plan.from, &plan.to) {
724 let subscriptions =
725 PushRepository::new(self.source_pool.clone()).list().await?;
726 let target_push = PushRepository::new(target_manager.database_pool());
727 target_push.import(&subscriptions).await?;
728 if options.clean_target {
729 for stale in target_push.list().await? {
730 if !subscriptions.iter().any(|s| s.id == stale.id) {
731 target_push.delete(&stale.id).await?;
732 }
733 }
734 }
735 tracing::info!(
736 "[Migration] Migrated {} push subscriptions",
737 subscriptions.len()
738 );
739 }
740
741 if !same_database(&plan.from, &plan.to) {
742 let devices = DeviceRepository::new(self.source_pool.clone())
743 .list()
744 .await?;
745 let target_devices = DeviceRepository::new(target_manager.database_pool());
746 target_devices.import(&devices).await?;
747 if options.clean_target {
748 for stale in target_devices.list().await? {
749 if !devices.iter().any(|d| d.id == stale.id) {
750 target_devices.delete(&stale.id).await?;
751 }
752 }
753 }
754 tracing::info!("[Migration] Migrated {} paired devices", devices.len());
755 }
756
757 if !same_database(&plan.from, &plan.to) {
758 let pushes = DevicePushRepository::new(self.source_pool.clone())
759 .list()
760 .await?;
761 let target_pushes = DevicePushRepository::new(target_manager.database_pool());
762 target_pushes.import(&pushes).await?;
763 if options.clean_target {
764 for stale in target_pushes.list().await? {
765 if !pushes.iter().any(|p| p.device_id == stale.device_id) {
766 target_pushes.delete(&stale.device_id).await?;
767 }
768 }
769 }
770 tracing::info!(
771 "[Migration] Migrated {} device push registrations",
772 pushes.len()
773 );
774 }
775
776 if !same_database(&plan.from, &plan.to) {
777 let mapping = &result.provider_id_mapping;
778 let mut export = PipelineViewRepository::new(self.source_pool.clone())
779 .export()
780 .await?;
781 let remap = |pipeline_id: &mut String, provider_id: &mut i64| match (
782 remap_pipeline_id(pipeline_id, mapping),
783 mapping.get(provider_id),
784 ) {
785 (Some(next), Some(target)) => {
786 *pipeline_id = next;
787 *provider_id = *target;
788 true
789 }
790 _ => false,
791 };
792 export
793 .members
794 .retain_mut(|m| remap(&mut m.pipeline_id, &mut m.provider_id));
795 for view in &mut export.views {
796 view.rules.provider_ids = view
797 .rules
798 .provider_ids
799 .iter()
800 .filter_map(|id| mapping.get(id).copied())
801 .collect();
802 }
803 PipelineViewRepository::new(target_manager.database_pool())
804 .import(&export, options.clean_target)
805 .await?;
806 tracing::info!("[Migration] Migrated {} pipeline views", export.views.len());
807 }
808
809 if options.clean_target {
810 let source_names: std::collections::HashSet<String> =
811 source_providers.iter().map(|p| p.name.clone()).collect();
812
813 let orphaned: Vec<_> = target_providers
814 .iter()
815 .filter(|p| !source_names.contains(&p.name))
816 .collect();
817
818 for orphan in &orphaned {
819 if let Some(id) = orphan.id {
820 tracing::info!(
821 "[Migration] Removing orphaned provider: {} (id={})",
822 orphan.name,
823 id
824 );
825 target_manager.config_backend().delete_provider(id).await?;
826 let _ = target_manager.token_store().await.delete_token(id).await;
827 result.stats.tokens_cleaned += 1;
828 }
829 }
830
831 if !orphaned.is_empty() {
832 result.stats.providers_cleaned = orphaned.len();
833 tracing::info!(
834 "[Migration] Cleaned {} orphaned providers from target",
835 orphaned.len()
836 );
837 }
838 }
839
840 if !same_database(&plan.from, &plan.to) {
841 let alerts: Vec<DeviceAlert> =
842 DeviceAlertRepository::new(self.source_pool.clone())
843 .list()
844 .await?
845 .into_iter()
846 .filter_map(|alert| remap_alert(alert, &result.provider_id_mapping))
847 .collect();
848 let target_alerts = DeviceAlertRepository::new(target_manager.database_pool());
849 if options.clean_target {
850 target_alerts.delete_all().await?;
851 target_alerts.import(&alerts).await?;
852 } else {
853 target_alerts.merge(&alerts).await?;
854 }
855 tracing::info!("[Migration] Migrated {} device alerts", alerts.len());
856 }
857 }
858
859 MigrationStep::MigrateCache => {
860 let objects = self.source_cache_backend.list(None).await?;
861 tracing::info!(
862 "[Migration] Migrating {} cache entries from {:?} to {:?}",
863 objects.len(),
864 self.source_config.data_dir().join("cache"),
865 plan.to.data_dir().join("cache")
866 );
867 for obj in objects {
868 let data = self.source_cache_backend.get(&obj.key).await?;
869 target_manager
870 .cache_backend()
871 .put(&obj.key, &data, obj.content_type.as_deref())
872 .await?;
873 result.stats.cache_entries_migrated += 1;
874 }
875
876 if !same_database(&plan.from, &plan.to) {
877 let source_repo = Repository::new(
878 self.source_config_backend.clone(),
879 self.source_pool.clone(),
880 self.source_token_store.clone(),
881 );
882 let target_repo = Repository::new(
883 target_manager.config_backend(),
884 target_manager.database_pool(),
885 target_manager.token_store().await,
886 );
887 for source_id in source_repo.list_run_history_pipeline_ids().await? {
888 let Some(target_id) =
889 remap_pipeline_id(&source_id, &result.provider_id_mapping)
890 else {
891 continue;
892 };
893 let mut runs = source_repo
894 .get_cached_run_history(&source_id, i64::MAX as usize)
895 .await?;
896 for run in &mut runs {
897 run.pipeline_id.clone_from(&target_id);
898 }
899 target_repo.cache_run_history(&target_id, &runs).await?;
900 result.stats.cache_entries_migrated += runs.len();
901 }
902
903 let target_metrics =
904 MetricsRepository::new_from_pool(target_manager.database_pool());
905 let pipeline_query = |pipeline_id: &str| MetricsQuery {
906 pipeline_id: Some(pipeline_id.to_string()),
907 limit: None,
908 ..Default::default()
909 };
910 let mut pipelines = 0;
911 for source_id in self.source_metrics.list_pipeline_ids().await? {
912 let Some(target_id) =
913 remap_pipeline_id(&source_id, &result.provider_id_mapping)
914 else {
915 continue;
916 };
917 let last_run = self
918 .source_metrics
919 .get_last_processed_run(&source_id)
920 .await?;
921 let mut runs: BTreeMap<i64, RecordMetrics> = BTreeMap::new();
922 for entry in self
923 .source_metrics
924 .query_metrics(pipeline_query(&source_id))
925 .await?
926 {
927 let slot = entry.metric_type as usize;
928 let record =
929 runs.entry(entry.run_number)
930 .or_insert_with(|| RecordMetrics {
931 run_number: entry.run_number,
932 run_hash: entry.run_hash.clone().unwrap_or_default(),
933 entries: [None, None, None],
934 });
935 record.entries[slot] = Some(entry);
936 }
937 let records: Vec<_> = runs.into_values().collect();
938 target_metrics
939 .refresh_record_metrics(&target_id, &records)
940 .await?;
941 result.stats.metrics_migrated += records
942 .iter()
943 .map(|record| record.entries.iter().flatten().count())
944 .sum::<usize>();
945 pipelines += 1;
946
947 let Some(run) = last_run else {
948 continue;
949 };
950 if target_metrics
951 .get_last_processed_run(&target_id)
952 .await?
953 .is_none_or(|target_run| target_run < run)
954 {
955 target_metrics
956 .update_last_processed_run(&target_id, run)
957 .await?;
958 }
959 }
960 tracing::info!(
961 "[Migration] Migrated {} metric entries for {} pipelines",
962 result.stats.metrics_migrated,
963 pipelines
964 );
965 }
966 }
967
968 MigrationStep::VerifyMigration => {
969 let source_providers = self.source_config_backend.list_providers().await?;
970
971 if plan.migrate_configs && !source_providers.is_empty() {
972 let target_providers: Vec<ProviderConfig> =
973 target_manager.config_backend().list_providers().await?;
974
975 for source_provider in &source_providers {
976 let found = target_providers
977 .iter()
978 .any(|t| t.name == source_provider.name);
979 if !found {
980 return Err(DomainError::DatabaseError(format!(
981 "Provider '{}' from source was not found in target after migration",
982 source_provider.name
983 )));
984 }
985 }
986
987 if options.clean_target {
988 if source_providers.len() != target_providers.len() {
989 return Err(DomainError::DatabaseError(format!(
990 "Provider count mismatch after cleanup: source={}, target={}",
991 source_providers.len(),
992 target_providers.len()
993 )));
994 }
995 tracing::info!(
996 "[Migration] Verified exact match: {} providers",
997 source_providers.len()
998 );
999 } else if target_providers.len() > source_providers.len() {
1000 tracing::info!(
1001 "[Migration] Merge complete: {} migrated, {} pre-existing kept (total: {})",
1002 source_providers.len(),
1003 target_providers.len() - source_providers.len(),
1004 target_providers.len()
1005 );
1006 } else {
1007 tracing::info!(
1008 "[Migration] Verified {} providers migrated successfully",
1009 source_providers.len()
1010 );
1011 }
1012 } else if plan.migrate_configs {
1013 tracing::info!("Skipping provider verification: source database is empty");
1014 }
1015
1016 if plan.migrate_tokens {
1017 let source_tokens = self.source_token_store.get_all_tokens().await?;
1018
1019 if !source_tokens.is_empty() {
1020 let target_tokens: HashMap<i64, String> =
1021 target_manager.token_store().await.get_all_tokens().await?;
1022
1023 if options.clean_target {
1024 if source_tokens.len() != target_tokens.len() {
1025 return Err(DomainError::DatabaseError(format!(
1026 "Token count mismatch after cleanup: source={}, target={}",
1027 source_tokens.len(),
1028 target_tokens.len()
1029 )));
1030 }
1031 tracing::info!(
1032 "[Migration] Verified exact match: {} tokens",
1033 source_tokens.len()
1034 );
1035 } else {
1036 if target_tokens.len() < source_tokens.len() {
1037 return Err(DomainError::DatabaseError(format!(
1038 "Token count too low after migration: source={}, target={}",
1039 source_tokens.len(),
1040 target_tokens.len()
1041 )));
1042 }
1043 tracing::info!(
1044 "[Migration] Token migration complete: {} migrated (target has {} total)",
1045 source_tokens.len(),
1046 target_tokens.len()
1047 );
1048 }
1049 } else {
1050 tracing::info!("Skipping token verification: source has no tokens");
1051 }
1052 }
1053 }
1054
1055 MigrationStep::UpdateConfig => {
1056 use crate::infrastructure::config::ConfigLoader;
1057
1058 let config_path = ConfigLoader::discover_config_path();
1059
1060 tracing::info!(
1061 "Saving migrated config to: {} (data_dir: {:?}, backend: {})",
1062 config_path.display(),
1063 plan.to.data_dir(),
1064 plan.to.storage.backend
1065 );
1066
1067 ConfigLoader::save(&plan.to, &config_path).map_err(|e| {
1068 DomainError::DatabaseError(format!("Failed to save config: {}", e))
1069 })?;
1070 }
1071 }
1072
1073 Ok(())
1074 }
1075
1076 async fn emit_progress(&self, step: MigrationStep, step_index: usize, total_steps: usize) {
1077 if let Some(ref event_bus) = self.event_bus {
1078 event_bus
1079 .emit(crate::event::CoreEvent::MigrationProgress {
1080 step: format!("{:?}", step),
1081 step_index,
1082 total_steps,
1083 message: step.description().to_string(),
1084 })
1085 .await;
1086 }
1087 }
1088
1089 async fn rollback(&self, _result: &MigrationResult) {
1090 tracing::warn!("Migration failed. Manual intervention may be required.");
1091 }
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096 use tempfile::TempDir;
1097
1098 use super::*;
1099 use crate::{
1100 infrastructure::token_store::MemoryTokenStore,
1101 test_env::EnvGuard,
1102 };
1103
1104 async fn create_test_manager(temp_dir: &TempDir) -> StorageManager {
1105 let mut config = PipedashConfig::default();
1106 config.storage.data_dir = temp_dir.path().to_string_lossy().to_string();
1107
1108 let token_store: Arc<dyn TokenStore> = Arc::new(MemoryTokenStore::new());
1109 StorageManager::with_token_store(config, token_store, true)
1110 .await
1111 .unwrap()
1112 }
1113
1114 #[tokio::test]
1115 async fn test_plan_migration_no_changes() {
1116 let temp_dir = TempDir::new().unwrap();
1117 let manager = create_test_manager(&temp_dir).await;
1118
1119 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1120
1121 let target_config = manager.config().clone();
1122 let plan = orchestrator
1123 .plan_migration(target_config, &MigrationOptions::default())
1124 .unwrap();
1125
1126 assert!(!plan.steps.contains(&MigrationStep::MigrateConfigs));
1127 assert!(!plan.steps.contains(&MigrationStep::MigrateTokens));
1128 }
1129
1130 #[tokio::test]
1131 async fn test_migration_dry_run() {
1132 let temp_dir = TempDir::new().unwrap();
1133 let manager = create_test_manager(&temp_dir).await;
1134
1135 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1136
1137 let target_config = manager.config().clone();
1138 let plan = MigrationPlan {
1139 from: manager.config().clone(),
1140 to: target_config,
1141 steps: vec![MigrationStep::ValidateTarget],
1142 migrate_tokens: false,
1143 migrate_configs: false,
1144 migrate_cache: false,
1145 backend_changed: false,
1146 data_dir_changed: false,
1147 created_at: Utc::now(),
1148 };
1149
1150 let result = orchestrator
1151 .execute_migration(
1152 plan,
1153 MigrationOptions {
1154 dry_run: true,
1155 ..Default::default()
1156 },
1157 true, )
1159 .await;
1160
1161 assert!(result.success);
1162 }
1163
1164 #[test]
1165 fn test_migration_step_descriptions() {
1166 assert!(!MigrationStep::ValidateTarget.description().is_empty());
1167 assert!(!MigrationStep::MigrateTokens.description().is_empty());
1168 assert!(!MigrationStep::MigrateConfigs.description().is_empty());
1169 }
1170
1171 #[test]
1172 fn test_migration_result_summary() {
1173 let result = MigrationResult {
1174 success: true,
1175 steps_completed: vec![MigrationStep::MigrateConfigs],
1176 errors: vec![],
1177 duration_ms: 100,
1178 stats: MigrationStats {
1179 providers_migrated: 5,
1180 tokens_migrated: 3,
1181 cache_entries_migrated: 10,
1182 metrics_migrated: 0,
1183 permissions_migrated: 2,
1184 providers_cleaned: 0,
1185 tokens_cleaned: 0,
1186 },
1187 provider_id_mapping: std::collections::HashMap::new(),
1188 backups: None,
1189 };
1190
1191 let summary = result.summary();
1192 assert!(summary.contains("5 providers"));
1193 assert!(summary.contains("10 cache entries"));
1194 }
1195
1196 #[tokio::test]
1197 async fn migrate_tokens_after_a_provider_was_deleted() {
1198 let source_dir = TempDir::new().unwrap();
1199 let target_dir = TempDir::new().unwrap();
1200 let config_path = source_dir.path().join("config.toml");
1201 std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1202 let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1203
1204 let mut source = PipedashConfig::default();
1205 source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1206 source.storage.vault_password = Some("test-password".to_string());
1207 let mut target = source.clone();
1208 target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1209
1210 let manager = StorageManager::from_config(source, false).await.unwrap();
1211 let backend = manager.config_backend();
1212 let tokens = manager.token_store().await;
1213 let mut ids = Vec::new();
1214 for name in ["a", "b", "c"] {
1215 let provider = ProviderConfig {
1216 id: None,
1217 name: name.to_string(),
1218 provider_type: "github".to_string(),
1219 token: String::new(),
1220 config: HashMap::new(),
1221 refresh_interval: 30,
1222 version: None,
1223 };
1224 ids.push(backend.create_provider(&provider).await.unwrap());
1225 }
1226 backend.delete_provider(ids[1]).await.unwrap();
1227 tokens.store_token(ids[0], "tok-a").await.unwrap();
1228 tokens.store_token(ids[2], "tok-c").await.unwrap();
1229
1230 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1231 let options = MigrationOptions {
1232 migrate_tokens: true,
1233 token_password: Some("test-password".to_string()),
1234 ..Default::default()
1235 };
1236 let plan = orchestrator
1237 .plan_migration(target.clone(), &options)
1238 .unwrap();
1239 let result = orchestrator.execute_migration(plan, options, false).await;
1240 assert!(result.success, "{:?}", result.errors);
1241
1242 let target_manager = StorageManager::from_config(target, false).await.unwrap();
1243 let mut migrated = HashMap::new();
1244 for provider in target_manager
1245 .config_backend()
1246 .list_providers()
1247 .await
1248 .unwrap()
1249 {
1250 let token = target_manager
1251 .token_store()
1252 .await
1253 .get_token(provider.id.unwrap())
1254 .await
1255 .unwrap();
1256 migrated.insert(provider.name, token);
1257 }
1258 assert_eq!(
1259 migrated,
1260 HashMap::from([
1261 ("a".to_string(), "tok-a".to_string()),
1262 ("c".to_string(), "tok-c".to_string()),
1263 ])
1264 );
1265 }
1266
1267 #[tokio::test]
1268 async fn migration_keeps_archived_runs_with_remapped_ids_after_restart() {
1269 use crate::domain::PipelineRun;
1270 use crate::infrastructure::database::Repository;
1271
1272 let source_dir = TempDir::new().unwrap();
1273 let target_dir = TempDir::new().unwrap();
1274 let config_path = source_dir.path().join("config.toml");
1275 std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1276 let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1277
1278 let mut source = PipedashConfig::default();
1279 source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1280 source.storage.vault_password = Some("test-password".to_string());
1281 let mut target = source.clone();
1282 target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1283
1284 let manager = StorageManager::from_config(source, false).await.unwrap();
1285 let backend = manager.config_backend();
1286 let mut ids = Vec::new();
1287 for name in ["deleted", "flux"] {
1288 ids.push(
1289 backend
1290 .create_provider(&ProviderConfig {
1291 id: None,
1292 name: name.to_string(),
1293 provider_type: "fluxcd".to_string(),
1294 token: String::new(),
1295 config: HashMap::new(),
1296 refresh_interval: 30,
1297 version: None,
1298 })
1299 .await
1300 .unwrap(),
1301 );
1302 }
1303 backend.delete_provider(ids[0]).await.unwrap();
1304 let repo = Repository::new(
1305 backend,
1306 manager.database_pool(),
1307 manager.token_store().await,
1308 );
1309 let source_pipeline = format!("fluxcd__{}__HelmRelease__apps__web", ids[1]);
1310 let runs: Vec<PipelineRun> = (1..=105)
1311 .rev()
1312 .map(|n| {
1313 serde_json::from_value(serde_json::json!({
1314 "id": format!("release-{n}"),
1315 "pipeline_id": source_pipeline,
1316 "run_number": n,
1317 "status": "success",
1318 "started_at": "2026-10-20T00:00:00Z",
1319 "logs_url": "",
1320 "metadata": {
1321 "uid": "resource-1",
1322 "record_source": "status.history",
1323 "release_version": n,
1324 "archived": true
1325 }
1326 }))
1327 .unwrap()
1328 })
1329 .collect();
1330 repo.cache_run_history(&source_pipeline, &runs)
1331 .await
1332 .unwrap();
1333 let other_pipeline = format!("fluxcd__{}__Kustomization__apps__api", ids[1]);
1334 let mut other = runs[0].clone();
1335 other.pipeline_id = other_pipeline.clone();
1336 repo.cache_run_history(&other_pipeline, &[other.clone()])
1337 .await
1338 .unwrap();
1339 let orphan_pipeline = format!("fluxcd__{}__HelmRelease__apps__web", ids[0]);
1340 let mut orphan = runs[0].clone();
1341 orphan.id = "deleted-provider-run".to_string();
1342 orphan.pipeline_id = orphan_pipeline;
1343 repo.cache_run_history(&orphan.pipeline_id, &[orphan.clone()])
1344 .await
1345 .unwrap();
1346
1347 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1348 let options = MigrationOptions {
1349 migrate_cache: true,
1350 ..Default::default()
1351 };
1352 let plan = orchestrator
1353 .plan_migration(target.clone(), &options)
1354 .unwrap();
1355 let result = orchestrator.execute_migration(plan, options, false).await;
1356 assert!(result.success, "{:?}", result.errors);
1357 let target_id = result.provider_id_mapping[&ids[1]];
1358 assert_ne!(target_id, ids[1]);
1359 assert_eq!(result.stats.cache_entries_migrated, 106);
1360 assert_eq!(
1361 repo.get_cached_run_history(&source_pipeline, 200)
1362 .await
1363 .unwrap(),
1364 runs
1365 );
1366 drop(orchestrator);
1367 drop(repo);
1368 drop(manager);
1369
1370 let reopened = StorageManager::from_config(target, false).await.unwrap();
1371 let target_repo = Repository::new(
1372 reopened.config_backend(),
1373 reopened.database_pool(),
1374 reopened.token_store().await,
1375 );
1376 let target_pipeline = format!("fluxcd__{target_id}__HelmRelease__apps__web");
1377 let mut expected = runs;
1378 for run in &mut expected {
1379 run.pipeline_id = target_pipeline.clone();
1380 }
1381 assert_eq!(
1382 target_repo
1383 .get_cached_run_history(&target_pipeline, 200)
1384 .await
1385 .unwrap(),
1386 expected
1387 );
1388 other.pipeline_id = format!("fluxcd__{target_id}__Kustomization__apps__api");
1389 assert_eq!(
1390 target_repo
1391 .get_cached_run(&other.pipeline_id, other.run_number)
1392 .await
1393 .unwrap(),
1394 Some(other)
1395 );
1396 assert!(target_repo
1397 .get_cached_run_history(&source_pipeline, 200)
1398 .await
1399 .unwrap()
1400 .is_empty());
1401 }
1402
1403 #[tokio::test]
1404 async fn migration_keeps_pipeline_metrics() {
1405 use crate::domain::{
1406 MetricEntry,
1407 MetricType,
1408 };
1409 use crate::infrastructure::database::RecordMetrics;
1410
1411 let source_dir = TempDir::new().unwrap();
1412 let target_dir = TempDir::new().unwrap();
1413 let config_path = source_dir.path().join("config.toml");
1414 std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1415 let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1416
1417 let mut source = PipedashConfig::default();
1418 source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1419 source.storage.vault_password = Some("test-password".to_string());
1420 let mut target = source.clone();
1421 target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1422
1423 let manager = StorageManager::from_config(source, false).await.unwrap();
1424 let backend = manager.config_backend();
1425 let mut ids = Vec::new();
1426 for name in ["deleted", "gh"] {
1427 let provider = ProviderConfig {
1428 id: None,
1429 name: name.to_string(),
1430 provider_type: "github".to_string(),
1431 token: String::new(),
1432 config: HashMap::new(),
1433 refresh_interval: 30,
1434 version: None,
1435 };
1436 ids.push(backend.create_provider(&provider).await.unwrap());
1437 }
1438 backend.delete_provider(ids[0]).await.unwrap();
1439 let orphan_pipeline = format!("github__{}__o__r__1", ids[0]);
1440 let source_pipeline = format!("github__{}__o__r__1", ids[1]);
1441 let expired_pipeline = format!("github__{}__o__r__2", ids[1]);
1442
1443 let metrics = MetricsRepository::new_from_pool(manager.database_pool());
1444 metrics.update_global_config(true, 30).await.unwrap();
1445 metrics
1446 .upsert_pipeline_config(&source_pipeline, true, 14)
1447 .await
1448 .unwrap();
1449 metrics
1450 .upsert_pipeline_config(&orphan_pipeline, false, 3)
1451 .await
1452 .unwrap();
1453 let entry = |pipeline_id: &str, n: i64| MetricEntry {
1454 id: 0,
1455 pipeline_id: pipeline_id.to_string(),
1456 run_number: n,
1457 timestamp: Utc::now(),
1458 metric_type: MetricType::RunDuration,
1459 value: 60.0 * n as f64,
1460 metadata: None,
1461 created_at: Utc::now(),
1462 run_hash: Some(format!("hash-{n}")),
1463 };
1464 let mut entries: Vec<MetricEntry> = (1..=5).map(|n| entry(&source_pipeline, n)).collect();
1465 entries.push(entry(&orphan_pipeline, 1));
1466 metrics.insert_metrics_batch(entries).await.unwrap();
1467 metrics
1468 .update_last_processed_run(&source_pipeline, 5)
1469 .await
1470 .unwrap();
1471 metrics
1472 .update_last_processed_run(&expired_pipeline, 9)
1473 .await
1474 .unwrap();
1475
1476 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1477 let options = MigrationOptions {
1478 migrate_tokens: true,
1479 migrate_cache: true,
1480 token_password: Some("test-password".to_string()),
1481 ..Default::default()
1482 };
1483 let plan = orchestrator
1484 .plan_migration(target.clone(), &options)
1485 .unwrap();
1486 let migrate = || orchestrator.execute_migration(plan.clone(), options.clone(), false);
1487 let result = migrate().await;
1488 assert!(result.success, "{:?}", result.errors);
1489 assert_eq!(result.stats.metrics_migrated, 5);
1490 let refreshed = MetricEntry {
1491 run_hash: Some("hash-4b".to_string()),
1492 value: 999.0,
1493 ..entry(&source_pipeline, 4)
1494 };
1495 metrics
1496 .refresh_record_metrics(
1497 &source_pipeline,
1498 &[RecordMetrics {
1499 run_number: 4,
1500 run_hash: "hash-4b".to_string(),
1501 entries: [Some(refreshed), None, None],
1502 }],
1503 )
1504 .await
1505 .unwrap();
1506 let result = migrate().await;
1507 assert!(result.success, "{:?}", result.errors);
1508
1509 let target_manager = StorageManager::from_config(target, false).await.unwrap();
1510 let target_id = target_manager
1511 .config_backend()
1512 .list_providers()
1513 .await
1514 .unwrap()[0]
1515 .id
1516 .unwrap();
1517 assert_ne!(target_id, ids[1]);
1518 let target_pipeline = format!("github__{target_id}__o__r__1");
1519 let target_metrics = MetricsRepository::new_from_pool(target_manager.database_pool());
1520
1521 let copied = target_metrics
1522 .query_metrics(MetricsQuery {
1523 pipeline_id: Some(target_pipeline.clone()),
1524 ..Default::default()
1525 })
1526 .await
1527 .unwrap();
1528 assert_eq!(copied.len(), 5);
1529 let run_4: Vec<_> = copied
1530 .into_iter()
1531 .filter(|entry| entry.run_number == 4)
1532 .map(|entry| (entry.run_hash, entry.value))
1533 .collect();
1534 assert_eq!(run_4, [(Some("hash-4b".to_string()), 999.0)]);
1535 let config = target_metrics
1536 .get_pipeline_config(&target_pipeline)
1537 .await
1538 .unwrap()
1539 .unwrap();
1540 assert!(config.enabled);
1541 assert_eq!(config.retention_days, 14);
1542 let global = target_metrics.get_global_config().await.unwrap();
1543 assert!(global.enabled);
1544 assert_eq!(global.default_retention_days, 30);
1545 assert_eq!(
1546 target_metrics
1547 .get_last_processed_run(&target_pipeline)
1548 .await
1549 .unwrap(),
1550 Some(5)
1551 );
1552 assert_eq!(
1553 target_metrics
1554 .get_last_processed_run(&format!("github__{target_id}__o__r__2"))
1555 .await
1556 .unwrap(),
1557 Some(9)
1558 );
1559 assert_eq!(
1560 target_metrics
1561 .export_config()
1562 .await
1563 .unwrap()
1564 .pipeline_configs
1565 .len(),
1566 1
1567 );
1568 assert_eq!(
1569 target_metrics
1570 .calculate_storage_stats()
1571 .await
1572 .unwrap()
1573 .total_metrics_count,
1574 5
1575 );
1576 }
1577
1578 #[tokio::test]
1579 async fn push_subscriptions_follow_a_storage_migration_without_the_cache() {
1580 let source_dir = TempDir::new().unwrap();
1581 let target_dir = TempDir::new().unwrap();
1582 let config_path = source_dir.path().join("config.toml");
1583 std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1584 let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1585
1586 let mut source = PipedashConfig::default();
1587 source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1588 source.storage.vault_password = Some("test-password".to_string());
1589 let mut target = source.clone();
1590 target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1591
1592 let manager = StorageManager::from_config(source, false).await.unwrap();
1593 let push = PushRepository::new(manager.database_pool());
1594 push.register("relay-a", "iPhone", "ios").await.unwrap();
1595 let paired = push.register("relay-b", "iPad", "ios").await.unwrap();
1596 push.mark_success(&paired.id).await.unwrap();
1597 let expected = push.list().await.unwrap();
1598
1599 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1600 let options = MigrationOptions {
1601 migrate_tokens: false,
1602 migrate_cache: false,
1603 ..Default::default()
1604 };
1605 let plan = orchestrator
1606 .plan_migration(target.clone(), &options)
1607 .unwrap();
1608 let result = orchestrator.execute_migration(plan, options, false).await;
1609 assert!(result.success, "{:?}", result.errors);
1610
1611 let target_manager = StorageManager::from_config(target, false).await.unwrap();
1612 let migrated = PushRepository::new(target_manager.database_pool())
1613 .list()
1614 .await
1615 .unwrap();
1616 assert_eq!(migrated, expected);
1617 }
1618
1619 #[tokio::test]
1620 async fn device_alerts_and_push_registrations_follow_a_storage_migration() {
1621 use crate::domain::{
1622 AlertKind,
1623 NewDeviceAlert,
1624 };
1625
1626 let source_dir = TempDir::new().unwrap();
1627 let target_dir = TempDir::new().unwrap();
1628 let config_path = source_dir.path().join("config.toml");
1629 std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1630 let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1631
1632 let mut source = PipedashConfig::default();
1633 source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1634 source.storage.vault_password = Some("test-password".to_string());
1635 let mut target = source.clone();
1636 target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1637
1638 let manager = StorageManager::from_config(source, false).await.unwrap();
1639 let backend = manager.config_backend();
1640 let mut ids = Vec::new();
1641 for name in ["deleted", "gh"] {
1642 let provider = ProviderConfig {
1643 id: None,
1644 name: name.to_string(),
1645 provider_type: "github".to_string(),
1646 token: String::new(),
1647 config: HashMap::new(),
1648 refresh_interval: 30,
1649 version: None,
1650 };
1651 ids.push(backend.create_provider(&provider).await.unwrap());
1652 }
1653 backend.delete_provider(ids[0]).await.unwrap();
1654 let (gone, provider) = (ids[0], ids[1]);
1655 let pipeline = format!("github__{provider}__o__r__1");
1656 let new_alert =
1657 |key: &str, kind: AlertKind, pipeline_id: Option<&str>, provider_id: i64| {
1658 NewDeviceAlert {
1659 key: key.to_string(),
1660 kind,
1661 title: "title".to_string(),
1662 body: "body".to_string(),
1663 pipeline_id: pipeline_id.map(str::to_string),
1664 provider_id: Some(provider_id),
1665 }
1666 };
1667 let alerts = DeviceAlertRepository::new(manager.database_pool());
1668 for _ in 0..3 {
1669 alerts
1670 .insert(&new_alert("x", AlertKind::Outage, None, gone))
1671 .await
1672 .unwrap();
1673 }
1674 alerts.delete_all().await.unwrap();
1675 for new in [
1676 new_alert(
1677 &format!("pipeline:{pipeline}"),
1678 AlertKind::Failed,
1679 Some(&pipeline),
1680 provider,
1681 ),
1682 new_alert(
1683 &format!("connection:{provider}"),
1684 AlertKind::Outage,
1685 None,
1686 provider,
1687 ),
1688 new_alert("connection:99", AlertKind::Outage, None, gone),
1689 ] {
1690 alerts.insert(&new).await.unwrap();
1691 }
1692 let pushes = DevicePushRepository::new(manager.database_pool());
1693 pushes.set("device-1", "sub-1").await.unwrap();
1694 pushes.mark_success("device-1").await.unwrap();
1695 let expected_pushes = pushes.list().await.unwrap();
1696
1697 let target_manager = StorageManager::from_config(target.clone(), false)
1698 .await
1699 .unwrap();
1700 DeviceAlertRepository::new(target_manager.database_pool())
1701 .insert(&new_alert("stale", AlertKind::Failed, None, 1))
1702 .await
1703 .unwrap();
1704 DevicePushRepository::new(target_manager.database_pool())
1705 .set("device-1", "stale-sub")
1706 .await
1707 .unwrap();
1708 drop(target_manager);
1709
1710 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1711 let options = MigrationOptions {
1712 migrate_tokens: false,
1713 migrate_cache: false,
1714 clean_target: true,
1715 ..Default::default()
1716 };
1717 let plan = orchestrator
1718 .plan_migration(target.clone(), &options)
1719 .unwrap();
1720 let result = orchestrator.execute_migration(plan, options, false).await;
1721 assert!(result.success, "{:?}", result.errors);
1722
1723 let target_manager = StorageManager::from_config(target, false).await.unwrap();
1724 let target_provider = target_manager
1725 .config_backend()
1726 .list_providers()
1727 .await
1728 .unwrap()
1729 .into_iter()
1730 .find(|p| p.name == "gh")
1731 .and_then(|p| p.id)
1732 .unwrap();
1733 let migrated = DeviceAlertRepository::new(target_manager.database_pool());
1734 let moved = migrated.list().await.unwrap();
1735 assert_eq!(
1736 moved
1737 .iter()
1738 .map(|a| (a.seq, a.key.as_str(), a.provider_id))
1739 .collect::<Vec<_>>(),
1740 vec![
1741 (
1742 4,
1743 format!("pipeline:github__{target_provider}__o__r__1").as_str(),
1744 Some(target_provider)
1745 ),
1746 (
1747 5,
1748 format!("connection:{target_provider}").as_str(),
1749 Some(target_provider)
1750 ),
1751 ]
1752 );
1753 assert_eq!(
1754 moved[0].pipeline_id.as_deref(),
1755 Some(format!("github__{target_provider}__o__r__1").as_str())
1756 );
1757 assert_eq!(
1758 DevicePushRepository::new(target_manager.database_pool())
1759 .list()
1760 .await
1761 .unwrap(),
1762 expected_pushes
1763 );
1764 let next = migrated
1765 .insert(&new_alert("c", AlertKind::Failed, None, target_provider))
1766 .await
1767 .unwrap();
1768 assert_eq!(next.seq, 6);
1769 }
1770
1771 #[tokio::test]
1772 async fn a_storage_migration_keeps_the_target_alerts_unless_the_target_is_cleaned() {
1773 use crate::domain::{
1774 AlertKind,
1775 NewDeviceAlert,
1776 };
1777
1778 let source_dir = TempDir::new().unwrap();
1779 let target_dir = TempDir::new().unwrap();
1780 let config_path = source_dir.path().join("config.toml");
1781 std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1782 let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1783
1784 let mut source = PipedashConfig::default();
1785 source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1786 source.storage.vault_password = Some("test-password".to_string());
1787 let mut target = source.clone();
1788 target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1789
1790 let new_alert = |key: &str| NewDeviceAlert {
1791 key: key.to_string(),
1792 kind: AlertKind::Failed,
1793 title: "title".to_string(),
1794 body: "body".to_string(),
1795 pipeline_id: None,
1796 provider_id: None,
1797 };
1798 let manager = StorageManager::from_config(source, false).await.unwrap();
1799 let source_alerts = DeviceAlertRepository::new(manager.database_pool());
1800 for key in ["source-a", "source-b"] {
1801 source_alerts.insert(&new_alert(key)).await.unwrap();
1802 }
1803
1804 let target_manager = StorageManager::from_config(target.clone(), false)
1805 .await
1806 .unwrap();
1807 let target_alerts = DeviceAlertRepository::new(target_manager.database_pool());
1808 let mut target_ids = Vec::new();
1809 for key in ["target-a", "target-b", "target-c"] {
1810 target_ids.push(target_alerts.insert(&new_alert(key)).await.unwrap().id);
1811 }
1812 drop(target_manager);
1813
1814 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
1815 let options = MigrationOptions {
1816 migrate_tokens: false,
1817 migrate_cache: false,
1818 ..Default::default()
1819 };
1820 let plan = orchestrator
1821 .plan_migration(target.clone(), &options)
1822 .unwrap();
1823 let result = orchestrator.execute_migration(plan, options, false).await;
1824 assert!(result.success, "{:?}", result.errors);
1825
1826 let target_manager = StorageManager::from_config(target, false).await.unwrap();
1827 let migrated = DeviceAlertRepository::new(target_manager.database_pool());
1828 let merged = migrated.list().await.unwrap();
1829 assert_eq!(
1830 merged
1831 .iter()
1832 .map(|a| (a.seq, a.key.as_str()))
1833 .collect::<Vec<_>>(),
1834 vec![
1835 (1, "source-a"),
1836 (2, "source-b"),
1837 (3, "target-c"),
1838 (4, "target-a"),
1839 (5, "target-b"),
1840 ]
1841 );
1842 for id in &target_ids {
1843 assert!(migrated.get(id).await.unwrap().is_some());
1844 }
1845 assert_eq!(migrated.insert(&new_alert("next")).await.unwrap().seq, 6);
1846 }
1847
1848 #[tokio::test]
1849 async fn views_and_pins_survive_a_restart_and_a_storage_migration() {
1850 use crate::domain::{
1851 PinKind,
1852 SectionMode,
1853 ViewDisplay,
1854 ViewKind,
1855 ViewLayout,
1856 ViewRules,
1857 };
1858 use crate::infrastructure::database::ViewExport;
1859
1860 let source_dir = TempDir::new().unwrap();
1861 let target_dir = TempDir::new().unwrap();
1862 let config_path = source_dir.path().join("config.toml");
1863 std::fs::write(&config_path, "[storage]\nbackend = \"sqlite\"\n").unwrap();
1864 let _env = EnvGuard::set(&[("PIPEDASH_CONFIG_PATH", config_path.to_str())]);
1865
1866 let mut source = PipedashConfig::default();
1867 source.storage.data_dir = source_dir.path().to_string_lossy().to_string();
1868 source.storage.vault_password = Some("test-password".to_string());
1869 let mut target = source.clone();
1870 target.storage.data_dir = target_dir.path().to_string_lossy().to_string();
1871
1872 let manager = StorageManager::from_config(source.clone(), false)
1873 .await
1874 .unwrap();
1875 let backend = manager.config_backend();
1876 let mut ids = Vec::new();
1877 for name in ["deleted", "gh"] {
1878 let provider = ProviderConfig {
1879 id: None,
1880 name: name.to_string(),
1881 provider_type: "github".to_string(),
1882 token: String::new(),
1883 config: HashMap::new(),
1884 refresh_interval: 30,
1885 version: None,
1886 };
1887 ids.push(backend.create_provider(&provider).await.unwrap());
1888 }
1889 backend.delete_provider(ids[0]).await.unwrap();
1890 let provider = ids[1];
1891 let pipeline = |n: u32| format!("github__{provider}__o__r__{n}");
1892
1893 let repo = PipelineViewRepository::new(manager.database_pool());
1894 let rules = ViewRules {
1895 provider_ids: vec![provider],
1896 repositories: vec!["o/*".to_string()],
1897 name_contains: Some("deploy".to_string()),
1898 statuses: Vec::new(),
1899 };
1900 let cards = ViewDisplay {
1901 layout: ViewLayout::Cards,
1902 sections: SectionMode::Provider,
1903 ..ViewDisplay::default()
1904 };
1905 let release = repo
1906 .insert_custom("Release", None, &rules, &cards)
1907 .await
1908 .unwrap();
1909 let prod = repo
1910 .insert_custom(
1911 "Prod",
1912 Some(release),
1913 &ViewRules::default(),
1914 &ViewDisplay::default(),
1915 )
1916 .await
1917 .unwrap();
1918 let eu = repo
1919 .insert_custom(
1920 "EU",
1921 Some(prod),
1922 &ViewRules::default(),
1923 &ViewDisplay::default(),
1924 )
1925 .await
1926 .unwrap();
1927 repo.upsert_member(eu, provider, &pipeline(1), false)
1928 .await
1929 .unwrap();
1930 repo.upsert_member(release, provider, &pipeline(2), true)
1931 .await
1932 .unwrap();
1933 let pinned = repo
1934 .list_views()
1935 .await
1936 .unwrap()
1937 .into_iter()
1938 .find(|v| v.kind == ViewKind::Pinned)
1939 .unwrap();
1940 repo.update_view(
1941 pinned.id,
1942 &pinned.name,
1943 None,
1944 &pinned.rules,
1945 &ViewDisplay {
1946 layout: ViewLayout::Table,
1947 ..pinned.display
1948 },
1949 )
1950 .await
1951 .unwrap();
1952 repo.insert_pin(PinKind::View, &eu.to_string())
1953 .await
1954 .unwrap();
1955 repo.insert_pin(PinKind::Repository, "o/r").await.unwrap();
1956 repo.insert_pin(PinKind::View, &release.to_string())
1957 .await
1958 .unwrap();
1959
1960 let snapshot = |export: ViewExport, provider: i64| {
1961 let pid = |id: &str| id.replace(&format!("__{provider}__"), "__P__");
1962 let view_name = |id: i64| {
1963 export
1964 .views
1965 .iter()
1966 .find(|v| v.id == id)
1967 .unwrap()
1968 .name
1969 .clone()
1970 };
1971 let views: Vec<_> = export
1972 .views
1973 .iter()
1974 .map(|v| {
1975 let mut rules = v.rules.clone();
1976 rules.provider_ids = rules
1977 .provider_ids
1978 .iter()
1979 .map(|id| i64::from(*id == provider))
1980 .collect();
1981 (
1982 v.kind,
1983 v.name.clone(),
1984 v.parent_id.map(view_name),
1985 v.position,
1986 rules,
1987 v.display,
1988 )
1989 })
1990 .collect();
1991 let members: Vec<_> = export
1992 .members
1993 .iter()
1994 .map(|m| {
1995 (
1996 view_name(m.view_id),
1997 pid(&m.pipeline_id),
1998 m.excluded,
1999 m.provider_id == provider,
2000 )
2001 })
2002 .collect();
2003 let pins: Vec<_> = export
2004 .pins
2005 .iter()
2006 .map(|p| match p.kind {
2007 PinKind::View => view_name(p.reference.parse().unwrap()),
2008 PinKind::Repository => p.reference.clone(),
2009 })
2010 .collect();
2011 format!("{views:?}\n{members:?}\n{pins:?}")
2012 };
2013 let before = snapshot(repo.export().await.unwrap(), provider);
2014 drop(repo);
2015 drop(manager);
2016
2017 let manager = StorageManager::from_config(source, false).await.unwrap();
2018 let reopened = PipelineViewRepository::new(manager.database_pool())
2019 .export()
2020 .await
2021 .unwrap();
2022 assert_eq!(snapshot(reopened, provider), before);
2023
2024 let orchestrator = MigrationOrchestrator::from_manager(&manager, None, None).await;
2025 let options = MigrationOptions {
2026 migrate_tokens: false,
2027 migrate_cache: false,
2028 ..Default::default()
2029 };
2030 let plan = orchestrator
2031 .plan_migration(target.clone(), &options)
2032 .unwrap();
2033 let result = orchestrator.execute_migration(plan, options, false).await;
2034 assert!(result.success, "{:?}", result.errors);
2035
2036 let target_manager = StorageManager::from_config(target, false).await.unwrap();
2037 let target_provider = target_manager
2038 .config_backend()
2039 .list_providers()
2040 .await
2041 .unwrap()[0]
2042 .id
2043 .unwrap();
2044 assert_ne!(target_provider, provider);
2045 let migrated = PipelineViewRepository::new(target_manager.database_pool())
2046 .export()
2047 .await
2048 .unwrap();
2049 assert_eq!(snapshot(migrated, target_provider), before);
2050 }
2051}