1use crate::manifest::{
2 FleetBackupManifest, FleetMember, IdentityMode, ManifestValidationError, SourceSnapshot,
3 VerificationCheck,
4};
5use candid::Principal;
6use serde::{Deserialize, Serialize};
7use std::{
8 collections::{BTreeMap, BTreeSet},
9 str::FromStr,
10};
11use thiserror::Error as ThisError;
12
13#[derive(Clone, Debug, Default, Deserialize, Serialize)]
18pub struct RestoreMapping {
19 pub members: Vec<RestoreMappingEntry>,
20}
21
22impl RestoreMapping {
23 fn target_for(&self, source_canister: &str) -> Option<&str> {
25 self.members
26 .iter()
27 .find(|entry| entry.source_canister == source_canister)
28 .map(|entry| entry.target_canister.as_str())
29 }
30}
31
32#[derive(Clone, Debug, Deserialize, Serialize)]
37pub struct RestoreMappingEntry {
38 pub source_canister: String,
39 pub target_canister: String,
40}
41
42#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
47pub struct RestorePlan {
48 pub backup_id: String,
49 pub source_environment: String,
50 pub source_root_canister: String,
51 pub topology_hash: String,
52 pub member_count: usize,
53 pub identity_summary: RestoreIdentitySummary,
54 pub snapshot_summary: RestoreSnapshotSummary,
55 pub verification_summary: RestoreVerificationSummary,
56 pub readiness_summary: RestoreReadinessSummary,
57 pub operation_summary: RestoreOperationSummary,
58 pub ordering_summary: RestoreOrderingSummary,
59 pub phases: Vec<RestorePhase>,
60}
61
62impl RestorePlan {
63 #[must_use]
65 pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
66 self.phases
67 .iter()
68 .flat_map(|phase| phase.members.iter())
69 .collect()
70 }
71}
72
73#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78pub struct RestoreStatus {
79 pub status_version: u16,
80 pub backup_id: String,
81 pub source_environment: String,
82 pub source_root_canister: String,
83 pub topology_hash: String,
84 pub ready: bool,
85 pub readiness_reasons: Vec<String>,
86 pub verification_required: bool,
87 pub member_count: usize,
88 pub phase_count: usize,
89 pub planned_snapshot_loads: usize,
90 pub planned_code_reinstalls: usize,
91 pub planned_verification_checks: usize,
92 pub phases: Vec<RestoreStatusPhase>,
93}
94
95impl RestoreStatus {
96 #[must_use]
98 pub fn from_plan(plan: &RestorePlan) -> Self {
99 Self {
100 status_version: 1,
101 backup_id: plan.backup_id.clone(),
102 source_environment: plan.source_environment.clone(),
103 source_root_canister: plan.source_root_canister.clone(),
104 topology_hash: plan.topology_hash.clone(),
105 ready: plan.readiness_summary.ready,
106 readiness_reasons: plan.readiness_summary.reasons.clone(),
107 verification_required: plan.verification_summary.verification_required,
108 member_count: plan.member_count,
109 phase_count: plan.ordering_summary.phase_count,
110 planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
111 planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
112 planned_verification_checks: plan.operation_summary.planned_verification_checks,
113 phases: plan
114 .phases
115 .iter()
116 .map(RestoreStatusPhase::from_plan_phase)
117 .collect(),
118 }
119 }
120}
121
122#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
127pub struct RestoreStatusPhase {
128 pub restore_group: u16,
129 pub members: Vec<RestoreStatusMember>,
130}
131
132impl RestoreStatusPhase {
133 fn from_plan_phase(phase: &RestorePhase) -> Self {
135 Self {
136 restore_group: phase.restore_group,
137 members: phase
138 .members
139 .iter()
140 .map(RestoreStatusMember::from_plan_member)
141 .collect(),
142 }
143 }
144}
145
146#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
151pub struct RestoreStatusMember {
152 pub source_canister: String,
153 pub target_canister: String,
154 pub role: String,
155 pub restore_group: u16,
156 pub phase_order: usize,
157 pub snapshot_id: String,
158 pub artifact_path: String,
159 pub state: RestoreMemberState,
160}
161
162impl RestoreStatusMember {
163 fn from_plan_member(member: &RestorePlanMember) -> Self {
165 Self {
166 source_canister: member.source_canister.clone(),
167 target_canister: member.target_canister.clone(),
168 role: member.role.clone(),
169 restore_group: member.restore_group,
170 phase_order: member.phase_order,
171 snapshot_id: member.source_snapshot.snapshot_id.clone(),
172 artifact_path: member.source_snapshot.artifact_path.clone(),
173 state: RestoreMemberState::Planned,
174 }
175 }
176}
177
178#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
183#[serde(rename_all = "kebab-case")]
184pub enum RestoreMemberState {
185 Planned,
186}
187
188#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
193pub struct RestoreIdentitySummary {
194 pub mapping_supplied: bool,
195 pub all_sources_mapped: bool,
196 pub fixed_members: usize,
197 pub relocatable_members: usize,
198 pub in_place_members: usize,
199 pub mapped_members: usize,
200 pub remapped_members: usize,
201}
202
203#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
208#[expect(
209 clippy::struct_excessive_bools,
210 reason = "restore summaries intentionally expose machine-readable readiness flags"
211)]
212pub struct RestoreSnapshotSummary {
213 pub all_members_have_module_hash: bool,
214 pub all_members_have_wasm_hash: bool,
215 pub all_members_have_code_version: bool,
216 pub all_members_have_checksum: bool,
217 pub members_with_module_hash: usize,
218 pub members_with_wasm_hash: usize,
219 pub members_with_code_version: usize,
220 pub members_with_checksum: usize,
221}
222
223#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
228pub struct RestoreVerificationSummary {
229 pub verification_required: bool,
230 pub all_members_have_checks: bool,
231 pub fleet_checks: usize,
232 pub member_check_groups: usize,
233 pub member_checks: usize,
234 pub members_with_checks: usize,
235 pub total_checks: usize,
236}
237
238#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
243pub struct RestoreReadinessSummary {
244 pub ready: bool,
245 pub reasons: Vec<String>,
246}
247
248#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
253pub struct RestoreOperationSummary {
254 pub planned_snapshot_loads: usize,
255 pub planned_code_reinstalls: usize,
256 pub planned_verification_checks: usize,
257 pub planned_phases: usize,
258}
259
260#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
265pub struct RestoreOrderingSummary {
266 pub phase_count: usize,
267 pub dependency_free_members: usize,
268 pub in_group_parent_edges: usize,
269 pub cross_group_parent_edges: usize,
270}
271
272#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
277pub struct RestorePhase {
278 pub restore_group: u16,
279 pub members: Vec<RestorePlanMember>,
280}
281
282#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
287pub struct RestorePlanMember {
288 pub source_canister: String,
289 pub target_canister: String,
290 pub role: String,
291 pub parent_source_canister: Option<String>,
292 pub parent_target_canister: Option<String>,
293 pub ordering_dependency: Option<RestoreOrderingDependency>,
294 pub phase_order: usize,
295 pub restore_group: u16,
296 pub identity_mode: IdentityMode,
297 pub verification_class: String,
298 pub verification_checks: Vec<VerificationCheck>,
299 pub source_snapshot: SourceSnapshot,
300}
301
302#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
307pub struct RestoreOrderingDependency {
308 pub source_canister: String,
309 pub target_canister: String,
310 pub relationship: RestoreOrderingRelationship,
311}
312
313#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
318#[serde(rename_all = "kebab-case")]
319pub enum RestoreOrderingRelationship {
320 ParentInSameGroup,
321 ParentInEarlierGroup,
322}
323
324pub struct RestorePlanner;
329
330impl RestorePlanner {
331 pub fn plan(
333 manifest: &FleetBackupManifest,
334 mapping: Option<&RestoreMapping>,
335 ) -> Result<RestorePlan, RestorePlanError> {
336 manifest.validate()?;
337 if let Some(mapping) = mapping {
338 validate_mapping(mapping)?;
339 validate_mapping_sources(manifest, mapping)?;
340 }
341
342 let members = resolve_members(manifest, mapping)?;
343 let identity_summary = restore_identity_summary(&members, mapping.is_some());
344 let snapshot_summary = restore_snapshot_summary(&members);
345 let verification_summary = restore_verification_summary(manifest, &members);
346 let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
347 validate_restore_group_dependencies(&members)?;
348 let phases = group_and_order_members(members)?;
349 let ordering_summary = restore_ordering_summary(&phases);
350 let operation_summary =
351 restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
352
353 Ok(RestorePlan {
354 backup_id: manifest.backup_id.clone(),
355 source_environment: manifest.source.environment.clone(),
356 source_root_canister: manifest.source.root_canister.clone(),
357 topology_hash: manifest.fleet.topology_hash.clone(),
358 member_count: manifest.fleet.members.len(),
359 identity_summary,
360 snapshot_summary,
361 verification_summary,
362 readiness_summary,
363 operation_summary,
364 ordering_summary,
365 phases,
366 })
367 }
368}
369
370#[derive(Debug, ThisError)]
375pub enum RestorePlanError {
376 #[error(transparent)]
377 InvalidManifest(#[from] ManifestValidationError),
378
379 #[error("field {field} must be a valid principal: {value}")]
380 InvalidPrincipal { field: &'static str, value: String },
381
382 #[error("mapping contains duplicate source canister {0}")]
383 DuplicateMappingSource(String),
384
385 #[error("mapping contains duplicate target canister {0}")]
386 DuplicateMappingTarget(String),
387
388 #[error("mapping references unknown source canister {0}")]
389 UnknownMappingSource(String),
390
391 #[error("mapping is missing source canister {0}")]
392 MissingMappingSource(String),
393
394 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
395 FixedIdentityRemap {
396 source_canister: String,
397 target_canister: String,
398 },
399
400 #[error("restore plan contains duplicate target canister {0}")]
401 DuplicatePlanTarget(String),
402
403 #[error("restore group {0} contains a parent cycle or unresolved dependency")]
404 RestoreOrderCycle(u16),
405
406 #[error(
407 "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
408 )]
409 ParentRestoreGroupAfterChild {
410 child_source_canister: String,
411 parent_source_canister: String,
412 child_restore_group: u16,
413 parent_restore_group: u16,
414 },
415}
416
417fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
419 let mut sources = BTreeSet::new();
420 let mut targets = BTreeSet::new();
421
422 for entry in &mapping.members {
423 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
424 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
425
426 if !sources.insert(entry.source_canister.clone()) {
427 return Err(RestorePlanError::DuplicateMappingSource(
428 entry.source_canister.clone(),
429 ));
430 }
431
432 if !targets.insert(entry.target_canister.clone()) {
433 return Err(RestorePlanError::DuplicateMappingTarget(
434 entry.target_canister.clone(),
435 ));
436 }
437 }
438
439 Ok(())
440}
441
442fn validate_mapping_sources(
444 manifest: &FleetBackupManifest,
445 mapping: &RestoreMapping,
446) -> Result<(), RestorePlanError> {
447 let sources = manifest
448 .fleet
449 .members
450 .iter()
451 .map(|member| member.canister_id.as_str())
452 .collect::<BTreeSet<_>>();
453
454 for entry in &mapping.members {
455 if !sources.contains(entry.source_canister.as_str()) {
456 return Err(RestorePlanError::UnknownMappingSource(
457 entry.source_canister.clone(),
458 ));
459 }
460 }
461
462 Ok(())
463}
464
465fn resolve_members(
467 manifest: &FleetBackupManifest,
468 mapping: Option<&RestoreMapping>,
469) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
470 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
471 let mut targets = BTreeSet::new();
472 let mut source_to_target = BTreeMap::new();
473
474 for member in &manifest.fleet.members {
475 let target = resolve_target(member, mapping)?;
476 if !targets.insert(target.clone()) {
477 return Err(RestorePlanError::DuplicatePlanTarget(target));
478 }
479
480 source_to_target.insert(member.canister_id.clone(), target.clone());
481 plan_members.push(RestorePlanMember {
482 source_canister: member.canister_id.clone(),
483 target_canister: target,
484 role: member.role.clone(),
485 parent_source_canister: member.parent_canister_id.clone(),
486 parent_target_canister: None,
487 ordering_dependency: None,
488 phase_order: 0,
489 restore_group: member.restore_group,
490 identity_mode: member.identity_mode.clone(),
491 verification_class: member.verification_class.clone(),
492 verification_checks: member.verification_checks.clone(),
493 source_snapshot: member.source_snapshot.clone(),
494 });
495 }
496
497 for member in &mut plan_members {
498 member.parent_target_canister = member
499 .parent_source_canister
500 .as_ref()
501 .and_then(|parent| source_to_target.get(parent))
502 .cloned();
503 }
504
505 Ok(plan_members)
506}
507
508fn resolve_target(
510 member: &FleetMember,
511 mapping: Option<&RestoreMapping>,
512) -> Result<String, RestorePlanError> {
513 let target = match mapping {
514 Some(mapping) => mapping
515 .target_for(&member.canister_id)
516 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
517 .to_string(),
518 None => member.canister_id.clone(),
519 };
520
521 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
522 return Err(RestorePlanError::FixedIdentityRemap {
523 source_canister: member.canister_id.clone(),
524 target_canister: target,
525 });
526 }
527
528 Ok(target)
529}
530
531fn restore_identity_summary(
533 members: &[RestorePlanMember],
534 mapping_supplied: bool,
535) -> RestoreIdentitySummary {
536 let mut summary = RestoreIdentitySummary {
537 mapping_supplied,
538 all_sources_mapped: false,
539 fixed_members: 0,
540 relocatable_members: 0,
541 in_place_members: 0,
542 mapped_members: 0,
543 remapped_members: 0,
544 };
545
546 for member in members {
547 match member.identity_mode {
548 IdentityMode::Fixed => summary.fixed_members += 1,
549 IdentityMode::Relocatable => summary.relocatable_members += 1,
550 }
551
552 if member.source_canister == member.target_canister {
553 summary.in_place_members += 1;
554 } else {
555 summary.remapped_members += 1;
556 }
557 if mapping_supplied {
558 summary.mapped_members += 1;
559 }
560 }
561
562 summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
563
564 summary
565}
566
567fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
569 let members_with_module_hash = members
570 .iter()
571 .filter(|member| member.source_snapshot.module_hash.is_some())
572 .count();
573 let members_with_wasm_hash = members
574 .iter()
575 .filter(|member| member.source_snapshot.wasm_hash.is_some())
576 .count();
577 let members_with_code_version = members
578 .iter()
579 .filter(|member| member.source_snapshot.code_version.is_some())
580 .count();
581 let members_with_checksum = members
582 .iter()
583 .filter(|member| member.source_snapshot.checksum.is_some())
584 .count();
585
586 RestoreSnapshotSummary {
587 all_members_have_module_hash: members_with_module_hash == members.len(),
588 all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
589 all_members_have_code_version: members_with_code_version == members.len(),
590 all_members_have_checksum: members_with_checksum == members.len(),
591 members_with_module_hash,
592 members_with_wasm_hash,
593 members_with_code_version,
594 members_with_checksum,
595 }
596}
597
598fn restore_readiness_summary(
600 snapshot: &RestoreSnapshotSummary,
601 verification: &RestoreVerificationSummary,
602) -> RestoreReadinessSummary {
603 let mut reasons = Vec::new();
604
605 if !snapshot.all_members_have_module_hash {
606 reasons.push("missing-module-hash".to_string());
607 }
608 if !snapshot.all_members_have_wasm_hash {
609 reasons.push("missing-wasm-hash".to_string());
610 }
611 if !snapshot.all_members_have_code_version {
612 reasons.push("missing-code-version".to_string());
613 }
614 if !snapshot.all_members_have_checksum {
615 reasons.push("missing-snapshot-checksum".to_string());
616 }
617 if !verification.all_members_have_checks {
618 reasons.push("missing-verification-checks".to_string());
619 }
620
621 RestoreReadinessSummary {
622 ready: reasons.is_empty(),
623 reasons,
624 }
625}
626
627fn restore_verification_summary(
629 manifest: &FleetBackupManifest,
630 members: &[RestorePlanMember],
631) -> RestoreVerificationSummary {
632 let fleet_checks = manifest.verification.fleet_checks.len();
633 let member_check_groups = manifest.verification.member_checks.len();
634 let role_check_counts = manifest
635 .verification
636 .member_checks
637 .iter()
638 .map(|group| (group.role.as_str(), group.checks.len()))
639 .collect::<BTreeMap<_, _>>();
640 let inline_member_checks = members
641 .iter()
642 .map(|member| member.verification_checks.len())
643 .sum::<usize>();
644 let role_member_checks = members
645 .iter()
646 .map(|member| {
647 role_check_counts
648 .get(member.role.as_str())
649 .copied()
650 .unwrap_or(0)
651 })
652 .sum::<usize>();
653 let member_checks = inline_member_checks + role_member_checks;
654 let members_with_checks = members
655 .iter()
656 .filter(|member| {
657 !member.verification_checks.is_empty()
658 || role_check_counts.contains_key(member.role.as_str())
659 })
660 .count();
661
662 RestoreVerificationSummary {
663 verification_required: true,
664 all_members_have_checks: members_with_checks == members.len(),
665 fleet_checks,
666 member_check_groups,
667 member_checks,
668 members_with_checks,
669 total_checks: fleet_checks + member_checks,
670 }
671}
672
673const fn restore_operation_summary(
675 member_count: usize,
676 verification_summary: &RestoreVerificationSummary,
677 phases: &[RestorePhase],
678) -> RestoreOperationSummary {
679 RestoreOperationSummary {
680 planned_snapshot_loads: member_count,
681 planned_code_reinstalls: member_count,
682 planned_verification_checks: verification_summary.total_checks,
683 planned_phases: phases.len(),
684 }
685}
686
687fn validate_restore_group_dependencies(
689 members: &[RestorePlanMember],
690) -> Result<(), RestorePlanError> {
691 let groups_by_source = members
692 .iter()
693 .map(|member| (member.source_canister.as_str(), member.restore_group))
694 .collect::<BTreeMap<_, _>>();
695
696 for member in members {
697 let Some(parent) = &member.parent_source_canister else {
698 continue;
699 };
700 let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
701 continue;
702 };
703
704 if *parent_group > member.restore_group {
705 return Err(RestorePlanError::ParentRestoreGroupAfterChild {
706 child_source_canister: member.source_canister.clone(),
707 parent_source_canister: parent.clone(),
708 child_restore_group: member.restore_group,
709 parent_restore_group: *parent_group,
710 });
711 }
712 }
713
714 Ok(())
715}
716
717fn group_and_order_members(
719 members: Vec<RestorePlanMember>,
720) -> Result<Vec<RestorePhase>, RestorePlanError> {
721 let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
722 for member in members {
723 groups.entry(member.restore_group).or_default().push(member);
724 }
725
726 groups
727 .into_iter()
728 .map(|(restore_group, members)| {
729 let members = order_group(restore_group, members)?;
730 Ok(RestorePhase {
731 restore_group,
732 members,
733 })
734 })
735 .collect()
736}
737
738fn order_group(
740 restore_group: u16,
741 members: Vec<RestorePlanMember>,
742) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
743 let mut remaining = members;
744 let group_sources = remaining
745 .iter()
746 .map(|member| member.source_canister.clone())
747 .collect::<BTreeSet<_>>();
748 let mut emitted = BTreeSet::new();
749 let mut ordered = Vec::with_capacity(remaining.len());
750
751 while !remaining.is_empty() {
752 let Some(index) = remaining
753 .iter()
754 .position(|member| parent_satisfied(member, &group_sources, &emitted))
755 else {
756 return Err(RestorePlanError::RestoreOrderCycle(restore_group));
757 };
758
759 let mut member = remaining.remove(index);
760 member.phase_order = ordered.len();
761 member.ordering_dependency = ordering_dependency(&member, &group_sources);
762 emitted.insert(member.source_canister.clone());
763 ordered.push(member);
764 }
765
766 Ok(ordered)
767}
768
769fn ordering_dependency(
771 member: &RestorePlanMember,
772 group_sources: &BTreeSet<String>,
773) -> Option<RestoreOrderingDependency> {
774 let parent_source = member.parent_source_canister.as_ref()?;
775 let parent_target = member.parent_target_canister.as_ref()?;
776 let relationship = if group_sources.contains(parent_source) {
777 RestoreOrderingRelationship::ParentInSameGroup
778 } else {
779 RestoreOrderingRelationship::ParentInEarlierGroup
780 };
781
782 Some(RestoreOrderingDependency {
783 source_canister: parent_source.clone(),
784 target_canister: parent_target.clone(),
785 relationship,
786 })
787}
788
789fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
791 let mut summary = RestoreOrderingSummary {
792 phase_count: phases.len(),
793 dependency_free_members: 0,
794 in_group_parent_edges: 0,
795 cross_group_parent_edges: 0,
796 };
797
798 for member in phases.iter().flat_map(|phase| phase.members.iter()) {
799 match &member.ordering_dependency {
800 Some(dependency)
801 if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
802 {
803 summary.in_group_parent_edges += 1;
804 }
805 Some(dependency)
806 if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
807 {
808 summary.cross_group_parent_edges += 1;
809 }
810 Some(_) => {}
811 None => summary.dependency_free_members += 1,
812 }
813 }
814
815 summary
816}
817
818fn parent_satisfied(
820 member: &RestorePlanMember,
821 group_sources: &BTreeSet<String>,
822 emitted: &BTreeSet<String>,
823) -> bool {
824 match &member.parent_source_canister {
825 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
826 _ => true,
827 }
828}
829
830fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
832 Principal::from_str(value)
833 .map(|_| ())
834 .map_err(|_| RestorePlanError::InvalidPrincipal {
835 field,
836 value: value.to_string(),
837 })
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843 use crate::manifest::{
844 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
845 MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
846 VerificationPlan,
847 };
848
849 const ROOT: &str = "aaaaa-aa";
850 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
851 const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
852 const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
853 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
854
855 fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
857 FleetBackupManifest {
858 manifest_version: 1,
859 backup_id: "fbk_test_001".to_string(),
860 created_at: "2026-04-10T12:00:00Z".to_string(),
861 tool: ToolMetadata {
862 name: "canic".to_string(),
863 version: "v1".to_string(),
864 },
865 source: SourceMetadata {
866 environment: "local".to_string(),
867 root_canister: ROOT.to_string(),
868 },
869 consistency: ConsistencySection {
870 mode: ConsistencyMode::CrashConsistent,
871 backup_units: vec![BackupUnit {
872 unit_id: "whole-fleet".to_string(),
873 kind: BackupUnitKind::WholeFleet,
874 roles: vec!["root".to_string(), "app".to_string()],
875 consistency_reason: None,
876 dependency_closure: Vec::new(),
877 topology_validation: "subtree-closed".to_string(),
878 quiescence_strategy: None,
879 }],
880 },
881 fleet: FleetSection {
882 topology_hash_algorithm: "sha256".to_string(),
883 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
884 discovery_topology_hash: HASH.to_string(),
885 pre_snapshot_topology_hash: HASH.to_string(),
886 topology_hash: HASH.to_string(),
887 members: vec![
888 fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
889 fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
890 ],
891 },
892 verification: VerificationPlan {
893 fleet_checks: Vec::new(),
894 member_checks: Vec::new(),
895 },
896 }
897 }
898
899 fn fleet_member(
901 role: &str,
902 canister_id: &str,
903 parent_canister_id: Option<&str>,
904 identity_mode: IdentityMode,
905 restore_group: u16,
906 ) -> FleetMember {
907 FleetMember {
908 role: role.to_string(),
909 canister_id: canister_id.to_string(),
910 parent_canister_id: parent_canister_id.map(str::to_string),
911 subnet_canister_id: None,
912 controller_hint: Some(ROOT.to_string()),
913 identity_mode,
914 restore_group,
915 verification_class: "basic".to_string(),
916 verification_checks: vec![VerificationCheck {
917 kind: "call".to_string(),
918 method: Some("canic_ready".to_string()),
919 roles: Vec::new(),
920 }],
921 source_snapshot: SourceSnapshot {
922 snapshot_id: format!("snap-{role}"),
923 module_hash: Some(HASH.to_string()),
924 wasm_hash: Some(HASH.to_string()),
925 code_version: Some("v0.30.0".to_string()),
926 artifact_path: format!("artifacts/{role}"),
927 checksum_algorithm: "sha256".to_string(),
928 checksum: Some(HASH.to_string()),
929 },
930 }
931 }
932
933 #[test]
935 fn in_place_plan_orders_parent_before_child() {
936 let manifest = valid_manifest(IdentityMode::Relocatable);
937
938 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
939 let ordered = plan.ordered_members();
940
941 assert_eq!(plan.backup_id, "fbk_test_001");
942 assert_eq!(plan.source_environment, "local");
943 assert_eq!(plan.source_root_canister, ROOT);
944 assert_eq!(plan.topology_hash, HASH);
945 assert_eq!(plan.member_count, 2);
946 assert_eq!(plan.identity_summary.fixed_members, 1);
947 assert_eq!(plan.identity_summary.relocatable_members, 1);
948 assert_eq!(plan.identity_summary.in_place_members, 2);
949 assert_eq!(plan.identity_summary.mapped_members, 0);
950 assert_eq!(plan.identity_summary.remapped_members, 0);
951 assert!(plan.verification_summary.verification_required);
952 assert!(plan.verification_summary.all_members_have_checks);
953 assert!(plan.readiness_summary.ready);
954 assert!(plan.readiness_summary.reasons.is_empty());
955 assert_eq!(plan.verification_summary.fleet_checks, 0);
956 assert_eq!(plan.verification_summary.member_check_groups, 0);
957 assert_eq!(plan.verification_summary.member_checks, 2);
958 assert_eq!(plan.verification_summary.members_with_checks, 2);
959 assert_eq!(plan.verification_summary.total_checks, 2);
960 assert_eq!(plan.ordering_summary.phase_count, 1);
961 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
962 assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
963 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
964 assert_eq!(ordered[0].phase_order, 0);
965 assert_eq!(ordered[1].phase_order, 1);
966 assert_eq!(ordered[0].source_canister, ROOT);
967 assert_eq!(ordered[1].source_canister, CHILD);
968 assert_eq!(
969 ordered[1].ordering_dependency,
970 Some(RestoreOrderingDependency {
971 source_canister: ROOT.to_string(),
972 target_canister: ROOT.to_string(),
973 relationship: RestoreOrderingRelationship::ParentInSameGroup,
974 })
975 );
976 }
977
978 #[test]
980 fn plan_reports_parent_dependency_from_earlier_group() {
981 let mut manifest = valid_manifest(IdentityMode::Relocatable);
982 manifest.fleet.members[0].restore_group = 2;
983 manifest.fleet.members[1].restore_group = 1;
984
985 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
986 let ordered = plan.ordered_members();
987
988 assert_eq!(plan.phases.len(), 2);
989 assert_eq!(plan.ordering_summary.phase_count, 2);
990 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
991 assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
992 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
993 assert_eq!(ordered[0].source_canister, ROOT);
994 assert_eq!(ordered[1].source_canister, CHILD);
995 assert_eq!(
996 ordered[1].ordering_dependency,
997 Some(RestoreOrderingDependency {
998 source_canister: ROOT.to_string(),
999 target_canister: ROOT.to_string(),
1000 relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
1001 })
1002 );
1003 }
1004
1005 #[test]
1007 fn plan_rejects_parent_in_later_restore_group() {
1008 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1009 manifest.fleet.members[0].restore_group = 1;
1010 manifest.fleet.members[1].restore_group = 2;
1011
1012 let err = RestorePlanner::plan(&manifest, None)
1013 .expect_err("parent-after-child group ordering should fail");
1014
1015 assert!(matches!(
1016 err,
1017 RestorePlanError::ParentRestoreGroupAfterChild { .. }
1018 ));
1019 }
1020
1021 #[test]
1023 fn fixed_identity_member_cannot_be_remapped() {
1024 let manifest = valid_manifest(IdentityMode::Fixed);
1025 let mapping = RestoreMapping {
1026 members: vec![
1027 RestoreMappingEntry {
1028 source_canister: ROOT.to_string(),
1029 target_canister: ROOT.to_string(),
1030 },
1031 RestoreMappingEntry {
1032 source_canister: CHILD.to_string(),
1033 target_canister: TARGET.to_string(),
1034 },
1035 ],
1036 };
1037
1038 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1039 .expect_err("fixed member remap should fail");
1040
1041 assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
1042 }
1043
1044 #[test]
1046 fn relocatable_member_can_be_mapped() {
1047 let manifest = valid_manifest(IdentityMode::Relocatable);
1048 let mapping = RestoreMapping {
1049 members: vec![
1050 RestoreMappingEntry {
1051 source_canister: ROOT.to_string(),
1052 target_canister: ROOT.to_string(),
1053 },
1054 RestoreMappingEntry {
1055 source_canister: CHILD.to_string(),
1056 target_canister: TARGET.to_string(),
1057 },
1058 ],
1059 };
1060
1061 let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1062 let child = plan
1063 .ordered_members()
1064 .into_iter()
1065 .find(|member| member.source_canister == CHILD)
1066 .expect("child member should be planned");
1067
1068 assert_eq!(plan.identity_summary.fixed_members, 1);
1069 assert_eq!(plan.identity_summary.relocatable_members, 1);
1070 assert_eq!(plan.identity_summary.in_place_members, 1);
1071 assert_eq!(plan.identity_summary.mapped_members, 2);
1072 assert_eq!(plan.identity_summary.remapped_members, 1);
1073 assert_eq!(child.target_canister, TARGET);
1074 assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
1075 }
1076
1077 #[test]
1079 fn plan_members_include_snapshot_and_verification_metadata() {
1080 let manifest = valid_manifest(IdentityMode::Relocatable);
1081
1082 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1083 let root = plan
1084 .ordered_members()
1085 .into_iter()
1086 .find(|member| member.source_canister == ROOT)
1087 .expect("root member should be planned");
1088
1089 assert_eq!(root.identity_mode, IdentityMode::Fixed);
1090 assert_eq!(root.verification_class, "basic");
1091 assert_eq!(root.verification_checks[0].kind, "call");
1092 assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
1093 assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
1094 }
1095
1096 #[test]
1098 fn plan_includes_mapping_summary() {
1099 let manifest = valid_manifest(IdentityMode::Relocatable);
1100 let in_place = RestorePlanner::plan(&manifest, None).expect("plan should build");
1101
1102 assert!(!in_place.identity_summary.mapping_supplied);
1103 assert!(!in_place.identity_summary.all_sources_mapped);
1104 assert_eq!(in_place.identity_summary.mapped_members, 0);
1105
1106 let mapping = RestoreMapping {
1107 members: vec![
1108 RestoreMappingEntry {
1109 source_canister: ROOT.to_string(),
1110 target_canister: ROOT.to_string(),
1111 },
1112 RestoreMappingEntry {
1113 source_canister: CHILD.to_string(),
1114 target_canister: TARGET.to_string(),
1115 },
1116 ],
1117 };
1118 let mapped = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1119
1120 assert!(mapped.identity_summary.mapping_supplied);
1121 assert!(mapped.identity_summary.all_sources_mapped);
1122 assert_eq!(mapped.identity_summary.mapped_members, 2);
1123 assert_eq!(mapped.identity_summary.remapped_members, 1);
1124 }
1125
1126 #[test]
1128 fn plan_includes_snapshot_summary() {
1129 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1130 manifest.fleet.members[1].source_snapshot.module_hash = None;
1131 manifest.fleet.members[1].source_snapshot.wasm_hash = None;
1132 manifest.fleet.members[1].source_snapshot.checksum = None;
1133
1134 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1135
1136 assert!(!plan.snapshot_summary.all_members_have_module_hash);
1137 assert!(!plan.snapshot_summary.all_members_have_wasm_hash);
1138 assert!(plan.snapshot_summary.all_members_have_code_version);
1139 assert!(!plan.snapshot_summary.all_members_have_checksum);
1140 assert_eq!(plan.snapshot_summary.members_with_module_hash, 1);
1141 assert_eq!(plan.snapshot_summary.members_with_wasm_hash, 1);
1142 assert_eq!(plan.snapshot_summary.members_with_code_version, 2);
1143 assert_eq!(plan.snapshot_summary.members_with_checksum, 1);
1144 assert!(!plan.readiness_summary.ready);
1145 assert_eq!(
1146 plan.readiness_summary.reasons,
1147 [
1148 "missing-module-hash",
1149 "missing-wasm-hash",
1150 "missing-snapshot-checksum"
1151 ]
1152 );
1153 }
1154
1155 #[test]
1157 fn plan_includes_verification_summary() {
1158 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1159 manifest.verification.fleet_checks.push(VerificationCheck {
1160 kind: "fleet-ready".to_string(),
1161 method: None,
1162 roles: Vec::new(),
1163 });
1164 manifest
1165 .verification
1166 .member_checks
1167 .push(MemberVerificationChecks {
1168 role: "app".to_string(),
1169 checks: vec![VerificationCheck {
1170 kind: "app-ready".to_string(),
1171 method: Some("ready".to_string()),
1172 roles: Vec::new(),
1173 }],
1174 });
1175
1176 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1177
1178 assert!(plan.verification_summary.verification_required);
1179 assert!(plan.verification_summary.all_members_have_checks);
1180 assert_eq!(plan.verification_summary.fleet_checks, 1);
1181 assert_eq!(plan.verification_summary.member_check_groups, 1);
1182 assert_eq!(plan.verification_summary.member_checks, 3);
1183 assert_eq!(plan.verification_summary.members_with_checks, 2);
1184 assert_eq!(plan.verification_summary.total_checks, 4);
1185 }
1186
1187 #[test]
1189 fn plan_includes_operation_summary() {
1190 let manifest = valid_manifest(IdentityMode::Relocatable);
1191
1192 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1193
1194 assert_eq!(plan.operation_summary.planned_snapshot_loads, 2);
1195 assert_eq!(plan.operation_summary.planned_code_reinstalls, 2);
1196 assert_eq!(plan.operation_summary.planned_verification_checks, 2);
1197 assert_eq!(plan.operation_summary.planned_phases, 1);
1198 }
1199
1200 #[test]
1202 fn restore_status_starts_all_members_as_planned() {
1203 let manifest = valid_manifest(IdentityMode::Relocatable);
1204
1205 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1206 let status = RestoreStatus::from_plan(&plan);
1207
1208 assert_eq!(status.status_version, 1);
1209 assert_eq!(status.backup_id.as_str(), plan.backup_id.as_str());
1210 assert_eq!(
1211 status.source_environment.as_str(),
1212 plan.source_environment.as_str()
1213 );
1214 assert_eq!(
1215 status.source_root_canister.as_str(),
1216 plan.source_root_canister.as_str()
1217 );
1218 assert_eq!(status.topology_hash.as_str(), plan.topology_hash.as_str());
1219 assert!(status.ready);
1220 assert!(status.readiness_reasons.is_empty());
1221 assert!(status.verification_required);
1222 assert_eq!(status.member_count, 2);
1223 assert_eq!(status.phase_count, 1);
1224 assert_eq!(status.planned_snapshot_loads, 2);
1225 assert_eq!(status.planned_code_reinstalls, 2);
1226 assert_eq!(status.planned_verification_checks, 2);
1227 assert_eq!(status.phases.len(), 1);
1228 assert_eq!(status.phases[0].restore_group, 1);
1229 assert_eq!(status.phases[0].members.len(), 2);
1230 assert_eq!(
1231 status.phases[0].members[0].state,
1232 RestoreMemberState::Planned
1233 );
1234 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
1235 assert_eq!(status.phases[0].members[0].target_canister, ROOT);
1236 assert_eq!(status.phases[0].members[0].snapshot_id, "snap-root");
1237 assert_eq!(status.phases[0].members[0].artifact_path, "artifacts/root");
1238 assert_eq!(
1239 status.phases[0].members[1].state,
1240 RestoreMemberState::Planned
1241 );
1242 assert_eq!(status.phases[0].members[1].source_canister, CHILD);
1243 }
1244
1245 #[test]
1247 fn plan_expands_role_verification_checks_per_matching_member() {
1248 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1249 manifest.fleet.members.push(fleet_member(
1250 "app",
1251 CHILD_TWO,
1252 Some(ROOT),
1253 IdentityMode::Relocatable,
1254 1,
1255 ));
1256 manifest
1257 .verification
1258 .member_checks
1259 .push(MemberVerificationChecks {
1260 role: "app".to_string(),
1261 checks: vec![VerificationCheck {
1262 kind: "app-ready".to_string(),
1263 method: Some("ready".to_string()),
1264 roles: Vec::new(),
1265 }],
1266 });
1267
1268 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1269
1270 assert_eq!(plan.verification_summary.fleet_checks, 0);
1271 assert_eq!(plan.verification_summary.member_check_groups, 1);
1272 assert_eq!(plan.verification_summary.member_checks, 5);
1273 assert_eq!(plan.verification_summary.members_with_checks, 3);
1274 assert_eq!(plan.verification_summary.total_checks, 5);
1275 }
1276
1277 #[test]
1279 fn mapped_restore_requires_complete_mapping() {
1280 let manifest = valid_manifest(IdentityMode::Relocatable);
1281 let mapping = RestoreMapping {
1282 members: vec![RestoreMappingEntry {
1283 source_canister: ROOT.to_string(),
1284 target_canister: ROOT.to_string(),
1285 }],
1286 };
1287
1288 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1289 .expect_err("incomplete mapping should fail");
1290
1291 assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
1292 }
1293
1294 #[test]
1296 fn mapped_restore_rejects_unknown_mapping_sources() {
1297 let manifest = valid_manifest(IdentityMode::Relocatable);
1298 let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
1299 let mapping = RestoreMapping {
1300 members: vec![
1301 RestoreMappingEntry {
1302 source_canister: ROOT.to_string(),
1303 target_canister: ROOT.to_string(),
1304 },
1305 RestoreMappingEntry {
1306 source_canister: CHILD.to_string(),
1307 target_canister: TARGET.to_string(),
1308 },
1309 RestoreMappingEntry {
1310 source_canister: unknown.to_string(),
1311 target_canister: unknown.to_string(),
1312 },
1313 ],
1314 };
1315
1316 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1317 .expect_err("unknown mapping source should fail");
1318
1319 assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
1320 }
1321
1322 #[test]
1324 fn duplicate_mapping_targets_fail_validation() {
1325 let manifest = valid_manifest(IdentityMode::Relocatable);
1326 let mapping = RestoreMapping {
1327 members: vec![
1328 RestoreMappingEntry {
1329 source_canister: ROOT.to_string(),
1330 target_canister: ROOT.to_string(),
1331 },
1332 RestoreMappingEntry {
1333 source_canister: CHILD.to_string(),
1334 target_canister: ROOT.to_string(),
1335 },
1336 ],
1337 };
1338
1339 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1340 .expect_err("duplicate targets should fail");
1341
1342 assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
1343 }
1344}