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