1use crate::{
2 artifacts::{ArtifactChecksum, ArtifactChecksumError},
3 manifest::{
4 FleetBackupManifest, FleetMember, IdentityMode, ManifestValidationError, SourceSnapshot,
5 VerificationCheck, VerificationPlan,
6 },
7};
8use candid::Principal;
9use serde::{Deserialize, Serialize};
10use std::{
11 collections::{BTreeMap, BTreeSet},
12 path::{Component, Path, PathBuf},
13 str::FromStr,
14};
15use thiserror::Error as ThisError;
16
17#[derive(Clone, Debug, Default, Deserialize, Serialize)]
22pub struct RestoreMapping {
23 pub members: Vec<RestoreMappingEntry>,
24}
25
26impl RestoreMapping {
27 fn target_for(&self, source_canister: &str) -> Option<&str> {
29 self.members
30 .iter()
31 .find(|entry| entry.source_canister == source_canister)
32 .map(|entry| entry.target_canister.as_str())
33 }
34}
35
36#[derive(Clone, Debug, Deserialize, Serialize)]
41pub struct RestoreMappingEntry {
42 pub source_canister: String,
43 pub target_canister: String,
44}
45
46#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51pub struct RestorePlan {
52 pub backup_id: String,
53 pub source_environment: String,
54 pub source_root_canister: String,
55 pub topology_hash: String,
56 pub member_count: usize,
57 pub identity_summary: RestoreIdentitySummary,
58 pub snapshot_summary: RestoreSnapshotSummary,
59 pub verification_summary: RestoreVerificationSummary,
60 pub readiness_summary: RestoreReadinessSummary,
61 pub operation_summary: RestoreOperationSummary,
62 pub ordering_summary: RestoreOrderingSummary,
63 #[serde(default)]
64 pub fleet_verification_checks: Vec<VerificationCheck>,
65 pub phases: Vec<RestorePhase>,
66}
67
68impl RestorePlan {
69 #[must_use]
71 pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
72 self.phases
73 .iter()
74 .flat_map(|phase| phase.members.iter())
75 .collect()
76 }
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84pub struct RestoreStatus {
85 pub status_version: u16,
86 pub backup_id: String,
87 pub source_environment: String,
88 pub source_root_canister: String,
89 pub topology_hash: String,
90 pub ready: bool,
91 pub readiness_reasons: Vec<String>,
92 pub verification_required: bool,
93 pub member_count: usize,
94 pub phase_count: usize,
95 #[serde(default)]
96 pub planned_snapshot_uploads: usize,
97 pub planned_snapshot_loads: usize,
98 pub planned_code_reinstalls: usize,
99 pub planned_verification_checks: usize,
100 #[serde(default)]
101 pub planned_operations: usize,
102 pub phases: Vec<RestoreStatusPhase>,
103}
104
105impl RestoreStatus {
106 #[must_use]
108 pub fn from_plan(plan: &RestorePlan) -> Self {
109 Self {
110 status_version: 1,
111 backup_id: plan.backup_id.clone(),
112 source_environment: plan.source_environment.clone(),
113 source_root_canister: plan.source_root_canister.clone(),
114 topology_hash: plan.topology_hash.clone(),
115 ready: plan.readiness_summary.ready,
116 readiness_reasons: plan.readiness_summary.reasons.clone(),
117 verification_required: plan.verification_summary.verification_required,
118 member_count: plan.member_count,
119 phase_count: plan.ordering_summary.phase_count,
120 planned_snapshot_uploads: plan
121 .operation_summary
122 .effective_planned_snapshot_uploads(plan.member_count),
123 planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
124 planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
125 planned_verification_checks: plan.operation_summary.planned_verification_checks,
126 planned_operations: plan
127 .operation_summary
128 .effective_planned_operations(plan.member_count),
129 phases: plan
130 .phases
131 .iter()
132 .map(RestoreStatusPhase::from_plan_phase)
133 .collect(),
134 }
135 }
136}
137
138#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
143pub struct RestoreStatusPhase {
144 pub restore_group: u16,
145 pub members: Vec<RestoreStatusMember>,
146}
147
148impl RestoreStatusPhase {
149 fn from_plan_phase(phase: &RestorePhase) -> Self {
151 Self {
152 restore_group: phase.restore_group,
153 members: phase
154 .members
155 .iter()
156 .map(RestoreStatusMember::from_plan_member)
157 .collect(),
158 }
159 }
160}
161
162#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
167pub struct RestoreStatusMember {
168 pub source_canister: String,
169 pub target_canister: String,
170 pub role: String,
171 pub restore_group: u16,
172 pub phase_order: usize,
173 pub snapshot_id: String,
174 pub artifact_path: String,
175 pub state: RestoreMemberState,
176}
177
178impl RestoreStatusMember {
179 fn from_plan_member(member: &RestorePlanMember) -> Self {
181 Self {
182 source_canister: member.source_canister.clone(),
183 target_canister: member.target_canister.clone(),
184 role: member.role.clone(),
185 restore_group: member.restore_group,
186 phase_order: member.phase_order,
187 snapshot_id: member.source_snapshot.snapshot_id.clone(),
188 artifact_path: member.source_snapshot.artifact_path.clone(),
189 state: RestoreMemberState::Planned,
190 }
191 }
192}
193
194#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
199#[serde(rename_all = "kebab-case")]
200pub enum RestoreMemberState {
201 Planned,
202}
203
204#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
209pub struct RestoreApplyDryRun {
210 pub dry_run_version: u16,
211 pub backup_id: String,
212 pub ready: bool,
213 pub readiness_reasons: Vec<String>,
214 pub member_count: usize,
215 pub phase_count: usize,
216 pub status_supplied: bool,
217 #[serde(default)]
218 pub planned_snapshot_uploads: usize,
219 pub planned_snapshot_loads: usize,
220 pub planned_code_reinstalls: usize,
221 pub planned_verification_checks: usize,
222 #[serde(default)]
223 pub planned_operations: usize,
224 pub rendered_operations: usize,
225 #[serde(default)]
226 pub operation_counts: RestoreApplyOperationKindCounts,
227 pub artifact_validation: Option<RestoreApplyArtifactValidation>,
228 pub phases: Vec<RestoreApplyDryRunPhase>,
229}
230
231impl RestoreApplyDryRun {
232 pub fn try_from_plan(
234 plan: &RestorePlan,
235 status: Option<&RestoreStatus>,
236 ) -> Result<Self, RestoreApplyDryRunError> {
237 if let Some(status) = status {
238 validate_restore_status_matches_plan(plan, status)?;
239 }
240
241 Ok(Self::from_validated_plan(plan, status))
242 }
243
244 pub fn try_from_plan_with_artifacts(
246 plan: &RestorePlan,
247 status: Option<&RestoreStatus>,
248 backup_root: &Path,
249 ) -> Result<Self, RestoreApplyDryRunError> {
250 let mut dry_run = Self::try_from_plan(plan, status)?;
251 dry_run.artifact_validation = Some(validate_restore_apply_artifacts(plan, backup_root)?);
252 Ok(dry_run)
253 }
254
255 fn from_validated_plan(plan: &RestorePlan, status: Option<&RestoreStatus>) -> Self {
257 let mut next_sequence = 0;
258 let phases = plan
259 .phases
260 .iter()
261 .map(|phase| RestoreApplyDryRunPhase::from_plan_phase(phase, &mut next_sequence))
262 .collect::<Vec<_>>();
263 let mut phases = phases;
264 append_fleet_verification_operations(plan, &mut phases, &mut next_sequence);
265 let rendered_operations = phases
266 .iter()
267 .map(|phase| phase.operations.len())
268 .sum::<usize>();
269 let operation_counts = RestoreApplyOperationKindCounts::from_dry_run_phases(&phases);
270
271 Self {
272 dry_run_version: 1,
273 backup_id: plan.backup_id.clone(),
274 ready: status.map_or(plan.readiness_summary.ready, |status| status.ready),
275 readiness_reasons: status.map_or_else(
276 || plan.readiness_summary.reasons.clone(),
277 |status| status.readiness_reasons.clone(),
278 ),
279 member_count: plan.member_count,
280 phase_count: plan.ordering_summary.phase_count,
281 status_supplied: status.is_some(),
282 planned_snapshot_uploads: plan
283 .operation_summary
284 .effective_planned_snapshot_uploads(plan.member_count),
285 planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
286 planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
287 planned_verification_checks: plan.operation_summary.planned_verification_checks,
288 planned_operations: plan
289 .operation_summary
290 .effective_planned_operations(plan.member_count),
291 rendered_operations,
292 operation_counts,
293 artifact_validation: None,
294 phases,
295 }
296 }
297}
298
299#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
304pub struct RestoreApplyJournal {
305 pub journal_version: u16,
306 pub backup_id: String,
307 pub ready: bool,
308 pub blocked_reasons: Vec<String>,
309 pub operation_count: usize,
310 pub pending_operations: usize,
311 pub ready_operations: usize,
312 pub blocked_operations: usize,
313 pub completed_operations: usize,
314 pub failed_operations: usize,
315 pub operations: Vec<RestoreApplyJournalOperation>,
316}
317
318impl RestoreApplyJournal {
319 #[must_use]
321 pub fn from_dry_run(dry_run: &RestoreApplyDryRun) -> Self {
322 let blocked_reasons = restore_apply_blocked_reasons(dry_run);
323 let initial_state = if blocked_reasons.is_empty() {
324 RestoreApplyOperationState::Ready
325 } else {
326 RestoreApplyOperationState::Blocked
327 };
328 let operations = dry_run
329 .phases
330 .iter()
331 .flat_map(|phase| phase.operations.iter())
332 .map(|operation| {
333 RestoreApplyJournalOperation::from_dry_run_operation(
334 operation,
335 initial_state.clone(),
336 &blocked_reasons,
337 )
338 })
339 .collect::<Vec<_>>();
340 let ready_operations = operations
341 .iter()
342 .filter(|operation| operation.state == RestoreApplyOperationState::Ready)
343 .count();
344 let blocked_operations = operations
345 .iter()
346 .filter(|operation| operation.state == RestoreApplyOperationState::Blocked)
347 .count();
348
349 Self {
350 journal_version: 1,
351 backup_id: dry_run.backup_id.clone(),
352 ready: blocked_reasons.is_empty(),
353 blocked_reasons,
354 operation_count: operations.len(),
355 pending_operations: 0,
356 ready_operations,
357 blocked_operations,
358 completed_operations: 0,
359 failed_operations: 0,
360 operations,
361 }
362 }
363
364 pub fn validate(&self) -> Result<(), RestoreApplyJournalError> {
366 validate_apply_journal_version(self.journal_version)?;
367 validate_apply_journal_nonempty("backup_id", &self.backup_id)?;
368 validate_apply_journal_count(
369 "operation_count",
370 self.operation_count,
371 self.operations.len(),
372 )?;
373
374 let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
375 validate_apply_journal_count(
376 "pending_operations",
377 self.pending_operations,
378 state_counts.pending,
379 )?;
380 validate_apply_journal_count(
381 "ready_operations",
382 self.ready_operations,
383 state_counts.ready,
384 )?;
385 validate_apply_journal_count(
386 "blocked_operations",
387 self.blocked_operations,
388 state_counts.blocked,
389 )?;
390 validate_apply_journal_count(
391 "completed_operations",
392 self.completed_operations,
393 state_counts.completed,
394 )?;
395 validate_apply_journal_count(
396 "failed_operations",
397 self.failed_operations,
398 state_counts.failed,
399 )?;
400
401 if self.ready && (!self.blocked_reasons.is_empty() || self.blocked_operations > 0) {
402 return Err(RestoreApplyJournalError::ReadyJournalHasBlockingState);
403 }
404
405 validate_apply_journal_sequences(&self.operations)?;
406 for operation in &self.operations {
407 operation.validate()?;
408 }
409
410 Ok(())
411 }
412
413 #[must_use]
415 pub fn status(&self) -> RestoreApplyJournalStatus {
416 RestoreApplyJournalStatus::from_journal(self)
417 }
418
419 #[must_use]
421 pub fn report(&self) -> RestoreApplyJournalReport {
422 RestoreApplyJournalReport::from_journal(self)
423 }
424
425 #[must_use]
427 pub fn next_ready_operation(&self) -> Option<&RestoreApplyJournalOperation> {
428 self.operations
429 .iter()
430 .filter(|operation| operation.state == RestoreApplyOperationState::Ready)
431 .min_by_key(|operation| operation.sequence)
432 }
433
434 #[must_use]
436 pub fn next_transition_operation(&self) -> Option<&RestoreApplyJournalOperation> {
437 self.operations
438 .iter()
439 .filter(|operation| {
440 matches!(
441 operation.state,
442 RestoreApplyOperationState::Ready | RestoreApplyOperationState::Pending
443 )
444 })
445 .min_by_key(|operation| operation.sequence)
446 }
447
448 #[must_use]
450 pub fn next_operation(&self) -> RestoreApplyNextOperation {
451 RestoreApplyNextOperation::from_journal(self)
452 }
453
454 #[must_use]
456 pub fn next_command_preview(&self) -> RestoreApplyCommandPreview {
457 RestoreApplyCommandPreview::from_journal(self)
458 }
459
460 #[must_use]
462 pub fn next_command_preview_with_config(
463 &self,
464 config: &RestoreApplyCommandConfig,
465 ) -> RestoreApplyCommandPreview {
466 RestoreApplyCommandPreview::from_journal_with_config(self, config)
467 }
468
469 pub fn mark_next_operation_pending(&mut self) -> Result<(), RestoreApplyJournalError> {
471 self.mark_next_operation_pending_at(None)
472 }
473
474 pub fn mark_next_operation_pending_at(
476 &mut self,
477 updated_at: Option<String>,
478 ) -> Result<(), RestoreApplyJournalError> {
479 let sequence = self
480 .next_transition_sequence()
481 .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
482 self.mark_operation_pending_at(sequence, updated_at)
483 }
484
485 pub fn mark_operation_pending(
487 &mut self,
488 sequence: usize,
489 ) -> Result<(), RestoreApplyJournalError> {
490 self.mark_operation_pending_at(sequence, None)
491 }
492
493 pub fn mark_operation_pending_at(
495 &mut self,
496 sequence: usize,
497 updated_at: Option<String>,
498 ) -> Result<(), RestoreApplyJournalError> {
499 self.transition_operation(
500 sequence,
501 RestoreApplyOperationState::Pending,
502 Vec::new(),
503 updated_at,
504 )
505 }
506
507 pub fn mark_next_operation_ready(&mut self) -> Result<(), RestoreApplyJournalError> {
509 self.mark_next_operation_ready_at(None)
510 }
511
512 pub fn mark_next_operation_ready_at(
514 &mut self,
515 updated_at: Option<String>,
516 ) -> Result<(), RestoreApplyJournalError> {
517 let operation = self
518 .next_transition_operation()
519 .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
520 if operation.state != RestoreApplyOperationState::Pending {
521 return Err(RestoreApplyJournalError::NoPendingOperation);
522 }
523
524 self.mark_operation_ready_at(operation.sequence, updated_at)
525 }
526
527 pub fn mark_operation_ready(
529 &mut self,
530 sequence: usize,
531 ) -> Result<(), RestoreApplyJournalError> {
532 self.mark_operation_ready_at(sequence, None)
533 }
534
535 pub fn mark_operation_ready_at(
537 &mut self,
538 sequence: usize,
539 updated_at: Option<String>,
540 ) -> Result<(), RestoreApplyJournalError> {
541 self.transition_operation(
542 sequence,
543 RestoreApplyOperationState::Ready,
544 Vec::new(),
545 updated_at,
546 )
547 }
548
549 pub fn mark_operation_completed(
551 &mut self,
552 sequence: usize,
553 ) -> Result<(), RestoreApplyJournalError> {
554 self.mark_operation_completed_at(sequence, None)
555 }
556
557 pub fn mark_operation_completed_at(
559 &mut self,
560 sequence: usize,
561 updated_at: Option<String>,
562 ) -> Result<(), RestoreApplyJournalError> {
563 self.transition_operation(
564 sequence,
565 RestoreApplyOperationState::Completed,
566 Vec::new(),
567 updated_at,
568 )
569 }
570
571 pub fn mark_operation_failed(
573 &mut self,
574 sequence: usize,
575 reason: String,
576 ) -> Result<(), RestoreApplyJournalError> {
577 self.mark_operation_failed_at(sequence, reason, None)
578 }
579
580 pub fn mark_operation_failed_at(
582 &mut self,
583 sequence: usize,
584 reason: String,
585 updated_at: Option<String>,
586 ) -> Result<(), RestoreApplyJournalError> {
587 if reason.trim().is_empty() {
588 return Err(RestoreApplyJournalError::FailureReasonRequired(sequence));
589 }
590
591 self.transition_operation(
592 sequence,
593 RestoreApplyOperationState::Failed,
594 vec![reason],
595 updated_at,
596 )
597 }
598
599 fn transition_operation(
601 &mut self,
602 sequence: usize,
603 next_state: RestoreApplyOperationState,
604 blocking_reasons: Vec<String>,
605 updated_at: Option<String>,
606 ) -> Result<(), RestoreApplyJournalError> {
607 let index = self
608 .operations
609 .iter()
610 .position(|operation| operation.sequence == sequence)
611 .ok_or(RestoreApplyJournalError::OperationNotFound(sequence))?;
612 let operation = &self.operations[index];
613
614 if !operation.can_transition_to(&next_state) {
615 return Err(RestoreApplyJournalError::InvalidOperationTransition {
616 sequence,
617 from: operation.state.clone(),
618 to: next_state,
619 });
620 }
621
622 self.validate_operation_transition_order(operation, &next_state)?;
623
624 let operation = &mut self.operations[index];
625 operation.state = next_state;
626 operation.blocking_reasons = blocking_reasons;
627 operation.state_updated_at = updated_at;
628 self.refresh_operation_counts();
629 self.validate()
630 }
631
632 fn validate_operation_transition_order(
634 &self,
635 operation: &RestoreApplyJournalOperation,
636 next_state: &RestoreApplyOperationState,
637 ) -> Result<(), RestoreApplyJournalError> {
638 if operation.state == *next_state {
639 return Ok(());
640 }
641
642 let next_sequence = self
643 .next_transition_sequence()
644 .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
645
646 if operation.sequence == next_sequence {
647 return Ok(());
648 }
649
650 Err(RestoreApplyJournalError::OutOfOrderOperationTransition {
651 requested: operation.sequence,
652 next: next_sequence,
653 })
654 }
655
656 fn next_transition_sequence(&self) -> Option<usize> {
658 self.next_transition_operation()
659 .map(|operation| operation.sequence)
660 }
661
662 fn refresh_operation_counts(&mut self) {
664 let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
665 self.operation_count = self.operations.len();
666 self.pending_operations = state_counts.pending;
667 self.ready_operations = state_counts.ready;
668 self.blocked_operations = state_counts.blocked;
669 self.completed_operations = state_counts.completed;
670 self.failed_operations = state_counts.failed;
671 }
672}
673
674const fn validate_apply_journal_version(version: u16) -> Result<(), RestoreApplyJournalError> {
676 if version == 1 {
677 return Ok(());
678 }
679
680 Err(RestoreApplyJournalError::UnsupportedVersion(version))
681}
682
683fn validate_apply_journal_nonempty(
685 field: &'static str,
686 value: &str,
687) -> Result<(), RestoreApplyJournalError> {
688 if !value.trim().is_empty() {
689 return Ok(());
690 }
691
692 Err(RestoreApplyJournalError::MissingField(field))
693}
694
695const fn validate_apply_journal_count(
697 field: &'static str,
698 reported: usize,
699 actual: usize,
700) -> Result<(), RestoreApplyJournalError> {
701 if reported == actual {
702 return Ok(());
703 }
704
705 Err(RestoreApplyJournalError::CountMismatch {
706 field,
707 reported,
708 actual,
709 })
710}
711
712fn validate_apply_journal_sequences(
714 operations: &[RestoreApplyJournalOperation],
715) -> Result<(), RestoreApplyJournalError> {
716 let mut sequences = BTreeSet::new();
717 for operation in operations {
718 if !sequences.insert(operation.sequence) {
719 return Err(RestoreApplyJournalError::DuplicateSequence(
720 operation.sequence,
721 ));
722 }
723 }
724
725 for expected in 0..operations.len() {
726 if !sequences.contains(&expected) {
727 return Err(RestoreApplyJournalError::MissingSequence(expected));
728 }
729 }
730
731 Ok(())
732}
733
734#[derive(Clone, Debug, Default, Eq, PartialEq)]
739struct RestoreApplyJournalStateCounts {
740 pending: usize,
741 ready: usize,
742 blocked: usize,
743 completed: usize,
744 failed: usize,
745}
746
747impl RestoreApplyJournalStateCounts {
748 fn from_operations(operations: &[RestoreApplyJournalOperation]) -> Self {
750 let mut counts = Self::default();
751 for operation in operations {
752 match operation.state {
753 RestoreApplyOperationState::Pending => counts.pending += 1,
754 RestoreApplyOperationState::Ready => counts.ready += 1,
755 RestoreApplyOperationState::Blocked => counts.blocked += 1,
756 RestoreApplyOperationState::Completed => counts.completed += 1,
757 RestoreApplyOperationState::Failed => counts.failed += 1,
758 }
759 }
760 counts
761 }
762}
763
764#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
769pub struct RestoreApplyOperationKindCounts {
770 pub snapshot_uploads: usize,
771 pub snapshot_loads: usize,
772 pub code_reinstalls: usize,
773 pub member_verifications: usize,
774 pub fleet_verifications: usize,
775 pub verification_operations: usize,
776}
777
778impl RestoreApplyOperationKindCounts {
779 #[must_use]
781 pub fn from_operations(operations: &[RestoreApplyJournalOperation]) -> Self {
782 let mut counts = Self::default();
783 for operation in operations {
784 counts.record(&operation.operation);
785 }
786 counts
787 }
788
789 #[must_use]
791 pub fn from_dry_run_phases(phases: &[RestoreApplyDryRunPhase]) -> Self {
792 let mut counts = Self::default();
793 for operation in phases.iter().flat_map(|phase| {
794 phase
795 .operations
796 .iter()
797 .map(|operation| &operation.operation)
798 }) {
799 counts.record(operation);
800 }
801 counts
802 }
803
804 const fn record(&mut self, operation: &RestoreApplyOperationKind) {
806 match operation {
807 RestoreApplyOperationKind::UploadSnapshot => self.snapshot_uploads += 1,
808 RestoreApplyOperationKind::LoadSnapshot => self.snapshot_loads += 1,
809 RestoreApplyOperationKind::ReinstallCode => self.code_reinstalls += 1,
810 RestoreApplyOperationKind::VerifyMember => {
811 self.member_verifications += 1;
812 self.verification_operations += 1;
813 }
814 RestoreApplyOperationKind::VerifyFleet => {
815 self.fleet_verifications += 1;
816 self.verification_operations += 1;
817 }
818 }
819 }
820}
821
822fn restore_apply_blocked_reasons(dry_run: &RestoreApplyDryRun) -> Vec<String> {
824 let mut reasons = dry_run.readiness_reasons.clone();
825
826 match &dry_run.artifact_validation {
827 Some(validation) => {
828 if !validation.artifacts_present {
829 reasons.push("missing-artifacts".to_string());
830 }
831 if !validation.checksums_verified {
832 reasons.push("artifact-checksum-validation-incomplete".to_string());
833 }
834 }
835 None => reasons.push("missing-artifact-validation".to_string()),
836 }
837
838 reasons.sort();
839 reasons.dedup();
840 reasons
841}
842
843#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
848pub struct RestoreApplyJournalStatus {
849 pub status_version: u16,
850 pub backup_id: String,
851 pub ready: bool,
852 pub complete: bool,
853 pub blocked_reasons: Vec<String>,
854 pub operation_count: usize,
855 #[serde(default)]
856 pub operation_counts: RestoreApplyOperationKindCounts,
857 pub pending_operations: usize,
858 pub ready_operations: usize,
859 pub blocked_operations: usize,
860 pub completed_operations: usize,
861 pub failed_operations: usize,
862 pub next_ready_sequence: Option<usize>,
863 pub next_ready_operation: Option<RestoreApplyOperationKind>,
864 pub next_transition_sequence: Option<usize>,
865 pub next_transition_state: Option<RestoreApplyOperationState>,
866 pub next_transition_operation: Option<RestoreApplyOperationKind>,
867 pub next_transition_updated_at: Option<String>,
868}
869
870impl RestoreApplyJournalStatus {
871 #[must_use]
873 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
874 let next_ready = journal.next_ready_operation();
875 let next_transition = journal.next_transition_operation();
876
877 Self {
878 status_version: 1,
879 backup_id: journal.backup_id.clone(),
880 ready: journal.ready,
881 complete: journal.operation_count > 0
882 && journal.completed_operations == journal.operation_count,
883 blocked_reasons: journal.blocked_reasons.clone(),
884 operation_count: journal.operation_count,
885 operation_counts: RestoreApplyOperationKindCounts::from_operations(&journal.operations),
886 pending_operations: journal.pending_operations,
887 ready_operations: journal.ready_operations,
888 blocked_operations: journal.blocked_operations,
889 completed_operations: journal.completed_operations,
890 failed_operations: journal.failed_operations,
891 next_ready_sequence: next_ready.map(|operation| operation.sequence),
892 next_ready_operation: next_ready.map(|operation| operation.operation.clone()),
893 next_transition_sequence: next_transition.map(|operation| operation.sequence),
894 next_transition_state: next_transition.map(|operation| operation.state.clone()),
895 next_transition_operation: next_transition.map(|operation| operation.operation.clone()),
896 next_transition_updated_at: next_transition
897 .and_then(|operation| operation.state_updated_at.clone()),
898 }
899 }
900}
901
902#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
907pub struct RestoreApplyJournalReport {
908 pub report_version: u16,
909 pub backup_id: String,
910 pub outcome: RestoreApplyReportOutcome,
911 pub attention_required: bool,
912 pub ready: bool,
913 pub complete: bool,
914 pub blocked_reasons: Vec<String>,
915 pub operation_count: usize,
916 #[serde(default)]
917 pub operation_counts: RestoreApplyOperationKindCounts,
918 pub pending_operations: usize,
919 pub ready_operations: usize,
920 pub blocked_operations: usize,
921 pub completed_operations: usize,
922 pub failed_operations: usize,
923 pub next_transition: Option<RestoreApplyReportOperation>,
924 pub pending: Vec<RestoreApplyReportOperation>,
925 pub failed: Vec<RestoreApplyReportOperation>,
926 pub blocked: Vec<RestoreApplyReportOperation>,
927}
928
929impl RestoreApplyJournalReport {
930 #[must_use]
932 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
933 let complete =
934 journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
935 let outcome = RestoreApplyReportOutcome::from_journal(journal, complete);
936 let pending = report_operations_with_state(journal, RestoreApplyOperationState::Pending);
937 let failed = report_operations_with_state(journal, RestoreApplyOperationState::Failed);
938 let blocked = report_operations_with_state(journal, RestoreApplyOperationState::Blocked);
939
940 Self {
941 report_version: 1,
942 backup_id: journal.backup_id.clone(),
943 outcome: outcome.clone(),
944 attention_required: outcome.attention_required(),
945 ready: journal.ready,
946 complete,
947 blocked_reasons: journal.blocked_reasons.clone(),
948 operation_count: journal.operation_count,
949 operation_counts: RestoreApplyOperationKindCounts::from_operations(&journal.operations),
950 pending_operations: journal.pending_operations,
951 ready_operations: journal.ready_operations,
952 blocked_operations: journal.blocked_operations,
953 completed_operations: journal.completed_operations,
954 failed_operations: journal.failed_operations,
955 next_transition: journal
956 .next_transition_operation()
957 .map(RestoreApplyReportOperation::from_journal_operation),
958 pending,
959 failed,
960 blocked,
961 }
962 }
963}
964
965#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
970#[serde(rename_all = "kebab-case")]
971pub enum RestoreApplyReportOutcome {
972 Empty,
973 Complete,
974 Failed,
975 Blocked,
976 Pending,
977 InProgress,
978}
979
980impl RestoreApplyReportOutcome {
981 const fn from_journal(journal: &RestoreApplyJournal, complete: bool) -> Self {
983 if journal.operation_count == 0 {
984 return Self::Empty;
985 }
986 if complete {
987 return Self::Complete;
988 }
989 if journal.failed_operations > 0 {
990 return Self::Failed;
991 }
992 if !journal.ready || journal.blocked_operations > 0 {
993 return Self::Blocked;
994 }
995 if journal.pending_operations > 0 {
996 return Self::Pending;
997 }
998 Self::InProgress
999 }
1000
1001 const fn attention_required(&self) -> bool {
1003 matches!(self, Self::Failed | Self::Blocked | Self::Pending)
1004 }
1005}
1006
1007#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1012pub struct RestoreApplyReportOperation {
1013 pub sequence: usize,
1014 pub operation: RestoreApplyOperationKind,
1015 pub state: RestoreApplyOperationState,
1016 pub restore_group: u16,
1017 pub phase_order: usize,
1018 pub role: String,
1019 pub source_canister: String,
1020 pub target_canister: String,
1021 pub state_updated_at: Option<String>,
1022 pub reasons: Vec<String>,
1023}
1024
1025impl RestoreApplyReportOperation {
1026 fn from_journal_operation(operation: &RestoreApplyJournalOperation) -> Self {
1028 Self {
1029 sequence: operation.sequence,
1030 operation: operation.operation.clone(),
1031 state: operation.state.clone(),
1032 restore_group: operation.restore_group,
1033 phase_order: operation.phase_order,
1034 role: operation.role.clone(),
1035 source_canister: operation.source_canister.clone(),
1036 target_canister: operation.target_canister.clone(),
1037 state_updated_at: operation.state_updated_at.clone(),
1038 reasons: operation.blocking_reasons.clone(),
1039 }
1040 }
1041}
1042
1043fn report_operations_with_state(
1045 journal: &RestoreApplyJournal,
1046 state: RestoreApplyOperationState,
1047) -> Vec<RestoreApplyReportOperation> {
1048 journal
1049 .operations
1050 .iter()
1051 .filter(|operation| operation.state == state)
1052 .map(RestoreApplyReportOperation::from_journal_operation)
1053 .collect()
1054}
1055
1056#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1061pub struct RestoreApplyNextOperation {
1062 pub response_version: u16,
1063 pub backup_id: String,
1064 pub ready: bool,
1065 pub complete: bool,
1066 pub operation_available: bool,
1067 pub blocked_reasons: Vec<String>,
1068 pub operation: Option<RestoreApplyJournalOperation>,
1069}
1070
1071impl RestoreApplyNextOperation {
1072 #[must_use]
1074 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1075 let complete =
1076 journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
1077 let operation = journal.next_transition_operation().cloned();
1078
1079 Self {
1080 response_version: 1,
1081 backup_id: journal.backup_id.clone(),
1082 ready: journal.ready,
1083 complete,
1084 operation_available: operation.is_some(),
1085 blocked_reasons: journal.blocked_reasons.clone(),
1086 operation,
1087 }
1088 }
1089}
1090
1091#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1096#[expect(
1097 clippy::struct_excessive_bools,
1098 reason = "runner preview exposes machine-readable availability and safety flags"
1099)]
1100pub struct RestoreApplyCommandPreview {
1101 pub response_version: u16,
1102 pub backup_id: String,
1103 pub ready: bool,
1104 pub complete: bool,
1105 pub operation_available: bool,
1106 pub command_available: bool,
1107 pub blocked_reasons: Vec<String>,
1108 pub operation: Option<RestoreApplyJournalOperation>,
1109 pub command: Option<RestoreApplyRunnerCommand>,
1110}
1111
1112impl RestoreApplyCommandPreview {
1113 #[must_use]
1115 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1116 Self::from_journal_with_config(journal, &RestoreApplyCommandConfig::default())
1117 }
1118
1119 #[must_use]
1121 pub fn from_journal_with_config(
1122 journal: &RestoreApplyJournal,
1123 config: &RestoreApplyCommandConfig,
1124 ) -> Self {
1125 let complete =
1126 journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
1127 let operation = journal.next_transition_operation().cloned();
1128 let command = operation
1129 .as_ref()
1130 .and_then(|operation| RestoreApplyRunnerCommand::from_operation(operation, config));
1131
1132 Self {
1133 response_version: 1,
1134 backup_id: journal.backup_id.clone(),
1135 ready: journal.ready,
1136 complete,
1137 operation_available: operation.is_some(),
1138 command_available: command.is_some(),
1139 blocked_reasons: journal.blocked_reasons.clone(),
1140 operation,
1141 command,
1142 }
1143 }
1144}
1145
1146#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1151pub struct RestoreApplyCommandConfig {
1152 pub program: String,
1153 pub network: Option<String>,
1154}
1155
1156impl Default for RestoreApplyCommandConfig {
1157 fn default() -> Self {
1159 Self {
1160 program: "dfx".to_string(),
1161 network: None,
1162 }
1163 }
1164}
1165
1166#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1171pub struct RestoreApplyRunnerCommand {
1172 pub program: String,
1173 pub args: Vec<String>,
1174 pub mutates: bool,
1175 pub requires_stopped_canister: bool,
1176 pub note: String,
1177}
1178
1179impl RestoreApplyRunnerCommand {
1180 fn from_operation(
1182 operation: &RestoreApplyJournalOperation,
1183 config: &RestoreApplyCommandConfig,
1184 ) -> Option<Self> {
1185 match operation.operation {
1186 RestoreApplyOperationKind::UploadSnapshot => {
1187 let artifact_path = operation.artifact_path.as_ref()?;
1188 Some(Self {
1189 program: config.program.clone(),
1190 args: dfx_canister_args(
1191 config,
1192 vec![
1193 "snapshot".to_string(),
1194 "upload".to_string(),
1195 "--dir".to_string(),
1196 artifact_path.clone(),
1197 operation.target_canister.clone(),
1198 ],
1199 ),
1200 mutates: true,
1201 requires_stopped_canister: false,
1202 note: "uploads the downloaded snapshot artifact to the target canister"
1203 .to_string(),
1204 })
1205 }
1206 RestoreApplyOperationKind::LoadSnapshot => {
1207 let snapshot_id = operation.snapshot_id.as_ref()?;
1208 Some(Self {
1209 program: config.program.clone(),
1210 args: dfx_canister_args(
1211 config,
1212 vec![
1213 "snapshot".to_string(),
1214 "load".to_string(),
1215 operation.target_canister.clone(),
1216 snapshot_id.clone(),
1217 ],
1218 ),
1219 mutates: true,
1220 requires_stopped_canister: true,
1221 note: "loads the uploaded snapshot into the target canister".to_string(),
1222 })
1223 }
1224 RestoreApplyOperationKind::ReinstallCode => Some(Self {
1225 program: config.program.clone(),
1226 args: dfx_canister_args(
1227 config,
1228 vec![
1229 "install".to_string(),
1230 "--mode".to_string(),
1231 "reinstall".to_string(),
1232 "--yes".to_string(),
1233 operation.target_canister.clone(),
1234 ],
1235 ),
1236 mutates: true,
1237 requires_stopped_canister: false,
1238 note: "reinstalls target canister code using the local dfx project configuration"
1239 .to_string(),
1240 }),
1241 RestoreApplyOperationKind::VerifyMember | RestoreApplyOperationKind::VerifyFleet => {
1242 match operation.verification_kind.as_deref() {
1243 Some("status") => Some(Self {
1244 program: config.program.clone(),
1245 args: dfx_canister_args(
1246 config,
1247 vec!["status".to_string(), operation.target_canister.clone()],
1248 ),
1249 mutates: false,
1250 requires_stopped_canister: false,
1251 note: verification_command_note(
1252 &operation.operation,
1253 "checks target canister status",
1254 "checks target fleet root canister status",
1255 )
1256 .to_string(),
1257 }),
1258 Some(_) => {
1259 let method = operation.verification_method.as_ref()?;
1260 Some(Self {
1261 program: config.program.clone(),
1262 args: dfx_canister_args(
1263 config,
1264 vec![
1265 "call".to_string(),
1266 operation.target_canister.clone(),
1267 method.clone(),
1268 ],
1269 ),
1270 mutates: false,
1271 requires_stopped_canister: false,
1272 note: verification_command_note(
1273 &operation.operation,
1274 "calls the declared verification method",
1275 "calls the declared fleet verification method",
1276 )
1277 .to_string(),
1278 })
1279 }
1280 None => None,
1281 }
1282 }
1283 }
1284 }
1285}
1286
1287const fn verification_command_note(
1289 operation: &RestoreApplyOperationKind,
1290 member_note: &'static str,
1291 fleet_note: &'static str,
1292) -> &'static str {
1293 match operation {
1294 RestoreApplyOperationKind::VerifyFleet => fleet_note,
1295 RestoreApplyOperationKind::UploadSnapshot
1296 | RestoreApplyOperationKind::LoadSnapshot
1297 | RestoreApplyOperationKind::ReinstallCode
1298 | RestoreApplyOperationKind::VerifyMember => member_note,
1299 }
1300}
1301
1302fn dfx_canister_args(config: &RestoreApplyCommandConfig, mut tail: Vec<String>) -> Vec<String> {
1304 let mut args = vec!["canister".to_string()];
1305 if let Some(network) = &config.network {
1306 args.push("--network".to_string());
1307 args.push(network.clone());
1308 }
1309 args.append(&mut tail);
1310 args
1311}
1312
1313#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1318pub struct RestoreApplyJournalOperation {
1319 pub sequence: usize,
1320 pub operation: RestoreApplyOperationKind,
1321 pub state: RestoreApplyOperationState,
1322 #[serde(default, skip_serializing_if = "Option::is_none")]
1323 pub state_updated_at: Option<String>,
1324 pub blocking_reasons: Vec<String>,
1325 pub restore_group: u16,
1326 pub phase_order: usize,
1327 pub source_canister: String,
1328 pub target_canister: String,
1329 pub role: String,
1330 pub snapshot_id: Option<String>,
1331 pub artifact_path: Option<String>,
1332 pub verification_kind: Option<String>,
1333 pub verification_method: Option<String>,
1334}
1335
1336impl RestoreApplyJournalOperation {
1337 fn from_dry_run_operation(
1339 operation: &RestoreApplyDryRunOperation,
1340 state: RestoreApplyOperationState,
1341 blocked_reasons: &[String],
1342 ) -> Self {
1343 Self {
1344 sequence: operation.sequence,
1345 operation: operation.operation.clone(),
1346 state: state.clone(),
1347 state_updated_at: None,
1348 blocking_reasons: if state == RestoreApplyOperationState::Blocked {
1349 blocked_reasons.to_vec()
1350 } else {
1351 Vec::new()
1352 },
1353 restore_group: operation.restore_group,
1354 phase_order: operation.phase_order,
1355 source_canister: operation.source_canister.clone(),
1356 target_canister: operation.target_canister.clone(),
1357 role: operation.role.clone(),
1358 snapshot_id: operation.snapshot_id.clone(),
1359 artifact_path: operation.artifact_path.clone(),
1360 verification_kind: operation.verification_kind.clone(),
1361 verification_method: operation.verification_method.clone(),
1362 }
1363 }
1364
1365 fn validate(&self) -> Result<(), RestoreApplyJournalError> {
1367 validate_apply_journal_nonempty("operations[].source_canister", &self.source_canister)?;
1368 validate_apply_journal_nonempty("operations[].target_canister", &self.target_canister)?;
1369 validate_apply_journal_nonempty("operations[].role", &self.role)?;
1370 if let Some(updated_at) = &self.state_updated_at {
1371 validate_apply_journal_nonempty("operations[].state_updated_at", updated_at)?;
1372 }
1373 self.validate_operation_fields()?;
1374
1375 match self.state {
1376 RestoreApplyOperationState::Blocked if self.blocking_reasons.is_empty() => Err(
1377 RestoreApplyJournalError::BlockedOperationMissingReason(self.sequence),
1378 ),
1379 RestoreApplyOperationState::Failed if self.blocking_reasons.is_empty() => Err(
1380 RestoreApplyJournalError::FailureReasonRequired(self.sequence),
1381 ),
1382 RestoreApplyOperationState::Pending
1383 | RestoreApplyOperationState::Ready
1384 | RestoreApplyOperationState::Completed
1385 if !self.blocking_reasons.is_empty() =>
1386 {
1387 Err(RestoreApplyJournalError::UnblockedOperationHasReasons(
1388 self.sequence,
1389 ))
1390 }
1391 RestoreApplyOperationState::Blocked
1392 | RestoreApplyOperationState::Failed
1393 | RestoreApplyOperationState::Pending
1394 | RestoreApplyOperationState::Ready
1395 | RestoreApplyOperationState::Completed => Ok(()),
1396 }
1397 }
1398
1399 fn validate_operation_fields(&self) -> Result<(), RestoreApplyJournalError> {
1401 match self.operation {
1402 RestoreApplyOperationKind::UploadSnapshot => self
1403 .validate_required_field("operations[].artifact_path", self.artifact_path.as_ref())
1404 .map(|_| ()),
1405 RestoreApplyOperationKind::LoadSnapshot => self
1406 .validate_required_field("operations[].snapshot_id", self.snapshot_id.as_ref())
1407 .map(|_| ()),
1408 RestoreApplyOperationKind::ReinstallCode => Ok(()),
1409 RestoreApplyOperationKind::VerifyMember | RestoreApplyOperationKind::VerifyFleet => {
1410 let kind = self.validate_required_field(
1411 "operations[].verification_kind",
1412 self.verification_kind.as_ref(),
1413 )?;
1414 if kind == "status" {
1415 return Ok(());
1416 }
1417 self.validate_required_field(
1418 "operations[].verification_method",
1419 self.verification_method.as_ref(),
1420 )
1421 .map(|_| ())
1422 }
1423 }
1424 }
1425
1426 fn validate_required_field<'a>(
1428 &self,
1429 field: &'static str,
1430 value: Option<&'a String>,
1431 ) -> Result<&'a str, RestoreApplyJournalError> {
1432 let value = value.map(String::as_str).ok_or_else(|| {
1433 RestoreApplyJournalError::OperationMissingField {
1434 sequence: self.sequence,
1435 operation: self.operation.clone(),
1436 field,
1437 }
1438 })?;
1439 if value.trim().is_empty() {
1440 return Err(RestoreApplyJournalError::OperationMissingField {
1441 sequence: self.sequence,
1442 operation: self.operation.clone(),
1443 field,
1444 });
1445 }
1446
1447 Ok(value)
1448 }
1449
1450 const fn can_transition_to(&self, next_state: &RestoreApplyOperationState) -> bool {
1452 match (&self.state, next_state) {
1453 (
1454 RestoreApplyOperationState::Ready | RestoreApplyOperationState::Pending,
1455 RestoreApplyOperationState::Pending,
1456 )
1457 | (RestoreApplyOperationState::Pending, RestoreApplyOperationState::Ready)
1458 | (
1459 RestoreApplyOperationState::Ready
1460 | RestoreApplyOperationState::Pending
1461 | RestoreApplyOperationState::Completed,
1462 RestoreApplyOperationState::Completed,
1463 )
1464 | (
1465 RestoreApplyOperationState::Ready
1466 | RestoreApplyOperationState::Pending
1467 | RestoreApplyOperationState::Failed,
1468 RestoreApplyOperationState::Failed,
1469 ) => true,
1470 (
1471 RestoreApplyOperationState::Blocked
1472 | RestoreApplyOperationState::Completed
1473 | RestoreApplyOperationState::Failed
1474 | RestoreApplyOperationState::Pending
1475 | RestoreApplyOperationState::Ready,
1476 _,
1477 ) => false,
1478 }
1479 }
1480}
1481
1482#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1487#[serde(rename_all = "kebab-case")]
1488pub enum RestoreApplyOperationState {
1489 Pending,
1490 Ready,
1491 Blocked,
1492 Completed,
1493 Failed,
1494}
1495
1496#[derive(Debug, ThisError)]
1501pub enum RestoreApplyJournalError {
1502 #[error("unsupported restore apply journal version {0}")]
1503 UnsupportedVersion(u16),
1504
1505 #[error("restore apply journal field {0} is required")]
1506 MissingField(&'static str),
1507
1508 #[error("restore apply journal count {field} mismatch: reported={reported}, actual={actual}")]
1509 CountMismatch {
1510 field: &'static str,
1511 reported: usize,
1512 actual: usize,
1513 },
1514
1515 #[error("restore apply journal has duplicate operation sequence {0}")]
1516 DuplicateSequence(usize),
1517
1518 #[error("restore apply journal is missing operation sequence {0}")]
1519 MissingSequence(usize),
1520
1521 #[error("ready restore apply journal cannot include blocked reasons or blocked operations")]
1522 ReadyJournalHasBlockingState,
1523
1524 #[error("blocked restore apply journal operation {0} is missing a blocking reason")]
1525 BlockedOperationMissingReason(usize),
1526
1527 #[error("unblocked restore apply journal operation {0} cannot have blocking reasons")]
1528 UnblockedOperationHasReasons(usize),
1529
1530 #[error("restore apply journal operation {sequence} {operation:?} is missing field {field}")]
1531 OperationMissingField {
1532 sequence: usize,
1533 operation: RestoreApplyOperationKind,
1534 field: &'static str,
1535 },
1536
1537 #[error("restore apply journal operation {0} was not found")]
1538 OperationNotFound(usize),
1539
1540 #[error("restore apply journal operation {sequence} cannot transition from {from:?} to {to:?}")]
1541 InvalidOperationTransition {
1542 sequence: usize,
1543 from: RestoreApplyOperationState,
1544 to: RestoreApplyOperationState,
1545 },
1546
1547 #[error("failed restore apply journal operation {0} requires a reason")]
1548 FailureReasonRequired(usize),
1549
1550 #[error("restore apply journal has no operation that can be advanced")]
1551 NoTransitionableOperation,
1552
1553 #[error("restore apply journal has no pending operation to release")]
1554 NoPendingOperation,
1555
1556 #[error("restore apply journal operation {requested} cannot advance before operation {next}")]
1557 OutOfOrderOperationTransition { requested: usize, next: usize },
1558}
1559
1560fn validate_restore_apply_artifacts(
1562 plan: &RestorePlan,
1563 backup_root: &Path,
1564) -> Result<RestoreApplyArtifactValidation, RestoreApplyDryRunError> {
1565 let mut checks = Vec::new();
1566
1567 for member in plan.ordered_members() {
1568 checks.push(validate_restore_apply_artifact(member, backup_root)?);
1569 }
1570
1571 let members_with_expected_checksums = checks
1572 .iter()
1573 .filter(|check| check.checksum_expected.is_some())
1574 .count();
1575 let artifacts_present = checks.iter().all(|check| check.exists);
1576 let checksums_verified = members_with_expected_checksums == plan.member_count
1577 && checks.iter().all(|check| check.checksum_verified);
1578
1579 Ok(RestoreApplyArtifactValidation {
1580 backup_root: backup_root.to_string_lossy().to_string(),
1581 checked_members: checks.len(),
1582 artifacts_present,
1583 checksums_verified,
1584 members_with_expected_checksums,
1585 checks,
1586 })
1587}
1588
1589fn validate_restore_apply_artifact(
1591 member: &RestorePlanMember,
1592 backup_root: &Path,
1593) -> Result<RestoreApplyArtifactCheck, RestoreApplyDryRunError> {
1594 let artifact_path = safe_restore_artifact_path(
1595 &member.source_canister,
1596 &member.source_snapshot.artifact_path,
1597 )?;
1598 let resolved_path = backup_root.join(&artifact_path);
1599
1600 if !resolved_path.exists() {
1601 return Err(RestoreApplyDryRunError::ArtifactMissing {
1602 source_canister: member.source_canister.clone(),
1603 artifact_path: member.source_snapshot.artifact_path.clone(),
1604 resolved_path: resolved_path.to_string_lossy().to_string(),
1605 });
1606 }
1607
1608 let (checksum_actual, checksum_verified) =
1609 if let Some(expected) = &member.source_snapshot.checksum {
1610 let checksum = ArtifactChecksum::from_path(&resolved_path).map_err(|source| {
1611 RestoreApplyDryRunError::ArtifactChecksum {
1612 source_canister: member.source_canister.clone(),
1613 artifact_path: member.source_snapshot.artifact_path.clone(),
1614 source,
1615 }
1616 })?;
1617 checksum.verify(expected).map_err(|source| {
1618 RestoreApplyDryRunError::ArtifactChecksum {
1619 source_canister: member.source_canister.clone(),
1620 artifact_path: member.source_snapshot.artifact_path.clone(),
1621 source,
1622 }
1623 })?;
1624 (Some(checksum.hash), true)
1625 } else {
1626 (None, false)
1627 };
1628
1629 Ok(RestoreApplyArtifactCheck {
1630 source_canister: member.source_canister.clone(),
1631 target_canister: member.target_canister.clone(),
1632 snapshot_id: member.source_snapshot.snapshot_id.clone(),
1633 artifact_path: member.source_snapshot.artifact_path.clone(),
1634 resolved_path: resolved_path.to_string_lossy().to_string(),
1635 exists: true,
1636 checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
1637 checksum_expected: member.source_snapshot.checksum.clone(),
1638 checksum_actual,
1639 checksum_verified,
1640 })
1641}
1642
1643fn safe_restore_artifact_path(
1645 source_canister: &str,
1646 artifact_path: &str,
1647) -> Result<PathBuf, RestoreApplyDryRunError> {
1648 let path = Path::new(artifact_path);
1649 let is_safe = path
1650 .components()
1651 .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
1652
1653 if is_safe {
1654 return Ok(path.to_path_buf());
1655 }
1656
1657 Err(RestoreApplyDryRunError::ArtifactPathEscapesBackup {
1658 source_canister: source_canister.to_string(),
1659 artifact_path: artifact_path.to_string(),
1660 })
1661}
1662
1663fn validate_restore_status_matches_plan(
1665 plan: &RestorePlan,
1666 status: &RestoreStatus,
1667) -> Result<(), RestoreApplyDryRunError> {
1668 validate_status_string_field("backup_id", &plan.backup_id, &status.backup_id)?;
1669 validate_status_string_field(
1670 "source_environment",
1671 &plan.source_environment,
1672 &status.source_environment,
1673 )?;
1674 validate_status_string_field(
1675 "source_root_canister",
1676 &plan.source_root_canister,
1677 &status.source_root_canister,
1678 )?;
1679 validate_status_string_field("topology_hash", &plan.topology_hash, &status.topology_hash)?;
1680 validate_status_usize_field("member_count", plan.member_count, status.member_count)?;
1681 validate_status_usize_field(
1682 "phase_count",
1683 plan.ordering_summary.phase_count,
1684 status.phase_count,
1685 )?;
1686 Ok(())
1687}
1688
1689fn validate_status_string_field(
1691 field: &'static str,
1692 plan: &str,
1693 status: &str,
1694) -> Result<(), RestoreApplyDryRunError> {
1695 if plan == status {
1696 return Ok(());
1697 }
1698
1699 Err(RestoreApplyDryRunError::StatusPlanMismatch {
1700 field,
1701 plan: plan.to_string(),
1702 status: status.to_string(),
1703 })
1704}
1705
1706const fn validate_status_usize_field(
1708 field: &'static str,
1709 plan: usize,
1710 status: usize,
1711) -> Result<(), RestoreApplyDryRunError> {
1712 if plan == status {
1713 return Ok(());
1714 }
1715
1716 Err(RestoreApplyDryRunError::StatusPlanCountMismatch {
1717 field,
1718 plan,
1719 status,
1720 })
1721}
1722
1723#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1728pub struct RestoreApplyArtifactValidation {
1729 pub backup_root: String,
1730 pub checked_members: usize,
1731 pub artifacts_present: bool,
1732 pub checksums_verified: bool,
1733 pub members_with_expected_checksums: usize,
1734 pub checks: Vec<RestoreApplyArtifactCheck>,
1735}
1736
1737#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1742pub struct RestoreApplyArtifactCheck {
1743 pub source_canister: String,
1744 pub target_canister: String,
1745 pub snapshot_id: String,
1746 pub artifact_path: String,
1747 pub resolved_path: String,
1748 pub exists: bool,
1749 pub checksum_algorithm: String,
1750 pub checksum_expected: Option<String>,
1751 pub checksum_actual: Option<String>,
1752 pub checksum_verified: bool,
1753}
1754
1755#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1760pub struct RestoreApplyDryRunPhase {
1761 pub restore_group: u16,
1762 pub operations: Vec<RestoreApplyDryRunOperation>,
1763}
1764
1765impl RestoreApplyDryRunPhase {
1766 fn from_plan_phase(phase: &RestorePhase, next_sequence: &mut usize) -> Self {
1768 let mut operations = Vec::new();
1769
1770 for member in &phase.members {
1771 push_member_operation(
1772 &mut operations,
1773 next_sequence,
1774 RestoreApplyOperationKind::UploadSnapshot,
1775 member,
1776 None,
1777 );
1778 push_member_operation(
1779 &mut operations,
1780 next_sequence,
1781 RestoreApplyOperationKind::LoadSnapshot,
1782 member,
1783 None,
1784 );
1785 push_member_operation(
1786 &mut operations,
1787 next_sequence,
1788 RestoreApplyOperationKind::ReinstallCode,
1789 member,
1790 None,
1791 );
1792
1793 for check in &member.verification_checks {
1794 push_member_operation(
1795 &mut operations,
1796 next_sequence,
1797 RestoreApplyOperationKind::VerifyMember,
1798 member,
1799 Some(check),
1800 );
1801 }
1802 }
1803
1804 Self {
1805 restore_group: phase.restore_group,
1806 operations,
1807 }
1808 }
1809}
1810
1811fn push_member_operation(
1813 operations: &mut Vec<RestoreApplyDryRunOperation>,
1814 next_sequence: &mut usize,
1815 operation: RestoreApplyOperationKind,
1816 member: &RestorePlanMember,
1817 check: Option<&VerificationCheck>,
1818) {
1819 let sequence = *next_sequence;
1820 *next_sequence += 1;
1821
1822 operations.push(RestoreApplyDryRunOperation {
1823 sequence,
1824 operation,
1825 restore_group: member.restore_group,
1826 phase_order: member.phase_order,
1827 source_canister: member.source_canister.clone(),
1828 target_canister: member.target_canister.clone(),
1829 role: member.role.clone(),
1830 snapshot_id: Some(member.source_snapshot.snapshot_id.clone()),
1831 artifact_path: Some(member.source_snapshot.artifact_path.clone()),
1832 verification_kind: check.map(|check| check.kind.clone()),
1833 verification_method: check.and_then(|check| check.method.clone()),
1834 });
1835}
1836
1837fn append_fleet_verification_operations(
1839 plan: &RestorePlan,
1840 phases: &mut [RestoreApplyDryRunPhase],
1841 next_sequence: &mut usize,
1842) {
1843 if plan.fleet_verification_checks.is_empty() {
1844 return;
1845 }
1846
1847 let Some(phase) = phases.last_mut() else {
1848 return;
1849 };
1850 let root = plan
1851 .phases
1852 .iter()
1853 .flat_map(|phase| phase.members.iter())
1854 .find(|member| member.source_canister == plan.source_root_canister);
1855 let source_canister = root.map_or_else(
1856 || plan.source_root_canister.clone(),
1857 |member| member.source_canister.clone(),
1858 );
1859 let target_canister = root.map_or_else(
1860 || plan.source_root_canister.clone(),
1861 |member| member.target_canister.clone(),
1862 );
1863 let restore_group = phase.restore_group;
1864
1865 for check in &plan.fleet_verification_checks {
1866 push_fleet_operation(
1867 &mut phase.operations,
1868 next_sequence,
1869 restore_group,
1870 &source_canister,
1871 &target_canister,
1872 check,
1873 );
1874 }
1875}
1876
1877fn push_fleet_operation(
1879 operations: &mut Vec<RestoreApplyDryRunOperation>,
1880 next_sequence: &mut usize,
1881 restore_group: u16,
1882 source_canister: &str,
1883 target_canister: &str,
1884 check: &VerificationCheck,
1885) {
1886 let sequence = *next_sequence;
1887 *next_sequence += 1;
1888 let phase_order = operations.len();
1889
1890 operations.push(RestoreApplyDryRunOperation {
1891 sequence,
1892 operation: RestoreApplyOperationKind::VerifyFleet,
1893 restore_group,
1894 phase_order,
1895 source_canister: source_canister.to_string(),
1896 target_canister: target_canister.to_string(),
1897 role: "fleet".to_string(),
1898 snapshot_id: None,
1899 artifact_path: None,
1900 verification_kind: Some(check.kind.clone()),
1901 verification_method: check.method.clone(),
1902 });
1903}
1904
1905#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1910pub struct RestoreApplyDryRunOperation {
1911 pub sequence: usize,
1912 pub operation: RestoreApplyOperationKind,
1913 pub restore_group: u16,
1914 pub phase_order: usize,
1915 pub source_canister: String,
1916 pub target_canister: String,
1917 pub role: String,
1918 pub snapshot_id: Option<String>,
1919 pub artifact_path: Option<String>,
1920 pub verification_kind: Option<String>,
1921 pub verification_method: Option<String>,
1922}
1923
1924#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1929#[serde(rename_all = "kebab-case")]
1930pub enum RestoreApplyOperationKind {
1931 UploadSnapshot,
1932 LoadSnapshot,
1933 ReinstallCode,
1934 VerifyMember,
1935 VerifyFleet,
1936}
1937
1938#[derive(Debug, ThisError)]
1943pub enum RestoreApplyDryRunError {
1944 #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
1945 StatusPlanMismatch {
1946 field: &'static str,
1947 plan: String,
1948 status: String,
1949 },
1950
1951 #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
1952 StatusPlanCountMismatch {
1953 field: &'static str,
1954 plan: usize,
1955 status: usize,
1956 },
1957
1958 #[error("restore artifact path for {source_canister} escapes backup root: {artifact_path}")]
1959 ArtifactPathEscapesBackup {
1960 source_canister: String,
1961 artifact_path: String,
1962 },
1963
1964 #[error(
1965 "restore artifact for {source_canister} is missing: {artifact_path} at {resolved_path}"
1966 )]
1967 ArtifactMissing {
1968 source_canister: String,
1969 artifact_path: String,
1970 resolved_path: String,
1971 },
1972
1973 #[error("restore artifact checksum failed for {source_canister} at {artifact_path}: {source}")]
1974 ArtifactChecksum {
1975 source_canister: String,
1976 artifact_path: String,
1977 #[source]
1978 source: ArtifactChecksumError,
1979 },
1980}
1981
1982#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1987pub struct RestoreIdentitySummary {
1988 pub mapping_supplied: bool,
1989 pub all_sources_mapped: bool,
1990 pub fixed_members: usize,
1991 pub relocatable_members: usize,
1992 pub in_place_members: usize,
1993 pub mapped_members: usize,
1994 pub remapped_members: usize,
1995}
1996
1997#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2002#[expect(
2003 clippy::struct_excessive_bools,
2004 reason = "restore summaries intentionally expose machine-readable readiness flags"
2005)]
2006pub struct RestoreSnapshotSummary {
2007 pub all_members_have_module_hash: bool,
2008 pub all_members_have_wasm_hash: bool,
2009 pub all_members_have_code_version: bool,
2010 pub all_members_have_checksum: bool,
2011 pub members_with_module_hash: usize,
2012 pub members_with_wasm_hash: usize,
2013 pub members_with_code_version: usize,
2014 pub members_with_checksum: usize,
2015}
2016
2017#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2022pub struct RestoreVerificationSummary {
2023 pub verification_required: bool,
2024 pub all_members_have_checks: bool,
2025 pub fleet_checks: usize,
2026 pub member_check_groups: usize,
2027 pub member_checks: usize,
2028 pub members_with_checks: usize,
2029 pub total_checks: usize,
2030}
2031
2032#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2037pub struct RestoreReadinessSummary {
2038 pub ready: bool,
2039 pub reasons: Vec<String>,
2040}
2041
2042#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2047pub struct RestoreOperationSummary {
2048 #[serde(default)]
2049 pub planned_snapshot_uploads: usize,
2050 pub planned_snapshot_loads: usize,
2051 pub planned_code_reinstalls: usize,
2052 pub planned_verification_checks: usize,
2053 #[serde(default)]
2054 pub planned_operations: usize,
2055 pub planned_phases: usize,
2056}
2057
2058impl RestoreOperationSummary {
2059 #[must_use]
2061 pub const fn effective_planned_snapshot_uploads(&self, member_count: usize) -> usize {
2062 if self.planned_snapshot_uploads == 0 && member_count > 0 {
2063 return member_count;
2064 }
2065
2066 self.planned_snapshot_uploads
2067 }
2068
2069 #[must_use]
2071 pub const fn effective_planned_operations(&self, member_count: usize) -> usize {
2072 if self.planned_operations == 0 {
2073 return self.effective_planned_snapshot_uploads(member_count)
2074 + self.planned_snapshot_loads
2075 + self.planned_code_reinstalls
2076 + self.planned_verification_checks;
2077 }
2078
2079 self.planned_operations
2080 }
2081}
2082
2083#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2088pub struct RestoreOrderingSummary {
2089 pub phase_count: usize,
2090 pub dependency_free_members: usize,
2091 pub in_group_parent_edges: usize,
2092 pub cross_group_parent_edges: usize,
2093}
2094
2095#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2100pub struct RestorePhase {
2101 pub restore_group: u16,
2102 pub members: Vec<RestorePlanMember>,
2103}
2104
2105#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2110pub struct RestorePlanMember {
2111 pub source_canister: String,
2112 pub target_canister: String,
2113 pub role: String,
2114 pub parent_source_canister: Option<String>,
2115 pub parent_target_canister: Option<String>,
2116 pub ordering_dependency: Option<RestoreOrderingDependency>,
2117 pub phase_order: usize,
2118 pub restore_group: u16,
2119 pub identity_mode: IdentityMode,
2120 pub verification_class: String,
2121 pub verification_checks: Vec<VerificationCheck>,
2122 pub source_snapshot: SourceSnapshot,
2123}
2124
2125#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2130pub struct RestoreOrderingDependency {
2131 pub source_canister: String,
2132 pub target_canister: String,
2133 pub relationship: RestoreOrderingRelationship,
2134}
2135
2136#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2141#[serde(rename_all = "kebab-case")]
2142pub enum RestoreOrderingRelationship {
2143 ParentInSameGroup,
2144 ParentInEarlierGroup,
2145}
2146
2147pub struct RestorePlanner;
2152
2153impl RestorePlanner {
2154 pub fn plan(
2156 manifest: &FleetBackupManifest,
2157 mapping: Option<&RestoreMapping>,
2158 ) -> Result<RestorePlan, RestorePlanError> {
2159 manifest.validate()?;
2160 if let Some(mapping) = mapping {
2161 validate_mapping(mapping)?;
2162 validate_mapping_sources(manifest, mapping)?;
2163 }
2164
2165 let members = resolve_members(manifest, mapping)?;
2166 let identity_summary = restore_identity_summary(&members, mapping.is_some());
2167 let snapshot_summary = restore_snapshot_summary(&members);
2168 let verification_summary = restore_verification_summary(manifest, &members);
2169 let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
2170 validate_restore_group_dependencies(&members)?;
2171 let phases = group_and_order_members(members)?;
2172 let ordering_summary = restore_ordering_summary(&phases);
2173 let operation_summary =
2174 restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
2175
2176 Ok(RestorePlan {
2177 backup_id: manifest.backup_id.clone(),
2178 source_environment: manifest.source.environment.clone(),
2179 source_root_canister: manifest.source.root_canister.clone(),
2180 topology_hash: manifest.fleet.topology_hash.clone(),
2181 member_count: manifest.fleet.members.len(),
2182 identity_summary,
2183 snapshot_summary,
2184 verification_summary,
2185 readiness_summary,
2186 operation_summary,
2187 ordering_summary,
2188 fleet_verification_checks: manifest.verification.fleet_checks.clone(),
2189 phases,
2190 })
2191 }
2192}
2193
2194#[derive(Debug, ThisError)]
2199pub enum RestorePlanError {
2200 #[error(transparent)]
2201 InvalidManifest(#[from] ManifestValidationError),
2202
2203 #[error("field {field} must be a valid principal: {value}")]
2204 InvalidPrincipal { field: &'static str, value: String },
2205
2206 #[error("mapping contains duplicate source canister {0}")]
2207 DuplicateMappingSource(String),
2208
2209 #[error("mapping contains duplicate target canister {0}")]
2210 DuplicateMappingTarget(String),
2211
2212 #[error("mapping references unknown source canister {0}")]
2213 UnknownMappingSource(String),
2214
2215 #[error("mapping is missing source canister {0}")]
2216 MissingMappingSource(String),
2217
2218 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
2219 FixedIdentityRemap {
2220 source_canister: String,
2221 target_canister: String,
2222 },
2223
2224 #[error("restore plan contains duplicate target canister {0}")]
2225 DuplicatePlanTarget(String),
2226
2227 #[error("restore group {0} contains a parent cycle or unresolved dependency")]
2228 RestoreOrderCycle(u16),
2229
2230 #[error(
2231 "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
2232 )]
2233 ParentRestoreGroupAfterChild {
2234 child_source_canister: String,
2235 parent_source_canister: String,
2236 child_restore_group: u16,
2237 parent_restore_group: u16,
2238 },
2239}
2240
2241fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
2243 let mut sources = BTreeSet::new();
2244 let mut targets = BTreeSet::new();
2245
2246 for entry in &mapping.members {
2247 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
2248 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
2249
2250 if !sources.insert(entry.source_canister.clone()) {
2251 return Err(RestorePlanError::DuplicateMappingSource(
2252 entry.source_canister.clone(),
2253 ));
2254 }
2255
2256 if !targets.insert(entry.target_canister.clone()) {
2257 return Err(RestorePlanError::DuplicateMappingTarget(
2258 entry.target_canister.clone(),
2259 ));
2260 }
2261 }
2262
2263 Ok(())
2264}
2265
2266fn validate_mapping_sources(
2268 manifest: &FleetBackupManifest,
2269 mapping: &RestoreMapping,
2270) -> Result<(), RestorePlanError> {
2271 let sources = manifest
2272 .fleet
2273 .members
2274 .iter()
2275 .map(|member| member.canister_id.as_str())
2276 .collect::<BTreeSet<_>>();
2277
2278 for entry in &mapping.members {
2279 if !sources.contains(entry.source_canister.as_str()) {
2280 return Err(RestorePlanError::UnknownMappingSource(
2281 entry.source_canister.clone(),
2282 ));
2283 }
2284 }
2285
2286 Ok(())
2287}
2288
2289fn resolve_members(
2291 manifest: &FleetBackupManifest,
2292 mapping: Option<&RestoreMapping>,
2293) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
2294 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
2295 let mut targets = BTreeSet::new();
2296 let mut source_to_target = BTreeMap::new();
2297
2298 for member in &manifest.fleet.members {
2299 let target = resolve_target(member, mapping)?;
2300 if !targets.insert(target.clone()) {
2301 return Err(RestorePlanError::DuplicatePlanTarget(target));
2302 }
2303
2304 source_to_target.insert(member.canister_id.clone(), target.clone());
2305 plan_members.push(RestorePlanMember {
2306 source_canister: member.canister_id.clone(),
2307 target_canister: target,
2308 role: member.role.clone(),
2309 parent_source_canister: member.parent_canister_id.clone(),
2310 parent_target_canister: None,
2311 ordering_dependency: None,
2312 phase_order: 0,
2313 restore_group: member.restore_group,
2314 identity_mode: member.identity_mode.clone(),
2315 verification_class: member.verification_class.clone(),
2316 verification_checks: concrete_member_verification_checks(
2317 member,
2318 &manifest.verification,
2319 ),
2320 source_snapshot: member.source_snapshot.clone(),
2321 });
2322 }
2323
2324 for member in &mut plan_members {
2325 member.parent_target_canister = member
2326 .parent_source_canister
2327 .as_ref()
2328 .and_then(|parent| source_to_target.get(parent))
2329 .cloned();
2330 }
2331
2332 Ok(plan_members)
2333}
2334
2335fn concrete_member_verification_checks(
2337 member: &FleetMember,
2338 verification: &VerificationPlan,
2339) -> Vec<VerificationCheck> {
2340 let mut checks = member
2341 .verification_checks
2342 .iter()
2343 .filter(|check| verification_check_applies_to_role(check, &member.role))
2344 .cloned()
2345 .collect::<Vec<_>>();
2346
2347 for group in &verification.member_checks {
2348 if group.role != member.role {
2349 continue;
2350 }
2351
2352 checks.extend(
2353 group
2354 .checks
2355 .iter()
2356 .filter(|check| verification_check_applies_to_role(check, &member.role))
2357 .cloned(),
2358 );
2359 }
2360
2361 checks
2362}
2363
2364fn verification_check_applies_to_role(check: &VerificationCheck, role: &str) -> bool {
2366 check.roles.is_empty() || check.roles.iter().any(|check_role| check_role == role)
2367}
2368
2369fn resolve_target(
2371 member: &FleetMember,
2372 mapping: Option<&RestoreMapping>,
2373) -> Result<String, RestorePlanError> {
2374 let target = match mapping {
2375 Some(mapping) => mapping
2376 .target_for(&member.canister_id)
2377 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
2378 .to_string(),
2379 None => member.canister_id.clone(),
2380 };
2381
2382 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
2383 return Err(RestorePlanError::FixedIdentityRemap {
2384 source_canister: member.canister_id.clone(),
2385 target_canister: target,
2386 });
2387 }
2388
2389 Ok(target)
2390}
2391
2392fn restore_identity_summary(
2394 members: &[RestorePlanMember],
2395 mapping_supplied: bool,
2396) -> RestoreIdentitySummary {
2397 let mut summary = RestoreIdentitySummary {
2398 mapping_supplied,
2399 all_sources_mapped: false,
2400 fixed_members: 0,
2401 relocatable_members: 0,
2402 in_place_members: 0,
2403 mapped_members: 0,
2404 remapped_members: 0,
2405 };
2406
2407 for member in members {
2408 match member.identity_mode {
2409 IdentityMode::Fixed => summary.fixed_members += 1,
2410 IdentityMode::Relocatable => summary.relocatable_members += 1,
2411 }
2412
2413 if member.source_canister == member.target_canister {
2414 summary.in_place_members += 1;
2415 } else {
2416 summary.remapped_members += 1;
2417 }
2418 if mapping_supplied {
2419 summary.mapped_members += 1;
2420 }
2421 }
2422
2423 summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
2424
2425 summary
2426}
2427
2428fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
2430 let members_with_module_hash = members
2431 .iter()
2432 .filter(|member| member.source_snapshot.module_hash.is_some())
2433 .count();
2434 let members_with_wasm_hash = members
2435 .iter()
2436 .filter(|member| member.source_snapshot.wasm_hash.is_some())
2437 .count();
2438 let members_with_code_version = members
2439 .iter()
2440 .filter(|member| member.source_snapshot.code_version.is_some())
2441 .count();
2442 let members_with_checksum = members
2443 .iter()
2444 .filter(|member| member.source_snapshot.checksum.is_some())
2445 .count();
2446
2447 RestoreSnapshotSummary {
2448 all_members_have_module_hash: members_with_module_hash == members.len(),
2449 all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
2450 all_members_have_code_version: members_with_code_version == members.len(),
2451 all_members_have_checksum: members_with_checksum == members.len(),
2452 members_with_module_hash,
2453 members_with_wasm_hash,
2454 members_with_code_version,
2455 members_with_checksum,
2456 }
2457}
2458
2459fn restore_readiness_summary(
2461 snapshot: &RestoreSnapshotSummary,
2462 verification: &RestoreVerificationSummary,
2463) -> RestoreReadinessSummary {
2464 let mut reasons = Vec::new();
2465
2466 if !snapshot.all_members_have_module_hash {
2467 reasons.push("missing-module-hash".to_string());
2468 }
2469 if !snapshot.all_members_have_wasm_hash {
2470 reasons.push("missing-wasm-hash".to_string());
2471 }
2472 if !snapshot.all_members_have_code_version {
2473 reasons.push("missing-code-version".to_string());
2474 }
2475 if !snapshot.all_members_have_checksum {
2476 reasons.push("missing-snapshot-checksum".to_string());
2477 }
2478 if !verification.all_members_have_checks {
2479 reasons.push("missing-verification-checks".to_string());
2480 }
2481
2482 RestoreReadinessSummary {
2483 ready: reasons.is_empty(),
2484 reasons,
2485 }
2486}
2487
2488fn restore_verification_summary(
2490 manifest: &FleetBackupManifest,
2491 members: &[RestorePlanMember],
2492) -> RestoreVerificationSummary {
2493 let fleet_checks = manifest.verification.fleet_checks.len();
2494 let member_check_groups = manifest.verification.member_checks.len();
2495 let member_checks = members
2496 .iter()
2497 .map(|member| member.verification_checks.len())
2498 .sum::<usize>();
2499 let members_with_checks = members
2500 .iter()
2501 .filter(|member| !member.verification_checks.is_empty())
2502 .count();
2503
2504 RestoreVerificationSummary {
2505 verification_required: true,
2506 all_members_have_checks: members_with_checks == members.len(),
2507 fleet_checks,
2508 member_check_groups,
2509 member_checks,
2510 members_with_checks,
2511 total_checks: fleet_checks + member_checks,
2512 }
2513}
2514
2515const fn restore_operation_summary(
2517 member_count: usize,
2518 verification_summary: &RestoreVerificationSummary,
2519 phases: &[RestorePhase],
2520) -> RestoreOperationSummary {
2521 RestoreOperationSummary {
2522 planned_snapshot_uploads: member_count,
2523 planned_snapshot_loads: member_count,
2524 planned_code_reinstalls: member_count,
2525 planned_verification_checks: verification_summary.total_checks,
2526 planned_operations: member_count
2527 + member_count
2528 + member_count
2529 + verification_summary.total_checks,
2530 planned_phases: phases.len(),
2531 }
2532}
2533
2534fn validate_restore_group_dependencies(
2536 members: &[RestorePlanMember],
2537) -> Result<(), RestorePlanError> {
2538 let groups_by_source = members
2539 .iter()
2540 .map(|member| (member.source_canister.as_str(), member.restore_group))
2541 .collect::<BTreeMap<_, _>>();
2542
2543 for member in members {
2544 let Some(parent) = &member.parent_source_canister else {
2545 continue;
2546 };
2547 let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
2548 continue;
2549 };
2550
2551 if *parent_group > member.restore_group {
2552 return Err(RestorePlanError::ParentRestoreGroupAfterChild {
2553 child_source_canister: member.source_canister.clone(),
2554 parent_source_canister: parent.clone(),
2555 child_restore_group: member.restore_group,
2556 parent_restore_group: *parent_group,
2557 });
2558 }
2559 }
2560
2561 Ok(())
2562}
2563
2564fn group_and_order_members(
2566 members: Vec<RestorePlanMember>,
2567) -> Result<Vec<RestorePhase>, RestorePlanError> {
2568 let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
2569 for member in members {
2570 groups.entry(member.restore_group).or_default().push(member);
2571 }
2572
2573 groups
2574 .into_iter()
2575 .map(|(restore_group, members)| {
2576 let members = order_group(restore_group, members)?;
2577 Ok(RestorePhase {
2578 restore_group,
2579 members,
2580 })
2581 })
2582 .collect()
2583}
2584
2585fn order_group(
2587 restore_group: u16,
2588 members: Vec<RestorePlanMember>,
2589) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
2590 let mut remaining = members;
2591 let group_sources = remaining
2592 .iter()
2593 .map(|member| member.source_canister.clone())
2594 .collect::<BTreeSet<_>>();
2595 let mut emitted = BTreeSet::new();
2596 let mut ordered = Vec::with_capacity(remaining.len());
2597
2598 while !remaining.is_empty() {
2599 let Some(index) = remaining
2600 .iter()
2601 .position(|member| parent_satisfied(member, &group_sources, &emitted))
2602 else {
2603 return Err(RestorePlanError::RestoreOrderCycle(restore_group));
2604 };
2605
2606 let mut member = remaining.remove(index);
2607 member.phase_order = ordered.len();
2608 member.ordering_dependency = ordering_dependency(&member, &group_sources);
2609 emitted.insert(member.source_canister.clone());
2610 ordered.push(member);
2611 }
2612
2613 Ok(ordered)
2614}
2615
2616fn ordering_dependency(
2618 member: &RestorePlanMember,
2619 group_sources: &BTreeSet<String>,
2620) -> Option<RestoreOrderingDependency> {
2621 let parent_source = member.parent_source_canister.as_ref()?;
2622 let parent_target = member.parent_target_canister.as_ref()?;
2623 let relationship = if group_sources.contains(parent_source) {
2624 RestoreOrderingRelationship::ParentInSameGroup
2625 } else {
2626 RestoreOrderingRelationship::ParentInEarlierGroup
2627 };
2628
2629 Some(RestoreOrderingDependency {
2630 source_canister: parent_source.clone(),
2631 target_canister: parent_target.clone(),
2632 relationship,
2633 })
2634}
2635
2636fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
2638 let mut summary = RestoreOrderingSummary {
2639 phase_count: phases.len(),
2640 dependency_free_members: 0,
2641 in_group_parent_edges: 0,
2642 cross_group_parent_edges: 0,
2643 };
2644
2645 for member in phases.iter().flat_map(|phase| phase.members.iter()) {
2646 match &member.ordering_dependency {
2647 Some(dependency)
2648 if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
2649 {
2650 summary.in_group_parent_edges += 1;
2651 }
2652 Some(dependency)
2653 if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
2654 {
2655 summary.cross_group_parent_edges += 1;
2656 }
2657 Some(_) => {}
2658 None => summary.dependency_free_members += 1,
2659 }
2660 }
2661
2662 summary
2663}
2664
2665fn parent_satisfied(
2667 member: &RestorePlanMember,
2668 group_sources: &BTreeSet<String>,
2669 emitted: &BTreeSet<String>,
2670) -> bool {
2671 match &member.parent_source_canister {
2672 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
2673 _ => true,
2674 }
2675}
2676
2677fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
2679 Principal::from_str(value)
2680 .map(|_| ())
2681 .map_err(|_| RestorePlanError::InvalidPrincipal {
2682 field,
2683 value: value.to_string(),
2684 })
2685}
2686
2687#[cfg(test)]
2688mod tests {
2689 use super::*;
2690 use crate::manifest::{
2691 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
2692 MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
2693 VerificationPlan,
2694 };
2695 use std::{
2696 env, fs,
2697 path::{Path, PathBuf},
2698 time::{SystemTime, UNIX_EPOCH},
2699 };
2700
2701 const ROOT: &str = "aaaaa-aa";
2702 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
2703 const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
2704 const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
2705 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2706
2707 fn command_preview_journal(
2709 operation: RestoreApplyOperationKind,
2710 verification_kind: Option<&str>,
2711 verification_method: Option<&str>,
2712 ) -> RestoreApplyJournal {
2713 let journal = RestoreApplyJournal {
2714 journal_version: 1,
2715 backup_id: "fbk_test_001".to_string(),
2716 ready: true,
2717 blocked_reasons: Vec::new(),
2718 operation_count: 1,
2719 pending_operations: 0,
2720 ready_operations: 1,
2721 blocked_operations: 0,
2722 completed_operations: 0,
2723 failed_operations: 0,
2724 operations: vec![RestoreApplyJournalOperation {
2725 sequence: 0,
2726 operation,
2727 state: RestoreApplyOperationState::Ready,
2728 state_updated_at: None,
2729 blocking_reasons: Vec::new(),
2730 restore_group: 1,
2731 phase_order: 0,
2732 source_canister: ROOT.to_string(),
2733 target_canister: ROOT.to_string(),
2734 role: "root".to_string(),
2735 snapshot_id: Some("snap-root".to_string()),
2736 artifact_path: Some("artifacts/root".to_string()),
2737 verification_kind: verification_kind.map(str::to_string),
2738 verification_method: verification_method.map(str::to_string),
2739 }],
2740 };
2741
2742 journal.validate().expect("journal should validate");
2743 journal
2744 }
2745
2746 fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
2748 FleetBackupManifest {
2749 manifest_version: 1,
2750 backup_id: "fbk_test_001".to_string(),
2751 created_at: "2026-04-10T12:00:00Z".to_string(),
2752 tool: ToolMetadata {
2753 name: "canic".to_string(),
2754 version: "v1".to_string(),
2755 },
2756 source: SourceMetadata {
2757 environment: "local".to_string(),
2758 root_canister: ROOT.to_string(),
2759 },
2760 consistency: ConsistencySection {
2761 mode: ConsistencyMode::CrashConsistent,
2762 backup_units: vec![BackupUnit {
2763 unit_id: "whole-fleet".to_string(),
2764 kind: BackupUnitKind::WholeFleet,
2765 roles: vec!["root".to_string(), "app".to_string()],
2766 consistency_reason: None,
2767 dependency_closure: Vec::new(),
2768 topology_validation: "subtree-closed".to_string(),
2769 quiescence_strategy: None,
2770 }],
2771 },
2772 fleet: FleetSection {
2773 topology_hash_algorithm: "sha256".to_string(),
2774 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
2775 discovery_topology_hash: HASH.to_string(),
2776 pre_snapshot_topology_hash: HASH.to_string(),
2777 topology_hash: HASH.to_string(),
2778 members: vec![
2779 fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
2780 fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
2781 ],
2782 },
2783 verification: VerificationPlan {
2784 fleet_checks: Vec::new(),
2785 member_checks: Vec::new(),
2786 },
2787 }
2788 }
2789
2790 fn fleet_member(
2792 role: &str,
2793 canister_id: &str,
2794 parent_canister_id: Option<&str>,
2795 identity_mode: IdentityMode,
2796 restore_group: u16,
2797 ) -> FleetMember {
2798 FleetMember {
2799 role: role.to_string(),
2800 canister_id: canister_id.to_string(),
2801 parent_canister_id: parent_canister_id.map(str::to_string),
2802 subnet_canister_id: None,
2803 controller_hint: Some(ROOT.to_string()),
2804 identity_mode,
2805 restore_group,
2806 verification_class: "basic".to_string(),
2807 verification_checks: vec![VerificationCheck {
2808 kind: "call".to_string(),
2809 method: Some("canic_ready".to_string()),
2810 roles: Vec::new(),
2811 }],
2812 source_snapshot: SourceSnapshot {
2813 snapshot_id: format!("snap-{role}"),
2814 module_hash: Some(HASH.to_string()),
2815 wasm_hash: Some(HASH.to_string()),
2816 code_version: Some("v0.30.0".to_string()),
2817 artifact_path: format!("artifacts/{role}"),
2818 checksum_algorithm: "sha256".to_string(),
2819 checksum: Some(HASH.to_string()),
2820 },
2821 }
2822 }
2823
2824 #[test]
2826 fn in_place_plan_orders_parent_before_child() {
2827 let manifest = valid_manifest(IdentityMode::Relocatable);
2828
2829 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2830 let ordered = plan.ordered_members();
2831
2832 assert_eq!(plan.backup_id, "fbk_test_001");
2833 assert_eq!(plan.source_environment, "local");
2834 assert_eq!(plan.source_root_canister, ROOT);
2835 assert_eq!(plan.topology_hash, HASH);
2836 assert_eq!(plan.member_count, 2);
2837 assert_eq!(plan.identity_summary.fixed_members, 1);
2838 assert_eq!(plan.identity_summary.relocatable_members, 1);
2839 assert_eq!(plan.identity_summary.in_place_members, 2);
2840 assert_eq!(plan.identity_summary.mapped_members, 0);
2841 assert_eq!(plan.identity_summary.remapped_members, 0);
2842 assert!(plan.verification_summary.verification_required);
2843 assert!(plan.verification_summary.all_members_have_checks);
2844 assert!(plan.readiness_summary.ready);
2845 assert!(plan.readiness_summary.reasons.is_empty());
2846 assert_eq!(plan.verification_summary.fleet_checks, 0);
2847 assert_eq!(plan.verification_summary.member_check_groups, 0);
2848 assert_eq!(plan.verification_summary.member_checks, 2);
2849 assert_eq!(plan.verification_summary.members_with_checks, 2);
2850 assert_eq!(plan.verification_summary.total_checks, 2);
2851 assert_eq!(plan.ordering_summary.phase_count, 1);
2852 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
2853 assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
2854 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
2855 assert_eq!(ordered[0].phase_order, 0);
2856 assert_eq!(ordered[1].phase_order, 1);
2857 assert_eq!(ordered[0].source_canister, ROOT);
2858 assert_eq!(ordered[1].source_canister, CHILD);
2859 assert_eq!(
2860 ordered[1].ordering_dependency,
2861 Some(RestoreOrderingDependency {
2862 source_canister: ROOT.to_string(),
2863 target_canister: ROOT.to_string(),
2864 relationship: RestoreOrderingRelationship::ParentInSameGroup,
2865 })
2866 );
2867 }
2868
2869 #[test]
2871 fn plan_reports_parent_dependency_from_earlier_group() {
2872 let mut manifest = valid_manifest(IdentityMode::Relocatable);
2873 manifest.fleet.members[0].restore_group = 2;
2874 manifest.fleet.members[1].restore_group = 1;
2875
2876 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2877 let ordered = plan.ordered_members();
2878
2879 assert_eq!(plan.phases.len(), 2);
2880 assert_eq!(plan.ordering_summary.phase_count, 2);
2881 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
2882 assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
2883 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
2884 assert_eq!(ordered[0].source_canister, ROOT);
2885 assert_eq!(ordered[1].source_canister, CHILD);
2886 assert_eq!(
2887 ordered[1].ordering_dependency,
2888 Some(RestoreOrderingDependency {
2889 source_canister: ROOT.to_string(),
2890 target_canister: ROOT.to_string(),
2891 relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
2892 })
2893 );
2894 }
2895
2896 #[test]
2898 fn plan_rejects_parent_in_later_restore_group() {
2899 let mut manifest = valid_manifest(IdentityMode::Relocatable);
2900 manifest.fleet.members[0].restore_group = 1;
2901 manifest.fleet.members[1].restore_group = 2;
2902
2903 let err = RestorePlanner::plan(&manifest, None)
2904 .expect_err("parent-after-child group ordering should fail");
2905
2906 assert!(matches!(
2907 err,
2908 RestorePlanError::ParentRestoreGroupAfterChild { .. }
2909 ));
2910 }
2911
2912 #[test]
2914 fn fixed_identity_member_cannot_be_remapped() {
2915 let manifest = valid_manifest(IdentityMode::Fixed);
2916 let mapping = RestoreMapping {
2917 members: vec![
2918 RestoreMappingEntry {
2919 source_canister: ROOT.to_string(),
2920 target_canister: ROOT.to_string(),
2921 },
2922 RestoreMappingEntry {
2923 source_canister: CHILD.to_string(),
2924 target_canister: TARGET.to_string(),
2925 },
2926 ],
2927 };
2928
2929 let err = RestorePlanner::plan(&manifest, Some(&mapping))
2930 .expect_err("fixed member remap should fail");
2931
2932 assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
2933 }
2934
2935 #[test]
2937 fn relocatable_member_can_be_mapped() {
2938 let manifest = valid_manifest(IdentityMode::Relocatable);
2939 let mapping = RestoreMapping {
2940 members: vec![
2941 RestoreMappingEntry {
2942 source_canister: ROOT.to_string(),
2943 target_canister: ROOT.to_string(),
2944 },
2945 RestoreMappingEntry {
2946 source_canister: CHILD.to_string(),
2947 target_canister: TARGET.to_string(),
2948 },
2949 ],
2950 };
2951
2952 let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
2953 let child = plan
2954 .ordered_members()
2955 .into_iter()
2956 .find(|member| member.source_canister == CHILD)
2957 .expect("child member should be planned");
2958
2959 assert_eq!(plan.identity_summary.fixed_members, 1);
2960 assert_eq!(plan.identity_summary.relocatable_members, 1);
2961 assert_eq!(plan.identity_summary.in_place_members, 1);
2962 assert_eq!(plan.identity_summary.mapped_members, 2);
2963 assert_eq!(plan.identity_summary.remapped_members, 1);
2964 assert_eq!(child.target_canister, TARGET);
2965 assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
2966 }
2967
2968 #[test]
2970 fn plan_members_include_snapshot_and_verification_metadata() {
2971 let manifest = valid_manifest(IdentityMode::Relocatable);
2972
2973 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2974 let root = plan
2975 .ordered_members()
2976 .into_iter()
2977 .find(|member| member.source_canister == ROOT)
2978 .expect("root member should be planned");
2979
2980 assert_eq!(root.identity_mode, IdentityMode::Fixed);
2981 assert_eq!(root.verification_class, "basic");
2982 assert_eq!(root.verification_checks[0].kind, "call");
2983 assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
2984 assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
2985 }
2986
2987 #[test]
2989 fn plan_includes_mapping_summary() {
2990 let manifest = valid_manifest(IdentityMode::Relocatable);
2991 let in_place = RestorePlanner::plan(&manifest, None).expect("plan should build");
2992
2993 assert!(!in_place.identity_summary.mapping_supplied);
2994 assert!(!in_place.identity_summary.all_sources_mapped);
2995 assert_eq!(in_place.identity_summary.mapped_members, 0);
2996
2997 let mapping = RestoreMapping {
2998 members: vec![
2999 RestoreMappingEntry {
3000 source_canister: ROOT.to_string(),
3001 target_canister: ROOT.to_string(),
3002 },
3003 RestoreMappingEntry {
3004 source_canister: CHILD.to_string(),
3005 target_canister: TARGET.to_string(),
3006 },
3007 ],
3008 };
3009 let mapped = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
3010
3011 assert!(mapped.identity_summary.mapping_supplied);
3012 assert!(mapped.identity_summary.all_sources_mapped);
3013 assert_eq!(mapped.identity_summary.mapped_members, 2);
3014 assert_eq!(mapped.identity_summary.remapped_members, 1);
3015 }
3016
3017 #[test]
3019 fn plan_includes_snapshot_summary() {
3020 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3021 manifest.fleet.members[1].source_snapshot.module_hash = None;
3022 manifest.fleet.members[1].source_snapshot.wasm_hash = None;
3023 manifest.fleet.members[1].source_snapshot.checksum = None;
3024
3025 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3026
3027 assert!(!plan.snapshot_summary.all_members_have_module_hash);
3028 assert!(!plan.snapshot_summary.all_members_have_wasm_hash);
3029 assert!(plan.snapshot_summary.all_members_have_code_version);
3030 assert!(!plan.snapshot_summary.all_members_have_checksum);
3031 assert_eq!(plan.snapshot_summary.members_with_module_hash, 1);
3032 assert_eq!(plan.snapshot_summary.members_with_wasm_hash, 1);
3033 assert_eq!(plan.snapshot_summary.members_with_code_version, 2);
3034 assert_eq!(plan.snapshot_summary.members_with_checksum, 1);
3035 assert!(!plan.readiness_summary.ready);
3036 assert_eq!(
3037 plan.readiness_summary.reasons,
3038 [
3039 "missing-module-hash",
3040 "missing-wasm-hash",
3041 "missing-snapshot-checksum"
3042 ]
3043 );
3044 }
3045
3046 #[test]
3048 fn plan_includes_verification_summary() {
3049 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3050 manifest.verification.fleet_checks.push(VerificationCheck {
3051 kind: "fleet-ready".to_string(),
3052 method: None,
3053 roles: Vec::new(),
3054 });
3055 manifest
3056 .verification
3057 .member_checks
3058 .push(MemberVerificationChecks {
3059 role: "app".to_string(),
3060 checks: vec![VerificationCheck {
3061 kind: "app-ready".to_string(),
3062 method: Some("ready".to_string()),
3063 roles: Vec::new(),
3064 }],
3065 });
3066
3067 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3068
3069 assert!(plan.verification_summary.verification_required);
3070 assert!(plan.verification_summary.all_members_have_checks);
3071 let app = plan
3072 .ordered_members()
3073 .into_iter()
3074 .find(|member| member.role == "app")
3075 .expect("app member should be planned");
3076 assert_eq!(app.verification_checks.len(), 2);
3077 assert_eq!(plan.fleet_verification_checks.len(), 1);
3078 assert_eq!(plan.fleet_verification_checks[0].kind, "fleet-ready");
3079 assert_eq!(plan.verification_summary.fleet_checks, 1);
3080 assert_eq!(plan.verification_summary.member_check_groups, 1);
3081 assert_eq!(plan.verification_summary.member_checks, 3);
3082 assert_eq!(plan.verification_summary.members_with_checks, 2);
3083 assert_eq!(plan.verification_summary.total_checks, 4);
3084 }
3085
3086 #[test]
3088 fn plan_includes_operation_summary() {
3089 let manifest = valid_manifest(IdentityMode::Relocatable);
3090
3091 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3092
3093 assert_eq!(plan.operation_summary.planned_snapshot_uploads, 2);
3094 assert_eq!(plan.operation_summary.planned_snapshot_loads, 2);
3095 assert_eq!(plan.operation_summary.planned_code_reinstalls, 2);
3096 assert_eq!(plan.operation_summary.planned_verification_checks, 2);
3097 assert_eq!(plan.operation_summary.planned_operations, 8);
3098 assert_eq!(plan.operation_summary.planned_phases, 1);
3099 }
3100
3101 #[test]
3103 fn restore_plan_defaults_missing_newer_restore_fields() {
3104 let manifest = valid_manifest(IdentityMode::Relocatable);
3105 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3106 let mut value = serde_json::to_value(&plan).expect("serialize plan");
3107 value
3108 .as_object_mut()
3109 .expect("plan should serialize as an object")
3110 .remove("fleet_verification_checks");
3111 let operation_summary = value
3112 .get_mut("operation_summary")
3113 .and_then(serde_json::Value::as_object_mut)
3114 .expect("operation summary should serialize as an object");
3115 operation_summary.remove("planned_snapshot_uploads");
3116 operation_summary.remove("planned_operations");
3117
3118 let decoded: RestorePlan = serde_json::from_value(value).expect("decode old plan shape");
3119 let status = RestoreStatus::from_plan(&decoded);
3120 let dry_run =
3121 RestoreApplyDryRun::try_from_plan(&decoded, None).expect("old plan should dry-run");
3122
3123 assert!(decoded.fleet_verification_checks.is_empty());
3124 assert_eq!(decoded.operation_summary.planned_snapshot_uploads, 0);
3125 assert_eq!(decoded.operation_summary.planned_operations, 0);
3126 assert_eq!(status.planned_snapshot_uploads, 2);
3127 assert_eq!(status.planned_operations, 8);
3128 assert_eq!(dry_run.planned_snapshot_uploads, 2);
3129 assert_eq!(dry_run.planned_operations, 8);
3130 assert_eq!(decoded.backup_id, plan.backup_id);
3131 assert_eq!(decoded.member_count, plan.member_count);
3132 }
3133
3134 #[test]
3136 fn restore_status_starts_all_members_as_planned() {
3137 let manifest = valid_manifest(IdentityMode::Relocatable);
3138
3139 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3140 let status = RestoreStatus::from_plan(&plan);
3141
3142 assert_eq!(status.status_version, 1);
3143 assert_eq!(status.backup_id.as_str(), plan.backup_id.as_str());
3144 assert_eq!(
3145 status.source_environment.as_str(),
3146 plan.source_environment.as_str()
3147 );
3148 assert_eq!(
3149 status.source_root_canister.as_str(),
3150 plan.source_root_canister.as_str()
3151 );
3152 assert_eq!(status.topology_hash.as_str(), plan.topology_hash.as_str());
3153 assert!(status.ready);
3154 assert!(status.readiness_reasons.is_empty());
3155 assert!(status.verification_required);
3156 assert_eq!(status.member_count, 2);
3157 assert_eq!(status.phase_count, 1);
3158 assert_eq!(status.planned_snapshot_uploads, 2);
3159 assert_eq!(status.planned_snapshot_loads, 2);
3160 assert_eq!(status.planned_code_reinstalls, 2);
3161 assert_eq!(status.planned_verification_checks, 2);
3162 assert_eq!(status.planned_operations, 8);
3163 assert_eq!(status.phases.len(), 1);
3164 assert_eq!(status.phases[0].restore_group, 1);
3165 assert_eq!(status.phases[0].members.len(), 2);
3166 assert_eq!(
3167 status.phases[0].members[0].state,
3168 RestoreMemberState::Planned
3169 );
3170 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
3171 assert_eq!(status.phases[0].members[0].target_canister, ROOT);
3172 assert_eq!(status.phases[0].members[0].snapshot_id, "snap-root");
3173 assert_eq!(status.phases[0].members[0].artifact_path, "artifacts/root");
3174 assert_eq!(
3175 status.phases[0].members[1].state,
3176 RestoreMemberState::Planned
3177 );
3178 assert_eq!(status.phases[0].members[1].source_canister, CHILD);
3179 }
3180
3181 #[test]
3183 fn apply_dry_run_renders_ordered_member_operations() {
3184 let manifest = valid_manifest(IdentityMode::Relocatable);
3185
3186 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3187 let status = RestoreStatus::from_plan(&plan);
3188 let dry_run =
3189 RestoreApplyDryRun::try_from_plan(&plan, Some(&status)).expect("dry-run should build");
3190
3191 assert_eq!(dry_run.dry_run_version, 1);
3192 assert_eq!(dry_run.backup_id.as_str(), "fbk_test_001");
3193 assert!(dry_run.ready);
3194 assert!(dry_run.status_supplied);
3195 assert_eq!(dry_run.member_count, 2);
3196 assert_eq!(dry_run.phase_count, 1);
3197 assert_eq!(dry_run.planned_snapshot_uploads, 2);
3198 assert_eq!(dry_run.planned_snapshot_loads, 2);
3199 assert_eq!(dry_run.planned_code_reinstalls, 2);
3200 assert_eq!(dry_run.planned_verification_checks, 2);
3201 assert_eq!(dry_run.planned_operations, 8);
3202 assert_eq!(dry_run.rendered_operations, 8);
3203 assert_eq!(dry_run.operation_counts.snapshot_uploads, 2);
3204 assert_eq!(dry_run.operation_counts.snapshot_loads, 2);
3205 assert_eq!(dry_run.operation_counts.code_reinstalls, 2);
3206 assert_eq!(dry_run.operation_counts.member_verifications, 2);
3207 assert_eq!(dry_run.operation_counts.fleet_verifications, 0);
3208 assert_eq!(dry_run.operation_counts.verification_operations, 2);
3209 assert_eq!(dry_run.phases.len(), 1);
3210
3211 let operations = &dry_run.phases[0].operations;
3212 assert_eq!(operations[0].sequence, 0);
3213 assert_eq!(
3214 operations[0].operation,
3215 RestoreApplyOperationKind::UploadSnapshot
3216 );
3217 assert_eq!(operations[0].source_canister, ROOT);
3218 assert_eq!(operations[0].target_canister, ROOT);
3219 assert_eq!(operations[0].snapshot_id, Some("snap-root".to_string()));
3220 assert_eq!(
3221 operations[0].artifact_path,
3222 Some("artifacts/root".to_string())
3223 );
3224 assert_eq!(
3225 operations[1].operation,
3226 RestoreApplyOperationKind::LoadSnapshot
3227 );
3228 assert_eq!(
3229 operations[2].operation,
3230 RestoreApplyOperationKind::ReinstallCode
3231 );
3232 assert_eq!(
3233 operations[3].operation,
3234 RestoreApplyOperationKind::VerifyMember
3235 );
3236 assert_eq!(operations[3].verification_kind, Some("call".to_string()));
3237 assert_eq!(
3238 operations[3].verification_method,
3239 Some("canic_ready".to_string())
3240 );
3241 assert_eq!(operations[4].source_canister, CHILD);
3242 assert_eq!(
3243 operations[7].operation,
3244 RestoreApplyOperationKind::VerifyMember
3245 );
3246 }
3247
3248 #[test]
3250 fn apply_dry_run_renders_fleet_verification_operations() {
3251 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3252 manifest.verification.fleet_checks.push(VerificationCheck {
3253 kind: "fleet-ready".to_string(),
3254 method: Some("canic_fleet_ready".to_string()),
3255 roles: Vec::new(),
3256 });
3257
3258 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3259 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3260
3261 assert_eq!(plan.operation_summary.planned_verification_checks, 3);
3262 assert_eq!(dry_run.rendered_operations, 9);
3263 let operation = dry_run.phases[0]
3264 .operations
3265 .last()
3266 .expect("fleet verification operation should be rendered");
3267 assert_eq!(operation.sequence, 8);
3268 assert_eq!(operation.operation, RestoreApplyOperationKind::VerifyFleet);
3269 assert_eq!(operation.source_canister, ROOT);
3270 assert_eq!(operation.target_canister, ROOT);
3271 assert_eq!(operation.role, "fleet");
3272 assert_eq!(operation.snapshot_id, None);
3273 assert_eq!(operation.artifact_path, None);
3274 assert_eq!(operation.verification_kind, Some("fleet-ready".to_string()));
3275 assert_eq!(
3276 operation.verification_method,
3277 Some("canic_fleet_ready".to_string())
3278 );
3279 }
3280
3281 #[test]
3283 fn apply_dry_run_sequences_operations_across_phases() {
3284 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3285 manifest.fleet.members[0].restore_group = 2;
3286
3287 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3288 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3289
3290 assert_eq!(dry_run.phases.len(), 2);
3291 assert_eq!(dry_run.rendered_operations, 8);
3292 assert_eq!(dry_run.phases[0].operations[0].sequence, 0);
3293 assert_eq!(dry_run.phases[0].operations[3].sequence, 3);
3294 assert_eq!(dry_run.phases[1].operations[0].sequence, 4);
3295 assert_eq!(dry_run.phases[1].operations[3].sequence, 7);
3296 }
3297
3298 #[test]
3300 fn apply_dry_run_validates_artifacts_under_backup_root() {
3301 let root = temp_dir("canic-restore-apply-artifacts-ok");
3302 fs::create_dir_all(&root).expect("create temp root");
3303 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3304 set_member_artifact(
3305 &mut manifest,
3306 CHILD,
3307 &root,
3308 "artifacts/child",
3309 b"child-snapshot",
3310 );
3311 set_member_artifact(
3312 &mut manifest,
3313 ROOT,
3314 &root,
3315 "artifacts/root",
3316 b"root-snapshot",
3317 );
3318
3319 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3320 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3321 .expect("dry-run should validate artifacts");
3322
3323 let validation = dry_run
3324 .artifact_validation
3325 .expect("artifact validation should be present");
3326 assert_eq!(validation.checked_members, 2);
3327 assert!(validation.artifacts_present);
3328 assert!(validation.checksums_verified);
3329 assert_eq!(validation.members_with_expected_checksums, 2);
3330 assert_eq!(validation.checks[0].source_canister, ROOT);
3331 assert!(validation.checks[0].checksum_verified);
3332
3333 fs::remove_dir_all(root).expect("remove temp root");
3334 }
3335
3336 #[test]
3338 fn apply_journal_marks_validated_operations_ready() {
3339 let root = temp_dir("canic-restore-apply-journal-ready");
3340 fs::create_dir_all(&root).expect("create temp root");
3341 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3342 set_member_artifact(
3343 &mut manifest,
3344 CHILD,
3345 &root,
3346 "artifacts/child",
3347 b"child-snapshot",
3348 );
3349 set_member_artifact(
3350 &mut manifest,
3351 ROOT,
3352 &root,
3353 "artifacts/root",
3354 b"root-snapshot",
3355 );
3356
3357 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3358 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3359 .expect("dry-run should validate artifacts");
3360 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3361
3362 fs::remove_dir_all(root).expect("remove temp root");
3363 assert_eq!(journal.journal_version, 1);
3364 assert_eq!(journal.backup_id.as_str(), "fbk_test_001");
3365 assert!(journal.ready);
3366 assert!(journal.blocked_reasons.is_empty());
3367 assert_eq!(journal.operation_count, 8);
3368 assert_eq!(journal.ready_operations, 8);
3369 assert_eq!(journal.blocked_operations, 0);
3370 assert_eq!(journal.operations[0].sequence, 0);
3371 assert_eq!(
3372 journal.operations[0].state,
3373 RestoreApplyOperationState::Ready
3374 );
3375 assert!(journal.operations[0].blocking_reasons.is_empty());
3376 }
3377
3378 #[test]
3380 fn apply_journal_blocks_without_artifact_validation() {
3381 let manifest = valid_manifest(IdentityMode::Relocatable);
3382
3383 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3384 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3385 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3386
3387 assert!(!journal.ready);
3388 assert_eq!(journal.ready_operations, 0);
3389 assert_eq!(journal.blocked_operations, 8);
3390 assert!(
3391 journal
3392 .blocked_reasons
3393 .contains(&"missing-artifact-validation".to_string())
3394 );
3395 assert!(
3396 journal.operations[0]
3397 .blocking_reasons
3398 .contains(&"missing-artifact-validation".to_string())
3399 );
3400 }
3401
3402 #[test]
3404 fn apply_journal_status_reports_next_ready_operation() {
3405 let root = temp_dir("canic-restore-apply-journal-status");
3406 fs::create_dir_all(&root).expect("create temp root");
3407 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3408 set_member_artifact(
3409 &mut manifest,
3410 CHILD,
3411 &root,
3412 "artifacts/child",
3413 b"child-snapshot",
3414 );
3415 set_member_artifact(
3416 &mut manifest,
3417 ROOT,
3418 &root,
3419 "artifacts/root",
3420 b"root-snapshot",
3421 );
3422
3423 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3424 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3425 .expect("dry-run should validate artifacts");
3426 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3427 let status = journal.status();
3428 let report = journal.report();
3429
3430 fs::remove_dir_all(root).expect("remove temp root");
3431 assert_eq!(status.status_version, 1);
3432 assert_eq!(status.backup_id.as_str(), "fbk_test_001");
3433 assert!(status.ready);
3434 assert!(!status.complete);
3435 assert_eq!(status.operation_count, 8);
3436 assert_eq!(status.operation_counts.snapshot_uploads, 2);
3437 assert_eq!(status.operation_counts.snapshot_loads, 2);
3438 assert_eq!(status.operation_counts.code_reinstalls, 2);
3439 assert_eq!(status.operation_counts.member_verifications, 2);
3440 assert_eq!(status.operation_counts.fleet_verifications, 0);
3441 assert_eq!(status.operation_counts.verification_operations, 2);
3442 assert_eq!(report.operation_counts, status.operation_counts);
3443 assert_eq!(status.ready_operations, 8);
3444 assert_eq!(status.next_ready_sequence, Some(0));
3445 assert_eq!(
3446 status.next_ready_operation,
3447 Some(RestoreApplyOperationKind::UploadSnapshot)
3448 );
3449 assert_eq!(status.next_transition_sequence, Some(0));
3450 assert_eq!(
3451 status.next_transition_state,
3452 Some(RestoreApplyOperationState::Ready)
3453 );
3454 assert_eq!(
3455 status.next_transition_operation,
3456 Some(RestoreApplyOperationKind::UploadSnapshot)
3457 );
3458 }
3459
3460 #[test]
3462 fn apply_journal_next_operation_reports_full_ready_row() {
3463 let root = temp_dir("canic-restore-apply-journal-next");
3464 fs::create_dir_all(&root).expect("create temp root");
3465 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3466 set_member_artifact(
3467 &mut manifest,
3468 CHILD,
3469 &root,
3470 "artifacts/child",
3471 b"child-snapshot",
3472 );
3473 set_member_artifact(
3474 &mut manifest,
3475 ROOT,
3476 &root,
3477 "artifacts/root",
3478 b"root-snapshot",
3479 );
3480
3481 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3482 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3483 .expect("dry-run should validate artifacts");
3484 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3485 journal
3486 .mark_operation_completed(0)
3487 .expect("mark operation completed");
3488 let next = journal.next_operation();
3489
3490 fs::remove_dir_all(root).expect("remove temp root");
3491 assert!(next.ready);
3492 assert!(!next.complete);
3493 assert!(next.operation_available);
3494 let operation = next.operation.expect("next operation");
3495 assert_eq!(operation.sequence, 1);
3496 assert_eq!(operation.state, RestoreApplyOperationState::Ready);
3497 assert_eq!(operation.operation, RestoreApplyOperationKind::LoadSnapshot);
3498 assert_eq!(operation.source_canister, ROOT);
3499 }
3500
3501 #[test]
3503 fn apply_journal_next_operation_reports_blocked_state() {
3504 let manifest = valid_manifest(IdentityMode::Relocatable);
3505
3506 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3507 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3508 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3509 let next = journal.next_operation();
3510
3511 assert!(!next.ready);
3512 assert!(!next.operation_available);
3513 assert!(next.operation.is_none());
3514 assert!(
3515 next.blocked_reasons
3516 .contains(&"missing-artifact-validation".to_string())
3517 );
3518 }
3519
3520 #[test]
3522 fn apply_journal_command_preview_reports_upload_command() {
3523 let root = temp_dir("canic-restore-apply-command-upload");
3524 fs::create_dir_all(&root).expect("create temp root");
3525 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3526 set_member_artifact(
3527 &mut manifest,
3528 CHILD,
3529 &root,
3530 "artifacts/child",
3531 b"child-snapshot",
3532 );
3533 set_member_artifact(
3534 &mut manifest,
3535 ROOT,
3536 &root,
3537 "artifacts/root",
3538 b"root-snapshot",
3539 );
3540
3541 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3542 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3543 .expect("dry-run should validate artifacts");
3544 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3545 let preview = journal.next_command_preview();
3546
3547 fs::remove_dir_all(root).expect("remove temp root");
3548 assert!(preview.ready);
3549 assert!(preview.operation_available);
3550 assert!(preview.command_available);
3551 let command = preview.command.expect("command preview");
3552 assert_eq!(command.program, "dfx");
3553 assert_eq!(
3554 command.args,
3555 vec![
3556 "canister".to_string(),
3557 "snapshot".to_string(),
3558 "upload".to_string(),
3559 "--dir".to_string(),
3560 "artifacts/root".to_string(),
3561 ROOT.to_string(),
3562 ]
3563 );
3564 assert!(command.mutates);
3565 assert!(!command.requires_stopped_canister);
3566 }
3567
3568 #[test]
3570 fn apply_journal_command_preview_honors_command_config() {
3571 let root = temp_dir("canic-restore-apply-command-config");
3572 fs::create_dir_all(&root).expect("create temp root");
3573 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3574 set_member_artifact(
3575 &mut manifest,
3576 CHILD,
3577 &root,
3578 "artifacts/child",
3579 b"child-snapshot",
3580 );
3581 set_member_artifact(
3582 &mut manifest,
3583 ROOT,
3584 &root,
3585 "artifacts/root",
3586 b"root-snapshot",
3587 );
3588
3589 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3590 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3591 .expect("dry-run should validate artifacts");
3592 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3593 let preview = journal.next_command_preview_with_config(&RestoreApplyCommandConfig {
3594 program: "/tmp/dfx".to_string(),
3595 network: Some("local".to_string()),
3596 });
3597
3598 fs::remove_dir_all(root).expect("remove temp root");
3599 let command = preview.command.expect("command preview");
3600 assert_eq!(command.program, "/tmp/dfx");
3601 assert_eq!(
3602 command.args,
3603 vec![
3604 "canister".to_string(),
3605 "--network".to_string(),
3606 "local".to_string(),
3607 "snapshot".to_string(),
3608 "upload".to_string(),
3609 "--dir".to_string(),
3610 "artifacts/root".to_string(),
3611 ROOT.to_string(),
3612 ]
3613 );
3614 }
3615
3616 #[test]
3618 fn apply_journal_command_preview_reports_load_command() {
3619 let root = temp_dir("canic-restore-apply-command-load");
3620 fs::create_dir_all(&root).expect("create temp root");
3621 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3622 set_member_artifact(
3623 &mut manifest,
3624 CHILD,
3625 &root,
3626 "artifacts/child",
3627 b"child-snapshot",
3628 );
3629 set_member_artifact(
3630 &mut manifest,
3631 ROOT,
3632 &root,
3633 "artifacts/root",
3634 b"root-snapshot",
3635 );
3636
3637 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3638 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3639 .expect("dry-run should validate artifacts");
3640 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3641 journal
3642 .mark_operation_completed(0)
3643 .expect("mark upload completed");
3644 let preview = journal.next_command_preview();
3645
3646 fs::remove_dir_all(root).expect("remove temp root");
3647 let command = preview.command.expect("command preview");
3648 assert_eq!(
3649 command.args,
3650 vec![
3651 "canister".to_string(),
3652 "snapshot".to_string(),
3653 "load".to_string(),
3654 ROOT.to_string(),
3655 "snap-root".to_string(),
3656 ]
3657 );
3658 assert!(command.mutates);
3659 assert!(command.requires_stopped_canister);
3660 }
3661
3662 #[test]
3664 fn apply_journal_command_preview_reports_reinstall_command() {
3665 let journal = command_preview_journal(RestoreApplyOperationKind::ReinstallCode, None, None);
3666 let preview = journal.next_command_preview_with_config(&RestoreApplyCommandConfig {
3667 program: "dfx".to_string(),
3668 network: Some("local".to_string()),
3669 });
3670
3671 assert!(preview.command_available);
3672 let command = preview.command.expect("command preview");
3673 assert_eq!(
3674 command.args,
3675 vec![
3676 "canister".to_string(),
3677 "--network".to_string(),
3678 "local".to_string(),
3679 "install".to_string(),
3680 "--mode".to_string(),
3681 "reinstall".to_string(),
3682 "--yes".to_string(),
3683 ROOT.to_string(),
3684 ]
3685 );
3686 assert!(command.mutates);
3687 assert!(!command.requires_stopped_canister);
3688 }
3689
3690 #[test]
3692 fn apply_journal_command_preview_reports_status_verification_command() {
3693 let journal = command_preview_journal(
3694 RestoreApplyOperationKind::VerifyMember,
3695 Some("status"),
3696 None,
3697 );
3698 let preview = journal.next_command_preview();
3699
3700 assert!(preview.command_available);
3701 let command = preview.command.expect("command preview");
3702 assert_eq!(
3703 command.args,
3704 vec![
3705 "canister".to_string(),
3706 "status".to_string(),
3707 ROOT.to_string()
3708 ]
3709 );
3710 assert!(!command.mutates);
3711 assert!(!command.requires_stopped_canister);
3712 }
3713
3714 #[test]
3716 fn apply_journal_command_preview_reports_method_verification_command() {
3717 let journal = command_preview_journal(
3718 RestoreApplyOperationKind::VerifyMember,
3719 Some("query"),
3720 Some("health"),
3721 );
3722 let preview = journal.next_command_preview();
3723
3724 assert!(preview.command_available);
3725 let command = preview.command.expect("command preview");
3726 assert_eq!(
3727 command.args,
3728 vec![
3729 "canister".to_string(),
3730 "call".to_string(),
3731 ROOT.to_string(),
3732 "health".to_string(),
3733 ]
3734 );
3735 assert!(!command.mutates);
3736 assert!(!command.requires_stopped_canister);
3737 }
3738
3739 #[test]
3741 fn apply_journal_command_preview_reports_fleet_verification_command() {
3742 let journal = command_preview_journal(
3743 RestoreApplyOperationKind::VerifyFleet,
3744 Some("fleet-ready"),
3745 Some("canic_fleet_ready"),
3746 );
3747 let preview = journal.next_command_preview();
3748
3749 assert!(preview.command_available);
3750 let command = preview.command.expect("command preview");
3751 assert_eq!(
3752 command.args,
3753 vec![
3754 "canister".to_string(),
3755 "call".to_string(),
3756 ROOT.to_string(),
3757 "canic_fleet_ready".to_string(),
3758 ]
3759 );
3760 assert!(!command.mutates);
3761 assert!(!command.requires_stopped_canister);
3762 assert_eq!(command.note, "calls the declared fleet verification method");
3763 }
3764
3765 #[test]
3767 fn apply_journal_validation_rejects_method_verification_without_method() {
3768 let journal = RestoreApplyJournal {
3769 journal_version: 1,
3770 backup_id: "fbk_test_001".to_string(),
3771 ready: true,
3772 blocked_reasons: Vec::new(),
3773 operation_count: 1,
3774 pending_operations: 0,
3775 ready_operations: 1,
3776 blocked_operations: 0,
3777 completed_operations: 0,
3778 failed_operations: 0,
3779 operations: vec![RestoreApplyJournalOperation {
3780 sequence: 0,
3781 operation: RestoreApplyOperationKind::VerifyMember,
3782 state: RestoreApplyOperationState::Ready,
3783 state_updated_at: None,
3784 blocking_reasons: Vec::new(),
3785 restore_group: 1,
3786 phase_order: 0,
3787 source_canister: ROOT.to_string(),
3788 target_canister: ROOT.to_string(),
3789 role: "root".to_string(),
3790 snapshot_id: Some("snap-root".to_string()),
3791 artifact_path: Some("artifacts/root".to_string()),
3792 verification_kind: Some("query".to_string()),
3793 verification_method: None,
3794 }],
3795 };
3796
3797 let err = journal
3798 .validate()
3799 .expect_err("method verification without method should fail");
3800
3801 assert!(matches!(
3802 err,
3803 RestoreApplyJournalError::OperationMissingField {
3804 sequence: 0,
3805 operation: RestoreApplyOperationKind::VerifyMember,
3806 field: "operations[].verification_method",
3807 }
3808 ));
3809 }
3810
3811 #[test]
3813 fn apply_journal_validation_rejects_count_mismatch() {
3814 let manifest = valid_manifest(IdentityMode::Relocatable);
3815
3816 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3817 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3818 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3819 journal.blocked_operations = 0;
3820
3821 let err = journal.validate().expect_err("count mismatch should fail");
3822
3823 assert!(matches!(
3824 err,
3825 RestoreApplyJournalError::CountMismatch {
3826 field: "blocked_operations",
3827 ..
3828 }
3829 ));
3830 }
3831
3832 #[test]
3834 fn apply_journal_validation_rejects_duplicate_sequences() {
3835 let manifest = valid_manifest(IdentityMode::Relocatable);
3836
3837 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3838 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3839 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3840 journal.operations[1].sequence = journal.operations[0].sequence;
3841
3842 let err = journal
3843 .validate()
3844 .expect_err("duplicate sequence should fail");
3845
3846 assert!(matches!(
3847 err,
3848 RestoreApplyJournalError::DuplicateSequence(0)
3849 ));
3850 }
3851
3852 #[test]
3854 fn apply_journal_validation_rejects_failed_without_reason() {
3855 let manifest = valid_manifest(IdentityMode::Relocatable);
3856
3857 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3858 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3859 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3860 journal.operations[0].state = RestoreApplyOperationState::Failed;
3861 journal.operations[0].blocking_reasons = Vec::new();
3862 journal.blocked_operations -= 1;
3863 journal.failed_operations = 1;
3864
3865 let err = journal
3866 .validate()
3867 .expect_err("failed operation without reason should fail");
3868
3869 assert!(matches!(
3870 err,
3871 RestoreApplyJournalError::FailureReasonRequired(0)
3872 ));
3873 }
3874
3875 #[test]
3877 fn apply_journal_mark_next_operation_pending_claims_first_operation() {
3878 let mut journal =
3879 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
3880
3881 journal
3882 .mark_next_operation_pending_at(Some("2026-05-04T12:00:00Z".to_string()))
3883 .expect("mark operation pending");
3884 let status = journal.status();
3885 let next = journal.next_operation();
3886 let preview = journal.next_command_preview();
3887
3888 assert_eq!(journal.pending_operations, 1);
3889 assert_eq!(journal.ready_operations, 0);
3890 assert_eq!(
3891 journal.operations[0].state,
3892 RestoreApplyOperationState::Pending
3893 );
3894 assert_eq!(
3895 journal.operations[0].state_updated_at.as_deref(),
3896 Some("2026-05-04T12:00:00Z")
3897 );
3898 assert_eq!(status.next_ready_sequence, None);
3899 assert_eq!(status.next_transition_sequence, Some(0));
3900 assert_eq!(
3901 status.next_transition_state,
3902 Some(RestoreApplyOperationState::Pending)
3903 );
3904 assert_eq!(
3905 status.next_transition_updated_at.as_deref(),
3906 Some("2026-05-04T12:00:00Z")
3907 );
3908 assert!(next.operation_available);
3909 assert_eq!(
3910 next.operation.expect("next operation").state,
3911 RestoreApplyOperationState::Pending
3912 );
3913 assert!(preview.operation_available);
3914 assert!(preview.command_available);
3915 assert_eq!(
3916 preview.operation.expect("preview operation").state,
3917 RestoreApplyOperationState::Pending
3918 );
3919 }
3920
3921 #[test]
3923 fn apply_journal_mark_next_operation_ready_unclaims_pending_operation() {
3924 let mut journal =
3925 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
3926
3927 journal
3928 .mark_next_operation_pending_at(Some("2026-05-04T12:00:00Z".to_string()))
3929 .expect("mark operation pending");
3930 journal
3931 .mark_next_operation_ready_at(Some("2026-05-04T12:01:00Z".to_string()))
3932 .expect("mark operation ready");
3933 let status = journal.status();
3934 let next = journal.next_operation();
3935
3936 assert_eq!(journal.pending_operations, 0);
3937 assert_eq!(journal.ready_operations, 1);
3938 assert_eq!(
3939 journal.operations[0].state,
3940 RestoreApplyOperationState::Ready
3941 );
3942 assert_eq!(
3943 journal.operations[0].state_updated_at.as_deref(),
3944 Some("2026-05-04T12:01:00Z")
3945 );
3946 assert_eq!(status.next_ready_sequence, Some(0));
3947 assert_eq!(status.next_transition_sequence, Some(0));
3948 assert_eq!(
3949 status.next_transition_state,
3950 Some(RestoreApplyOperationState::Ready)
3951 );
3952 assert_eq!(
3953 status.next_transition_updated_at.as_deref(),
3954 Some("2026-05-04T12:01:00Z")
3955 );
3956 assert_eq!(
3957 next.operation.expect("next operation").state,
3958 RestoreApplyOperationState::Ready
3959 );
3960 }
3961
3962 #[test]
3964 fn apply_journal_validation_rejects_empty_state_updated_at() {
3965 let mut journal =
3966 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
3967
3968 journal.operations[0].state_updated_at = Some(String::new());
3969 let err = journal
3970 .validate()
3971 .expect_err("empty state update marker should fail");
3972
3973 assert!(matches!(
3974 err,
3975 RestoreApplyJournalError::MissingField("operations[].state_updated_at")
3976 ));
3977 }
3978
3979 #[test]
3981 fn apply_journal_validation_rejects_missing_operation_fields() {
3982 let mut upload =
3983 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
3984 upload.operations[0].artifact_path = None;
3985 let err = upload
3986 .validate()
3987 .expect_err("upload without artifact path should fail");
3988 assert!(matches!(
3989 err,
3990 RestoreApplyJournalError::OperationMissingField {
3991 sequence: 0,
3992 operation: RestoreApplyOperationKind::UploadSnapshot,
3993 field: "operations[].artifact_path",
3994 }
3995 ));
3996
3997 let mut load = command_preview_journal(RestoreApplyOperationKind::LoadSnapshot, None, None);
3998 load.operations[0].snapshot_id = None;
3999 let err = load
4000 .validate()
4001 .expect_err("load without snapshot id should fail");
4002 assert!(matches!(
4003 err,
4004 RestoreApplyJournalError::OperationMissingField {
4005 sequence: 0,
4006 operation: RestoreApplyOperationKind::LoadSnapshot,
4007 field: "operations[].snapshot_id",
4008 }
4009 ));
4010
4011 let mut verify = command_preview_journal(
4012 RestoreApplyOperationKind::VerifyMember,
4013 Some("query"),
4014 Some("health"),
4015 );
4016 verify.operations[0].verification_method = None;
4017 let err = verify
4018 .validate()
4019 .expect_err("method verification without method should fail");
4020 assert!(matches!(
4021 err,
4022 RestoreApplyJournalError::OperationMissingField {
4023 sequence: 0,
4024 operation: RestoreApplyOperationKind::VerifyMember,
4025 field: "operations[].verification_method",
4026 }
4027 ));
4028 }
4029
4030 #[test]
4032 fn apply_journal_mark_next_operation_ready_rejects_without_pending_operation() {
4033 let mut journal =
4034 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
4035
4036 let err = journal
4037 .mark_next_operation_ready()
4038 .expect_err("ready operation should not unclaim");
4039
4040 assert!(matches!(err, RestoreApplyJournalError::NoPendingOperation));
4041 assert_eq!(journal.ready_operations, 1);
4042 assert_eq!(journal.pending_operations, 0);
4043 }
4044
4045 #[test]
4047 fn apply_journal_mark_pending_rejects_out_of_order_operation() {
4048 let root = temp_dir("canic-restore-apply-journal-pending-out-of-order");
4049 fs::create_dir_all(&root).expect("create temp root");
4050 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4051 set_member_artifact(
4052 &mut manifest,
4053 CHILD,
4054 &root,
4055 "artifacts/child",
4056 b"child-snapshot",
4057 );
4058 set_member_artifact(
4059 &mut manifest,
4060 ROOT,
4061 &root,
4062 "artifacts/root",
4063 b"root-snapshot",
4064 );
4065
4066 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4067 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
4068 .expect("dry-run should validate artifacts");
4069 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
4070
4071 let err = journal
4072 .mark_operation_pending(1)
4073 .expect_err("out-of-order pending claim should fail");
4074
4075 fs::remove_dir_all(root).expect("remove temp root");
4076 assert!(matches!(
4077 err,
4078 RestoreApplyJournalError::OutOfOrderOperationTransition {
4079 requested: 1,
4080 next: 0
4081 }
4082 ));
4083 assert_eq!(journal.pending_operations, 0);
4084 assert_eq!(journal.ready_operations, 8);
4085 }
4086
4087 #[test]
4089 fn apply_journal_mark_completed_advances_next_ready_operation() {
4090 let root = temp_dir("canic-restore-apply-journal-completed");
4091 fs::create_dir_all(&root).expect("create temp root");
4092 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4093 set_member_artifact(
4094 &mut manifest,
4095 CHILD,
4096 &root,
4097 "artifacts/child",
4098 b"child-snapshot",
4099 );
4100 set_member_artifact(
4101 &mut manifest,
4102 ROOT,
4103 &root,
4104 "artifacts/root",
4105 b"root-snapshot",
4106 );
4107
4108 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4109 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
4110 .expect("dry-run should validate artifacts");
4111 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
4112
4113 journal
4114 .mark_operation_completed(0)
4115 .expect("mark operation completed");
4116 let status = journal.status();
4117
4118 fs::remove_dir_all(root).expect("remove temp root");
4119 assert_eq!(
4120 journal.operations[0].state,
4121 RestoreApplyOperationState::Completed
4122 );
4123 assert_eq!(journal.completed_operations, 1);
4124 assert_eq!(journal.ready_operations, 7);
4125 assert_eq!(status.next_ready_sequence, Some(1));
4126 }
4127
4128 #[test]
4130 fn apply_journal_mark_completed_rejects_out_of_order_operation() {
4131 let root = temp_dir("canic-restore-apply-journal-out-of-order");
4132 fs::create_dir_all(&root).expect("create temp root");
4133 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4134 set_member_artifact(
4135 &mut manifest,
4136 CHILD,
4137 &root,
4138 "artifacts/child",
4139 b"child-snapshot",
4140 );
4141 set_member_artifact(
4142 &mut manifest,
4143 ROOT,
4144 &root,
4145 "artifacts/root",
4146 b"root-snapshot",
4147 );
4148
4149 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4150 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
4151 .expect("dry-run should validate artifacts");
4152 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
4153
4154 let err = journal
4155 .mark_operation_completed(1)
4156 .expect_err("out-of-order operation should fail");
4157
4158 fs::remove_dir_all(root).expect("remove temp root");
4159 assert!(matches!(
4160 err,
4161 RestoreApplyJournalError::OutOfOrderOperationTransition {
4162 requested: 1,
4163 next: 0
4164 }
4165 ));
4166 assert_eq!(journal.completed_operations, 0);
4167 assert_eq!(journal.ready_operations, 8);
4168 }
4169
4170 #[test]
4172 fn apply_journal_mark_failed_records_reason() {
4173 let root = temp_dir("canic-restore-apply-journal-failed");
4174 fs::create_dir_all(&root).expect("create temp root");
4175 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4176 set_member_artifact(
4177 &mut manifest,
4178 CHILD,
4179 &root,
4180 "artifacts/child",
4181 b"child-snapshot",
4182 );
4183 set_member_artifact(
4184 &mut manifest,
4185 ROOT,
4186 &root,
4187 "artifacts/root",
4188 b"root-snapshot",
4189 );
4190
4191 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4192 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
4193 .expect("dry-run should validate artifacts");
4194 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
4195
4196 journal
4197 .mark_operation_failed(0, "dfx-load-failed".to_string())
4198 .expect("mark operation failed");
4199
4200 fs::remove_dir_all(root).expect("remove temp root");
4201 assert_eq!(
4202 journal.operations[0].state,
4203 RestoreApplyOperationState::Failed
4204 );
4205 assert_eq!(
4206 journal.operations[0].blocking_reasons,
4207 vec!["dfx-load-failed".to_string()]
4208 );
4209 assert_eq!(journal.failed_operations, 1);
4210 assert_eq!(journal.ready_operations, 7);
4211 }
4212
4213 #[test]
4215 fn apply_journal_rejects_blocked_operation_completion() {
4216 let manifest = valid_manifest(IdentityMode::Relocatable);
4217
4218 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4219 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
4220 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
4221
4222 let err = journal
4223 .mark_operation_completed(0)
4224 .expect_err("blocked operation should not complete");
4225
4226 assert!(matches!(
4227 err,
4228 RestoreApplyJournalError::InvalidOperationTransition { sequence: 0, .. }
4229 ));
4230 }
4231
4232 #[test]
4234 fn apply_dry_run_rejects_missing_artifacts() {
4235 let root = temp_dir("canic-restore-apply-artifacts-missing");
4236 fs::create_dir_all(&root).expect("create temp root");
4237 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4238 manifest.fleet.members[0].source_snapshot.artifact_path = "missing-child".to_string();
4239
4240 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4241 let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
4242 .expect_err("missing artifact should fail");
4243
4244 fs::remove_dir_all(root).expect("remove temp root");
4245 assert!(matches!(
4246 err,
4247 RestoreApplyDryRunError::ArtifactMissing { .. }
4248 ));
4249 }
4250
4251 #[test]
4253 fn apply_dry_run_rejects_artifact_path_traversal() {
4254 let root = temp_dir("canic-restore-apply-artifacts-traversal");
4255 fs::create_dir_all(&root).expect("create temp root");
4256 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4257 manifest.fleet.members[1].source_snapshot.artifact_path = "../outside".to_string();
4258
4259 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4260 let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
4261 .expect_err("path traversal should fail");
4262
4263 fs::remove_dir_all(root).expect("remove temp root");
4264 assert!(matches!(
4265 err,
4266 RestoreApplyDryRunError::ArtifactPathEscapesBackup { .. }
4267 ));
4268 }
4269
4270 #[test]
4272 fn apply_dry_run_rejects_mismatched_status() {
4273 let manifest = valid_manifest(IdentityMode::Relocatable);
4274
4275 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4276 let mut status = RestoreStatus::from_plan(&plan);
4277 status.backup_id = "other-backup".to_string();
4278
4279 let err = RestoreApplyDryRun::try_from_plan(&plan, Some(&status))
4280 .expect_err("mismatched status should fail");
4281
4282 assert!(matches!(
4283 err,
4284 RestoreApplyDryRunError::StatusPlanMismatch {
4285 field: "backup_id",
4286 ..
4287 }
4288 ));
4289 }
4290
4291 #[test]
4293 fn plan_expands_role_verification_checks_per_matching_member() {
4294 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4295 manifest.fleet.members.push(fleet_member(
4296 "app",
4297 CHILD_TWO,
4298 Some(ROOT),
4299 IdentityMode::Relocatable,
4300 1,
4301 ));
4302 manifest
4303 .verification
4304 .member_checks
4305 .push(MemberVerificationChecks {
4306 role: "app".to_string(),
4307 checks: vec![VerificationCheck {
4308 kind: "app-ready".to_string(),
4309 method: Some("ready".to_string()),
4310 roles: Vec::new(),
4311 }],
4312 });
4313
4314 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4315
4316 assert_eq!(plan.verification_summary.fleet_checks, 0);
4317 assert_eq!(plan.verification_summary.member_check_groups, 1);
4318 assert_eq!(plan.verification_summary.member_checks, 5);
4319 assert_eq!(plan.verification_summary.members_with_checks, 3);
4320 assert_eq!(plan.verification_summary.total_checks, 5);
4321 }
4322
4323 #[test]
4325 fn plan_applies_member_verification_role_filters() {
4326 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4327 manifest.fleet.members[0]
4328 .verification_checks
4329 .push(VerificationCheck {
4330 kind: "root-only-inline".to_string(),
4331 method: Some("wrong_member".to_string()),
4332 roles: vec!["root".to_string()],
4333 });
4334 manifest
4335 .verification
4336 .member_checks
4337 .push(MemberVerificationChecks {
4338 role: "app".to_string(),
4339 checks: vec![
4340 VerificationCheck {
4341 kind: "app-role-check".to_string(),
4342 method: Some("app_ready".to_string()),
4343 roles: vec!["app".to_string()],
4344 },
4345 VerificationCheck {
4346 kind: "root-filtered-check".to_string(),
4347 method: Some("wrong_role".to_string()),
4348 roles: vec!["root".to_string()],
4349 },
4350 ],
4351 });
4352
4353 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4354 let app = plan
4355 .ordered_members()
4356 .into_iter()
4357 .find(|member| member.role == "app")
4358 .expect("app member should be planned");
4359 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
4360 let app_verification_methods = dry_run.phases[0]
4361 .operations
4362 .iter()
4363 .filter(|operation| {
4364 operation.source_canister == CHILD
4365 && operation.operation == RestoreApplyOperationKind::VerifyMember
4366 })
4367 .filter_map(|operation| operation.verification_method.as_deref())
4368 .collect::<Vec<_>>();
4369
4370 assert_eq!(app.verification_checks.len(), 2);
4371 assert_eq!(
4372 app.verification_checks
4373 .iter()
4374 .map(|check| check.kind.as_str())
4375 .collect::<Vec<_>>(),
4376 ["call", "app-role-check"]
4377 );
4378 assert_eq!(plan.verification_summary.member_checks, 3);
4379 assert_eq!(plan.verification_summary.total_checks, 3);
4380 assert_eq!(dry_run.rendered_operations, 9);
4381 assert_eq!(app_verification_methods, ["canic_ready", "app_ready"]);
4382 }
4383
4384 #[test]
4386 fn mapped_restore_requires_complete_mapping() {
4387 let manifest = valid_manifest(IdentityMode::Relocatable);
4388 let mapping = RestoreMapping {
4389 members: vec![RestoreMappingEntry {
4390 source_canister: ROOT.to_string(),
4391 target_canister: ROOT.to_string(),
4392 }],
4393 };
4394
4395 let err = RestorePlanner::plan(&manifest, Some(&mapping))
4396 .expect_err("incomplete mapping should fail");
4397
4398 assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
4399 }
4400
4401 #[test]
4403 fn mapped_restore_rejects_unknown_mapping_sources() {
4404 let manifest = valid_manifest(IdentityMode::Relocatable);
4405 let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
4406 let mapping = RestoreMapping {
4407 members: vec![
4408 RestoreMappingEntry {
4409 source_canister: ROOT.to_string(),
4410 target_canister: ROOT.to_string(),
4411 },
4412 RestoreMappingEntry {
4413 source_canister: CHILD.to_string(),
4414 target_canister: TARGET.to_string(),
4415 },
4416 RestoreMappingEntry {
4417 source_canister: unknown.to_string(),
4418 target_canister: unknown.to_string(),
4419 },
4420 ],
4421 };
4422
4423 let err = RestorePlanner::plan(&manifest, Some(&mapping))
4424 .expect_err("unknown mapping source should fail");
4425
4426 assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
4427 }
4428
4429 #[test]
4431 fn duplicate_mapping_targets_fail_validation() {
4432 let manifest = valid_manifest(IdentityMode::Relocatable);
4433 let mapping = RestoreMapping {
4434 members: vec![
4435 RestoreMappingEntry {
4436 source_canister: ROOT.to_string(),
4437 target_canister: ROOT.to_string(),
4438 },
4439 RestoreMappingEntry {
4440 source_canister: CHILD.to_string(),
4441 target_canister: ROOT.to_string(),
4442 },
4443 ],
4444 };
4445
4446 let err = RestorePlanner::plan(&manifest, Some(&mapping))
4447 .expect_err("duplicate targets should fail");
4448
4449 assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
4450 }
4451
4452 fn set_member_artifact(
4454 manifest: &mut FleetBackupManifest,
4455 canister_id: &str,
4456 root: &Path,
4457 artifact_path: &str,
4458 bytes: &[u8],
4459 ) {
4460 let full_path = root.join(artifact_path);
4461 fs::create_dir_all(full_path.parent().expect("artifact parent")).expect("create parent");
4462 fs::write(&full_path, bytes).expect("write artifact");
4463 let checksum = ArtifactChecksum::from_bytes(bytes);
4464 let member = manifest
4465 .fleet
4466 .members
4467 .iter_mut()
4468 .find(|member| member.canister_id == canister_id)
4469 .expect("member should exist");
4470 member.source_snapshot.artifact_path = artifact_path.to_string();
4471 member.source_snapshot.checksum = Some(checksum.hash);
4472 }
4473
4474 fn temp_dir(name: &str) -> PathBuf {
4476 let nanos = SystemTime::now()
4477 .duration_since(UNIX_EPOCH)
4478 .expect("system time should be after epoch")
4479 .as_nanos();
4480 env::temp_dir().join(format!("{name}-{nanos}"))
4481 }
4482}