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 RestoreApplyDryRun {
194 pub dry_run_version: u16,
195 pub backup_id: String,
196 pub ready: bool,
197 pub readiness_reasons: Vec<String>,
198 pub member_count: usize,
199 pub phase_count: usize,
200 pub status_supplied: bool,
201 pub planned_snapshot_loads: usize,
202 pub planned_code_reinstalls: usize,
203 pub planned_verification_checks: usize,
204 pub rendered_operations: usize,
205 pub phases: Vec<RestoreApplyDryRunPhase>,
206}
207
208impl RestoreApplyDryRun {
209 pub fn try_from_plan(
211 plan: &RestorePlan,
212 status: Option<&RestoreStatus>,
213 ) -> Result<Self, RestoreApplyDryRunError> {
214 if let Some(status) = status {
215 validate_restore_status_matches_plan(plan, status)?;
216 }
217
218 Ok(Self::from_validated_plan(plan, status))
219 }
220
221 fn from_validated_plan(plan: &RestorePlan, status: Option<&RestoreStatus>) -> Self {
223 let mut next_sequence = 0;
224 let phases = plan
225 .phases
226 .iter()
227 .map(|phase| RestoreApplyDryRunPhase::from_plan_phase(phase, &mut next_sequence))
228 .collect::<Vec<_>>();
229 let rendered_operations = phases
230 .iter()
231 .map(|phase| phase.operations.len())
232 .sum::<usize>();
233
234 Self {
235 dry_run_version: 1,
236 backup_id: plan.backup_id.clone(),
237 ready: status.map_or(plan.readiness_summary.ready, |status| status.ready),
238 readiness_reasons: status.map_or_else(
239 || plan.readiness_summary.reasons.clone(),
240 |status| status.readiness_reasons.clone(),
241 ),
242 member_count: plan.member_count,
243 phase_count: plan.ordering_summary.phase_count,
244 status_supplied: status.is_some(),
245 planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
246 planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
247 planned_verification_checks: plan.operation_summary.planned_verification_checks,
248 rendered_operations,
249 phases,
250 }
251 }
252}
253
254fn validate_restore_status_matches_plan(
256 plan: &RestorePlan,
257 status: &RestoreStatus,
258) -> Result<(), RestoreApplyDryRunError> {
259 validate_status_string_field("backup_id", &plan.backup_id, &status.backup_id)?;
260 validate_status_string_field(
261 "source_environment",
262 &plan.source_environment,
263 &status.source_environment,
264 )?;
265 validate_status_string_field(
266 "source_root_canister",
267 &plan.source_root_canister,
268 &status.source_root_canister,
269 )?;
270 validate_status_string_field("topology_hash", &plan.topology_hash, &status.topology_hash)?;
271 validate_status_usize_field("member_count", plan.member_count, status.member_count)?;
272 validate_status_usize_field(
273 "phase_count",
274 plan.ordering_summary.phase_count,
275 status.phase_count,
276 )?;
277 Ok(())
278}
279
280fn validate_status_string_field(
282 field: &'static str,
283 plan: &str,
284 status: &str,
285) -> Result<(), RestoreApplyDryRunError> {
286 if plan == status {
287 return Ok(());
288 }
289
290 Err(RestoreApplyDryRunError::StatusPlanMismatch {
291 field,
292 plan: plan.to_string(),
293 status: status.to_string(),
294 })
295}
296
297const fn validate_status_usize_field(
299 field: &'static str,
300 plan: usize,
301 status: usize,
302) -> Result<(), RestoreApplyDryRunError> {
303 if plan == status {
304 return Ok(());
305 }
306
307 Err(RestoreApplyDryRunError::StatusPlanCountMismatch {
308 field,
309 plan,
310 status,
311 })
312}
313
314#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
319pub struct RestoreApplyDryRunPhase {
320 pub restore_group: u16,
321 pub operations: Vec<RestoreApplyDryRunOperation>,
322}
323
324impl RestoreApplyDryRunPhase {
325 fn from_plan_phase(phase: &RestorePhase, next_sequence: &mut usize) -> Self {
327 let mut operations = Vec::new();
328
329 for member in &phase.members {
330 push_member_operation(
331 &mut operations,
332 next_sequence,
333 RestoreApplyOperationKind::UploadSnapshot,
334 member,
335 None,
336 );
337 push_member_operation(
338 &mut operations,
339 next_sequence,
340 RestoreApplyOperationKind::LoadSnapshot,
341 member,
342 None,
343 );
344 push_member_operation(
345 &mut operations,
346 next_sequence,
347 RestoreApplyOperationKind::ReinstallCode,
348 member,
349 None,
350 );
351
352 for check in &member.verification_checks {
353 push_member_operation(
354 &mut operations,
355 next_sequence,
356 RestoreApplyOperationKind::VerifyMember,
357 member,
358 Some(check),
359 );
360 }
361 }
362
363 Self {
364 restore_group: phase.restore_group,
365 operations,
366 }
367 }
368}
369
370fn push_member_operation(
372 operations: &mut Vec<RestoreApplyDryRunOperation>,
373 next_sequence: &mut usize,
374 operation: RestoreApplyOperationKind,
375 member: &RestorePlanMember,
376 check: Option<&VerificationCheck>,
377) {
378 let sequence = *next_sequence;
379 *next_sequence += 1;
380
381 operations.push(RestoreApplyDryRunOperation {
382 sequence,
383 operation,
384 restore_group: member.restore_group,
385 phase_order: member.phase_order,
386 source_canister: member.source_canister.clone(),
387 target_canister: member.target_canister.clone(),
388 role: member.role.clone(),
389 snapshot_id: Some(member.source_snapshot.snapshot_id.clone()),
390 artifact_path: Some(member.source_snapshot.artifact_path.clone()),
391 verification_kind: check.map(|check| check.kind.clone()),
392 verification_method: check.and_then(|check| check.method.clone()),
393 });
394}
395
396#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
401pub struct RestoreApplyDryRunOperation {
402 pub sequence: usize,
403 pub operation: RestoreApplyOperationKind,
404 pub restore_group: u16,
405 pub phase_order: usize,
406 pub source_canister: String,
407 pub target_canister: String,
408 pub role: String,
409 pub snapshot_id: Option<String>,
410 pub artifact_path: Option<String>,
411 pub verification_kind: Option<String>,
412 pub verification_method: Option<String>,
413}
414
415#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
420#[serde(rename_all = "kebab-case")]
421pub enum RestoreApplyOperationKind {
422 UploadSnapshot,
423 LoadSnapshot,
424 ReinstallCode,
425 VerifyMember,
426}
427
428#[derive(Debug, ThisError)]
433pub enum RestoreApplyDryRunError {
434 #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
435 StatusPlanMismatch {
436 field: &'static str,
437 plan: String,
438 status: String,
439 },
440
441 #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
442 StatusPlanCountMismatch {
443 field: &'static str,
444 plan: usize,
445 status: usize,
446 },
447}
448
449#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
454pub struct RestoreIdentitySummary {
455 pub mapping_supplied: bool,
456 pub all_sources_mapped: bool,
457 pub fixed_members: usize,
458 pub relocatable_members: usize,
459 pub in_place_members: usize,
460 pub mapped_members: usize,
461 pub remapped_members: usize,
462}
463
464#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
469#[expect(
470 clippy::struct_excessive_bools,
471 reason = "restore summaries intentionally expose machine-readable readiness flags"
472)]
473pub struct RestoreSnapshotSummary {
474 pub all_members_have_module_hash: bool,
475 pub all_members_have_wasm_hash: bool,
476 pub all_members_have_code_version: bool,
477 pub all_members_have_checksum: bool,
478 pub members_with_module_hash: usize,
479 pub members_with_wasm_hash: usize,
480 pub members_with_code_version: usize,
481 pub members_with_checksum: usize,
482}
483
484#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
489pub struct RestoreVerificationSummary {
490 pub verification_required: bool,
491 pub all_members_have_checks: bool,
492 pub fleet_checks: usize,
493 pub member_check_groups: usize,
494 pub member_checks: usize,
495 pub members_with_checks: usize,
496 pub total_checks: usize,
497}
498
499#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
504pub struct RestoreReadinessSummary {
505 pub ready: bool,
506 pub reasons: Vec<String>,
507}
508
509#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
514pub struct RestoreOperationSummary {
515 pub planned_snapshot_loads: usize,
516 pub planned_code_reinstalls: usize,
517 pub planned_verification_checks: usize,
518 pub planned_phases: usize,
519}
520
521#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
526pub struct RestoreOrderingSummary {
527 pub phase_count: usize,
528 pub dependency_free_members: usize,
529 pub in_group_parent_edges: usize,
530 pub cross_group_parent_edges: usize,
531}
532
533#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
538pub struct RestorePhase {
539 pub restore_group: u16,
540 pub members: Vec<RestorePlanMember>,
541}
542
543#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
548pub struct RestorePlanMember {
549 pub source_canister: String,
550 pub target_canister: String,
551 pub role: String,
552 pub parent_source_canister: Option<String>,
553 pub parent_target_canister: Option<String>,
554 pub ordering_dependency: Option<RestoreOrderingDependency>,
555 pub phase_order: usize,
556 pub restore_group: u16,
557 pub identity_mode: IdentityMode,
558 pub verification_class: String,
559 pub verification_checks: Vec<VerificationCheck>,
560 pub source_snapshot: SourceSnapshot,
561}
562
563#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
568pub struct RestoreOrderingDependency {
569 pub source_canister: String,
570 pub target_canister: String,
571 pub relationship: RestoreOrderingRelationship,
572}
573
574#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
579#[serde(rename_all = "kebab-case")]
580pub enum RestoreOrderingRelationship {
581 ParentInSameGroup,
582 ParentInEarlierGroup,
583}
584
585pub struct RestorePlanner;
590
591impl RestorePlanner {
592 pub fn plan(
594 manifest: &FleetBackupManifest,
595 mapping: Option<&RestoreMapping>,
596 ) -> Result<RestorePlan, RestorePlanError> {
597 manifest.validate()?;
598 if let Some(mapping) = mapping {
599 validate_mapping(mapping)?;
600 validate_mapping_sources(manifest, mapping)?;
601 }
602
603 let members = resolve_members(manifest, mapping)?;
604 let identity_summary = restore_identity_summary(&members, mapping.is_some());
605 let snapshot_summary = restore_snapshot_summary(&members);
606 let verification_summary = restore_verification_summary(manifest, &members);
607 let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
608 validate_restore_group_dependencies(&members)?;
609 let phases = group_and_order_members(members)?;
610 let ordering_summary = restore_ordering_summary(&phases);
611 let operation_summary =
612 restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
613
614 Ok(RestorePlan {
615 backup_id: manifest.backup_id.clone(),
616 source_environment: manifest.source.environment.clone(),
617 source_root_canister: manifest.source.root_canister.clone(),
618 topology_hash: manifest.fleet.topology_hash.clone(),
619 member_count: manifest.fleet.members.len(),
620 identity_summary,
621 snapshot_summary,
622 verification_summary,
623 readiness_summary,
624 operation_summary,
625 ordering_summary,
626 phases,
627 })
628 }
629}
630
631#[derive(Debug, ThisError)]
636pub enum RestorePlanError {
637 #[error(transparent)]
638 InvalidManifest(#[from] ManifestValidationError),
639
640 #[error("field {field} must be a valid principal: {value}")]
641 InvalidPrincipal { field: &'static str, value: String },
642
643 #[error("mapping contains duplicate source canister {0}")]
644 DuplicateMappingSource(String),
645
646 #[error("mapping contains duplicate target canister {0}")]
647 DuplicateMappingTarget(String),
648
649 #[error("mapping references unknown source canister {0}")]
650 UnknownMappingSource(String),
651
652 #[error("mapping is missing source canister {0}")]
653 MissingMappingSource(String),
654
655 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
656 FixedIdentityRemap {
657 source_canister: String,
658 target_canister: String,
659 },
660
661 #[error("restore plan contains duplicate target canister {0}")]
662 DuplicatePlanTarget(String),
663
664 #[error("restore group {0} contains a parent cycle or unresolved dependency")]
665 RestoreOrderCycle(u16),
666
667 #[error(
668 "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
669 )]
670 ParentRestoreGroupAfterChild {
671 child_source_canister: String,
672 parent_source_canister: String,
673 child_restore_group: u16,
674 parent_restore_group: u16,
675 },
676}
677
678fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
680 let mut sources = BTreeSet::new();
681 let mut targets = BTreeSet::new();
682
683 for entry in &mapping.members {
684 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
685 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
686
687 if !sources.insert(entry.source_canister.clone()) {
688 return Err(RestorePlanError::DuplicateMappingSource(
689 entry.source_canister.clone(),
690 ));
691 }
692
693 if !targets.insert(entry.target_canister.clone()) {
694 return Err(RestorePlanError::DuplicateMappingTarget(
695 entry.target_canister.clone(),
696 ));
697 }
698 }
699
700 Ok(())
701}
702
703fn validate_mapping_sources(
705 manifest: &FleetBackupManifest,
706 mapping: &RestoreMapping,
707) -> Result<(), RestorePlanError> {
708 let sources = manifest
709 .fleet
710 .members
711 .iter()
712 .map(|member| member.canister_id.as_str())
713 .collect::<BTreeSet<_>>();
714
715 for entry in &mapping.members {
716 if !sources.contains(entry.source_canister.as_str()) {
717 return Err(RestorePlanError::UnknownMappingSource(
718 entry.source_canister.clone(),
719 ));
720 }
721 }
722
723 Ok(())
724}
725
726fn resolve_members(
728 manifest: &FleetBackupManifest,
729 mapping: Option<&RestoreMapping>,
730) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
731 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
732 let mut targets = BTreeSet::new();
733 let mut source_to_target = BTreeMap::new();
734
735 for member in &manifest.fleet.members {
736 let target = resolve_target(member, mapping)?;
737 if !targets.insert(target.clone()) {
738 return Err(RestorePlanError::DuplicatePlanTarget(target));
739 }
740
741 source_to_target.insert(member.canister_id.clone(), target.clone());
742 plan_members.push(RestorePlanMember {
743 source_canister: member.canister_id.clone(),
744 target_canister: target,
745 role: member.role.clone(),
746 parent_source_canister: member.parent_canister_id.clone(),
747 parent_target_canister: None,
748 ordering_dependency: None,
749 phase_order: 0,
750 restore_group: member.restore_group,
751 identity_mode: member.identity_mode.clone(),
752 verification_class: member.verification_class.clone(),
753 verification_checks: member.verification_checks.clone(),
754 source_snapshot: member.source_snapshot.clone(),
755 });
756 }
757
758 for member in &mut plan_members {
759 member.parent_target_canister = member
760 .parent_source_canister
761 .as_ref()
762 .and_then(|parent| source_to_target.get(parent))
763 .cloned();
764 }
765
766 Ok(plan_members)
767}
768
769fn resolve_target(
771 member: &FleetMember,
772 mapping: Option<&RestoreMapping>,
773) -> Result<String, RestorePlanError> {
774 let target = match mapping {
775 Some(mapping) => mapping
776 .target_for(&member.canister_id)
777 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
778 .to_string(),
779 None => member.canister_id.clone(),
780 };
781
782 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
783 return Err(RestorePlanError::FixedIdentityRemap {
784 source_canister: member.canister_id.clone(),
785 target_canister: target,
786 });
787 }
788
789 Ok(target)
790}
791
792fn restore_identity_summary(
794 members: &[RestorePlanMember],
795 mapping_supplied: bool,
796) -> RestoreIdentitySummary {
797 let mut summary = RestoreIdentitySummary {
798 mapping_supplied,
799 all_sources_mapped: false,
800 fixed_members: 0,
801 relocatable_members: 0,
802 in_place_members: 0,
803 mapped_members: 0,
804 remapped_members: 0,
805 };
806
807 for member in members {
808 match member.identity_mode {
809 IdentityMode::Fixed => summary.fixed_members += 1,
810 IdentityMode::Relocatable => summary.relocatable_members += 1,
811 }
812
813 if member.source_canister == member.target_canister {
814 summary.in_place_members += 1;
815 } else {
816 summary.remapped_members += 1;
817 }
818 if mapping_supplied {
819 summary.mapped_members += 1;
820 }
821 }
822
823 summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
824
825 summary
826}
827
828fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
830 let members_with_module_hash = members
831 .iter()
832 .filter(|member| member.source_snapshot.module_hash.is_some())
833 .count();
834 let members_with_wasm_hash = members
835 .iter()
836 .filter(|member| member.source_snapshot.wasm_hash.is_some())
837 .count();
838 let members_with_code_version = members
839 .iter()
840 .filter(|member| member.source_snapshot.code_version.is_some())
841 .count();
842 let members_with_checksum = members
843 .iter()
844 .filter(|member| member.source_snapshot.checksum.is_some())
845 .count();
846
847 RestoreSnapshotSummary {
848 all_members_have_module_hash: members_with_module_hash == members.len(),
849 all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
850 all_members_have_code_version: members_with_code_version == members.len(),
851 all_members_have_checksum: members_with_checksum == members.len(),
852 members_with_module_hash,
853 members_with_wasm_hash,
854 members_with_code_version,
855 members_with_checksum,
856 }
857}
858
859fn restore_readiness_summary(
861 snapshot: &RestoreSnapshotSummary,
862 verification: &RestoreVerificationSummary,
863) -> RestoreReadinessSummary {
864 let mut reasons = Vec::new();
865
866 if !snapshot.all_members_have_module_hash {
867 reasons.push("missing-module-hash".to_string());
868 }
869 if !snapshot.all_members_have_wasm_hash {
870 reasons.push("missing-wasm-hash".to_string());
871 }
872 if !snapshot.all_members_have_code_version {
873 reasons.push("missing-code-version".to_string());
874 }
875 if !snapshot.all_members_have_checksum {
876 reasons.push("missing-snapshot-checksum".to_string());
877 }
878 if !verification.all_members_have_checks {
879 reasons.push("missing-verification-checks".to_string());
880 }
881
882 RestoreReadinessSummary {
883 ready: reasons.is_empty(),
884 reasons,
885 }
886}
887
888fn restore_verification_summary(
890 manifest: &FleetBackupManifest,
891 members: &[RestorePlanMember],
892) -> RestoreVerificationSummary {
893 let fleet_checks = manifest.verification.fleet_checks.len();
894 let member_check_groups = manifest.verification.member_checks.len();
895 let role_check_counts = manifest
896 .verification
897 .member_checks
898 .iter()
899 .map(|group| (group.role.as_str(), group.checks.len()))
900 .collect::<BTreeMap<_, _>>();
901 let inline_member_checks = members
902 .iter()
903 .map(|member| member.verification_checks.len())
904 .sum::<usize>();
905 let role_member_checks = members
906 .iter()
907 .map(|member| {
908 role_check_counts
909 .get(member.role.as_str())
910 .copied()
911 .unwrap_or(0)
912 })
913 .sum::<usize>();
914 let member_checks = inline_member_checks + role_member_checks;
915 let members_with_checks = members
916 .iter()
917 .filter(|member| {
918 !member.verification_checks.is_empty()
919 || role_check_counts.contains_key(member.role.as_str())
920 })
921 .count();
922
923 RestoreVerificationSummary {
924 verification_required: true,
925 all_members_have_checks: members_with_checks == members.len(),
926 fleet_checks,
927 member_check_groups,
928 member_checks,
929 members_with_checks,
930 total_checks: fleet_checks + member_checks,
931 }
932}
933
934const fn restore_operation_summary(
936 member_count: usize,
937 verification_summary: &RestoreVerificationSummary,
938 phases: &[RestorePhase],
939) -> RestoreOperationSummary {
940 RestoreOperationSummary {
941 planned_snapshot_loads: member_count,
942 planned_code_reinstalls: member_count,
943 planned_verification_checks: verification_summary.total_checks,
944 planned_phases: phases.len(),
945 }
946}
947
948fn validate_restore_group_dependencies(
950 members: &[RestorePlanMember],
951) -> Result<(), RestorePlanError> {
952 let groups_by_source = members
953 .iter()
954 .map(|member| (member.source_canister.as_str(), member.restore_group))
955 .collect::<BTreeMap<_, _>>();
956
957 for member in members {
958 let Some(parent) = &member.parent_source_canister else {
959 continue;
960 };
961 let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
962 continue;
963 };
964
965 if *parent_group > member.restore_group {
966 return Err(RestorePlanError::ParentRestoreGroupAfterChild {
967 child_source_canister: member.source_canister.clone(),
968 parent_source_canister: parent.clone(),
969 child_restore_group: member.restore_group,
970 parent_restore_group: *parent_group,
971 });
972 }
973 }
974
975 Ok(())
976}
977
978fn group_and_order_members(
980 members: Vec<RestorePlanMember>,
981) -> Result<Vec<RestorePhase>, RestorePlanError> {
982 let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
983 for member in members {
984 groups.entry(member.restore_group).or_default().push(member);
985 }
986
987 groups
988 .into_iter()
989 .map(|(restore_group, members)| {
990 let members = order_group(restore_group, members)?;
991 Ok(RestorePhase {
992 restore_group,
993 members,
994 })
995 })
996 .collect()
997}
998
999fn order_group(
1001 restore_group: u16,
1002 members: Vec<RestorePlanMember>,
1003) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
1004 let mut remaining = members;
1005 let group_sources = remaining
1006 .iter()
1007 .map(|member| member.source_canister.clone())
1008 .collect::<BTreeSet<_>>();
1009 let mut emitted = BTreeSet::new();
1010 let mut ordered = Vec::with_capacity(remaining.len());
1011
1012 while !remaining.is_empty() {
1013 let Some(index) = remaining
1014 .iter()
1015 .position(|member| parent_satisfied(member, &group_sources, &emitted))
1016 else {
1017 return Err(RestorePlanError::RestoreOrderCycle(restore_group));
1018 };
1019
1020 let mut member = remaining.remove(index);
1021 member.phase_order = ordered.len();
1022 member.ordering_dependency = ordering_dependency(&member, &group_sources);
1023 emitted.insert(member.source_canister.clone());
1024 ordered.push(member);
1025 }
1026
1027 Ok(ordered)
1028}
1029
1030fn ordering_dependency(
1032 member: &RestorePlanMember,
1033 group_sources: &BTreeSet<String>,
1034) -> Option<RestoreOrderingDependency> {
1035 let parent_source = member.parent_source_canister.as_ref()?;
1036 let parent_target = member.parent_target_canister.as_ref()?;
1037 let relationship = if group_sources.contains(parent_source) {
1038 RestoreOrderingRelationship::ParentInSameGroup
1039 } else {
1040 RestoreOrderingRelationship::ParentInEarlierGroup
1041 };
1042
1043 Some(RestoreOrderingDependency {
1044 source_canister: parent_source.clone(),
1045 target_canister: parent_target.clone(),
1046 relationship,
1047 })
1048}
1049
1050fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
1052 let mut summary = RestoreOrderingSummary {
1053 phase_count: phases.len(),
1054 dependency_free_members: 0,
1055 in_group_parent_edges: 0,
1056 cross_group_parent_edges: 0,
1057 };
1058
1059 for member in phases.iter().flat_map(|phase| phase.members.iter()) {
1060 match &member.ordering_dependency {
1061 Some(dependency)
1062 if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
1063 {
1064 summary.in_group_parent_edges += 1;
1065 }
1066 Some(dependency)
1067 if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
1068 {
1069 summary.cross_group_parent_edges += 1;
1070 }
1071 Some(_) => {}
1072 None => summary.dependency_free_members += 1,
1073 }
1074 }
1075
1076 summary
1077}
1078
1079fn parent_satisfied(
1081 member: &RestorePlanMember,
1082 group_sources: &BTreeSet<String>,
1083 emitted: &BTreeSet<String>,
1084) -> bool {
1085 match &member.parent_source_canister {
1086 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
1087 _ => true,
1088 }
1089}
1090
1091fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
1093 Principal::from_str(value)
1094 .map(|_| ())
1095 .map_err(|_| RestorePlanError::InvalidPrincipal {
1096 field,
1097 value: value.to_string(),
1098 })
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use super::*;
1104 use crate::manifest::{
1105 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
1106 MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
1107 VerificationPlan,
1108 };
1109
1110 const ROOT: &str = "aaaaa-aa";
1111 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
1112 const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
1113 const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
1114 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1115
1116 fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
1118 FleetBackupManifest {
1119 manifest_version: 1,
1120 backup_id: "fbk_test_001".to_string(),
1121 created_at: "2026-04-10T12:00:00Z".to_string(),
1122 tool: ToolMetadata {
1123 name: "canic".to_string(),
1124 version: "v1".to_string(),
1125 },
1126 source: SourceMetadata {
1127 environment: "local".to_string(),
1128 root_canister: ROOT.to_string(),
1129 },
1130 consistency: ConsistencySection {
1131 mode: ConsistencyMode::CrashConsistent,
1132 backup_units: vec![BackupUnit {
1133 unit_id: "whole-fleet".to_string(),
1134 kind: BackupUnitKind::WholeFleet,
1135 roles: vec!["root".to_string(), "app".to_string()],
1136 consistency_reason: None,
1137 dependency_closure: Vec::new(),
1138 topology_validation: "subtree-closed".to_string(),
1139 quiescence_strategy: None,
1140 }],
1141 },
1142 fleet: FleetSection {
1143 topology_hash_algorithm: "sha256".to_string(),
1144 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1145 discovery_topology_hash: HASH.to_string(),
1146 pre_snapshot_topology_hash: HASH.to_string(),
1147 topology_hash: HASH.to_string(),
1148 members: vec![
1149 fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
1150 fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
1151 ],
1152 },
1153 verification: VerificationPlan {
1154 fleet_checks: Vec::new(),
1155 member_checks: Vec::new(),
1156 },
1157 }
1158 }
1159
1160 fn fleet_member(
1162 role: &str,
1163 canister_id: &str,
1164 parent_canister_id: Option<&str>,
1165 identity_mode: IdentityMode,
1166 restore_group: u16,
1167 ) -> FleetMember {
1168 FleetMember {
1169 role: role.to_string(),
1170 canister_id: canister_id.to_string(),
1171 parent_canister_id: parent_canister_id.map(str::to_string),
1172 subnet_canister_id: None,
1173 controller_hint: Some(ROOT.to_string()),
1174 identity_mode,
1175 restore_group,
1176 verification_class: "basic".to_string(),
1177 verification_checks: vec![VerificationCheck {
1178 kind: "call".to_string(),
1179 method: Some("canic_ready".to_string()),
1180 roles: Vec::new(),
1181 }],
1182 source_snapshot: SourceSnapshot {
1183 snapshot_id: format!("snap-{role}"),
1184 module_hash: Some(HASH.to_string()),
1185 wasm_hash: Some(HASH.to_string()),
1186 code_version: Some("v0.30.0".to_string()),
1187 artifact_path: format!("artifacts/{role}"),
1188 checksum_algorithm: "sha256".to_string(),
1189 checksum: Some(HASH.to_string()),
1190 },
1191 }
1192 }
1193
1194 #[test]
1196 fn in_place_plan_orders_parent_before_child() {
1197 let manifest = valid_manifest(IdentityMode::Relocatable);
1198
1199 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1200 let ordered = plan.ordered_members();
1201
1202 assert_eq!(plan.backup_id, "fbk_test_001");
1203 assert_eq!(plan.source_environment, "local");
1204 assert_eq!(plan.source_root_canister, ROOT);
1205 assert_eq!(plan.topology_hash, HASH);
1206 assert_eq!(plan.member_count, 2);
1207 assert_eq!(plan.identity_summary.fixed_members, 1);
1208 assert_eq!(plan.identity_summary.relocatable_members, 1);
1209 assert_eq!(plan.identity_summary.in_place_members, 2);
1210 assert_eq!(plan.identity_summary.mapped_members, 0);
1211 assert_eq!(plan.identity_summary.remapped_members, 0);
1212 assert!(plan.verification_summary.verification_required);
1213 assert!(plan.verification_summary.all_members_have_checks);
1214 assert!(plan.readiness_summary.ready);
1215 assert!(plan.readiness_summary.reasons.is_empty());
1216 assert_eq!(plan.verification_summary.fleet_checks, 0);
1217 assert_eq!(plan.verification_summary.member_check_groups, 0);
1218 assert_eq!(plan.verification_summary.member_checks, 2);
1219 assert_eq!(plan.verification_summary.members_with_checks, 2);
1220 assert_eq!(plan.verification_summary.total_checks, 2);
1221 assert_eq!(plan.ordering_summary.phase_count, 1);
1222 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
1223 assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
1224 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
1225 assert_eq!(ordered[0].phase_order, 0);
1226 assert_eq!(ordered[1].phase_order, 1);
1227 assert_eq!(ordered[0].source_canister, ROOT);
1228 assert_eq!(ordered[1].source_canister, CHILD);
1229 assert_eq!(
1230 ordered[1].ordering_dependency,
1231 Some(RestoreOrderingDependency {
1232 source_canister: ROOT.to_string(),
1233 target_canister: ROOT.to_string(),
1234 relationship: RestoreOrderingRelationship::ParentInSameGroup,
1235 })
1236 );
1237 }
1238
1239 #[test]
1241 fn plan_reports_parent_dependency_from_earlier_group() {
1242 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1243 manifest.fleet.members[0].restore_group = 2;
1244 manifest.fleet.members[1].restore_group = 1;
1245
1246 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1247 let ordered = plan.ordered_members();
1248
1249 assert_eq!(plan.phases.len(), 2);
1250 assert_eq!(plan.ordering_summary.phase_count, 2);
1251 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
1252 assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
1253 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
1254 assert_eq!(ordered[0].source_canister, ROOT);
1255 assert_eq!(ordered[1].source_canister, CHILD);
1256 assert_eq!(
1257 ordered[1].ordering_dependency,
1258 Some(RestoreOrderingDependency {
1259 source_canister: ROOT.to_string(),
1260 target_canister: ROOT.to_string(),
1261 relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
1262 })
1263 );
1264 }
1265
1266 #[test]
1268 fn plan_rejects_parent_in_later_restore_group() {
1269 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1270 manifest.fleet.members[0].restore_group = 1;
1271 manifest.fleet.members[1].restore_group = 2;
1272
1273 let err = RestorePlanner::plan(&manifest, None)
1274 .expect_err("parent-after-child group ordering should fail");
1275
1276 assert!(matches!(
1277 err,
1278 RestorePlanError::ParentRestoreGroupAfterChild { .. }
1279 ));
1280 }
1281
1282 #[test]
1284 fn fixed_identity_member_cannot_be_remapped() {
1285 let manifest = valid_manifest(IdentityMode::Fixed);
1286 let mapping = RestoreMapping {
1287 members: vec![
1288 RestoreMappingEntry {
1289 source_canister: ROOT.to_string(),
1290 target_canister: ROOT.to_string(),
1291 },
1292 RestoreMappingEntry {
1293 source_canister: CHILD.to_string(),
1294 target_canister: TARGET.to_string(),
1295 },
1296 ],
1297 };
1298
1299 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1300 .expect_err("fixed member remap should fail");
1301
1302 assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
1303 }
1304
1305 #[test]
1307 fn relocatable_member_can_be_mapped() {
1308 let manifest = valid_manifest(IdentityMode::Relocatable);
1309 let mapping = RestoreMapping {
1310 members: vec![
1311 RestoreMappingEntry {
1312 source_canister: ROOT.to_string(),
1313 target_canister: ROOT.to_string(),
1314 },
1315 RestoreMappingEntry {
1316 source_canister: CHILD.to_string(),
1317 target_canister: TARGET.to_string(),
1318 },
1319 ],
1320 };
1321
1322 let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1323 let child = plan
1324 .ordered_members()
1325 .into_iter()
1326 .find(|member| member.source_canister == CHILD)
1327 .expect("child member should be planned");
1328
1329 assert_eq!(plan.identity_summary.fixed_members, 1);
1330 assert_eq!(plan.identity_summary.relocatable_members, 1);
1331 assert_eq!(plan.identity_summary.in_place_members, 1);
1332 assert_eq!(plan.identity_summary.mapped_members, 2);
1333 assert_eq!(plan.identity_summary.remapped_members, 1);
1334 assert_eq!(child.target_canister, TARGET);
1335 assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
1336 }
1337
1338 #[test]
1340 fn plan_members_include_snapshot_and_verification_metadata() {
1341 let manifest = valid_manifest(IdentityMode::Relocatable);
1342
1343 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1344 let root = plan
1345 .ordered_members()
1346 .into_iter()
1347 .find(|member| member.source_canister == ROOT)
1348 .expect("root member should be planned");
1349
1350 assert_eq!(root.identity_mode, IdentityMode::Fixed);
1351 assert_eq!(root.verification_class, "basic");
1352 assert_eq!(root.verification_checks[0].kind, "call");
1353 assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
1354 assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
1355 }
1356
1357 #[test]
1359 fn plan_includes_mapping_summary() {
1360 let manifest = valid_manifest(IdentityMode::Relocatable);
1361 let in_place = RestorePlanner::plan(&manifest, None).expect("plan should build");
1362
1363 assert!(!in_place.identity_summary.mapping_supplied);
1364 assert!(!in_place.identity_summary.all_sources_mapped);
1365 assert_eq!(in_place.identity_summary.mapped_members, 0);
1366
1367 let mapping = RestoreMapping {
1368 members: vec![
1369 RestoreMappingEntry {
1370 source_canister: ROOT.to_string(),
1371 target_canister: ROOT.to_string(),
1372 },
1373 RestoreMappingEntry {
1374 source_canister: CHILD.to_string(),
1375 target_canister: TARGET.to_string(),
1376 },
1377 ],
1378 };
1379 let mapped = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
1380
1381 assert!(mapped.identity_summary.mapping_supplied);
1382 assert!(mapped.identity_summary.all_sources_mapped);
1383 assert_eq!(mapped.identity_summary.mapped_members, 2);
1384 assert_eq!(mapped.identity_summary.remapped_members, 1);
1385 }
1386
1387 #[test]
1389 fn plan_includes_snapshot_summary() {
1390 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1391 manifest.fleet.members[1].source_snapshot.module_hash = None;
1392 manifest.fleet.members[1].source_snapshot.wasm_hash = None;
1393 manifest.fleet.members[1].source_snapshot.checksum = None;
1394
1395 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1396
1397 assert!(!plan.snapshot_summary.all_members_have_module_hash);
1398 assert!(!plan.snapshot_summary.all_members_have_wasm_hash);
1399 assert!(plan.snapshot_summary.all_members_have_code_version);
1400 assert!(!plan.snapshot_summary.all_members_have_checksum);
1401 assert_eq!(plan.snapshot_summary.members_with_module_hash, 1);
1402 assert_eq!(plan.snapshot_summary.members_with_wasm_hash, 1);
1403 assert_eq!(plan.snapshot_summary.members_with_code_version, 2);
1404 assert_eq!(plan.snapshot_summary.members_with_checksum, 1);
1405 assert!(!plan.readiness_summary.ready);
1406 assert_eq!(
1407 plan.readiness_summary.reasons,
1408 [
1409 "missing-module-hash",
1410 "missing-wasm-hash",
1411 "missing-snapshot-checksum"
1412 ]
1413 );
1414 }
1415
1416 #[test]
1418 fn plan_includes_verification_summary() {
1419 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1420 manifest.verification.fleet_checks.push(VerificationCheck {
1421 kind: "fleet-ready".to_string(),
1422 method: None,
1423 roles: Vec::new(),
1424 });
1425 manifest
1426 .verification
1427 .member_checks
1428 .push(MemberVerificationChecks {
1429 role: "app".to_string(),
1430 checks: vec![VerificationCheck {
1431 kind: "app-ready".to_string(),
1432 method: Some("ready".to_string()),
1433 roles: Vec::new(),
1434 }],
1435 });
1436
1437 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1438
1439 assert!(plan.verification_summary.verification_required);
1440 assert!(plan.verification_summary.all_members_have_checks);
1441 assert_eq!(plan.verification_summary.fleet_checks, 1);
1442 assert_eq!(plan.verification_summary.member_check_groups, 1);
1443 assert_eq!(plan.verification_summary.member_checks, 3);
1444 assert_eq!(plan.verification_summary.members_with_checks, 2);
1445 assert_eq!(plan.verification_summary.total_checks, 4);
1446 }
1447
1448 #[test]
1450 fn plan_includes_operation_summary() {
1451 let manifest = valid_manifest(IdentityMode::Relocatable);
1452
1453 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1454
1455 assert_eq!(plan.operation_summary.planned_snapshot_loads, 2);
1456 assert_eq!(plan.operation_summary.planned_code_reinstalls, 2);
1457 assert_eq!(plan.operation_summary.planned_verification_checks, 2);
1458 assert_eq!(plan.operation_summary.planned_phases, 1);
1459 }
1460
1461 #[test]
1463 fn restore_status_starts_all_members_as_planned() {
1464 let manifest = valid_manifest(IdentityMode::Relocatable);
1465
1466 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1467 let status = RestoreStatus::from_plan(&plan);
1468
1469 assert_eq!(status.status_version, 1);
1470 assert_eq!(status.backup_id.as_str(), plan.backup_id.as_str());
1471 assert_eq!(
1472 status.source_environment.as_str(),
1473 plan.source_environment.as_str()
1474 );
1475 assert_eq!(
1476 status.source_root_canister.as_str(),
1477 plan.source_root_canister.as_str()
1478 );
1479 assert_eq!(status.topology_hash.as_str(), plan.topology_hash.as_str());
1480 assert!(status.ready);
1481 assert!(status.readiness_reasons.is_empty());
1482 assert!(status.verification_required);
1483 assert_eq!(status.member_count, 2);
1484 assert_eq!(status.phase_count, 1);
1485 assert_eq!(status.planned_snapshot_loads, 2);
1486 assert_eq!(status.planned_code_reinstalls, 2);
1487 assert_eq!(status.planned_verification_checks, 2);
1488 assert_eq!(status.phases.len(), 1);
1489 assert_eq!(status.phases[0].restore_group, 1);
1490 assert_eq!(status.phases[0].members.len(), 2);
1491 assert_eq!(
1492 status.phases[0].members[0].state,
1493 RestoreMemberState::Planned
1494 );
1495 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
1496 assert_eq!(status.phases[0].members[0].target_canister, ROOT);
1497 assert_eq!(status.phases[0].members[0].snapshot_id, "snap-root");
1498 assert_eq!(status.phases[0].members[0].artifact_path, "artifacts/root");
1499 assert_eq!(
1500 status.phases[0].members[1].state,
1501 RestoreMemberState::Planned
1502 );
1503 assert_eq!(status.phases[0].members[1].source_canister, CHILD);
1504 }
1505
1506 #[test]
1508 fn apply_dry_run_renders_ordered_member_operations() {
1509 let manifest = valid_manifest(IdentityMode::Relocatable);
1510
1511 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1512 let status = RestoreStatus::from_plan(&plan);
1513 let dry_run =
1514 RestoreApplyDryRun::try_from_plan(&plan, Some(&status)).expect("dry-run should build");
1515
1516 assert_eq!(dry_run.dry_run_version, 1);
1517 assert_eq!(dry_run.backup_id.as_str(), "fbk_test_001");
1518 assert!(dry_run.ready);
1519 assert!(dry_run.status_supplied);
1520 assert_eq!(dry_run.member_count, 2);
1521 assert_eq!(dry_run.phase_count, 1);
1522 assert_eq!(dry_run.planned_snapshot_loads, 2);
1523 assert_eq!(dry_run.planned_code_reinstalls, 2);
1524 assert_eq!(dry_run.planned_verification_checks, 2);
1525 assert_eq!(dry_run.rendered_operations, 8);
1526 assert_eq!(dry_run.phases.len(), 1);
1527
1528 let operations = &dry_run.phases[0].operations;
1529 assert_eq!(operations[0].sequence, 0);
1530 assert_eq!(
1531 operations[0].operation,
1532 RestoreApplyOperationKind::UploadSnapshot
1533 );
1534 assert_eq!(operations[0].source_canister, ROOT);
1535 assert_eq!(operations[0].target_canister, ROOT);
1536 assert_eq!(operations[0].snapshot_id, Some("snap-root".to_string()));
1537 assert_eq!(
1538 operations[0].artifact_path,
1539 Some("artifacts/root".to_string())
1540 );
1541 assert_eq!(
1542 operations[1].operation,
1543 RestoreApplyOperationKind::LoadSnapshot
1544 );
1545 assert_eq!(
1546 operations[2].operation,
1547 RestoreApplyOperationKind::ReinstallCode
1548 );
1549 assert_eq!(
1550 operations[3].operation,
1551 RestoreApplyOperationKind::VerifyMember
1552 );
1553 assert_eq!(operations[3].verification_kind, Some("call".to_string()));
1554 assert_eq!(
1555 operations[3].verification_method,
1556 Some("canic_ready".to_string())
1557 );
1558 assert_eq!(operations[4].source_canister, CHILD);
1559 assert_eq!(
1560 operations[7].operation,
1561 RestoreApplyOperationKind::VerifyMember
1562 );
1563 }
1564
1565 #[test]
1567 fn apply_dry_run_sequences_operations_across_phases() {
1568 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1569 manifest.fleet.members[0].restore_group = 2;
1570
1571 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1572 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
1573
1574 assert_eq!(dry_run.phases.len(), 2);
1575 assert_eq!(dry_run.rendered_operations, 8);
1576 assert_eq!(dry_run.phases[0].operations[0].sequence, 0);
1577 assert_eq!(dry_run.phases[0].operations[3].sequence, 3);
1578 assert_eq!(dry_run.phases[1].operations[0].sequence, 4);
1579 assert_eq!(dry_run.phases[1].operations[3].sequence, 7);
1580 }
1581
1582 #[test]
1584 fn apply_dry_run_rejects_mismatched_status() {
1585 let manifest = valid_manifest(IdentityMode::Relocatable);
1586
1587 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1588 let mut status = RestoreStatus::from_plan(&plan);
1589 status.backup_id = "other-backup".to_string();
1590
1591 let err = RestoreApplyDryRun::try_from_plan(&plan, Some(&status))
1592 .expect_err("mismatched status should fail");
1593
1594 assert!(matches!(
1595 err,
1596 RestoreApplyDryRunError::StatusPlanMismatch {
1597 field: "backup_id",
1598 ..
1599 }
1600 ));
1601 }
1602
1603 #[test]
1605 fn plan_expands_role_verification_checks_per_matching_member() {
1606 let mut manifest = valid_manifest(IdentityMode::Relocatable);
1607 manifest.fleet.members.push(fleet_member(
1608 "app",
1609 CHILD_TWO,
1610 Some(ROOT),
1611 IdentityMode::Relocatable,
1612 1,
1613 ));
1614 manifest
1615 .verification
1616 .member_checks
1617 .push(MemberVerificationChecks {
1618 role: "app".to_string(),
1619 checks: vec![VerificationCheck {
1620 kind: "app-ready".to_string(),
1621 method: Some("ready".to_string()),
1622 roles: Vec::new(),
1623 }],
1624 });
1625
1626 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
1627
1628 assert_eq!(plan.verification_summary.fleet_checks, 0);
1629 assert_eq!(plan.verification_summary.member_check_groups, 1);
1630 assert_eq!(plan.verification_summary.member_checks, 5);
1631 assert_eq!(plan.verification_summary.members_with_checks, 3);
1632 assert_eq!(plan.verification_summary.total_checks, 5);
1633 }
1634
1635 #[test]
1637 fn mapped_restore_requires_complete_mapping() {
1638 let manifest = valid_manifest(IdentityMode::Relocatable);
1639 let mapping = RestoreMapping {
1640 members: vec![RestoreMappingEntry {
1641 source_canister: ROOT.to_string(),
1642 target_canister: ROOT.to_string(),
1643 }],
1644 };
1645
1646 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1647 .expect_err("incomplete mapping should fail");
1648
1649 assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
1650 }
1651
1652 #[test]
1654 fn mapped_restore_rejects_unknown_mapping_sources() {
1655 let manifest = valid_manifest(IdentityMode::Relocatable);
1656 let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
1657 let mapping = RestoreMapping {
1658 members: vec![
1659 RestoreMappingEntry {
1660 source_canister: ROOT.to_string(),
1661 target_canister: ROOT.to_string(),
1662 },
1663 RestoreMappingEntry {
1664 source_canister: CHILD.to_string(),
1665 target_canister: TARGET.to_string(),
1666 },
1667 RestoreMappingEntry {
1668 source_canister: unknown.to_string(),
1669 target_canister: unknown.to_string(),
1670 },
1671 ],
1672 };
1673
1674 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1675 .expect_err("unknown mapping source should fail");
1676
1677 assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
1678 }
1679
1680 #[test]
1682 fn duplicate_mapping_targets_fail_validation() {
1683 let manifest = valid_manifest(IdentityMode::Relocatable);
1684 let mapping = RestoreMapping {
1685 members: vec![
1686 RestoreMappingEntry {
1687 source_canister: ROOT.to_string(),
1688 target_canister: ROOT.to_string(),
1689 },
1690 RestoreMappingEntry {
1691 source_canister: CHILD.to_string(),
1692 target_canister: ROOT.to_string(),
1693 },
1694 ],
1695 };
1696
1697 let err = RestorePlanner::plan(&manifest, Some(&mapping))
1698 .expect_err("duplicate targets should fail");
1699
1700 assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
1701 }
1702}