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 pending_operations: usize,
923 pub ready_operations: usize,
924 pub blocked_operations: usize,
925 pub completed_operations: usize,
926 pub failed_operations: usize,
927 pub next_ready_sequence: Option<usize>,
928 pub next_ready_operation: Option<RestoreApplyOperationKind>,
929 pub next_transition_sequence: Option<usize>,
930 pub next_transition_state: Option<RestoreApplyOperationState>,
931 pub next_transition_operation: Option<RestoreApplyOperationKind>,
932 pub next_transition_updated_at: Option<String>,
933}
934
935impl RestoreApplyJournalStatus {
936 #[must_use]
938 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
939 let next_ready = journal.next_ready_operation();
940 let next_transition = journal.next_transition_operation();
941
942 Self {
943 status_version: 1,
944 backup_id: journal.backup_id.clone(),
945 ready: journal.ready,
946 complete: journal.operation_count > 0
947 && journal.completed_operations == journal.operation_count,
948 blocked_reasons: journal.blocked_reasons.clone(),
949 operation_count: journal.operation_count,
950 operation_counts: RestoreApplyOperationKindCounts::from_operations(&journal.operations),
951 operation_counts_supplied: journal.operation_counts_supplied(),
952 pending_operations: journal.pending_operations,
953 ready_operations: journal.ready_operations,
954 blocked_operations: journal.blocked_operations,
955 completed_operations: journal.completed_operations,
956 failed_operations: journal.failed_operations,
957 next_ready_sequence: next_ready.map(|operation| operation.sequence),
958 next_ready_operation: next_ready.map(|operation| operation.operation.clone()),
959 next_transition_sequence: next_transition.map(|operation| operation.sequence),
960 next_transition_state: next_transition.map(|operation| operation.state.clone()),
961 next_transition_operation: next_transition.map(|operation| operation.operation.clone()),
962 next_transition_updated_at: next_transition
963 .and_then(|operation| operation.state_updated_at.clone()),
964 }
965 }
966}
967
968#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
973#[expect(
974 clippy::struct_excessive_bools,
975 reason = "apply reports intentionally expose stable JSON flags for operators and CI"
976)]
977pub struct RestoreApplyJournalReport {
978 pub report_version: u16,
979 pub backup_id: String,
980 pub outcome: RestoreApplyReportOutcome,
981 pub attention_required: bool,
982 pub ready: bool,
983 pub complete: bool,
984 pub blocked_reasons: Vec<String>,
985 pub operation_count: usize,
986 #[serde(default)]
987 pub operation_counts: RestoreApplyOperationKindCounts,
988 pub operation_counts_supplied: bool,
989 pub pending_operations: usize,
990 pub ready_operations: usize,
991 pub blocked_operations: usize,
992 pub completed_operations: usize,
993 pub failed_operations: usize,
994 pub next_transition: Option<RestoreApplyReportOperation>,
995 pub pending: Vec<RestoreApplyReportOperation>,
996 pub failed: Vec<RestoreApplyReportOperation>,
997 pub blocked: Vec<RestoreApplyReportOperation>,
998}
999
1000impl RestoreApplyJournalReport {
1001 #[must_use]
1003 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1004 let complete =
1005 journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
1006 let outcome = RestoreApplyReportOutcome::from_journal(journal, complete);
1007 let pending = report_operations_with_state(journal, RestoreApplyOperationState::Pending);
1008 let failed = report_operations_with_state(journal, RestoreApplyOperationState::Failed);
1009 let blocked = report_operations_with_state(journal, RestoreApplyOperationState::Blocked);
1010
1011 Self {
1012 report_version: 1,
1013 backup_id: journal.backup_id.clone(),
1014 outcome: outcome.clone(),
1015 attention_required: outcome.attention_required(),
1016 ready: journal.ready,
1017 complete,
1018 blocked_reasons: journal.blocked_reasons.clone(),
1019 operation_count: journal.operation_count,
1020 operation_counts: RestoreApplyOperationKindCounts::from_operations(&journal.operations),
1021 operation_counts_supplied: journal.operation_counts_supplied(),
1022 pending_operations: journal.pending_operations,
1023 ready_operations: journal.ready_operations,
1024 blocked_operations: journal.blocked_operations,
1025 completed_operations: journal.completed_operations,
1026 failed_operations: journal.failed_operations,
1027 next_transition: journal
1028 .next_transition_operation()
1029 .map(RestoreApplyReportOperation::from_journal_operation),
1030 pending,
1031 failed,
1032 blocked,
1033 }
1034 }
1035}
1036
1037#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1042#[serde(rename_all = "kebab-case")]
1043pub enum RestoreApplyReportOutcome {
1044 Empty,
1045 Complete,
1046 Failed,
1047 Blocked,
1048 Pending,
1049 InProgress,
1050}
1051
1052impl RestoreApplyReportOutcome {
1053 const fn from_journal(journal: &RestoreApplyJournal, complete: bool) -> Self {
1055 if journal.operation_count == 0 {
1056 return Self::Empty;
1057 }
1058 if complete {
1059 return Self::Complete;
1060 }
1061 if journal.failed_operations > 0 {
1062 return Self::Failed;
1063 }
1064 if !journal.ready || journal.blocked_operations > 0 {
1065 return Self::Blocked;
1066 }
1067 if journal.pending_operations > 0 {
1068 return Self::Pending;
1069 }
1070 Self::InProgress
1071 }
1072
1073 const fn attention_required(&self) -> bool {
1075 matches!(self, Self::Failed | Self::Blocked | Self::Pending)
1076 }
1077}
1078
1079#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1084pub struct RestoreApplyReportOperation {
1085 pub sequence: usize,
1086 pub operation: RestoreApplyOperationKind,
1087 pub state: RestoreApplyOperationState,
1088 pub restore_group: u16,
1089 pub phase_order: usize,
1090 pub role: String,
1091 pub source_canister: String,
1092 pub target_canister: String,
1093 pub state_updated_at: Option<String>,
1094 pub reasons: Vec<String>,
1095}
1096
1097impl RestoreApplyReportOperation {
1098 fn from_journal_operation(operation: &RestoreApplyJournalOperation) -> Self {
1100 Self {
1101 sequence: operation.sequence,
1102 operation: operation.operation.clone(),
1103 state: operation.state.clone(),
1104 restore_group: operation.restore_group,
1105 phase_order: operation.phase_order,
1106 role: operation.role.clone(),
1107 source_canister: operation.source_canister.clone(),
1108 target_canister: operation.target_canister.clone(),
1109 state_updated_at: operation.state_updated_at.clone(),
1110 reasons: operation.blocking_reasons.clone(),
1111 }
1112 }
1113}
1114
1115fn report_operations_with_state(
1117 journal: &RestoreApplyJournal,
1118 state: RestoreApplyOperationState,
1119) -> Vec<RestoreApplyReportOperation> {
1120 journal
1121 .operations
1122 .iter()
1123 .filter(|operation| operation.state == state)
1124 .map(RestoreApplyReportOperation::from_journal_operation)
1125 .collect()
1126}
1127
1128#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1133pub struct RestoreApplyNextOperation {
1134 pub response_version: u16,
1135 pub backup_id: String,
1136 pub ready: bool,
1137 pub complete: bool,
1138 pub operation_available: bool,
1139 pub blocked_reasons: Vec<String>,
1140 pub operation: Option<RestoreApplyJournalOperation>,
1141}
1142
1143impl RestoreApplyNextOperation {
1144 #[must_use]
1146 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1147 let complete =
1148 journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
1149 let operation = journal.next_transition_operation().cloned();
1150
1151 Self {
1152 response_version: 1,
1153 backup_id: journal.backup_id.clone(),
1154 ready: journal.ready,
1155 complete,
1156 operation_available: operation.is_some(),
1157 blocked_reasons: journal.blocked_reasons.clone(),
1158 operation,
1159 }
1160 }
1161}
1162
1163#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1168#[expect(
1169 clippy::struct_excessive_bools,
1170 reason = "runner preview exposes machine-readable availability and safety flags"
1171)]
1172pub struct RestoreApplyCommandPreview {
1173 pub response_version: u16,
1174 pub backup_id: String,
1175 pub ready: bool,
1176 pub complete: bool,
1177 pub operation_available: bool,
1178 pub command_available: bool,
1179 pub blocked_reasons: Vec<String>,
1180 pub operation: Option<RestoreApplyJournalOperation>,
1181 pub command: Option<RestoreApplyRunnerCommand>,
1182}
1183
1184impl RestoreApplyCommandPreview {
1185 #[must_use]
1187 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1188 Self::from_journal_with_config(journal, &RestoreApplyCommandConfig::default())
1189 }
1190
1191 #[must_use]
1193 pub fn from_journal_with_config(
1194 journal: &RestoreApplyJournal,
1195 config: &RestoreApplyCommandConfig,
1196 ) -> Self {
1197 let complete =
1198 journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
1199 let operation = journal.next_transition_operation().cloned();
1200 let command = operation
1201 .as_ref()
1202 .and_then(|operation| RestoreApplyRunnerCommand::from_operation(operation, config));
1203
1204 Self {
1205 response_version: 1,
1206 backup_id: journal.backup_id.clone(),
1207 ready: journal.ready,
1208 complete,
1209 operation_available: operation.is_some(),
1210 command_available: command.is_some(),
1211 blocked_reasons: journal.blocked_reasons.clone(),
1212 operation,
1213 command,
1214 }
1215 }
1216}
1217
1218#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1223pub struct RestoreApplyCommandConfig {
1224 pub program: String,
1225 pub network: Option<String>,
1226}
1227
1228impl Default for RestoreApplyCommandConfig {
1229 fn default() -> Self {
1231 Self {
1232 program: "dfx".to_string(),
1233 network: None,
1234 }
1235 }
1236}
1237
1238#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1243pub struct RestoreApplyRunnerCommand {
1244 pub program: String,
1245 pub args: Vec<String>,
1246 pub mutates: bool,
1247 pub requires_stopped_canister: bool,
1248 pub note: String,
1249}
1250
1251impl RestoreApplyRunnerCommand {
1252 fn from_operation(
1254 operation: &RestoreApplyJournalOperation,
1255 config: &RestoreApplyCommandConfig,
1256 ) -> Option<Self> {
1257 match operation.operation {
1258 RestoreApplyOperationKind::UploadSnapshot => {
1259 let artifact_path = operation.artifact_path.as_ref()?;
1260 Some(Self {
1261 program: config.program.clone(),
1262 args: dfx_canister_args(
1263 config,
1264 vec![
1265 "snapshot".to_string(),
1266 "upload".to_string(),
1267 "--dir".to_string(),
1268 artifact_path.clone(),
1269 operation.target_canister.clone(),
1270 ],
1271 ),
1272 mutates: true,
1273 requires_stopped_canister: false,
1274 note: "uploads the downloaded snapshot artifact to the target canister"
1275 .to_string(),
1276 })
1277 }
1278 RestoreApplyOperationKind::LoadSnapshot => {
1279 let snapshot_id = operation.snapshot_id.as_ref()?;
1280 Some(Self {
1281 program: config.program.clone(),
1282 args: dfx_canister_args(
1283 config,
1284 vec![
1285 "snapshot".to_string(),
1286 "load".to_string(),
1287 operation.target_canister.clone(),
1288 snapshot_id.clone(),
1289 ],
1290 ),
1291 mutates: true,
1292 requires_stopped_canister: true,
1293 note: "loads the uploaded snapshot into the target canister".to_string(),
1294 })
1295 }
1296 RestoreApplyOperationKind::ReinstallCode => Some(Self {
1297 program: config.program.clone(),
1298 args: dfx_canister_args(
1299 config,
1300 vec![
1301 "install".to_string(),
1302 "--mode".to_string(),
1303 "reinstall".to_string(),
1304 "--yes".to_string(),
1305 operation.target_canister.clone(),
1306 ],
1307 ),
1308 mutates: true,
1309 requires_stopped_canister: false,
1310 note: "reinstalls target canister code using the local dfx project configuration"
1311 .to_string(),
1312 }),
1313 RestoreApplyOperationKind::VerifyMember | RestoreApplyOperationKind::VerifyFleet => {
1314 match operation.verification_kind.as_deref() {
1315 Some("status") => Some(Self {
1316 program: config.program.clone(),
1317 args: dfx_canister_args(
1318 config,
1319 vec!["status".to_string(), operation.target_canister.clone()],
1320 ),
1321 mutates: false,
1322 requires_stopped_canister: false,
1323 note: verification_command_note(
1324 &operation.operation,
1325 "checks target canister status",
1326 "checks target fleet root canister status",
1327 )
1328 .to_string(),
1329 }),
1330 Some(_) => {
1331 let method = operation.verification_method.as_ref()?;
1332 Some(Self {
1333 program: config.program.clone(),
1334 args: dfx_canister_args(
1335 config,
1336 vec![
1337 "call".to_string(),
1338 operation.target_canister.clone(),
1339 method.clone(),
1340 ],
1341 ),
1342 mutates: false,
1343 requires_stopped_canister: false,
1344 note: verification_command_note(
1345 &operation.operation,
1346 "calls the declared verification method",
1347 "calls the declared fleet verification method",
1348 )
1349 .to_string(),
1350 })
1351 }
1352 None => None,
1353 }
1354 }
1355 }
1356 }
1357}
1358
1359const fn verification_command_note(
1361 operation: &RestoreApplyOperationKind,
1362 member_note: &'static str,
1363 fleet_note: &'static str,
1364) -> &'static str {
1365 match operation {
1366 RestoreApplyOperationKind::VerifyFleet => fleet_note,
1367 RestoreApplyOperationKind::UploadSnapshot
1368 | RestoreApplyOperationKind::LoadSnapshot
1369 | RestoreApplyOperationKind::ReinstallCode
1370 | RestoreApplyOperationKind::VerifyMember => member_note,
1371 }
1372}
1373
1374fn dfx_canister_args(config: &RestoreApplyCommandConfig, mut tail: Vec<String>) -> Vec<String> {
1376 let mut args = vec!["canister".to_string()];
1377 if let Some(network) = &config.network {
1378 args.push("--network".to_string());
1379 args.push(network.clone());
1380 }
1381 args.append(&mut tail);
1382 args
1383}
1384
1385#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1390pub struct RestoreApplyJournalOperation {
1391 pub sequence: usize,
1392 pub operation: RestoreApplyOperationKind,
1393 pub state: RestoreApplyOperationState,
1394 #[serde(default, skip_serializing_if = "Option::is_none")]
1395 pub state_updated_at: Option<String>,
1396 pub blocking_reasons: Vec<String>,
1397 pub restore_group: u16,
1398 pub phase_order: usize,
1399 pub source_canister: String,
1400 pub target_canister: String,
1401 pub role: String,
1402 pub snapshot_id: Option<String>,
1403 pub artifact_path: Option<String>,
1404 pub verification_kind: Option<String>,
1405 pub verification_method: Option<String>,
1406}
1407
1408impl RestoreApplyJournalOperation {
1409 fn from_dry_run_operation(
1411 operation: &RestoreApplyDryRunOperation,
1412 state: RestoreApplyOperationState,
1413 blocked_reasons: &[String],
1414 ) -> Self {
1415 Self {
1416 sequence: operation.sequence,
1417 operation: operation.operation.clone(),
1418 state: state.clone(),
1419 state_updated_at: None,
1420 blocking_reasons: if state == RestoreApplyOperationState::Blocked {
1421 blocked_reasons.to_vec()
1422 } else {
1423 Vec::new()
1424 },
1425 restore_group: operation.restore_group,
1426 phase_order: operation.phase_order,
1427 source_canister: operation.source_canister.clone(),
1428 target_canister: operation.target_canister.clone(),
1429 role: operation.role.clone(),
1430 snapshot_id: operation.snapshot_id.clone(),
1431 artifact_path: operation.artifact_path.clone(),
1432 verification_kind: operation.verification_kind.clone(),
1433 verification_method: operation.verification_method.clone(),
1434 }
1435 }
1436
1437 fn validate(&self) -> Result<(), RestoreApplyJournalError> {
1439 validate_apply_journal_nonempty("operations[].source_canister", &self.source_canister)?;
1440 validate_apply_journal_nonempty("operations[].target_canister", &self.target_canister)?;
1441 validate_apply_journal_nonempty("operations[].role", &self.role)?;
1442 if let Some(updated_at) = &self.state_updated_at {
1443 validate_apply_journal_nonempty("operations[].state_updated_at", updated_at)?;
1444 }
1445 self.validate_operation_fields()?;
1446
1447 match self.state {
1448 RestoreApplyOperationState::Blocked if self.blocking_reasons.is_empty() => Err(
1449 RestoreApplyJournalError::BlockedOperationMissingReason(self.sequence),
1450 ),
1451 RestoreApplyOperationState::Failed if self.blocking_reasons.is_empty() => Err(
1452 RestoreApplyJournalError::FailureReasonRequired(self.sequence),
1453 ),
1454 RestoreApplyOperationState::Pending
1455 | RestoreApplyOperationState::Ready
1456 | RestoreApplyOperationState::Completed
1457 if !self.blocking_reasons.is_empty() =>
1458 {
1459 Err(RestoreApplyJournalError::UnblockedOperationHasReasons(
1460 self.sequence,
1461 ))
1462 }
1463 RestoreApplyOperationState::Blocked
1464 | RestoreApplyOperationState::Failed
1465 | RestoreApplyOperationState::Pending
1466 | RestoreApplyOperationState::Ready
1467 | RestoreApplyOperationState::Completed => Ok(()),
1468 }
1469 }
1470
1471 fn validate_operation_fields(&self) -> Result<(), RestoreApplyJournalError> {
1473 match self.operation {
1474 RestoreApplyOperationKind::UploadSnapshot => self
1475 .validate_required_field("operations[].artifact_path", self.artifact_path.as_ref())
1476 .map(|_| ()),
1477 RestoreApplyOperationKind::LoadSnapshot => self
1478 .validate_required_field("operations[].snapshot_id", self.snapshot_id.as_ref())
1479 .map(|_| ()),
1480 RestoreApplyOperationKind::ReinstallCode => Ok(()),
1481 RestoreApplyOperationKind::VerifyMember | RestoreApplyOperationKind::VerifyFleet => {
1482 let kind = self.validate_required_field(
1483 "operations[].verification_kind",
1484 self.verification_kind.as_ref(),
1485 )?;
1486 if kind == "status" {
1487 return Ok(());
1488 }
1489 self.validate_required_field(
1490 "operations[].verification_method",
1491 self.verification_method.as_ref(),
1492 )
1493 .map(|_| ())
1494 }
1495 }
1496 }
1497
1498 fn validate_required_field<'a>(
1500 &self,
1501 field: &'static str,
1502 value: Option<&'a String>,
1503 ) -> Result<&'a str, RestoreApplyJournalError> {
1504 let value = value.map(String::as_str).ok_or_else(|| {
1505 RestoreApplyJournalError::OperationMissingField {
1506 sequence: self.sequence,
1507 operation: self.operation.clone(),
1508 field,
1509 }
1510 })?;
1511 if value.trim().is_empty() {
1512 return Err(RestoreApplyJournalError::OperationMissingField {
1513 sequence: self.sequence,
1514 operation: self.operation.clone(),
1515 field,
1516 });
1517 }
1518
1519 Ok(value)
1520 }
1521
1522 const fn can_transition_to(&self, next_state: &RestoreApplyOperationState) -> bool {
1524 match (&self.state, next_state) {
1525 (
1526 RestoreApplyOperationState::Ready | RestoreApplyOperationState::Pending,
1527 RestoreApplyOperationState::Pending,
1528 )
1529 | (RestoreApplyOperationState::Pending, RestoreApplyOperationState::Ready)
1530 | (
1531 RestoreApplyOperationState::Ready
1532 | RestoreApplyOperationState::Pending
1533 | RestoreApplyOperationState::Completed,
1534 RestoreApplyOperationState::Completed,
1535 )
1536 | (
1537 RestoreApplyOperationState::Ready
1538 | RestoreApplyOperationState::Pending
1539 | RestoreApplyOperationState::Failed,
1540 RestoreApplyOperationState::Failed,
1541 ) => true,
1542 (
1543 RestoreApplyOperationState::Blocked
1544 | RestoreApplyOperationState::Completed
1545 | RestoreApplyOperationState::Failed
1546 | RestoreApplyOperationState::Pending
1547 | RestoreApplyOperationState::Ready,
1548 _,
1549 ) => false,
1550 }
1551 }
1552}
1553
1554#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1559#[serde(rename_all = "kebab-case")]
1560pub enum RestoreApplyOperationState {
1561 Pending,
1562 Ready,
1563 Blocked,
1564 Completed,
1565 Failed,
1566}
1567
1568#[derive(Debug, ThisError)]
1573pub enum RestoreApplyJournalError {
1574 #[error("unsupported restore apply journal version {0}")]
1575 UnsupportedVersion(u16),
1576
1577 #[error("restore apply journal field {0} is required")]
1578 MissingField(&'static str),
1579
1580 #[error("restore apply journal count {field} mismatch: reported={reported}, actual={actual}")]
1581 CountMismatch {
1582 field: &'static str,
1583 reported: usize,
1584 actual: usize,
1585 },
1586
1587 #[error("restore apply journal has duplicate operation sequence {0}")]
1588 DuplicateSequence(usize),
1589
1590 #[error("restore apply journal is missing operation sequence {0}")]
1591 MissingSequence(usize),
1592
1593 #[error("ready restore apply journal cannot include blocked reasons or blocked operations")]
1594 ReadyJournalHasBlockingState,
1595
1596 #[error("blocked restore apply journal operation {0} is missing a blocking reason")]
1597 BlockedOperationMissingReason(usize),
1598
1599 #[error("unblocked restore apply journal operation {0} cannot have blocking reasons")]
1600 UnblockedOperationHasReasons(usize),
1601
1602 #[error("restore apply journal operation {sequence} {operation:?} is missing field {field}")]
1603 OperationMissingField {
1604 sequence: usize,
1605 operation: RestoreApplyOperationKind,
1606 field: &'static str,
1607 },
1608
1609 #[error("restore apply journal operation {0} was not found")]
1610 OperationNotFound(usize),
1611
1612 #[error("restore apply journal operation {sequence} cannot transition from {from:?} to {to:?}")]
1613 InvalidOperationTransition {
1614 sequence: usize,
1615 from: RestoreApplyOperationState,
1616 to: RestoreApplyOperationState,
1617 },
1618
1619 #[error("failed restore apply journal operation {0} requires a reason")]
1620 FailureReasonRequired(usize),
1621
1622 #[error("restore apply journal has no operation that can be advanced")]
1623 NoTransitionableOperation,
1624
1625 #[error("restore apply journal has no pending operation to release")]
1626 NoPendingOperation,
1627
1628 #[error("restore apply journal operation {requested} cannot advance before operation {next}")]
1629 OutOfOrderOperationTransition { requested: usize, next: usize },
1630}
1631
1632fn validate_restore_apply_artifacts(
1634 plan: &RestorePlan,
1635 backup_root: &Path,
1636) -> Result<RestoreApplyArtifactValidation, RestoreApplyDryRunError> {
1637 let mut checks = Vec::new();
1638
1639 for member in plan.ordered_members() {
1640 checks.push(validate_restore_apply_artifact(member, backup_root)?);
1641 }
1642
1643 let members_with_expected_checksums = checks
1644 .iter()
1645 .filter(|check| check.checksum_expected.is_some())
1646 .count();
1647 let artifacts_present = checks.iter().all(|check| check.exists);
1648 let checksums_verified = members_with_expected_checksums == plan.member_count
1649 && checks.iter().all(|check| check.checksum_verified);
1650
1651 Ok(RestoreApplyArtifactValidation {
1652 backup_root: backup_root.to_string_lossy().to_string(),
1653 checked_members: checks.len(),
1654 artifacts_present,
1655 checksums_verified,
1656 members_with_expected_checksums,
1657 checks,
1658 })
1659}
1660
1661fn validate_restore_apply_artifact(
1663 member: &RestorePlanMember,
1664 backup_root: &Path,
1665) -> Result<RestoreApplyArtifactCheck, RestoreApplyDryRunError> {
1666 let artifact_path = safe_restore_artifact_path(
1667 &member.source_canister,
1668 &member.source_snapshot.artifact_path,
1669 )?;
1670 let resolved_path = backup_root.join(&artifact_path);
1671
1672 if !resolved_path.exists() {
1673 return Err(RestoreApplyDryRunError::ArtifactMissing {
1674 source_canister: member.source_canister.clone(),
1675 artifact_path: member.source_snapshot.artifact_path.clone(),
1676 resolved_path: resolved_path.to_string_lossy().to_string(),
1677 });
1678 }
1679
1680 let (checksum_actual, checksum_verified) =
1681 if let Some(expected) = &member.source_snapshot.checksum {
1682 let checksum = ArtifactChecksum::from_path(&resolved_path).map_err(|source| {
1683 RestoreApplyDryRunError::ArtifactChecksum {
1684 source_canister: member.source_canister.clone(),
1685 artifact_path: member.source_snapshot.artifact_path.clone(),
1686 source,
1687 }
1688 })?;
1689 checksum.verify(expected).map_err(|source| {
1690 RestoreApplyDryRunError::ArtifactChecksum {
1691 source_canister: member.source_canister.clone(),
1692 artifact_path: member.source_snapshot.artifact_path.clone(),
1693 source,
1694 }
1695 })?;
1696 (Some(checksum.hash), true)
1697 } else {
1698 (None, false)
1699 };
1700
1701 Ok(RestoreApplyArtifactCheck {
1702 source_canister: member.source_canister.clone(),
1703 target_canister: member.target_canister.clone(),
1704 snapshot_id: member.source_snapshot.snapshot_id.clone(),
1705 artifact_path: member.source_snapshot.artifact_path.clone(),
1706 resolved_path: resolved_path.to_string_lossy().to_string(),
1707 exists: true,
1708 checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
1709 checksum_expected: member.source_snapshot.checksum.clone(),
1710 checksum_actual,
1711 checksum_verified,
1712 })
1713}
1714
1715fn safe_restore_artifact_path(
1717 source_canister: &str,
1718 artifact_path: &str,
1719) -> Result<PathBuf, RestoreApplyDryRunError> {
1720 let path = Path::new(artifact_path);
1721 let is_safe = path
1722 .components()
1723 .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
1724
1725 if is_safe {
1726 return Ok(path.to_path_buf());
1727 }
1728
1729 Err(RestoreApplyDryRunError::ArtifactPathEscapesBackup {
1730 source_canister: source_canister.to_string(),
1731 artifact_path: artifact_path.to_string(),
1732 })
1733}
1734
1735fn validate_restore_status_matches_plan(
1737 plan: &RestorePlan,
1738 status: &RestoreStatus,
1739) -> Result<(), RestoreApplyDryRunError> {
1740 validate_status_string_field("backup_id", &plan.backup_id, &status.backup_id)?;
1741 validate_status_string_field(
1742 "source_environment",
1743 &plan.source_environment,
1744 &status.source_environment,
1745 )?;
1746 validate_status_string_field(
1747 "source_root_canister",
1748 &plan.source_root_canister,
1749 &status.source_root_canister,
1750 )?;
1751 validate_status_string_field("topology_hash", &plan.topology_hash, &status.topology_hash)?;
1752 validate_status_usize_field("member_count", plan.member_count, status.member_count)?;
1753 validate_status_usize_field(
1754 "phase_count",
1755 plan.ordering_summary.phase_count,
1756 status.phase_count,
1757 )?;
1758 Ok(())
1759}
1760
1761fn validate_status_string_field(
1763 field: &'static str,
1764 plan: &str,
1765 status: &str,
1766) -> Result<(), RestoreApplyDryRunError> {
1767 if plan == status {
1768 return Ok(());
1769 }
1770
1771 Err(RestoreApplyDryRunError::StatusPlanMismatch {
1772 field,
1773 plan: plan.to_string(),
1774 status: status.to_string(),
1775 })
1776}
1777
1778const fn validate_status_usize_field(
1780 field: &'static str,
1781 plan: usize,
1782 status: usize,
1783) -> Result<(), RestoreApplyDryRunError> {
1784 if plan == status {
1785 return Ok(());
1786 }
1787
1788 Err(RestoreApplyDryRunError::StatusPlanCountMismatch {
1789 field,
1790 plan,
1791 status,
1792 })
1793}
1794
1795#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1800pub struct RestoreApplyArtifactValidation {
1801 pub backup_root: String,
1802 pub checked_members: usize,
1803 pub artifacts_present: bool,
1804 pub checksums_verified: bool,
1805 pub members_with_expected_checksums: usize,
1806 pub checks: Vec<RestoreApplyArtifactCheck>,
1807}
1808
1809#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1814pub struct RestoreApplyArtifactCheck {
1815 pub source_canister: String,
1816 pub target_canister: String,
1817 pub snapshot_id: String,
1818 pub artifact_path: String,
1819 pub resolved_path: String,
1820 pub exists: bool,
1821 pub checksum_algorithm: String,
1822 pub checksum_expected: Option<String>,
1823 pub checksum_actual: Option<String>,
1824 pub checksum_verified: bool,
1825}
1826
1827#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1832pub struct RestoreApplyDryRunPhase {
1833 pub restore_group: u16,
1834 pub operations: Vec<RestoreApplyDryRunOperation>,
1835}
1836
1837impl RestoreApplyDryRunPhase {
1838 fn from_plan_phase(phase: &RestorePhase, next_sequence: &mut usize) -> Self {
1840 let mut operations = Vec::new();
1841
1842 for member in &phase.members {
1843 push_member_operation(
1844 &mut operations,
1845 next_sequence,
1846 RestoreApplyOperationKind::UploadSnapshot,
1847 member,
1848 None,
1849 );
1850 push_member_operation(
1851 &mut operations,
1852 next_sequence,
1853 RestoreApplyOperationKind::LoadSnapshot,
1854 member,
1855 None,
1856 );
1857 push_member_operation(
1858 &mut operations,
1859 next_sequence,
1860 RestoreApplyOperationKind::ReinstallCode,
1861 member,
1862 None,
1863 );
1864
1865 for check in &member.verification_checks {
1866 push_member_operation(
1867 &mut operations,
1868 next_sequence,
1869 RestoreApplyOperationKind::VerifyMember,
1870 member,
1871 Some(check),
1872 );
1873 }
1874 }
1875
1876 Self {
1877 restore_group: phase.restore_group,
1878 operations,
1879 }
1880 }
1881}
1882
1883fn push_member_operation(
1885 operations: &mut Vec<RestoreApplyDryRunOperation>,
1886 next_sequence: &mut usize,
1887 operation: RestoreApplyOperationKind,
1888 member: &RestorePlanMember,
1889 check: Option<&VerificationCheck>,
1890) {
1891 let sequence = *next_sequence;
1892 *next_sequence += 1;
1893
1894 operations.push(RestoreApplyDryRunOperation {
1895 sequence,
1896 operation,
1897 restore_group: member.restore_group,
1898 phase_order: member.phase_order,
1899 source_canister: member.source_canister.clone(),
1900 target_canister: member.target_canister.clone(),
1901 role: member.role.clone(),
1902 snapshot_id: Some(member.source_snapshot.snapshot_id.clone()),
1903 artifact_path: Some(member.source_snapshot.artifact_path.clone()),
1904 verification_kind: check.map(|check| check.kind.clone()),
1905 verification_method: check.and_then(|check| check.method.clone()),
1906 });
1907}
1908
1909fn append_fleet_verification_operations(
1911 plan: &RestorePlan,
1912 phases: &mut [RestoreApplyDryRunPhase],
1913 next_sequence: &mut usize,
1914) {
1915 if plan.fleet_verification_checks.is_empty() {
1916 return;
1917 }
1918
1919 let Some(phase) = phases.last_mut() else {
1920 return;
1921 };
1922 let root = plan
1923 .phases
1924 .iter()
1925 .flat_map(|phase| phase.members.iter())
1926 .find(|member| member.source_canister == plan.source_root_canister);
1927 let source_canister = root.map_or_else(
1928 || plan.source_root_canister.clone(),
1929 |member| member.source_canister.clone(),
1930 );
1931 let target_canister = root.map_or_else(
1932 || plan.source_root_canister.clone(),
1933 |member| member.target_canister.clone(),
1934 );
1935 let restore_group = phase.restore_group;
1936
1937 for check in &plan.fleet_verification_checks {
1938 push_fleet_operation(
1939 &mut phase.operations,
1940 next_sequence,
1941 restore_group,
1942 &source_canister,
1943 &target_canister,
1944 check,
1945 );
1946 }
1947}
1948
1949fn push_fleet_operation(
1951 operations: &mut Vec<RestoreApplyDryRunOperation>,
1952 next_sequence: &mut usize,
1953 restore_group: u16,
1954 source_canister: &str,
1955 target_canister: &str,
1956 check: &VerificationCheck,
1957) {
1958 let sequence = *next_sequence;
1959 *next_sequence += 1;
1960 let phase_order = operations.len();
1961
1962 operations.push(RestoreApplyDryRunOperation {
1963 sequence,
1964 operation: RestoreApplyOperationKind::VerifyFleet,
1965 restore_group,
1966 phase_order,
1967 source_canister: source_canister.to_string(),
1968 target_canister: target_canister.to_string(),
1969 role: "fleet".to_string(),
1970 snapshot_id: None,
1971 artifact_path: None,
1972 verification_kind: Some(check.kind.clone()),
1973 verification_method: check.method.clone(),
1974 });
1975}
1976
1977#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1982pub struct RestoreApplyDryRunOperation {
1983 pub sequence: usize,
1984 pub operation: RestoreApplyOperationKind,
1985 pub restore_group: u16,
1986 pub phase_order: usize,
1987 pub source_canister: String,
1988 pub target_canister: String,
1989 pub role: String,
1990 pub snapshot_id: Option<String>,
1991 pub artifact_path: Option<String>,
1992 pub verification_kind: Option<String>,
1993 pub verification_method: Option<String>,
1994}
1995
1996#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2001#[serde(rename_all = "kebab-case")]
2002pub enum RestoreApplyOperationKind {
2003 UploadSnapshot,
2004 LoadSnapshot,
2005 ReinstallCode,
2006 VerifyMember,
2007 VerifyFleet,
2008}
2009
2010#[derive(Debug, ThisError)]
2015pub enum RestoreApplyDryRunError {
2016 #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
2017 StatusPlanMismatch {
2018 field: &'static str,
2019 plan: String,
2020 status: String,
2021 },
2022
2023 #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
2024 StatusPlanCountMismatch {
2025 field: &'static str,
2026 plan: usize,
2027 status: usize,
2028 },
2029
2030 #[error("restore artifact path for {source_canister} escapes backup root: {artifact_path}")]
2031 ArtifactPathEscapesBackup {
2032 source_canister: String,
2033 artifact_path: String,
2034 },
2035
2036 #[error(
2037 "restore artifact for {source_canister} is missing: {artifact_path} at {resolved_path}"
2038 )]
2039 ArtifactMissing {
2040 source_canister: String,
2041 artifact_path: String,
2042 resolved_path: String,
2043 },
2044
2045 #[error("restore artifact checksum failed for {source_canister} at {artifact_path}: {source}")]
2046 ArtifactChecksum {
2047 source_canister: String,
2048 artifact_path: String,
2049 #[source]
2050 source: ArtifactChecksumError,
2051 },
2052}
2053
2054#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2059pub struct RestoreIdentitySummary {
2060 pub mapping_supplied: bool,
2061 pub all_sources_mapped: bool,
2062 pub fixed_members: usize,
2063 pub relocatable_members: usize,
2064 pub in_place_members: usize,
2065 pub mapped_members: usize,
2066 pub remapped_members: usize,
2067}
2068
2069#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2074#[expect(
2075 clippy::struct_excessive_bools,
2076 reason = "restore summaries intentionally expose machine-readable readiness flags"
2077)]
2078pub struct RestoreSnapshotSummary {
2079 pub all_members_have_module_hash: bool,
2080 pub all_members_have_wasm_hash: bool,
2081 pub all_members_have_code_version: bool,
2082 pub all_members_have_checksum: bool,
2083 pub members_with_module_hash: usize,
2084 pub members_with_wasm_hash: usize,
2085 pub members_with_code_version: usize,
2086 pub members_with_checksum: usize,
2087}
2088
2089#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2094pub struct RestoreVerificationSummary {
2095 pub verification_required: bool,
2096 pub all_members_have_checks: bool,
2097 pub fleet_checks: usize,
2098 pub member_check_groups: usize,
2099 pub member_checks: usize,
2100 pub members_with_checks: usize,
2101 pub total_checks: usize,
2102}
2103
2104#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2109pub struct RestoreReadinessSummary {
2110 pub ready: bool,
2111 pub reasons: Vec<String>,
2112}
2113
2114#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2119pub struct RestoreOperationSummary {
2120 #[serde(default)]
2121 pub planned_snapshot_uploads: usize,
2122 pub planned_snapshot_loads: usize,
2123 pub planned_code_reinstalls: usize,
2124 pub planned_verification_checks: usize,
2125 #[serde(default)]
2126 pub planned_operations: usize,
2127 pub planned_phases: usize,
2128}
2129
2130impl RestoreOperationSummary {
2131 #[must_use]
2133 pub const fn effective_planned_snapshot_uploads(&self, member_count: usize) -> usize {
2134 if self.planned_snapshot_uploads == 0 && member_count > 0 {
2135 return member_count;
2136 }
2137
2138 self.planned_snapshot_uploads
2139 }
2140
2141 #[must_use]
2143 pub const fn effective_planned_operations(&self, member_count: usize) -> usize {
2144 if self.planned_operations == 0 {
2145 return self.effective_planned_snapshot_uploads(member_count)
2146 + self.planned_snapshot_loads
2147 + self.planned_code_reinstalls
2148 + self.planned_verification_checks;
2149 }
2150
2151 self.planned_operations
2152 }
2153}
2154
2155#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2160pub struct RestoreOrderingSummary {
2161 pub phase_count: usize,
2162 pub dependency_free_members: usize,
2163 pub in_group_parent_edges: usize,
2164 pub cross_group_parent_edges: usize,
2165}
2166
2167#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2172pub struct RestorePhase {
2173 pub restore_group: u16,
2174 pub members: Vec<RestorePlanMember>,
2175}
2176
2177#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2182pub struct RestorePlanMember {
2183 pub source_canister: String,
2184 pub target_canister: String,
2185 pub role: String,
2186 pub parent_source_canister: Option<String>,
2187 pub parent_target_canister: Option<String>,
2188 pub ordering_dependency: Option<RestoreOrderingDependency>,
2189 pub phase_order: usize,
2190 pub restore_group: u16,
2191 pub identity_mode: IdentityMode,
2192 pub verification_class: String,
2193 pub verification_checks: Vec<VerificationCheck>,
2194 pub source_snapshot: SourceSnapshot,
2195}
2196
2197#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2202pub struct RestoreOrderingDependency {
2203 pub source_canister: String,
2204 pub target_canister: String,
2205 pub relationship: RestoreOrderingRelationship,
2206}
2207
2208#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2213#[serde(rename_all = "kebab-case")]
2214pub enum RestoreOrderingRelationship {
2215 ParentInSameGroup,
2216 ParentInEarlierGroup,
2217}
2218
2219pub struct RestorePlanner;
2224
2225impl RestorePlanner {
2226 pub fn plan(
2228 manifest: &FleetBackupManifest,
2229 mapping: Option<&RestoreMapping>,
2230 ) -> Result<RestorePlan, RestorePlanError> {
2231 manifest.validate()?;
2232 if let Some(mapping) = mapping {
2233 validate_mapping(mapping)?;
2234 validate_mapping_sources(manifest, mapping)?;
2235 }
2236
2237 let members = resolve_members(manifest, mapping)?;
2238 let identity_summary = restore_identity_summary(&members, mapping.is_some());
2239 let snapshot_summary = restore_snapshot_summary(&members);
2240 let verification_summary = restore_verification_summary(manifest, &members);
2241 let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
2242 validate_restore_group_dependencies(&members)?;
2243 let phases = group_and_order_members(members)?;
2244 let ordering_summary = restore_ordering_summary(&phases);
2245 let operation_summary =
2246 restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
2247
2248 Ok(RestorePlan {
2249 backup_id: manifest.backup_id.clone(),
2250 source_environment: manifest.source.environment.clone(),
2251 source_root_canister: manifest.source.root_canister.clone(),
2252 topology_hash: manifest.fleet.topology_hash.clone(),
2253 member_count: manifest.fleet.members.len(),
2254 identity_summary,
2255 snapshot_summary,
2256 verification_summary,
2257 readiness_summary,
2258 operation_summary,
2259 ordering_summary,
2260 fleet_verification_checks: manifest.verification.fleet_checks.clone(),
2261 phases,
2262 })
2263 }
2264}
2265
2266#[derive(Debug, ThisError)]
2271pub enum RestorePlanError {
2272 #[error(transparent)]
2273 InvalidManifest(#[from] ManifestValidationError),
2274
2275 #[error("field {field} must be a valid principal: {value}")]
2276 InvalidPrincipal { field: &'static str, value: String },
2277
2278 #[error("mapping contains duplicate source canister {0}")]
2279 DuplicateMappingSource(String),
2280
2281 #[error("mapping contains duplicate target canister {0}")]
2282 DuplicateMappingTarget(String),
2283
2284 #[error("mapping references unknown source canister {0}")]
2285 UnknownMappingSource(String),
2286
2287 #[error("mapping is missing source canister {0}")]
2288 MissingMappingSource(String),
2289
2290 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
2291 FixedIdentityRemap {
2292 source_canister: String,
2293 target_canister: String,
2294 },
2295
2296 #[error("restore plan contains duplicate target canister {0}")]
2297 DuplicatePlanTarget(String),
2298
2299 #[error("restore group {0} contains a parent cycle or unresolved dependency")]
2300 RestoreOrderCycle(u16),
2301
2302 #[error(
2303 "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
2304 )]
2305 ParentRestoreGroupAfterChild {
2306 child_source_canister: String,
2307 parent_source_canister: String,
2308 child_restore_group: u16,
2309 parent_restore_group: u16,
2310 },
2311}
2312
2313fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
2315 let mut sources = BTreeSet::new();
2316 let mut targets = BTreeSet::new();
2317
2318 for entry in &mapping.members {
2319 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
2320 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
2321
2322 if !sources.insert(entry.source_canister.clone()) {
2323 return Err(RestorePlanError::DuplicateMappingSource(
2324 entry.source_canister.clone(),
2325 ));
2326 }
2327
2328 if !targets.insert(entry.target_canister.clone()) {
2329 return Err(RestorePlanError::DuplicateMappingTarget(
2330 entry.target_canister.clone(),
2331 ));
2332 }
2333 }
2334
2335 Ok(())
2336}
2337
2338fn validate_mapping_sources(
2340 manifest: &FleetBackupManifest,
2341 mapping: &RestoreMapping,
2342) -> Result<(), RestorePlanError> {
2343 let sources = manifest
2344 .fleet
2345 .members
2346 .iter()
2347 .map(|member| member.canister_id.as_str())
2348 .collect::<BTreeSet<_>>();
2349
2350 for entry in &mapping.members {
2351 if !sources.contains(entry.source_canister.as_str()) {
2352 return Err(RestorePlanError::UnknownMappingSource(
2353 entry.source_canister.clone(),
2354 ));
2355 }
2356 }
2357
2358 Ok(())
2359}
2360
2361fn resolve_members(
2363 manifest: &FleetBackupManifest,
2364 mapping: Option<&RestoreMapping>,
2365) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
2366 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
2367 let mut targets = BTreeSet::new();
2368 let mut source_to_target = BTreeMap::new();
2369
2370 for member in &manifest.fleet.members {
2371 let target = resolve_target(member, mapping)?;
2372 if !targets.insert(target.clone()) {
2373 return Err(RestorePlanError::DuplicatePlanTarget(target));
2374 }
2375
2376 source_to_target.insert(member.canister_id.clone(), target.clone());
2377 plan_members.push(RestorePlanMember {
2378 source_canister: member.canister_id.clone(),
2379 target_canister: target,
2380 role: member.role.clone(),
2381 parent_source_canister: member.parent_canister_id.clone(),
2382 parent_target_canister: None,
2383 ordering_dependency: None,
2384 phase_order: 0,
2385 restore_group: member.restore_group,
2386 identity_mode: member.identity_mode.clone(),
2387 verification_class: member.verification_class.clone(),
2388 verification_checks: concrete_member_verification_checks(
2389 member,
2390 &manifest.verification,
2391 ),
2392 source_snapshot: member.source_snapshot.clone(),
2393 });
2394 }
2395
2396 for member in &mut plan_members {
2397 member.parent_target_canister = member
2398 .parent_source_canister
2399 .as_ref()
2400 .and_then(|parent| source_to_target.get(parent))
2401 .cloned();
2402 }
2403
2404 Ok(plan_members)
2405}
2406
2407fn concrete_member_verification_checks(
2409 member: &FleetMember,
2410 verification: &VerificationPlan,
2411) -> Vec<VerificationCheck> {
2412 let mut checks = member
2413 .verification_checks
2414 .iter()
2415 .filter(|check| verification_check_applies_to_role(check, &member.role))
2416 .cloned()
2417 .collect::<Vec<_>>();
2418
2419 for group in &verification.member_checks {
2420 if group.role != member.role {
2421 continue;
2422 }
2423
2424 checks.extend(
2425 group
2426 .checks
2427 .iter()
2428 .filter(|check| verification_check_applies_to_role(check, &member.role))
2429 .cloned(),
2430 );
2431 }
2432
2433 checks
2434}
2435
2436fn verification_check_applies_to_role(check: &VerificationCheck, role: &str) -> bool {
2438 check.roles.is_empty() || check.roles.iter().any(|check_role| check_role == role)
2439}
2440
2441fn resolve_target(
2443 member: &FleetMember,
2444 mapping: Option<&RestoreMapping>,
2445) -> Result<String, RestorePlanError> {
2446 let target = match mapping {
2447 Some(mapping) => mapping
2448 .target_for(&member.canister_id)
2449 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
2450 .to_string(),
2451 None => member.canister_id.clone(),
2452 };
2453
2454 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
2455 return Err(RestorePlanError::FixedIdentityRemap {
2456 source_canister: member.canister_id.clone(),
2457 target_canister: target,
2458 });
2459 }
2460
2461 Ok(target)
2462}
2463
2464fn restore_identity_summary(
2466 members: &[RestorePlanMember],
2467 mapping_supplied: bool,
2468) -> RestoreIdentitySummary {
2469 let mut summary = RestoreIdentitySummary {
2470 mapping_supplied,
2471 all_sources_mapped: false,
2472 fixed_members: 0,
2473 relocatable_members: 0,
2474 in_place_members: 0,
2475 mapped_members: 0,
2476 remapped_members: 0,
2477 };
2478
2479 for member in members {
2480 match member.identity_mode {
2481 IdentityMode::Fixed => summary.fixed_members += 1,
2482 IdentityMode::Relocatable => summary.relocatable_members += 1,
2483 }
2484
2485 if member.source_canister == member.target_canister {
2486 summary.in_place_members += 1;
2487 } else {
2488 summary.remapped_members += 1;
2489 }
2490 if mapping_supplied {
2491 summary.mapped_members += 1;
2492 }
2493 }
2494
2495 summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
2496
2497 summary
2498}
2499
2500fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
2502 let members_with_module_hash = members
2503 .iter()
2504 .filter(|member| member.source_snapshot.module_hash.is_some())
2505 .count();
2506 let members_with_wasm_hash = members
2507 .iter()
2508 .filter(|member| member.source_snapshot.wasm_hash.is_some())
2509 .count();
2510 let members_with_code_version = members
2511 .iter()
2512 .filter(|member| member.source_snapshot.code_version.is_some())
2513 .count();
2514 let members_with_checksum = members
2515 .iter()
2516 .filter(|member| member.source_snapshot.checksum.is_some())
2517 .count();
2518
2519 RestoreSnapshotSummary {
2520 all_members_have_module_hash: members_with_module_hash == members.len(),
2521 all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
2522 all_members_have_code_version: members_with_code_version == members.len(),
2523 all_members_have_checksum: members_with_checksum == members.len(),
2524 members_with_module_hash,
2525 members_with_wasm_hash,
2526 members_with_code_version,
2527 members_with_checksum,
2528 }
2529}
2530
2531fn restore_readiness_summary(
2533 snapshot: &RestoreSnapshotSummary,
2534 verification: &RestoreVerificationSummary,
2535) -> RestoreReadinessSummary {
2536 let mut reasons = Vec::new();
2537
2538 if !snapshot.all_members_have_module_hash {
2539 reasons.push("missing-module-hash".to_string());
2540 }
2541 if !snapshot.all_members_have_wasm_hash {
2542 reasons.push("missing-wasm-hash".to_string());
2543 }
2544 if !snapshot.all_members_have_code_version {
2545 reasons.push("missing-code-version".to_string());
2546 }
2547 if !snapshot.all_members_have_checksum {
2548 reasons.push("missing-snapshot-checksum".to_string());
2549 }
2550 if !verification.all_members_have_checks {
2551 reasons.push("missing-verification-checks".to_string());
2552 }
2553
2554 RestoreReadinessSummary {
2555 ready: reasons.is_empty(),
2556 reasons,
2557 }
2558}
2559
2560fn restore_verification_summary(
2562 manifest: &FleetBackupManifest,
2563 members: &[RestorePlanMember],
2564) -> RestoreVerificationSummary {
2565 let fleet_checks = manifest.verification.fleet_checks.len();
2566 let member_check_groups = manifest.verification.member_checks.len();
2567 let member_checks = members
2568 .iter()
2569 .map(|member| member.verification_checks.len())
2570 .sum::<usize>();
2571 let members_with_checks = members
2572 .iter()
2573 .filter(|member| !member.verification_checks.is_empty())
2574 .count();
2575
2576 RestoreVerificationSummary {
2577 verification_required: true,
2578 all_members_have_checks: members_with_checks == members.len(),
2579 fleet_checks,
2580 member_check_groups,
2581 member_checks,
2582 members_with_checks,
2583 total_checks: fleet_checks + member_checks,
2584 }
2585}
2586
2587const fn restore_operation_summary(
2589 member_count: usize,
2590 verification_summary: &RestoreVerificationSummary,
2591 phases: &[RestorePhase],
2592) -> RestoreOperationSummary {
2593 RestoreOperationSummary {
2594 planned_snapshot_uploads: member_count,
2595 planned_snapshot_loads: member_count,
2596 planned_code_reinstalls: member_count,
2597 planned_verification_checks: verification_summary.total_checks,
2598 planned_operations: member_count
2599 + member_count
2600 + member_count
2601 + verification_summary.total_checks,
2602 planned_phases: phases.len(),
2603 }
2604}
2605
2606fn validate_restore_group_dependencies(
2608 members: &[RestorePlanMember],
2609) -> Result<(), RestorePlanError> {
2610 let groups_by_source = members
2611 .iter()
2612 .map(|member| (member.source_canister.as_str(), member.restore_group))
2613 .collect::<BTreeMap<_, _>>();
2614
2615 for member in members {
2616 let Some(parent) = &member.parent_source_canister else {
2617 continue;
2618 };
2619 let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
2620 continue;
2621 };
2622
2623 if *parent_group > member.restore_group {
2624 return Err(RestorePlanError::ParentRestoreGroupAfterChild {
2625 child_source_canister: member.source_canister.clone(),
2626 parent_source_canister: parent.clone(),
2627 child_restore_group: member.restore_group,
2628 parent_restore_group: *parent_group,
2629 });
2630 }
2631 }
2632
2633 Ok(())
2634}
2635
2636fn group_and_order_members(
2638 members: Vec<RestorePlanMember>,
2639) -> Result<Vec<RestorePhase>, RestorePlanError> {
2640 let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
2641 for member in members {
2642 groups.entry(member.restore_group).or_default().push(member);
2643 }
2644
2645 groups
2646 .into_iter()
2647 .map(|(restore_group, members)| {
2648 let members = order_group(restore_group, members)?;
2649 Ok(RestorePhase {
2650 restore_group,
2651 members,
2652 })
2653 })
2654 .collect()
2655}
2656
2657fn order_group(
2659 restore_group: u16,
2660 members: Vec<RestorePlanMember>,
2661) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
2662 let mut remaining = members;
2663 let group_sources = remaining
2664 .iter()
2665 .map(|member| member.source_canister.clone())
2666 .collect::<BTreeSet<_>>();
2667 let mut emitted = BTreeSet::new();
2668 let mut ordered = Vec::with_capacity(remaining.len());
2669
2670 while !remaining.is_empty() {
2671 let Some(index) = remaining
2672 .iter()
2673 .position(|member| parent_satisfied(member, &group_sources, &emitted))
2674 else {
2675 return Err(RestorePlanError::RestoreOrderCycle(restore_group));
2676 };
2677
2678 let mut member = remaining.remove(index);
2679 member.phase_order = ordered.len();
2680 member.ordering_dependency = ordering_dependency(&member, &group_sources);
2681 emitted.insert(member.source_canister.clone());
2682 ordered.push(member);
2683 }
2684
2685 Ok(ordered)
2686}
2687
2688fn ordering_dependency(
2690 member: &RestorePlanMember,
2691 group_sources: &BTreeSet<String>,
2692) -> Option<RestoreOrderingDependency> {
2693 let parent_source = member.parent_source_canister.as_ref()?;
2694 let parent_target = member.parent_target_canister.as_ref()?;
2695 let relationship = if group_sources.contains(parent_source) {
2696 RestoreOrderingRelationship::ParentInSameGroup
2697 } else {
2698 RestoreOrderingRelationship::ParentInEarlierGroup
2699 };
2700
2701 Some(RestoreOrderingDependency {
2702 source_canister: parent_source.clone(),
2703 target_canister: parent_target.clone(),
2704 relationship,
2705 })
2706}
2707
2708fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
2710 let mut summary = RestoreOrderingSummary {
2711 phase_count: phases.len(),
2712 dependency_free_members: 0,
2713 in_group_parent_edges: 0,
2714 cross_group_parent_edges: 0,
2715 };
2716
2717 for member in phases.iter().flat_map(|phase| phase.members.iter()) {
2718 match &member.ordering_dependency {
2719 Some(dependency)
2720 if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
2721 {
2722 summary.in_group_parent_edges += 1;
2723 }
2724 Some(dependency)
2725 if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
2726 {
2727 summary.cross_group_parent_edges += 1;
2728 }
2729 Some(_) => {}
2730 None => summary.dependency_free_members += 1,
2731 }
2732 }
2733
2734 summary
2735}
2736
2737fn parent_satisfied(
2739 member: &RestorePlanMember,
2740 group_sources: &BTreeSet<String>,
2741 emitted: &BTreeSet<String>,
2742) -> bool {
2743 match &member.parent_source_canister {
2744 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
2745 _ => true,
2746 }
2747}
2748
2749fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
2751 Principal::from_str(value)
2752 .map(|_| ())
2753 .map_err(|_| RestorePlanError::InvalidPrincipal {
2754 field,
2755 value: value.to_string(),
2756 })
2757}
2758
2759#[cfg(test)]
2760mod tests {
2761 use super::*;
2762 use crate::manifest::{
2763 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
2764 MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
2765 VerificationPlan,
2766 };
2767 use std::{
2768 env, fs,
2769 path::{Path, PathBuf},
2770 time::{SystemTime, UNIX_EPOCH},
2771 };
2772
2773 const ROOT: &str = "aaaaa-aa";
2774 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
2775 const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
2776 const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
2777 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2778
2779 fn command_preview_journal(
2781 operation: RestoreApplyOperationKind,
2782 verification_kind: Option<&str>,
2783 verification_method: Option<&str>,
2784 ) -> RestoreApplyJournal {
2785 let journal = RestoreApplyJournal {
2786 journal_version: 1,
2787 backup_id: "fbk_test_001".to_string(),
2788 ready: true,
2789 blocked_reasons: Vec::new(),
2790 operation_count: 1,
2791 operation_counts: RestoreApplyOperationKindCounts::default(),
2792 pending_operations: 0,
2793 ready_operations: 1,
2794 blocked_operations: 0,
2795 completed_operations: 0,
2796 failed_operations: 0,
2797 operations: vec![RestoreApplyJournalOperation {
2798 sequence: 0,
2799 operation,
2800 state: RestoreApplyOperationState::Ready,
2801 state_updated_at: None,
2802 blocking_reasons: Vec::new(),
2803 restore_group: 1,
2804 phase_order: 0,
2805 source_canister: ROOT.to_string(),
2806 target_canister: ROOT.to_string(),
2807 role: "root".to_string(),
2808 snapshot_id: Some("snap-root".to_string()),
2809 artifact_path: Some("artifacts/root".to_string()),
2810 verification_kind: verification_kind.map(str::to_string),
2811 verification_method: verification_method.map(str::to_string),
2812 }],
2813 };
2814
2815 journal.validate().expect("journal should validate");
2816 journal
2817 }
2818
2819 fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
2821 FleetBackupManifest {
2822 manifest_version: 1,
2823 backup_id: "fbk_test_001".to_string(),
2824 created_at: "2026-04-10T12:00:00Z".to_string(),
2825 tool: ToolMetadata {
2826 name: "canic".to_string(),
2827 version: "v1".to_string(),
2828 },
2829 source: SourceMetadata {
2830 environment: "local".to_string(),
2831 root_canister: ROOT.to_string(),
2832 },
2833 consistency: ConsistencySection {
2834 mode: ConsistencyMode::CrashConsistent,
2835 backup_units: vec![BackupUnit {
2836 unit_id: "whole-fleet".to_string(),
2837 kind: BackupUnitKind::WholeFleet,
2838 roles: vec!["root".to_string(), "app".to_string()],
2839 consistency_reason: None,
2840 dependency_closure: Vec::new(),
2841 topology_validation: "subtree-closed".to_string(),
2842 quiescence_strategy: None,
2843 }],
2844 },
2845 fleet: FleetSection {
2846 topology_hash_algorithm: "sha256".to_string(),
2847 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
2848 discovery_topology_hash: HASH.to_string(),
2849 pre_snapshot_topology_hash: HASH.to_string(),
2850 topology_hash: HASH.to_string(),
2851 members: vec![
2852 fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
2853 fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
2854 ],
2855 },
2856 verification: VerificationPlan {
2857 fleet_checks: Vec::new(),
2858 member_checks: Vec::new(),
2859 },
2860 }
2861 }
2862
2863 fn fleet_member(
2865 role: &str,
2866 canister_id: &str,
2867 parent_canister_id: Option<&str>,
2868 identity_mode: IdentityMode,
2869 restore_group: u16,
2870 ) -> FleetMember {
2871 FleetMember {
2872 role: role.to_string(),
2873 canister_id: canister_id.to_string(),
2874 parent_canister_id: parent_canister_id.map(str::to_string),
2875 subnet_canister_id: None,
2876 controller_hint: Some(ROOT.to_string()),
2877 identity_mode,
2878 restore_group,
2879 verification_class: "basic".to_string(),
2880 verification_checks: vec![VerificationCheck {
2881 kind: "call".to_string(),
2882 method: Some("canic_ready".to_string()),
2883 roles: Vec::new(),
2884 }],
2885 source_snapshot: SourceSnapshot {
2886 snapshot_id: format!("snap-{role}"),
2887 module_hash: Some(HASH.to_string()),
2888 wasm_hash: Some(HASH.to_string()),
2889 code_version: Some("v0.30.0".to_string()),
2890 artifact_path: format!("artifacts/{role}"),
2891 checksum_algorithm: "sha256".to_string(),
2892 checksum: Some(HASH.to_string()),
2893 },
2894 }
2895 }
2896
2897 #[test]
2899 fn in_place_plan_orders_parent_before_child() {
2900 let manifest = valid_manifest(IdentityMode::Relocatable);
2901
2902 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2903 let ordered = plan.ordered_members();
2904
2905 assert_eq!(plan.backup_id, "fbk_test_001");
2906 assert_eq!(plan.source_environment, "local");
2907 assert_eq!(plan.source_root_canister, ROOT);
2908 assert_eq!(plan.topology_hash, HASH);
2909 assert_eq!(plan.member_count, 2);
2910 assert_eq!(plan.identity_summary.fixed_members, 1);
2911 assert_eq!(plan.identity_summary.relocatable_members, 1);
2912 assert_eq!(plan.identity_summary.in_place_members, 2);
2913 assert_eq!(plan.identity_summary.mapped_members, 0);
2914 assert_eq!(plan.identity_summary.remapped_members, 0);
2915 assert!(plan.verification_summary.verification_required);
2916 assert!(plan.verification_summary.all_members_have_checks);
2917 assert!(plan.readiness_summary.ready);
2918 assert!(plan.readiness_summary.reasons.is_empty());
2919 assert_eq!(plan.verification_summary.fleet_checks, 0);
2920 assert_eq!(plan.verification_summary.member_check_groups, 0);
2921 assert_eq!(plan.verification_summary.member_checks, 2);
2922 assert_eq!(plan.verification_summary.members_with_checks, 2);
2923 assert_eq!(plan.verification_summary.total_checks, 2);
2924 assert_eq!(plan.ordering_summary.phase_count, 1);
2925 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
2926 assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
2927 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
2928 assert_eq!(ordered[0].phase_order, 0);
2929 assert_eq!(ordered[1].phase_order, 1);
2930 assert_eq!(ordered[0].source_canister, ROOT);
2931 assert_eq!(ordered[1].source_canister, CHILD);
2932 assert_eq!(
2933 ordered[1].ordering_dependency,
2934 Some(RestoreOrderingDependency {
2935 source_canister: ROOT.to_string(),
2936 target_canister: ROOT.to_string(),
2937 relationship: RestoreOrderingRelationship::ParentInSameGroup,
2938 })
2939 );
2940 }
2941
2942 #[test]
2944 fn plan_reports_parent_dependency_from_earlier_group() {
2945 let mut manifest = valid_manifest(IdentityMode::Relocatable);
2946 manifest.fleet.members[0].restore_group = 2;
2947 manifest.fleet.members[1].restore_group = 1;
2948
2949 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2950 let ordered = plan.ordered_members();
2951
2952 assert_eq!(plan.phases.len(), 2);
2953 assert_eq!(plan.ordering_summary.phase_count, 2);
2954 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
2955 assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
2956 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
2957 assert_eq!(ordered[0].source_canister, ROOT);
2958 assert_eq!(ordered[1].source_canister, CHILD);
2959 assert_eq!(
2960 ordered[1].ordering_dependency,
2961 Some(RestoreOrderingDependency {
2962 source_canister: ROOT.to_string(),
2963 target_canister: ROOT.to_string(),
2964 relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
2965 })
2966 );
2967 }
2968
2969 #[test]
2971 fn plan_rejects_parent_in_later_restore_group() {
2972 let mut manifest = valid_manifest(IdentityMode::Relocatable);
2973 manifest.fleet.members[0].restore_group = 1;
2974 manifest.fleet.members[1].restore_group = 2;
2975
2976 let err = RestorePlanner::plan(&manifest, None)
2977 .expect_err("parent-after-child group ordering should fail");
2978
2979 assert!(matches!(
2980 err,
2981 RestorePlanError::ParentRestoreGroupAfterChild { .. }
2982 ));
2983 }
2984
2985 #[test]
2987 fn fixed_identity_member_cannot_be_remapped() {
2988 let manifest = valid_manifest(IdentityMode::Fixed);
2989 let mapping = RestoreMapping {
2990 members: vec![
2991 RestoreMappingEntry {
2992 source_canister: ROOT.to_string(),
2993 target_canister: ROOT.to_string(),
2994 },
2995 RestoreMappingEntry {
2996 source_canister: CHILD.to_string(),
2997 target_canister: TARGET.to_string(),
2998 },
2999 ],
3000 };
3001
3002 let err = RestorePlanner::plan(&manifest, Some(&mapping))
3003 .expect_err("fixed member remap should fail");
3004
3005 assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
3006 }
3007
3008 #[test]
3010 fn relocatable_member_can_be_mapped() {
3011 let manifest = valid_manifest(IdentityMode::Relocatable);
3012 let mapping = RestoreMapping {
3013 members: vec![
3014 RestoreMappingEntry {
3015 source_canister: ROOT.to_string(),
3016 target_canister: ROOT.to_string(),
3017 },
3018 RestoreMappingEntry {
3019 source_canister: CHILD.to_string(),
3020 target_canister: TARGET.to_string(),
3021 },
3022 ],
3023 };
3024
3025 let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
3026 let child = plan
3027 .ordered_members()
3028 .into_iter()
3029 .find(|member| member.source_canister == CHILD)
3030 .expect("child member should be planned");
3031
3032 assert_eq!(plan.identity_summary.fixed_members, 1);
3033 assert_eq!(plan.identity_summary.relocatable_members, 1);
3034 assert_eq!(plan.identity_summary.in_place_members, 1);
3035 assert_eq!(plan.identity_summary.mapped_members, 2);
3036 assert_eq!(plan.identity_summary.remapped_members, 1);
3037 assert_eq!(child.target_canister, TARGET);
3038 assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
3039 }
3040
3041 #[test]
3043 fn plan_members_include_snapshot_and_verification_metadata() {
3044 let manifest = valid_manifest(IdentityMode::Relocatable);
3045
3046 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3047 let root = plan
3048 .ordered_members()
3049 .into_iter()
3050 .find(|member| member.source_canister == ROOT)
3051 .expect("root member should be planned");
3052
3053 assert_eq!(root.identity_mode, IdentityMode::Fixed);
3054 assert_eq!(root.verification_class, "basic");
3055 assert_eq!(root.verification_checks[0].kind, "call");
3056 assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
3057 assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
3058 }
3059
3060 #[test]
3062 fn plan_includes_mapping_summary() {
3063 let manifest = valid_manifest(IdentityMode::Relocatable);
3064 let in_place = RestorePlanner::plan(&manifest, None).expect("plan should build");
3065
3066 assert!(!in_place.identity_summary.mapping_supplied);
3067 assert!(!in_place.identity_summary.all_sources_mapped);
3068 assert_eq!(in_place.identity_summary.mapped_members, 0);
3069
3070 let mapping = RestoreMapping {
3071 members: vec![
3072 RestoreMappingEntry {
3073 source_canister: ROOT.to_string(),
3074 target_canister: ROOT.to_string(),
3075 },
3076 RestoreMappingEntry {
3077 source_canister: CHILD.to_string(),
3078 target_canister: TARGET.to_string(),
3079 },
3080 ],
3081 };
3082 let mapped = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
3083
3084 assert!(mapped.identity_summary.mapping_supplied);
3085 assert!(mapped.identity_summary.all_sources_mapped);
3086 assert_eq!(mapped.identity_summary.mapped_members, 2);
3087 assert_eq!(mapped.identity_summary.remapped_members, 1);
3088 }
3089
3090 #[test]
3092 fn plan_includes_snapshot_summary() {
3093 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3094 manifest.fleet.members[1].source_snapshot.module_hash = None;
3095 manifest.fleet.members[1].source_snapshot.wasm_hash = None;
3096 manifest.fleet.members[1].source_snapshot.checksum = None;
3097
3098 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3099
3100 assert!(!plan.snapshot_summary.all_members_have_module_hash);
3101 assert!(!plan.snapshot_summary.all_members_have_wasm_hash);
3102 assert!(plan.snapshot_summary.all_members_have_code_version);
3103 assert!(!plan.snapshot_summary.all_members_have_checksum);
3104 assert_eq!(plan.snapshot_summary.members_with_module_hash, 1);
3105 assert_eq!(plan.snapshot_summary.members_with_wasm_hash, 1);
3106 assert_eq!(plan.snapshot_summary.members_with_code_version, 2);
3107 assert_eq!(plan.snapshot_summary.members_with_checksum, 1);
3108 assert!(!plan.readiness_summary.ready);
3109 assert_eq!(
3110 plan.readiness_summary.reasons,
3111 [
3112 "missing-module-hash",
3113 "missing-wasm-hash",
3114 "missing-snapshot-checksum"
3115 ]
3116 );
3117 }
3118
3119 #[test]
3121 fn plan_includes_verification_summary() {
3122 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3123 manifest.verification.fleet_checks.push(VerificationCheck {
3124 kind: "fleet-ready".to_string(),
3125 method: None,
3126 roles: Vec::new(),
3127 });
3128 manifest
3129 .verification
3130 .member_checks
3131 .push(MemberVerificationChecks {
3132 role: "app".to_string(),
3133 checks: vec![VerificationCheck {
3134 kind: "app-ready".to_string(),
3135 method: Some("ready".to_string()),
3136 roles: Vec::new(),
3137 }],
3138 });
3139
3140 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3141
3142 assert!(plan.verification_summary.verification_required);
3143 assert!(plan.verification_summary.all_members_have_checks);
3144 let app = plan
3145 .ordered_members()
3146 .into_iter()
3147 .find(|member| member.role == "app")
3148 .expect("app member should be planned");
3149 assert_eq!(app.verification_checks.len(), 2);
3150 assert_eq!(plan.fleet_verification_checks.len(), 1);
3151 assert_eq!(plan.fleet_verification_checks[0].kind, "fleet-ready");
3152 assert_eq!(plan.verification_summary.fleet_checks, 1);
3153 assert_eq!(plan.verification_summary.member_check_groups, 1);
3154 assert_eq!(plan.verification_summary.member_checks, 3);
3155 assert_eq!(plan.verification_summary.members_with_checks, 2);
3156 assert_eq!(plan.verification_summary.total_checks, 4);
3157 }
3158
3159 #[test]
3161 fn plan_includes_operation_summary() {
3162 let manifest = valid_manifest(IdentityMode::Relocatable);
3163
3164 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3165
3166 assert_eq!(plan.operation_summary.planned_snapshot_uploads, 2);
3167 assert_eq!(plan.operation_summary.planned_snapshot_loads, 2);
3168 assert_eq!(plan.operation_summary.planned_code_reinstalls, 2);
3169 assert_eq!(plan.operation_summary.planned_verification_checks, 2);
3170 assert_eq!(plan.operation_summary.planned_operations, 8);
3171 assert_eq!(plan.operation_summary.planned_phases, 1);
3172 }
3173
3174 #[test]
3176 fn restore_plan_defaults_missing_newer_restore_fields() {
3177 let manifest = valid_manifest(IdentityMode::Relocatable);
3178 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3179 let mut value = serde_json::to_value(&plan).expect("serialize plan");
3180 value
3181 .as_object_mut()
3182 .expect("plan should serialize as an object")
3183 .remove("fleet_verification_checks");
3184 let operation_summary = value
3185 .get_mut("operation_summary")
3186 .and_then(serde_json::Value::as_object_mut)
3187 .expect("operation summary should serialize as an object");
3188 operation_summary.remove("planned_snapshot_uploads");
3189 operation_summary.remove("planned_operations");
3190
3191 let decoded: RestorePlan = serde_json::from_value(value).expect("decode old plan shape");
3192 let status = RestoreStatus::from_plan(&decoded);
3193 let dry_run =
3194 RestoreApplyDryRun::try_from_plan(&decoded, None).expect("old plan should dry-run");
3195
3196 assert!(decoded.fleet_verification_checks.is_empty());
3197 assert_eq!(decoded.operation_summary.planned_snapshot_uploads, 0);
3198 assert_eq!(decoded.operation_summary.planned_operations, 0);
3199 assert_eq!(status.planned_snapshot_uploads, 2);
3200 assert_eq!(status.planned_operations, 8);
3201 assert_eq!(dry_run.planned_snapshot_uploads, 2);
3202 assert_eq!(dry_run.planned_operations, 8);
3203 assert_eq!(decoded.backup_id, plan.backup_id);
3204 assert_eq!(decoded.member_count, plan.member_count);
3205 }
3206
3207 #[test]
3209 fn restore_status_starts_all_members_as_planned() {
3210 let manifest = valid_manifest(IdentityMode::Relocatable);
3211
3212 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3213 let status = RestoreStatus::from_plan(&plan);
3214
3215 assert_eq!(status.status_version, 1);
3216 assert_eq!(status.backup_id.as_str(), plan.backup_id.as_str());
3217 assert_eq!(
3218 status.source_environment.as_str(),
3219 plan.source_environment.as_str()
3220 );
3221 assert_eq!(
3222 status.source_root_canister.as_str(),
3223 plan.source_root_canister.as_str()
3224 );
3225 assert_eq!(status.topology_hash.as_str(), plan.topology_hash.as_str());
3226 assert!(status.ready);
3227 assert!(status.readiness_reasons.is_empty());
3228 assert!(status.verification_required);
3229 assert_eq!(status.member_count, 2);
3230 assert_eq!(status.phase_count, 1);
3231 assert_eq!(status.planned_snapshot_uploads, 2);
3232 assert_eq!(status.planned_snapshot_loads, 2);
3233 assert_eq!(status.planned_code_reinstalls, 2);
3234 assert_eq!(status.planned_verification_checks, 2);
3235 assert_eq!(status.planned_operations, 8);
3236 assert_eq!(status.phases.len(), 1);
3237 assert_eq!(status.phases[0].restore_group, 1);
3238 assert_eq!(status.phases[0].members.len(), 2);
3239 assert_eq!(
3240 status.phases[0].members[0].state,
3241 RestoreMemberState::Planned
3242 );
3243 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
3244 assert_eq!(status.phases[0].members[0].target_canister, ROOT);
3245 assert_eq!(status.phases[0].members[0].snapshot_id, "snap-root");
3246 assert_eq!(status.phases[0].members[0].artifact_path, "artifacts/root");
3247 assert_eq!(
3248 status.phases[0].members[1].state,
3249 RestoreMemberState::Planned
3250 );
3251 assert_eq!(status.phases[0].members[1].source_canister, CHILD);
3252 }
3253
3254 #[test]
3256 fn apply_dry_run_renders_ordered_member_operations() {
3257 let manifest = valid_manifest(IdentityMode::Relocatable);
3258
3259 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3260 let status = RestoreStatus::from_plan(&plan);
3261 let dry_run =
3262 RestoreApplyDryRun::try_from_plan(&plan, Some(&status)).expect("dry-run should build");
3263
3264 assert_eq!(dry_run.dry_run_version, 1);
3265 assert_eq!(dry_run.backup_id.as_str(), "fbk_test_001");
3266 assert!(dry_run.ready);
3267 assert!(dry_run.status_supplied);
3268 assert_eq!(dry_run.member_count, 2);
3269 assert_eq!(dry_run.phase_count, 1);
3270 assert_eq!(dry_run.planned_snapshot_uploads, 2);
3271 assert_eq!(dry_run.planned_snapshot_loads, 2);
3272 assert_eq!(dry_run.planned_code_reinstalls, 2);
3273 assert_eq!(dry_run.planned_verification_checks, 2);
3274 assert_eq!(dry_run.planned_operations, 8);
3275 assert_eq!(dry_run.rendered_operations, 8);
3276 assert_eq!(dry_run.operation_counts.snapshot_uploads, 2);
3277 assert_eq!(dry_run.operation_counts.snapshot_loads, 2);
3278 assert_eq!(dry_run.operation_counts.code_reinstalls, 2);
3279 assert_eq!(dry_run.operation_counts.member_verifications, 2);
3280 assert_eq!(dry_run.operation_counts.fleet_verifications, 0);
3281 assert_eq!(dry_run.operation_counts.verification_operations, 2);
3282 assert_eq!(dry_run.phases.len(), 1);
3283
3284 let operations = &dry_run.phases[0].operations;
3285 assert_eq!(operations[0].sequence, 0);
3286 assert_eq!(
3287 operations[0].operation,
3288 RestoreApplyOperationKind::UploadSnapshot
3289 );
3290 assert_eq!(operations[0].source_canister, ROOT);
3291 assert_eq!(operations[0].target_canister, ROOT);
3292 assert_eq!(operations[0].snapshot_id, Some("snap-root".to_string()));
3293 assert_eq!(
3294 operations[0].artifact_path,
3295 Some("artifacts/root".to_string())
3296 );
3297 assert_eq!(
3298 operations[1].operation,
3299 RestoreApplyOperationKind::LoadSnapshot
3300 );
3301 assert_eq!(
3302 operations[2].operation,
3303 RestoreApplyOperationKind::ReinstallCode
3304 );
3305 assert_eq!(
3306 operations[3].operation,
3307 RestoreApplyOperationKind::VerifyMember
3308 );
3309 assert_eq!(operations[3].verification_kind, Some("call".to_string()));
3310 assert_eq!(
3311 operations[3].verification_method,
3312 Some("canic_ready".to_string())
3313 );
3314 assert_eq!(operations[4].source_canister, CHILD);
3315 assert_eq!(
3316 operations[7].operation,
3317 RestoreApplyOperationKind::VerifyMember
3318 );
3319 }
3320
3321 #[test]
3323 fn apply_dry_run_renders_fleet_verification_operations() {
3324 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3325 manifest.verification.fleet_checks.push(VerificationCheck {
3326 kind: "fleet-ready".to_string(),
3327 method: Some("canic_fleet_ready".to_string()),
3328 roles: Vec::new(),
3329 });
3330
3331 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3332 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3333
3334 assert_eq!(plan.operation_summary.planned_verification_checks, 3);
3335 assert_eq!(dry_run.rendered_operations, 9);
3336 let operation = dry_run.phases[0]
3337 .operations
3338 .last()
3339 .expect("fleet verification operation should be rendered");
3340 assert_eq!(operation.sequence, 8);
3341 assert_eq!(operation.operation, RestoreApplyOperationKind::VerifyFleet);
3342 assert_eq!(operation.source_canister, ROOT);
3343 assert_eq!(operation.target_canister, ROOT);
3344 assert_eq!(operation.role, "fleet");
3345 assert_eq!(operation.snapshot_id, None);
3346 assert_eq!(operation.artifact_path, None);
3347 assert_eq!(operation.verification_kind, Some("fleet-ready".to_string()));
3348 assert_eq!(
3349 operation.verification_method,
3350 Some("canic_fleet_ready".to_string())
3351 );
3352 }
3353
3354 #[test]
3356 fn apply_dry_run_sequences_operations_across_phases() {
3357 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3358 manifest.fleet.members[0].restore_group = 2;
3359
3360 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3361 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3362
3363 assert_eq!(dry_run.phases.len(), 2);
3364 assert_eq!(dry_run.rendered_operations, 8);
3365 assert_eq!(dry_run.phases[0].operations[0].sequence, 0);
3366 assert_eq!(dry_run.phases[0].operations[3].sequence, 3);
3367 assert_eq!(dry_run.phases[1].operations[0].sequence, 4);
3368 assert_eq!(dry_run.phases[1].operations[3].sequence, 7);
3369 }
3370
3371 #[test]
3373 fn apply_dry_run_validates_artifacts_under_backup_root() {
3374 let root = temp_dir("canic-restore-apply-artifacts-ok");
3375 fs::create_dir_all(&root).expect("create temp root");
3376 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3377 set_member_artifact(
3378 &mut manifest,
3379 CHILD,
3380 &root,
3381 "artifacts/child",
3382 b"child-snapshot",
3383 );
3384 set_member_artifact(
3385 &mut manifest,
3386 ROOT,
3387 &root,
3388 "artifacts/root",
3389 b"root-snapshot",
3390 );
3391
3392 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3393 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3394 .expect("dry-run should validate artifacts");
3395
3396 let validation = dry_run
3397 .artifact_validation
3398 .expect("artifact validation should be present");
3399 assert_eq!(validation.checked_members, 2);
3400 assert!(validation.artifacts_present);
3401 assert!(validation.checksums_verified);
3402 assert_eq!(validation.members_with_expected_checksums, 2);
3403 assert_eq!(validation.checks[0].source_canister, ROOT);
3404 assert!(validation.checks[0].checksum_verified);
3405
3406 fs::remove_dir_all(root).expect("remove temp root");
3407 }
3408
3409 #[test]
3411 fn apply_journal_marks_validated_operations_ready() {
3412 let root = temp_dir("canic-restore-apply-journal-ready");
3413 fs::create_dir_all(&root).expect("create temp root");
3414 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3415 set_member_artifact(
3416 &mut manifest,
3417 CHILD,
3418 &root,
3419 "artifacts/child",
3420 b"child-snapshot",
3421 );
3422 set_member_artifact(
3423 &mut manifest,
3424 ROOT,
3425 &root,
3426 "artifacts/root",
3427 b"root-snapshot",
3428 );
3429
3430 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3431 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3432 .expect("dry-run should validate artifacts");
3433 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3434
3435 fs::remove_dir_all(root).expect("remove temp root");
3436 assert_eq!(journal.journal_version, 1);
3437 assert_eq!(journal.backup_id.as_str(), "fbk_test_001");
3438 assert!(journal.ready);
3439 assert!(journal.blocked_reasons.is_empty());
3440 assert_eq!(journal.operation_count, 8);
3441 assert_eq!(journal.ready_operations, 8);
3442 assert_eq!(journal.blocked_operations, 0);
3443 assert_eq!(journal.operations[0].sequence, 0);
3444 assert_eq!(
3445 journal.operations[0].state,
3446 RestoreApplyOperationState::Ready
3447 );
3448 assert!(journal.operations[0].blocking_reasons.is_empty());
3449 }
3450
3451 #[test]
3453 fn apply_journal_blocks_without_artifact_validation() {
3454 let manifest = valid_manifest(IdentityMode::Relocatable);
3455
3456 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3457 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3458 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3459
3460 assert!(!journal.ready);
3461 assert_eq!(journal.ready_operations, 0);
3462 assert_eq!(journal.blocked_operations, 8);
3463 assert!(
3464 journal
3465 .blocked_reasons
3466 .contains(&"missing-artifact-validation".to_string())
3467 );
3468 assert!(
3469 journal.operations[0]
3470 .blocking_reasons
3471 .contains(&"missing-artifact-validation".to_string())
3472 );
3473 }
3474
3475 #[test]
3477 fn apply_journal_status_reports_next_ready_operation() {
3478 let root = temp_dir("canic-restore-apply-journal-status");
3479 fs::create_dir_all(&root).expect("create temp root");
3480 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3481 set_member_artifact(
3482 &mut manifest,
3483 CHILD,
3484 &root,
3485 "artifacts/child",
3486 b"child-snapshot",
3487 );
3488 set_member_artifact(
3489 &mut manifest,
3490 ROOT,
3491 &root,
3492 "artifacts/root",
3493 b"root-snapshot",
3494 );
3495
3496 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3497 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3498 .expect("dry-run should validate artifacts");
3499 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3500 let status = journal.status();
3501 let report = journal.report();
3502
3503 fs::remove_dir_all(root).expect("remove temp root");
3504 assert_eq!(status.status_version, 1);
3505 assert_eq!(status.backup_id.as_str(), "fbk_test_001");
3506 assert!(status.ready);
3507 assert!(!status.complete);
3508 assert_eq!(status.operation_count, 8);
3509 assert_eq!(status.operation_counts.snapshot_uploads, 2);
3510 assert_eq!(status.operation_counts.snapshot_loads, 2);
3511 assert_eq!(status.operation_counts.code_reinstalls, 2);
3512 assert_eq!(status.operation_counts.member_verifications, 2);
3513 assert_eq!(status.operation_counts.fleet_verifications, 0);
3514 assert_eq!(status.operation_counts.verification_operations, 2);
3515 assert!(status.operation_counts_supplied);
3516 assert_eq!(journal.operation_counts, status.operation_counts);
3517 assert_eq!(report.operation_counts, status.operation_counts);
3518 assert!(report.operation_counts_supplied);
3519 assert_eq!(status.ready_operations, 8);
3520 assert_eq!(status.next_ready_sequence, Some(0));
3521 assert_eq!(
3522 status.next_ready_operation,
3523 Some(RestoreApplyOperationKind::UploadSnapshot)
3524 );
3525 assert_eq!(status.next_transition_sequence, Some(0));
3526 assert_eq!(
3527 status.next_transition_state,
3528 Some(RestoreApplyOperationState::Ready)
3529 );
3530 assert_eq!(
3531 status.next_transition_operation,
3532 Some(RestoreApplyOperationKind::UploadSnapshot)
3533 );
3534 }
3535
3536 #[test]
3538 fn apply_journal_next_operation_reports_full_ready_row() {
3539 let root = temp_dir("canic-restore-apply-journal-next");
3540 fs::create_dir_all(&root).expect("create temp root");
3541 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3542 set_member_artifact(
3543 &mut manifest,
3544 CHILD,
3545 &root,
3546 "artifacts/child",
3547 b"child-snapshot",
3548 );
3549 set_member_artifact(
3550 &mut manifest,
3551 ROOT,
3552 &root,
3553 "artifacts/root",
3554 b"root-snapshot",
3555 );
3556
3557 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3558 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3559 .expect("dry-run should validate artifacts");
3560 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3561 journal
3562 .mark_operation_completed(0)
3563 .expect("mark operation completed");
3564 let next = journal.next_operation();
3565
3566 fs::remove_dir_all(root).expect("remove temp root");
3567 assert!(next.ready);
3568 assert!(!next.complete);
3569 assert!(next.operation_available);
3570 let operation = next.operation.expect("next operation");
3571 assert_eq!(operation.sequence, 1);
3572 assert_eq!(operation.state, RestoreApplyOperationState::Ready);
3573 assert_eq!(operation.operation, RestoreApplyOperationKind::LoadSnapshot);
3574 assert_eq!(operation.source_canister, ROOT);
3575 }
3576
3577 #[test]
3579 fn apply_journal_next_operation_reports_blocked_state() {
3580 let manifest = valid_manifest(IdentityMode::Relocatable);
3581
3582 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3583 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3584 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3585 let next = journal.next_operation();
3586
3587 assert!(!next.ready);
3588 assert!(!next.operation_available);
3589 assert!(next.operation.is_none());
3590 assert!(
3591 next.blocked_reasons
3592 .contains(&"missing-artifact-validation".to_string())
3593 );
3594 }
3595
3596 #[test]
3598 fn apply_journal_command_preview_reports_upload_command() {
3599 let root = temp_dir("canic-restore-apply-command-upload");
3600 fs::create_dir_all(&root).expect("create temp root");
3601 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3602 set_member_artifact(
3603 &mut manifest,
3604 CHILD,
3605 &root,
3606 "artifacts/child",
3607 b"child-snapshot",
3608 );
3609 set_member_artifact(
3610 &mut manifest,
3611 ROOT,
3612 &root,
3613 "artifacts/root",
3614 b"root-snapshot",
3615 );
3616
3617 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3618 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3619 .expect("dry-run should validate artifacts");
3620 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3621 let preview = journal.next_command_preview();
3622
3623 fs::remove_dir_all(root).expect("remove temp root");
3624 assert!(preview.ready);
3625 assert!(preview.operation_available);
3626 assert!(preview.command_available);
3627 let command = preview.command.expect("command preview");
3628 assert_eq!(command.program, "dfx");
3629 assert_eq!(
3630 command.args,
3631 vec![
3632 "canister".to_string(),
3633 "snapshot".to_string(),
3634 "upload".to_string(),
3635 "--dir".to_string(),
3636 "artifacts/root".to_string(),
3637 ROOT.to_string(),
3638 ]
3639 );
3640 assert!(command.mutates);
3641 assert!(!command.requires_stopped_canister);
3642 }
3643
3644 #[test]
3646 fn apply_journal_command_preview_honors_command_config() {
3647 let root = temp_dir("canic-restore-apply-command-config");
3648 fs::create_dir_all(&root).expect("create temp root");
3649 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3650 set_member_artifact(
3651 &mut manifest,
3652 CHILD,
3653 &root,
3654 "artifacts/child",
3655 b"child-snapshot",
3656 );
3657 set_member_artifact(
3658 &mut manifest,
3659 ROOT,
3660 &root,
3661 "artifacts/root",
3662 b"root-snapshot",
3663 );
3664
3665 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3666 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3667 .expect("dry-run should validate artifacts");
3668 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3669 let preview = journal.next_command_preview_with_config(&RestoreApplyCommandConfig {
3670 program: "/tmp/dfx".to_string(),
3671 network: Some("local".to_string()),
3672 });
3673
3674 fs::remove_dir_all(root).expect("remove temp root");
3675 let command = preview.command.expect("command preview");
3676 assert_eq!(command.program, "/tmp/dfx");
3677 assert_eq!(
3678 command.args,
3679 vec![
3680 "canister".to_string(),
3681 "--network".to_string(),
3682 "local".to_string(),
3683 "snapshot".to_string(),
3684 "upload".to_string(),
3685 "--dir".to_string(),
3686 "artifacts/root".to_string(),
3687 ROOT.to_string(),
3688 ]
3689 );
3690 }
3691
3692 #[test]
3694 fn apply_journal_command_preview_reports_load_command() {
3695 let root = temp_dir("canic-restore-apply-command-load");
3696 fs::create_dir_all(&root).expect("create temp root");
3697 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3698 set_member_artifact(
3699 &mut manifest,
3700 CHILD,
3701 &root,
3702 "artifacts/child",
3703 b"child-snapshot",
3704 );
3705 set_member_artifact(
3706 &mut manifest,
3707 ROOT,
3708 &root,
3709 "artifacts/root",
3710 b"root-snapshot",
3711 );
3712
3713 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3714 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3715 .expect("dry-run should validate artifacts");
3716 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3717 journal
3718 .mark_operation_completed(0)
3719 .expect("mark upload completed");
3720 let preview = journal.next_command_preview();
3721
3722 fs::remove_dir_all(root).expect("remove temp root");
3723 let command = preview.command.expect("command preview");
3724 assert_eq!(
3725 command.args,
3726 vec![
3727 "canister".to_string(),
3728 "snapshot".to_string(),
3729 "load".to_string(),
3730 ROOT.to_string(),
3731 "snap-root".to_string(),
3732 ]
3733 );
3734 assert!(command.mutates);
3735 assert!(command.requires_stopped_canister);
3736 }
3737
3738 #[test]
3740 fn apply_journal_command_preview_reports_reinstall_command() {
3741 let journal = command_preview_journal(RestoreApplyOperationKind::ReinstallCode, None, None);
3742 let preview = journal.next_command_preview_with_config(&RestoreApplyCommandConfig {
3743 program: "dfx".to_string(),
3744 network: Some("local".to_string()),
3745 });
3746
3747 assert!(preview.command_available);
3748 let command = preview.command.expect("command preview");
3749 assert_eq!(
3750 command.args,
3751 vec![
3752 "canister".to_string(),
3753 "--network".to_string(),
3754 "local".to_string(),
3755 "install".to_string(),
3756 "--mode".to_string(),
3757 "reinstall".to_string(),
3758 "--yes".to_string(),
3759 ROOT.to_string(),
3760 ]
3761 );
3762 assert!(command.mutates);
3763 assert!(!command.requires_stopped_canister);
3764 }
3765
3766 #[test]
3768 fn apply_journal_command_preview_reports_status_verification_command() {
3769 let journal = command_preview_journal(
3770 RestoreApplyOperationKind::VerifyMember,
3771 Some("status"),
3772 None,
3773 );
3774 let preview = journal.next_command_preview();
3775
3776 assert!(preview.command_available);
3777 let command = preview.command.expect("command preview");
3778 assert_eq!(
3779 command.args,
3780 vec![
3781 "canister".to_string(),
3782 "status".to_string(),
3783 ROOT.to_string()
3784 ]
3785 );
3786 assert!(!command.mutates);
3787 assert!(!command.requires_stopped_canister);
3788 }
3789
3790 #[test]
3792 fn apply_journal_command_preview_reports_method_verification_command() {
3793 let journal = command_preview_journal(
3794 RestoreApplyOperationKind::VerifyMember,
3795 Some("query"),
3796 Some("health"),
3797 );
3798 let preview = journal.next_command_preview();
3799
3800 assert!(preview.command_available);
3801 let command = preview.command.expect("command preview");
3802 assert_eq!(
3803 command.args,
3804 vec![
3805 "canister".to_string(),
3806 "call".to_string(),
3807 ROOT.to_string(),
3808 "health".to_string(),
3809 ]
3810 );
3811 assert!(!command.mutates);
3812 assert!(!command.requires_stopped_canister);
3813 }
3814
3815 #[test]
3817 fn apply_journal_command_preview_reports_fleet_verification_command() {
3818 let journal = command_preview_journal(
3819 RestoreApplyOperationKind::VerifyFleet,
3820 Some("fleet-ready"),
3821 Some("canic_fleet_ready"),
3822 );
3823 let preview = journal.next_command_preview();
3824
3825 assert!(preview.command_available);
3826 let command = preview.command.expect("command preview");
3827 assert_eq!(
3828 command.args,
3829 vec![
3830 "canister".to_string(),
3831 "call".to_string(),
3832 ROOT.to_string(),
3833 "canic_fleet_ready".to_string(),
3834 ]
3835 );
3836 assert!(!command.mutates);
3837 assert!(!command.requires_stopped_canister);
3838 assert_eq!(command.note, "calls the declared fleet verification method");
3839 }
3840
3841 #[test]
3843 fn apply_journal_validation_rejects_method_verification_without_method() {
3844 let journal = RestoreApplyJournal {
3845 journal_version: 1,
3846 backup_id: "fbk_test_001".to_string(),
3847 ready: true,
3848 blocked_reasons: Vec::new(),
3849 operation_count: 1,
3850 operation_counts: RestoreApplyOperationKindCounts::default(),
3851 pending_operations: 0,
3852 ready_operations: 1,
3853 blocked_operations: 0,
3854 completed_operations: 0,
3855 failed_operations: 0,
3856 operations: vec![RestoreApplyJournalOperation {
3857 sequence: 0,
3858 operation: RestoreApplyOperationKind::VerifyMember,
3859 state: RestoreApplyOperationState::Ready,
3860 state_updated_at: None,
3861 blocking_reasons: Vec::new(),
3862 restore_group: 1,
3863 phase_order: 0,
3864 source_canister: ROOT.to_string(),
3865 target_canister: ROOT.to_string(),
3866 role: "root".to_string(),
3867 snapshot_id: Some("snap-root".to_string()),
3868 artifact_path: Some("artifacts/root".to_string()),
3869 verification_kind: Some("query".to_string()),
3870 verification_method: None,
3871 }],
3872 };
3873
3874 let err = journal
3875 .validate()
3876 .expect_err("method verification without method should fail");
3877
3878 assert!(matches!(
3879 err,
3880 RestoreApplyJournalError::OperationMissingField {
3881 sequence: 0,
3882 operation: RestoreApplyOperationKind::VerifyMember,
3883 field: "operations[].verification_method",
3884 }
3885 ));
3886 }
3887
3888 #[test]
3890 fn apply_journal_validation_rejects_count_mismatch() {
3891 let manifest = valid_manifest(IdentityMode::Relocatable);
3892
3893 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3894 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3895 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3896 journal.blocked_operations = 0;
3897
3898 let err = journal.validate().expect_err("count mismatch should fail");
3899
3900 assert!(matches!(
3901 err,
3902 RestoreApplyJournalError::CountMismatch {
3903 field: "blocked_operations",
3904 ..
3905 }
3906 ));
3907 }
3908
3909 #[test]
3911 fn apply_journal_validation_rejects_operation_kind_count_mismatch() {
3912 let mut journal =
3913 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
3914 journal.operation_counts = RestoreApplyOperationKindCounts {
3915 snapshot_uploads: 0,
3916 snapshot_loads: 1,
3917 code_reinstalls: 0,
3918 member_verifications: 0,
3919 fleet_verifications: 0,
3920 verification_operations: 0,
3921 };
3922
3923 let err = journal
3924 .validate()
3925 .expect_err("operation-kind count mismatch should fail");
3926
3927 assert!(matches!(
3928 err,
3929 RestoreApplyJournalError::CountMismatch {
3930 field: "operation_counts.snapshot_uploads",
3931 reported: 0,
3932 actual: 1,
3933 }
3934 ));
3935 }
3936
3937 #[test]
3939 fn apply_journal_defaults_missing_operation_kind_counts() {
3940 let mut journal =
3941 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
3942 journal.operation_counts =
3943 RestoreApplyOperationKindCounts::from_operations(&journal.operations);
3944 let mut value = serde_json::to_value(&journal).expect("serialize journal");
3945 value
3946 .as_object_mut()
3947 .expect("journal should serialize as an object")
3948 .remove("operation_counts");
3949
3950 let decoded: RestoreApplyJournal =
3951 serde_json::from_value(value).expect("decode old journal shape");
3952 decoded.validate().expect("old journal should validate");
3953 let status = decoded.status();
3954
3955 assert_eq!(
3956 decoded.operation_counts,
3957 RestoreApplyOperationKindCounts::default()
3958 );
3959 assert_eq!(status.operation_counts.snapshot_uploads, 1);
3960 assert_eq!(status.operation_counts.snapshot_loads, 0);
3961 assert!(!status.operation_counts_supplied);
3962 }
3963
3964 #[test]
3966 fn apply_journal_validation_rejects_duplicate_sequences() {
3967 let manifest = valid_manifest(IdentityMode::Relocatable);
3968
3969 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3970 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3971 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3972 journal.operations[1].sequence = journal.operations[0].sequence;
3973
3974 let err = journal
3975 .validate()
3976 .expect_err("duplicate sequence should fail");
3977
3978 assert!(matches!(
3979 err,
3980 RestoreApplyJournalError::DuplicateSequence(0)
3981 ));
3982 }
3983
3984 #[test]
3986 fn apply_journal_validation_rejects_failed_without_reason() {
3987 let manifest = valid_manifest(IdentityMode::Relocatable);
3988
3989 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3990 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3991 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3992 journal.operations[0].state = RestoreApplyOperationState::Failed;
3993 journal.operations[0].blocking_reasons = Vec::new();
3994 journal.blocked_operations -= 1;
3995 journal.failed_operations = 1;
3996
3997 let err = journal
3998 .validate()
3999 .expect_err("failed operation without reason should fail");
4000
4001 assert!(matches!(
4002 err,
4003 RestoreApplyJournalError::FailureReasonRequired(0)
4004 ));
4005 }
4006
4007 #[test]
4009 fn apply_journal_mark_next_operation_pending_claims_first_operation() {
4010 let mut journal =
4011 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
4012
4013 journal
4014 .mark_next_operation_pending_at(Some("2026-05-04T12:00:00Z".to_string()))
4015 .expect("mark operation pending");
4016 let status = journal.status();
4017 let next = journal.next_operation();
4018 let preview = journal.next_command_preview();
4019
4020 assert_eq!(journal.pending_operations, 1);
4021 assert_eq!(journal.ready_operations, 0);
4022 assert_eq!(
4023 journal.operations[0].state,
4024 RestoreApplyOperationState::Pending
4025 );
4026 assert_eq!(
4027 journal.operations[0].state_updated_at.as_deref(),
4028 Some("2026-05-04T12:00:00Z")
4029 );
4030 assert_eq!(status.next_ready_sequence, None);
4031 assert_eq!(status.next_transition_sequence, Some(0));
4032 assert_eq!(
4033 status.next_transition_state,
4034 Some(RestoreApplyOperationState::Pending)
4035 );
4036 assert_eq!(
4037 status.next_transition_updated_at.as_deref(),
4038 Some("2026-05-04T12:00:00Z")
4039 );
4040 assert!(next.operation_available);
4041 assert_eq!(
4042 next.operation.expect("next operation").state,
4043 RestoreApplyOperationState::Pending
4044 );
4045 assert!(preview.operation_available);
4046 assert!(preview.command_available);
4047 assert_eq!(
4048 preview.operation.expect("preview operation").state,
4049 RestoreApplyOperationState::Pending
4050 );
4051 }
4052
4053 #[test]
4055 fn apply_journal_mark_next_operation_ready_unclaims_pending_operation() {
4056 let mut journal =
4057 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
4058
4059 journal
4060 .mark_next_operation_pending_at(Some("2026-05-04T12:00:00Z".to_string()))
4061 .expect("mark operation pending");
4062 journal
4063 .mark_next_operation_ready_at(Some("2026-05-04T12:01:00Z".to_string()))
4064 .expect("mark operation ready");
4065 let status = journal.status();
4066 let next = journal.next_operation();
4067
4068 assert_eq!(journal.pending_operations, 0);
4069 assert_eq!(journal.ready_operations, 1);
4070 assert_eq!(
4071 journal.operations[0].state,
4072 RestoreApplyOperationState::Ready
4073 );
4074 assert_eq!(
4075 journal.operations[0].state_updated_at.as_deref(),
4076 Some("2026-05-04T12:01:00Z")
4077 );
4078 assert_eq!(status.next_ready_sequence, Some(0));
4079 assert_eq!(status.next_transition_sequence, Some(0));
4080 assert_eq!(
4081 status.next_transition_state,
4082 Some(RestoreApplyOperationState::Ready)
4083 );
4084 assert_eq!(
4085 status.next_transition_updated_at.as_deref(),
4086 Some("2026-05-04T12:01:00Z")
4087 );
4088 assert_eq!(
4089 next.operation.expect("next operation").state,
4090 RestoreApplyOperationState::Ready
4091 );
4092 }
4093
4094 #[test]
4096 fn apply_journal_validation_rejects_empty_state_updated_at() {
4097 let mut journal =
4098 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
4099
4100 journal.operations[0].state_updated_at = Some(String::new());
4101 let err = journal
4102 .validate()
4103 .expect_err("empty state update marker should fail");
4104
4105 assert!(matches!(
4106 err,
4107 RestoreApplyJournalError::MissingField("operations[].state_updated_at")
4108 ));
4109 }
4110
4111 #[test]
4113 fn apply_journal_validation_rejects_missing_operation_fields() {
4114 let mut upload =
4115 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
4116 upload.operations[0].artifact_path = None;
4117 let err = upload
4118 .validate()
4119 .expect_err("upload without artifact path should fail");
4120 assert!(matches!(
4121 err,
4122 RestoreApplyJournalError::OperationMissingField {
4123 sequence: 0,
4124 operation: RestoreApplyOperationKind::UploadSnapshot,
4125 field: "operations[].artifact_path",
4126 }
4127 ));
4128
4129 let mut load = command_preview_journal(RestoreApplyOperationKind::LoadSnapshot, None, None);
4130 load.operations[0].snapshot_id = None;
4131 let err = load
4132 .validate()
4133 .expect_err("load without snapshot id should fail");
4134 assert!(matches!(
4135 err,
4136 RestoreApplyJournalError::OperationMissingField {
4137 sequence: 0,
4138 operation: RestoreApplyOperationKind::LoadSnapshot,
4139 field: "operations[].snapshot_id",
4140 }
4141 ));
4142
4143 let mut verify = command_preview_journal(
4144 RestoreApplyOperationKind::VerifyMember,
4145 Some("query"),
4146 Some("health"),
4147 );
4148 verify.operations[0].verification_method = None;
4149 let err = verify
4150 .validate()
4151 .expect_err("method verification without method should fail");
4152 assert!(matches!(
4153 err,
4154 RestoreApplyJournalError::OperationMissingField {
4155 sequence: 0,
4156 operation: RestoreApplyOperationKind::VerifyMember,
4157 field: "operations[].verification_method",
4158 }
4159 ));
4160 }
4161
4162 #[test]
4164 fn apply_journal_mark_next_operation_ready_rejects_without_pending_operation() {
4165 let mut journal =
4166 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
4167
4168 let err = journal
4169 .mark_next_operation_ready()
4170 .expect_err("ready operation should not unclaim");
4171
4172 assert!(matches!(err, RestoreApplyJournalError::NoPendingOperation));
4173 assert_eq!(journal.ready_operations, 1);
4174 assert_eq!(journal.pending_operations, 0);
4175 }
4176
4177 #[test]
4179 fn apply_journal_mark_pending_rejects_out_of_order_operation() {
4180 let root = temp_dir("canic-restore-apply-journal-pending-out-of-order");
4181 fs::create_dir_all(&root).expect("create temp root");
4182 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4183 set_member_artifact(
4184 &mut manifest,
4185 CHILD,
4186 &root,
4187 "artifacts/child",
4188 b"child-snapshot",
4189 );
4190 set_member_artifact(
4191 &mut manifest,
4192 ROOT,
4193 &root,
4194 "artifacts/root",
4195 b"root-snapshot",
4196 );
4197
4198 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4199 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
4200 .expect("dry-run should validate artifacts");
4201 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
4202
4203 let err = journal
4204 .mark_operation_pending(1)
4205 .expect_err("out-of-order pending claim should fail");
4206
4207 fs::remove_dir_all(root).expect("remove temp root");
4208 assert!(matches!(
4209 err,
4210 RestoreApplyJournalError::OutOfOrderOperationTransition {
4211 requested: 1,
4212 next: 0
4213 }
4214 ));
4215 assert_eq!(journal.pending_operations, 0);
4216 assert_eq!(journal.ready_operations, 8);
4217 }
4218
4219 #[test]
4221 fn apply_journal_mark_completed_advances_next_ready_operation() {
4222 let root = temp_dir("canic-restore-apply-journal-completed");
4223 fs::create_dir_all(&root).expect("create temp root");
4224 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4225 set_member_artifact(
4226 &mut manifest,
4227 CHILD,
4228 &root,
4229 "artifacts/child",
4230 b"child-snapshot",
4231 );
4232 set_member_artifact(
4233 &mut manifest,
4234 ROOT,
4235 &root,
4236 "artifacts/root",
4237 b"root-snapshot",
4238 );
4239
4240 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4241 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
4242 .expect("dry-run should validate artifacts");
4243 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
4244
4245 journal
4246 .mark_operation_completed(0)
4247 .expect("mark operation completed");
4248 let status = journal.status();
4249
4250 fs::remove_dir_all(root).expect("remove temp root");
4251 assert_eq!(
4252 journal.operations[0].state,
4253 RestoreApplyOperationState::Completed
4254 );
4255 assert_eq!(journal.completed_operations, 1);
4256 assert_eq!(journal.ready_operations, 7);
4257 assert_eq!(status.next_ready_sequence, Some(1));
4258 }
4259
4260 #[test]
4262 fn apply_journal_mark_completed_rejects_out_of_order_operation() {
4263 let root = temp_dir("canic-restore-apply-journal-out-of-order");
4264 fs::create_dir_all(&root).expect("create temp root");
4265 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4266 set_member_artifact(
4267 &mut manifest,
4268 CHILD,
4269 &root,
4270 "artifacts/child",
4271 b"child-snapshot",
4272 );
4273 set_member_artifact(
4274 &mut manifest,
4275 ROOT,
4276 &root,
4277 "artifacts/root",
4278 b"root-snapshot",
4279 );
4280
4281 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4282 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
4283 .expect("dry-run should validate artifacts");
4284 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
4285
4286 let err = journal
4287 .mark_operation_completed(1)
4288 .expect_err("out-of-order operation should fail");
4289
4290 fs::remove_dir_all(root).expect("remove temp root");
4291 assert!(matches!(
4292 err,
4293 RestoreApplyJournalError::OutOfOrderOperationTransition {
4294 requested: 1,
4295 next: 0
4296 }
4297 ));
4298 assert_eq!(journal.completed_operations, 0);
4299 assert_eq!(journal.ready_operations, 8);
4300 }
4301
4302 #[test]
4304 fn apply_journal_mark_failed_records_reason() {
4305 let root = temp_dir("canic-restore-apply-journal-failed");
4306 fs::create_dir_all(&root).expect("create temp root");
4307 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4308 set_member_artifact(
4309 &mut manifest,
4310 CHILD,
4311 &root,
4312 "artifacts/child",
4313 b"child-snapshot",
4314 );
4315 set_member_artifact(
4316 &mut manifest,
4317 ROOT,
4318 &root,
4319 "artifacts/root",
4320 b"root-snapshot",
4321 );
4322
4323 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4324 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
4325 .expect("dry-run should validate artifacts");
4326 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
4327
4328 journal
4329 .mark_operation_failed(0, "dfx-load-failed".to_string())
4330 .expect("mark operation failed");
4331
4332 fs::remove_dir_all(root).expect("remove temp root");
4333 assert_eq!(
4334 journal.operations[0].state,
4335 RestoreApplyOperationState::Failed
4336 );
4337 assert_eq!(
4338 journal.operations[0].blocking_reasons,
4339 vec!["dfx-load-failed".to_string()]
4340 );
4341 assert_eq!(journal.failed_operations, 1);
4342 assert_eq!(journal.ready_operations, 7);
4343 }
4344
4345 #[test]
4347 fn apply_journal_rejects_blocked_operation_completion() {
4348 let manifest = valid_manifest(IdentityMode::Relocatable);
4349
4350 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4351 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
4352 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
4353
4354 let err = journal
4355 .mark_operation_completed(0)
4356 .expect_err("blocked operation should not complete");
4357
4358 assert!(matches!(
4359 err,
4360 RestoreApplyJournalError::InvalidOperationTransition { sequence: 0, .. }
4361 ));
4362 }
4363
4364 #[test]
4366 fn apply_dry_run_rejects_missing_artifacts() {
4367 let root = temp_dir("canic-restore-apply-artifacts-missing");
4368 fs::create_dir_all(&root).expect("create temp root");
4369 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4370 manifest.fleet.members[0].source_snapshot.artifact_path = "missing-child".to_string();
4371
4372 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4373 let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
4374 .expect_err("missing artifact should fail");
4375
4376 fs::remove_dir_all(root).expect("remove temp root");
4377 assert!(matches!(
4378 err,
4379 RestoreApplyDryRunError::ArtifactMissing { .. }
4380 ));
4381 }
4382
4383 #[test]
4385 fn apply_dry_run_rejects_artifact_path_traversal() {
4386 let root = temp_dir("canic-restore-apply-artifacts-traversal");
4387 fs::create_dir_all(&root).expect("create temp root");
4388 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4389 manifest.fleet.members[1].source_snapshot.artifact_path = "../outside".to_string();
4390
4391 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4392 let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
4393 .expect_err("path traversal should fail");
4394
4395 fs::remove_dir_all(root).expect("remove temp root");
4396 assert!(matches!(
4397 err,
4398 RestoreApplyDryRunError::ArtifactPathEscapesBackup { .. }
4399 ));
4400 }
4401
4402 #[test]
4404 fn apply_dry_run_rejects_mismatched_status() {
4405 let manifest = valid_manifest(IdentityMode::Relocatable);
4406
4407 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4408 let mut status = RestoreStatus::from_plan(&plan);
4409 status.backup_id = "other-backup".to_string();
4410
4411 let err = RestoreApplyDryRun::try_from_plan(&plan, Some(&status))
4412 .expect_err("mismatched status should fail");
4413
4414 assert!(matches!(
4415 err,
4416 RestoreApplyDryRunError::StatusPlanMismatch {
4417 field: "backup_id",
4418 ..
4419 }
4420 ));
4421 }
4422
4423 #[test]
4425 fn plan_expands_role_verification_checks_per_matching_member() {
4426 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4427 manifest.fleet.members.push(fleet_member(
4428 "app",
4429 CHILD_TWO,
4430 Some(ROOT),
4431 IdentityMode::Relocatable,
4432 1,
4433 ));
4434 manifest
4435 .verification
4436 .member_checks
4437 .push(MemberVerificationChecks {
4438 role: "app".to_string(),
4439 checks: vec![VerificationCheck {
4440 kind: "app-ready".to_string(),
4441 method: Some("ready".to_string()),
4442 roles: Vec::new(),
4443 }],
4444 });
4445
4446 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4447
4448 assert_eq!(plan.verification_summary.fleet_checks, 0);
4449 assert_eq!(plan.verification_summary.member_check_groups, 1);
4450 assert_eq!(plan.verification_summary.member_checks, 5);
4451 assert_eq!(plan.verification_summary.members_with_checks, 3);
4452 assert_eq!(plan.verification_summary.total_checks, 5);
4453 }
4454
4455 #[test]
4457 fn plan_applies_member_verification_role_filters() {
4458 let mut manifest = valid_manifest(IdentityMode::Relocatable);
4459 manifest.fleet.members[0]
4460 .verification_checks
4461 .push(VerificationCheck {
4462 kind: "root-only-inline".to_string(),
4463 method: Some("wrong_member".to_string()),
4464 roles: vec!["root".to_string()],
4465 });
4466 manifest
4467 .verification
4468 .member_checks
4469 .push(MemberVerificationChecks {
4470 role: "app".to_string(),
4471 checks: vec![
4472 VerificationCheck {
4473 kind: "app-role-check".to_string(),
4474 method: Some("app_ready".to_string()),
4475 roles: vec!["app".to_string()],
4476 },
4477 VerificationCheck {
4478 kind: "root-filtered-check".to_string(),
4479 method: Some("wrong_role".to_string()),
4480 roles: vec!["root".to_string()],
4481 },
4482 ],
4483 });
4484
4485 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
4486 let app = plan
4487 .ordered_members()
4488 .into_iter()
4489 .find(|member| member.role == "app")
4490 .expect("app member should be planned");
4491 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
4492 let app_verification_methods = dry_run.phases[0]
4493 .operations
4494 .iter()
4495 .filter(|operation| {
4496 operation.source_canister == CHILD
4497 && operation.operation == RestoreApplyOperationKind::VerifyMember
4498 })
4499 .filter_map(|operation| operation.verification_method.as_deref())
4500 .collect::<Vec<_>>();
4501
4502 assert_eq!(app.verification_checks.len(), 2);
4503 assert_eq!(
4504 app.verification_checks
4505 .iter()
4506 .map(|check| check.kind.as_str())
4507 .collect::<Vec<_>>(),
4508 ["call", "app-role-check"]
4509 );
4510 assert_eq!(plan.verification_summary.member_checks, 3);
4511 assert_eq!(plan.verification_summary.total_checks, 3);
4512 assert_eq!(dry_run.rendered_operations, 9);
4513 assert_eq!(app_verification_methods, ["canic_ready", "app_ready"]);
4514 }
4515
4516 #[test]
4518 fn mapped_restore_requires_complete_mapping() {
4519 let manifest = valid_manifest(IdentityMode::Relocatable);
4520 let mapping = RestoreMapping {
4521 members: vec![RestoreMappingEntry {
4522 source_canister: ROOT.to_string(),
4523 target_canister: ROOT.to_string(),
4524 }],
4525 };
4526
4527 let err = RestorePlanner::plan(&manifest, Some(&mapping))
4528 .expect_err("incomplete mapping should fail");
4529
4530 assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
4531 }
4532
4533 #[test]
4535 fn mapped_restore_rejects_unknown_mapping_sources() {
4536 let manifest = valid_manifest(IdentityMode::Relocatable);
4537 let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
4538 let mapping = RestoreMapping {
4539 members: vec![
4540 RestoreMappingEntry {
4541 source_canister: ROOT.to_string(),
4542 target_canister: ROOT.to_string(),
4543 },
4544 RestoreMappingEntry {
4545 source_canister: CHILD.to_string(),
4546 target_canister: TARGET.to_string(),
4547 },
4548 RestoreMappingEntry {
4549 source_canister: unknown.to_string(),
4550 target_canister: unknown.to_string(),
4551 },
4552 ],
4553 };
4554
4555 let err = RestorePlanner::plan(&manifest, Some(&mapping))
4556 .expect_err("unknown mapping source should fail");
4557
4558 assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
4559 }
4560
4561 #[test]
4563 fn duplicate_mapping_targets_fail_validation() {
4564 let manifest = valid_manifest(IdentityMode::Relocatable);
4565 let mapping = RestoreMapping {
4566 members: vec![
4567 RestoreMappingEntry {
4568 source_canister: ROOT.to_string(),
4569 target_canister: ROOT.to_string(),
4570 },
4571 RestoreMappingEntry {
4572 source_canister: CHILD.to_string(),
4573 target_canister: ROOT.to_string(),
4574 },
4575 ],
4576 };
4577
4578 let err = RestorePlanner::plan(&manifest, Some(&mapping))
4579 .expect_err("duplicate targets should fail");
4580
4581 assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
4582 }
4583
4584 fn set_member_artifact(
4586 manifest: &mut FleetBackupManifest,
4587 canister_id: &str,
4588 root: &Path,
4589 artifact_path: &str,
4590 bytes: &[u8],
4591 ) {
4592 let full_path = root.join(artifact_path);
4593 fs::create_dir_all(full_path.parent().expect("artifact parent")).expect("create parent");
4594 fs::write(&full_path, bytes).expect("write artifact");
4595 let checksum = ArtifactChecksum::from_bytes(bytes);
4596 let member = manifest
4597 .fleet
4598 .members
4599 .iter_mut()
4600 .find(|member| member.canister_id == canister_id)
4601 .expect("member should exist");
4602 member.source_snapshot.artifact_path = artifact_path.to_string();
4603 member.source_snapshot.checksum = Some(checksum.hash);
4604 }
4605
4606 fn temp_dir(name: &str) -> PathBuf {
4608 let nanos = SystemTime::now()
4609 .duration_since(UNIX_EPOCH)
4610 .expect("system time should be after epoch")
4611 .as_nanos();
4612 env::temp_dir().join(format!("{name}-{nanos}"))
4613 }
4614}