1use crate::manifest::{
2 FleetBackupManifest, FleetMember, IdentityMode, ManifestDesignConformanceReport,
3 ManifestValidationError, SourceSnapshot, VerificationCheck, VerificationPlan,
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 #[serde(default)]
60 pub design_conformance: Option<ManifestDesignConformanceReport>,
61 #[serde(default)]
62 pub fleet_verification_checks: Vec<VerificationCheck>,
63 pub phases: Vec<RestorePhase>,
64}
65
66impl RestorePlan {
67 #[must_use]
69 pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
70 self.phases
71 .iter()
72 .flat_map(|phase| phase.members.iter())
73 .collect()
74 }
75}
76
77#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
82pub struct RestoreIdentitySummary {
83 pub mapping_supplied: bool,
84 pub all_sources_mapped: bool,
85 pub fixed_members: usize,
86 pub relocatable_members: usize,
87 pub in_place_members: usize,
88 pub mapped_members: usize,
89 pub remapped_members: usize,
90}
91
92#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
97#[expect(
98 clippy::struct_excessive_bools,
99 reason = "restore summaries intentionally expose machine-readable readiness flags"
100)]
101pub struct RestoreSnapshotSummary {
102 pub all_members_have_module_hash: bool,
103 pub all_members_have_wasm_hash: bool,
104 pub all_members_have_code_version: bool,
105 pub all_members_have_checksum: bool,
106 pub members_with_module_hash: usize,
107 pub members_with_wasm_hash: usize,
108 pub members_with_code_version: usize,
109 pub members_with_checksum: usize,
110}
111
112#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
117pub struct RestoreVerificationSummary {
118 pub verification_required: bool,
119 pub all_members_have_checks: bool,
120 pub fleet_checks: usize,
121 pub member_check_groups: usize,
122 pub member_checks: usize,
123 pub members_with_checks: usize,
124 pub total_checks: usize,
125}
126
127#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
132pub struct RestoreReadinessSummary {
133 pub ready: bool,
134 pub reasons: Vec<String>,
135}
136
137#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
142pub struct RestoreOperationSummary {
143 #[serde(default)]
144 pub planned_snapshot_uploads: usize,
145 pub planned_snapshot_loads: usize,
146 pub planned_code_reinstalls: usize,
147 pub planned_verification_checks: usize,
148 #[serde(default)]
149 pub planned_operations: usize,
150 pub planned_phases: usize,
151}
152
153impl RestoreOperationSummary {
154 #[must_use]
156 pub const fn effective_planned_snapshot_uploads(&self, member_count: usize) -> usize {
157 if self.planned_snapshot_uploads == 0 && member_count > 0 {
158 return member_count;
159 }
160
161 self.planned_snapshot_uploads
162 }
163
164 #[must_use]
166 pub const fn effective_planned_operations(&self, member_count: usize) -> usize {
167 if self.planned_operations == 0 {
168 return self.effective_planned_snapshot_uploads(member_count)
169 + self.planned_snapshot_loads
170 + self.planned_code_reinstalls
171 + self.planned_verification_checks;
172 }
173
174 self.planned_operations
175 }
176}
177
178#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
183pub struct RestoreOrderingSummary {
184 pub phase_count: usize,
185 pub dependency_free_members: usize,
186 pub in_group_parent_edges: usize,
187 pub cross_group_parent_edges: usize,
188}
189
190#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
195pub struct RestorePhase {
196 pub restore_group: u16,
197 pub members: Vec<RestorePlanMember>,
198}
199
200#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
205pub struct RestorePlanMember {
206 pub source_canister: String,
207 pub target_canister: String,
208 pub role: String,
209 pub parent_source_canister: Option<String>,
210 pub parent_target_canister: Option<String>,
211 pub ordering_dependency: Option<RestoreOrderingDependency>,
212 pub phase_order: usize,
213 pub restore_group: u16,
214 pub identity_mode: IdentityMode,
215 pub verification_class: String,
216 pub verification_checks: Vec<VerificationCheck>,
217 pub source_snapshot: SourceSnapshot,
218}
219
220#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
225pub struct RestoreOrderingDependency {
226 pub source_canister: String,
227 pub target_canister: String,
228 pub relationship: RestoreOrderingRelationship,
229}
230
231#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
236#[serde(rename_all = "kebab-case")]
237pub enum RestoreOrderingRelationship {
238 ParentInSameGroup,
239 ParentInEarlierGroup,
240}
241
242pub struct RestorePlanner;
247
248impl RestorePlanner {
249 pub fn plan(
251 manifest: &FleetBackupManifest,
252 mapping: Option<&RestoreMapping>,
253 ) -> Result<RestorePlan, RestorePlanError> {
254 manifest.validate()?;
255 if let Some(mapping) = mapping {
256 validate_mapping(mapping)?;
257 validate_mapping_sources(manifest, mapping)?;
258 }
259
260 let members = resolve_members(manifest, mapping)?;
261 let identity_summary = restore_identity_summary(&members, mapping.is_some());
262 let snapshot_summary = restore_snapshot_summary(&members);
263 let verification_summary = restore_verification_summary(manifest, &members);
264 let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
265 validate_restore_group_dependencies(&members)?;
266 let phases = group_and_order_members(members)?;
267 let ordering_summary = restore_ordering_summary(&phases);
268 let operation_summary =
269 restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
270
271 Ok(RestorePlan {
272 backup_id: manifest.backup_id.clone(),
273 source_environment: manifest.source.environment.clone(),
274 source_root_canister: manifest.source.root_canister.clone(),
275 topology_hash: manifest.fleet.topology_hash.clone(),
276 member_count: manifest.fleet.members.len(),
277 identity_summary,
278 snapshot_summary,
279 verification_summary,
280 readiness_summary,
281 operation_summary,
282 ordering_summary,
283 design_conformance: Some(manifest.design_conformance_report()),
284 fleet_verification_checks: manifest.verification.fleet_checks.clone(),
285 phases,
286 })
287 }
288}
289
290#[derive(Debug, ThisError)]
295pub enum RestorePlanError {
296 #[error(transparent)]
297 InvalidManifest(#[from] ManifestValidationError),
298
299 #[error("field {field} must be a valid principal: {value}")]
300 InvalidPrincipal { field: &'static str, value: String },
301
302 #[error("mapping contains duplicate source canister {0}")]
303 DuplicateMappingSource(String),
304
305 #[error("mapping contains duplicate target canister {0}")]
306 DuplicateMappingTarget(String),
307
308 #[error("mapping references unknown source canister {0}")]
309 UnknownMappingSource(String),
310
311 #[error("mapping is missing source canister {0}")]
312 MissingMappingSource(String),
313
314 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
315 FixedIdentityRemap {
316 source_canister: String,
317 target_canister: String,
318 },
319
320 #[error("restore plan contains duplicate target canister {0}")]
321 DuplicatePlanTarget(String),
322
323 #[error("restore group {0} contains a parent cycle or unresolved dependency")]
324 RestoreOrderCycle(u16),
325
326 #[error(
327 "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
328 )]
329 ParentRestoreGroupAfterChild {
330 child_source_canister: String,
331 parent_source_canister: String,
332 child_restore_group: u16,
333 parent_restore_group: u16,
334 },
335}
336
337fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
339 let mut sources = BTreeSet::new();
340 let mut targets = BTreeSet::new();
341
342 for entry in &mapping.members {
343 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
344 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
345
346 if !sources.insert(entry.source_canister.clone()) {
347 return Err(RestorePlanError::DuplicateMappingSource(
348 entry.source_canister.clone(),
349 ));
350 }
351
352 if !targets.insert(entry.target_canister.clone()) {
353 return Err(RestorePlanError::DuplicateMappingTarget(
354 entry.target_canister.clone(),
355 ));
356 }
357 }
358
359 Ok(())
360}
361
362fn validate_mapping_sources(
364 manifest: &FleetBackupManifest,
365 mapping: &RestoreMapping,
366) -> Result<(), RestorePlanError> {
367 let sources = manifest
368 .fleet
369 .members
370 .iter()
371 .map(|member| member.canister_id.as_str())
372 .collect::<BTreeSet<_>>();
373
374 for entry in &mapping.members {
375 if !sources.contains(entry.source_canister.as_str()) {
376 return Err(RestorePlanError::UnknownMappingSource(
377 entry.source_canister.clone(),
378 ));
379 }
380 }
381
382 Ok(())
383}
384
385fn resolve_members(
387 manifest: &FleetBackupManifest,
388 mapping: Option<&RestoreMapping>,
389) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
390 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
391 let mut targets = BTreeSet::new();
392 let mut source_to_target = BTreeMap::new();
393
394 for member in &manifest.fleet.members {
395 let target = resolve_target(member, mapping)?;
396 if !targets.insert(target.clone()) {
397 return Err(RestorePlanError::DuplicatePlanTarget(target));
398 }
399
400 source_to_target.insert(member.canister_id.clone(), target.clone());
401 plan_members.push(RestorePlanMember {
402 source_canister: member.canister_id.clone(),
403 target_canister: target,
404 role: member.role.clone(),
405 parent_source_canister: member.parent_canister_id.clone(),
406 parent_target_canister: None,
407 ordering_dependency: None,
408 phase_order: 0,
409 restore_group: member.restore_group,
410 identity_mode: member.identity_mode.clone(),
411 verification_class: member.verification_class.clone(),
412 verification_checks: concrete_member_verification_checks(
413 member,
414 &manifest.verification,
415 ),
416 source_snapshot: member.source_snapshot.clone(),
417 });
418 }
419
420 for member in &mut plan_members {
421 member.parent_target_canister = member
422 .parent_source_canister
423 .as_ref()
424 .and_then(|parent| source_to_target.get(parent))
425 .cloned();
426 }
427
428 Ok(plan_members)
429}
430
431fn concrete_member_verification_checks(
433 member: &FleetMember,
434 verification: &VerificationPlan,
435) -> Vec<VerificationCheck> {
436 let mut checks = member
437 .verification_checks
438 .iter()
439 .filter(|check| verification_check_applies_to_role(check, &member.role))
440 .cloned()
441 .collect::<Vec<_>>();
442
443 for group in &verification.member_checks {
444 if group.role != member.role {
445 continue;
446 }
447
448 checks.extend(
449 group
450 .checks
451 .iter()
452 .filter(|check| verification_check_applies_to_role(check, &member.role))
453 .cloned(),
454 );
455 }
456
457 checks
458}
459
460fn verification_check_applies_to_role(check: &VerificationCheck, role: &str) -> bool {
462 check.roles.is_empty() || check.roles.iter().any(|check_role| check_role == role)
463}
464
465fn resolve_target(
467 member: &FleetMember,
468 mapping: Option<&RestoreMapping>,
469) -> Result<String, RestorePlanError> {
470 let target = match mapping {
471 Some(mapping) => mapping
472 .target_for(&member.canister_id)
473 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
474 .to_string(),
475 None => member.canister_id.clone(),
476 };
477
478 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
479 return Err(RestorePlanError::FixedIdentityRemap {
480 source_canister: member.canister_id.clone(),
481 target_canister: target,
482 });
483 }
484
485 Ok(target)
486}
487
488fn restore_identity_summary(
490 members: &[RestorePlanMember],
491 mapping_supplied: bool,
492) -> RestoreIdentitySummary {
493 let mut summary = RestoreIdentitySummary {
494 mapping_supplied,
495 all_sources_mapped: false,
496 fixed_members: 0,
497 relocatable_members: 0,
498 in_place_members: 0,
499 mapped_members: 0,
500 remapped_members: 0,
501 };
502
503 for member in members {
504 match member.identity_mode {
505 IdentityMode::Fixed => summary.fixed_members += 1,
506 IdentityMode::Relocatable => summary.relocatable_members += 1,
507 }
508
509 if member.source_canister == member.target_canister {
510 summary.in_place_members += 1;
511 } else {
512 summary.remapped_members += 1;
513 }
514 if mapping_supplied {
515 summary.mapped_members += 1;
516 }
517 }
518
519 summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
520
521 summary
522}
523
524fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
526 let members_with_module_hash = members
527 .iter()
528 .filter(|member| member.source_snapshot.module_hash.is_some())
529 .count();
530 let members_with_wasm_hash = members
531 .iter()
532 .filter(|member| member.source_snapshot.wasm_hash.is_some())
533 .count();
534 let members_with_code_version = members
535 .iter()
536 .filter(|member| member.source_snapshot.code_version.is_some())
537 .count();
538 let members_with_checksum = members
539 .iter()
540 .filter(|member| member.source_snapshot.checksum.is_some())
541 .count();
542
543 RestoreSnapshotSummary {
544 all_members_have_module_hash: members_with_module_hash == members.len(),
545 all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
546 all_members_have_code_version: members_with_code_version == members.len(),
547 all_members_have_checksum: members_with_checksum == members.len(),
548 members_with_module_hash,
549 members_with_wasm_hash,
550 members_with_code_version,
551 members_with_checksum,
552 }
553}
554
555fn restore_readiness_summary(
557 snapshot: &RestoreSnapshotSummary,
558 verification: &RestoreVerificationSummary,
559) -> RestoreReadinessSummary {
560 let mut reasons = Vec::new();
561
562 if !snapshot.all_members_have_module_hash {
563 reasons.push("missing-module-hash".to_string());
564 }
565 if !snapshot.all_members_have_wasm_hash {
566 reasons.push("missing-wasm-hash".to_string());
567 }
568 if !snapshot.all_members_have_code_version {
569 reasons.push("missing-code-version".to_string());
570 }
571 if !snapshot.all_members_have_checksum {
572 reasons.push("missing-snapshot-checksum".to_string());
573 }
574 if !verification.all_members_have_checks {
575 reasons.push("missing-verification-checks".to_string());
576 }
577
578 RestoreReadinessSummary {
579 ready: reasons.is_empty(),
580 reasons,
581 }
582}
583
584fn restore_verification_summary(
586 manifest: &FleetBackupManifest,
587 members: &[RestorePlanMember],
588) -> RestoreVerificationSummary {
589 let fleet_checks = manifest.verification.fleet_checks.len();
590 let member_check_groups = manifest.verification.member_checks.len();
591 let member_checks = members
592 .iter()
593 .map(|member| member.verification_checks.len())
594 .sum::<usize>();
595 let members_with_checks = members
596 .iter()
597 .filter(|member| !member.verification_checks.is_empty())
598 .count();
599
600 RestoreVerificationSummary {
601 verification_required: true,
602 all_members_have_checks: members_with_checks == members.len(),
603 fleet_checks,
604 member_check_groups,
605 member_checks,
606 members_with_checks,
607 total_checks: fleet_checks + member_checks,
608 }
609}
610
611const fn restore_operation_summary(
613 member_count: usize,
614 verification_summary: &RestoreVerificationSummary,
615 phases: &[RestorePhase],
616) -> RestoreOperationSummary {
617 RestoreOperationSummary {
618 planned_snapshot_uploads: member_count,
619 planned_snapshot_loads: member_count,
620 planned_code_reinstalls: 0,
621 planned_verification_checks: verification_summary.total_checks,
622 planned_operations: member_count + member_count + verification_summary.total_checks,
623 planned_phases: phases.len(),
624 }
625}
626
627fn validate_restore_group_dependencies(
629 members: &[RestorePlanMember],
630) -> Result<(), RestorePlanError> {
631 let groups_by_source = members
632 .iter()
633 .map(|member| (member.source_canister.as_str(), member.restore_group))
634 .collect::<BTreeMap<_, _>>();
635
636 for member in members {
637 let Some(parent) = &member.parent_source_canister else {
638 continue;
639 };
640 let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
641 continue;
642 };
643
644 if *parent_group > member.restore_group {
645 return Err(RestorePlanError::ParentRestoreGroupAfterChild {
646 child_source_canister: member.source_canister.clone(),
647 parent_source_canister: parent.clone(),
648 child_restore_group: member.restore_group,
649 parent_restore_group: *parent_group,
650 });
651 }
652 }
653
654 Ok(())
655}
656
657fn group_and_order_members(
659 members: Vec<RestorePlanMember>,
660) -> Result<Vec<RestorePhase>, RestorePlanError> {
661 let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
662 for member in members {
663 groups.entry(member.restore_group).or_default().push(member);
664 }
665
666 groups
667 .into_iter()
668 .map(|(restore_group, members)| {
669 let members = order_group(restore_group, members)?;
670 Ok(RestorePhase {
671 restore_group,
672 members,
673 })
674 })
675 .collect()
676}
677
678fn order_group(
680 restore_group: u16,
681 members: Vec<RestorePlanMember>,
682) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
683 let mut remaining = members;
684 let group_sources = remaining
685 .iter()
686 .map(|member| member.source_canister.clone())
687 .collect::<BTreeSet<_>>();
688 let mut emitted = BTreeSet::new();
689 let mut ordered = Vec::with_capacity(remaining.len());
690
691 while !remaining.is_empty() {
692 let Some(index) = remaining
693 .iter()
694 .position(|member| parent_satisfied(member, &group_sources, &emitted))
695 else {
696 return Err(RestorePlanError::RestoreOrderCycle(restore_group));
697 };
698
699 let mut member = remaining.remove(index);
700 member.phase_order = ordered.len();
701 member.ordering_dependency = ordering_dependency(&member, &group_sources);
702 emitted.insert(member.source_canister.clone());
703 ordered.push(member);
704 }
705
706 Ok(ordered)
707}
708
709fn ordering_dependency(
711 member: &RestorePlanMember,
712 group_sources: &BTreeSet<String>,
713) -> Option<RestoreOrderingDependency> {
714 let parent_source = member.parent_source_canister.as_ref()?;
715 let parent_target = member.parent_target_canister.as_ref()?;
716 let relationship = if group_sources.contains(parent_source) {
717 RestoreOrderingRelationship::ParentInSameGroup
718 } else {
719 RestoreOrderingRelationship::ParentInEarlierGroup
720 };
721
722 Some(RestoreOrderingDependency {
723 source_canister: parent_source.clone(),
724 target_canister: parent_target.clone(),
725 relationship,
726 })
727}
728
729fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
731 let mut summary = RestoreOrderingSummary {
732 phase_count: phases.len(),
733 dependency_free_members: 0,
734 in_group_parent_edges: 0,
735 cross_group_parent_edges: 0,
736 };
737
738 for member in phases.iter().flat_map(|phase| phase.members.iter()) {
739 match &member.ordering_dependency {
740 Some(dependency)
741 if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
742 {
743 summary.in_group_parent_edges += 1;
744 }
745 Some(dependency)
746 if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
747 {
748 summary.cross_group_parent_edges += 1;
749 }
750 Some(_) => {}
751 None => summary.dependency_free_members += 1,
752 }
753 }
754
755 summary
756}
757
758fn parent_satisfied(
760 member: &RestorePlanMember,
761 group_sources: &BTreeSet<String>,
762 emitted: &BTreeSet<String>,
763) -> bool {
764 match &member.parent_source_canister {
765 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
766 _ => true,
767 }
768}
769
770fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
772 Principal::from_str(value)
773 .map(|_| ())
774 .map_err(|_| RestorePlanError::InvalidPrincipal {
775 field,
776 value: value.to_string(),
777 })
778}