1use crate::manifest::{
2 FleetBackupManifest, FleetMember, IdentityMode, ManifestValidationError, SourceSnapshot,
3 VerificationCheck, VerificationPlan,
4};
5use candid::Principal;
6use serde::{Deserialize, Serialize};
7use std::{collections::BTreeSet, str::FromStr};
8use thiserror::Error as ThisError;
9
10#[derive(Clone, Debug, Default, Deserialize, Serialize)]
15pub struct RestoreMapping {
16 pub members: Vec<RestoreMappingEntry>,
17}
18
19impl RestoreMapping {
20 fn target_for(&self, source_canister: &str) -> Option<&str> {
22 self.members
23 .iter()
24 .find(|entry| entry.source_canister == source_canister)
25 .map(|entry| entry.target_canister.as_str())
26 }
27}
28
29#[derive(Clone, Debug, Deserialize, Serialize)]
34pub struct RestoreMappingEntry {
35 pub source_canister: String,
36 pub target_canister: String,
37}
38
39#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44pub struct RestorePlan {
45 pub backup_id: String,
46 pub source_environment: String,
47 pub source_root_canister: String,
48 pub topology_hash: String,
49 pub member_count: usize,
50 pub identity_summary: RestoreIdentitySummary,
51 pub snapshot_summary: RestoreSnapshotSummary,
52 pub verification_summary: RestoreVerificationSummary,
53 pub readiness_summary: RestoreReadinessSummary,
54 pub operation_summary: RestoreOperationSummary,
55 pub ordering_summary: RestoreOrderingSummary,
56 #[serde(default)]
57 pub fleet_verification_checks: Vec<VerificationCheck>,
58 pub members: Vec<RestorePlanMember>,
59}
60
61impl RestorePlan {
62 #[must_use]
64 pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
65 self.members.iter().collect()
66 }
67}
68
69#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
74pub struct RestoreIdentitySummary {
75 pub mapping_supplied: bool,
76 pub all_sources_mapped: bool,
77 pub fixed_members: usize,
78 pub relocatable_members: usize,
79 pub in_place_members: usize,
80 pub mapped_members: usize,
81 pub remapped_members: usize,
82}
83
84#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
89pub struct RestoreSnapshotSummary {
90 pub all_members_have_module_hash: bool,
91 pub all_members_have_code_version: bool,
92 pub all_members_have_checksum: bool,
93 pub members_with_module_hash: usize,
94 pub members_with_code_version: usize,
95 pub members_with_checksum: usize,
96}
97
98#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
103pub struct RestoreVerificationSummary {
104 pub verification_required: bool,
105 pub all_members_have_checks: bool,
106 pub fleet_checks: usize,
107 pub member_check_groups: usize,
108 pub member_checks: usize,
109 pub members_with_checks: usize,
110 pub total_checks: usize,
111}
112
113#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
118pub struct RestoreReadinessSummary {
119 pub ready: bool,
120 pub reasons: Vec<String>,
121}
122
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
128pub struct RestoreOperationSummary {
129 pub planned_snapshot_uploads: usize,
130 pub planned_snapshot_loads: usize,
131 pub planned_verification_checks: usize,
132 pub planned_operations: usize,
133}
134
135#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
140pub struct RestoreOrderingSummary {
141 pub ordered_members: usize,
142 pub dependency_free_members: usize,
143 pub parent_edges: usize,
144}
145
146#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
151pub struct RestorePlanMember {
152 pub source_canister: String,
153 pub target_canister: String,
154 pub role: String,
155 pub parent_source_canister: Option<String>,
156 pub parent_target_canister: Option<String>,
157 pub ordering_dependency: Option<RestoreOrderingDependency>,
158 pub member_order: usize,
159 pub identity_mode: IdentityMode,
160 pub verification_checks: Vec<VerificationCheck>,
161 pub source_snapshot: SourceSnapshot,
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
169pub struct RestoreOrderingDependency {
170 pub source_canister: String,
171 pub target_canister: String,
172 pub relationship: RestoreOrderingRelationship,
173}
174
175#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
180#[serde(rename_all = "kebab-case")]
181pub enum RestoreOrderingRelationship {
182 ParentBeforeChild,
183}
184
185pub struct RestorePlanner;
190
191impl RestorePlanner {
192 pub fn plan(
194 manifest: &FleetBackupManifest,
195 mapping: Option<&RestoreMapping>,
196 ) -> Result<RestorePlan, RestorePlanError> {
197 manifest.validate()?;
198 if let Some(mapping) = mapping {
199 validate_mapping(mapping)?;
200 validate_mapping_sources(manifest, mapping)?;
201 }
202
203 let members = resolve_members(manifest, mapping)?;
204 let identity_summary = restore_identity_summary(&members, mapping.is_some());
205 let snapshot_summary = restore_snapshot_summary(&members);
206 let verification_summary = restore_verification_summary(manifest, &members);
207 let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
208 let members = order_members(members)?;
209 let ordering_summary = restore_ordering_summary(&members);
210 let operation_summary =
211 restore_operation_summary(manifest.fleet.members.len(), &verification_summary);
212
213 Ok(RestorePlan {
214 backup_id: manifest.backup_id.clone(),
215 source_environment: manifest.source.environment.clone(),
216 source_root_canister: manifest.source.root_canister.clone(),
217 topology_hash: manifest.fleet.topology_hash.clone(),
218 member_count: manifest.fleet.members.len(),
219 identity_summary,
220 snapshot_summary,
221 verification_summary,
222 readiness_summary,
223 operation_summary,
224 ordering_summary,
225 fleet_verification_checks: manifest.verification.fleet_checks.clone(),
226 members,
227 })
228 }
229}
230
231#[derive(Debug, ThisError)]
236pub enum RestorePlanError {
237 #[error(transparent)]
238 InvalidManifest(#[from] ManifestValidationError),
239
240 #[error("field {field} must be a valid principal: {value}")]
241 InvalidPrincipal { field: &'static str, value: String },
242
243 #[error("mapping contains duplicate source canister {0}")]
244 DuplicateMappingSource(String),
245
246 #[error("mapping contains duplicate target canister {0}")]
247 DuplicateMappingTarget(String),
248
249 #[error("mapping references unknown source canister {0}")]
250 UnknownMappingSource(String),
251
252 #[error("mapping is missing source canister {0}")]
253 MissingMappingSource(String),
254
255 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
256 FixedIdentityRemap {
257 source_canister: String,
258 target_canister: String,
259 },
260
261 #[error("restore plan contains duplicate target canister {0}")]
262 DuplicatePlanTarget(String),
263
264 #[error("restore plan contains a parent cycle or unresolved dependency")]
265 RestoreOrderCycle,
266}
267
268fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
270 let mut sources = BTreeSet::new();
271 let mut targets = BTreeSet::new();
272
273 for entry in &mapping.members {
274 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
275 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
276
277 if !sources.insert(entry.source_canister.clone()) {
278 return Err(RestorePlanError::DuplicateMappingSource(
279 entry.source_canister.clone(),
280 ));
281 }
282
283 if !targets.insert(entry.target_canister.clone()) {
284 return Err(RestorePlanError::DuplicateMappingTarget(
285 entry.target_canister.clone(),
286 ));
287 }
288 }
289
290 Ok(())
291}
292
293fn validate_mapping_sources(
295 manifest: &FleetBackupManifest,
296 mapping: &RestoreMapping,
297) -> Result<(), RestorePlanError> {
298 let sources = manifest
299 .fleet
300 .members
301 .iter()
302 .map(|member| member.canister_id.as_str())
303 .collect::<BTreeSet<_>>();
304
305 for entry in &mapping.members {
306 if !sources.contains(entry.source_canister.as_str()) {
307 return Err(RestorePlanError::UnknownMappingSource(
308 entry.source_canister.clone(),
309 ));
310 }
311 }
312
313 Ok(())
314}
315
316fn resolve_members(
318 manifest: &FleetBackupManifest,
319 mapping: Option<&RestoreMapping>,
320) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
321 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
322 let mut targets = BTreeSet::new();
323 let mut source_to_target = std::collections::BTreeMap::new();
324
325 for member in &manifest.fleet.members {
326 let target = resolve_target(member, mapping)?;
327 if !targets.insert(target.clone()) {
328 return Err(RestorePlanError::DuplicatePlanTarget(target));
329 }
330
331 source_to_target.insert(member.canister_id.clone(), target.clone());
332 plan_members.push(RestorePlanMember {
333 source_canister: member.canister_id.clone(),
334 target_canister: target,
335 role: member.role.clone(),
336 parent_source_canister: member.parent_canister_id.clone(),
337 parent_target_canister: None,
338 ordering_dependency: None,
339 member_order: 0,
340 identity_mode: member.identity_mode.clone(),
341 verification_checks: concrete_member_verification_checks(
342 member,
343 &manifest.verification,
344 ),
345 source_snapshot: member.source_snapshot.clone(),
346 });
347 }
348
349 for member in &mut plan_members {
350 member.parent_target_canister = member
351 .parent_source_canister
352 .as_ref()
353 .and_then(|parent| source_to_target.get(parent))
354 .cloned();
355 }
356
357 Ok(plan_members)
358}
359
360fn concrete_member_verification_checks(
362 member: &FleetMember,
363 verification: &VerificationPlan,
364) -> Vec<VerificationCheck> {
365 let mut checks = member
366 .verification_checks
367 .iter()
368 .filter(|check| verification_check_applies_to_role(check, &member.role))
369 .cloned()
370 .collect::<Vec<_>>();
371
372 for group in &verification.member_checks {
373 if group.role != member.role {
374 continue;
375 }
376
377 checks.extend(
378 group
379 .checks
380 .iter()
381 .filter(|check| verification_check_applies_to_role(check, &member.role))
382 .cloned(),
383 );
384 }
385
386 checks
387}
388
389fn verification_check_applies_to_role(check: &VerificationCheck, role: &str) -> bool {
391 check.roles.is_empty() || check.roles.iter().any(|check_role| check_role == role)
392}
393
394fn resolve_target(
396 member: &FleetMember,
397 mapping: Option<&RestoreMapping>,
398) -> Result<String, RestorePlanError> {
399 let target = match mapping {
400 Some(mapping) => mapping
401 .target_for(&member.canister_id)
402 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
403 .to_string(),
404 None => member.canister_id.clone(),
405 };
406
407 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
408 return Err(RestorePlanError::FixedIdentityRemap {
409 source_canister: member.canister_id.clone(),
410 target_canister: target,
411 });
412 }
413
414 Ok(target)
415}
416
417fn restore_identity_summary(
419 members: &[RestorePlanMember],
420 mapping_supplied: bool,
421) -> RestoreIdentitySummary {
422 let mut summary = RestoreIdentitySummary {
423 mapping_supplied,
424 all_sources_mapped: false,
425 fixed_members: 0,
426 relocatable_members: 0,
427 in_place_members: 0,
428 mapped_members: 0,
429 remapped_members: 0,
430 };
431
432 for member in members {
433 match member.identity_mode {
434 IdentityMode::Fixed => summary.fixed_members += 1,
435 IdentityMode::Relocatable => summary.relocatable_members += 1,
436 }
437
438 if member.source_canister == member.target_canister {
439 summary.in_place_members += 1;
440 } else {
441 summary.remapped_members += 1;
442 }
443 if mapping_supplied {
444 summary.mapped_members += 1;
445 }
446 }
447
448 summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
449
450 summary
451}
452
453fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
455 let members_with_module_hash = members
456 .iter()
457 .filter(|member| member.source_snapshot.module_hash.is_some())
458 .count();
459 let members_with_code_version = members
460 .iter()
461 .filter(|member| member.source_snapshot.code_version.is_some())
462 .count();
463 let members_with_checksum = members
464 .iter()
465 .filter(|member| member.source_snapshot.checksum.is_some())
466 .count();
467
468 RestoreSnapshotSummary {
469 all_members_have_module_hash: members_with_module_hash == members.len(),
470 all_members_have_code_version: members_with_code_version == members.len(),
471 all_members_have_checksum: members_with_checksum == members.len(),
472 members_with_module_hash,
473 members_with_code_version,
474 members_with_checksum,
475 }
476}
477
478fn restore_readiness_summary(
480 snapshot: &RestoreSnapshotSummary,
481 verification: &RestoreVerificationSummary,
482) -> RestoreReadinessSummary {
483 let mut reasons = Vec::new();
484
485 if !snapshot.all_members_have_checksum {
486 reasons.push("missing-snapshot-checksum".to_string());
487 }
488 if !verification.all_members_have_checks {
489 reasons.push("missing-verification-checks".to_string());
490 }
491
492 RestoreReadinessSummary {
493 ready: reasons.is_empty(),
494 reasons,
495 }
496}
497
498fn restore_verification_summary(
500 manifest: &FleetBackupManifest,
501 members: &[RestorePlanMember],
502) -> RestoreVerificationSummary {
503 let fleet_checks = manifest.verification.fleet_checks.len();
504 let member_check_groups = manifest.verification.member_checks.len();
505 let member_checks = members
506 .iter()
507 .map(|member| member.verification_checks.len())
508 .sum::<usize>();
509 let members_with_checks = members
510 .iter()
511 .filter(|member| !member.verification_checks.is_empty())
512 .count();
513
514 RestoreVerificationSummary {
515 verification_required: true,
516 all_members_have_checks: members_with_checks == members.len(),
517 fleet_checks,
518 member_check_groups,
519 member_checks,
520 members_with_checks,
521 total_checks: fleet_checks + member_checks,
522 }
523}
524
525const fn restore_operation_summary(
527 member_count: usize,
528 verification_summary: &RestoreVerificationSummary,
529) -> RestoreOperationSummary {
530 RestoreOperationSummary {
531 planned_snapshot_uploads: member_count,
532 planned_snapshot_loads: member_count,
533 planned_verification_checks: verification_summary.total_checks,
534 planned_operations: member_count + member_count + verification_summary.total_checks,
535 }
536}
537
538fn order_members(
540 members: Vec<RestorePlanMember>,
541) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
542 let mut remaining = members;
543 let group_sources = remaining
544 .iter()
545 .map(|member| member.source_canister.clone())
546 .collect::<BTreeSet<_>>();
547 let mut emitted = BTreeSet::new();
548 let mut ordered = Vec::with_capacity(remaining.len());
549
550 while !remaining.is_empty() {
551 let Some(index) = remaining
552 .iter()
553 .position(|member| parent_satisfied(member, &group_sources, &emitted))
554 else {
555 return Err(RestorePlanError::RestoreOrderCycle);
556 };
557
558 let mut member = remaining.remove(index);
559 member.member_order = ordered.len();
560 member.ordering_dependency = ordering_dependency(&member);
561 emitted.insert(member.source_canister.clone());
562 ordered.push(member);
563 }
564
565 Ok(ordered)
566}
567
568fn ordering_dependency(member: &RestorePlanMember) -> Option<RestoreOrderingDependency> {
570 let parent_source = member.parent_source_canister.as_ref()?;
571 let parent_target = member.parent_target_canister.as_ref()?;
572 let relationship = RestoreOrderingRelationship::ParentBeforeChild;
573
574 Some(RestoreOrderingDependency {
575 source_canister: parent_source.clone(),
576 target_canister: parent_target.clone(),
577 relationship,
578 })
579}
580
581fn restore_ordering_summary(members: &[RestorePlanMember]) -> RestoreOrderingSummary {
583 let mut summary = RestoreOrderingSummary {
584 ordered_members: members.len(),
585 dependency_free_members: 0,
586 parent_edges: 0,
587 };
588
589 for member in members {
590 if member.ordering_dependency.is_some() {
591 summary.parent_edges += 1;
592 } else {
593 summary.dependency_free_members += 1;
594 }
595 }
596
597 summary
598}
599
600fn parent_satisfied(
602 member: &RestorePlanMember,
603 group_sources: &BTreeSet<String>,
604 emitted: &BTreeSet<String>,
605) -> bool {
606 match &member.parent_source_canister {
607 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
608 _ => true,
609 }
610}
611
612fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
614 Principal::from_str(value)
615 .map(|_| ())
616 .map_err(|_| RestorePlanError::InvalidPrincipal {
617 field,
618 value: value.to_string(),
619 })
620}