Skip to main content

canic_backup/restore/plan/
mod.rs

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///
11/// RestoreMapping
12///
13
14#[derive(Clone, Debug, Default, Deserialize, Serialize)]
15pub struct RestoreMapping {
16    pub members: Vec<RestoreMappingEntry>,
17}
18
19impl RestoreMapping {
20    /// Resolve the target canister for one source member.
21    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///
30/// RestoreMappingEntry
31///
32
33#[derive(Clone, Debug, Deserialize, Serialize)]
34pub struct RestoreMappingEntry {
35    pub source_canister: String,
36    pub target_canister: String,
37}
38
39///
40/// RestorePlan
41///
42
43#[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    /// Return all planned members in execution order.
63    #[must_use]
64    pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
65        self.members.iter().collect()
66    }
67}
68
69///
70/// RestoreIdentitySummary
71///
72
73#[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///
85/// RestoreSnapshotSummary
86///
87
88#[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///
99/// RestoreVerificationSummary
100///
101
102#[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///
114/// RestoreReadinessSummary
115///
116
117#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
118pub struct RestoreReadinessSummary {
119    pub ready: bool,
120    pub reasons: Vec<String>,
121}
122
123///
124/// RestoreOperationSummary
125///
126
127#[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///
136/// RestoreOrderingSummary
137///
138
139#[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///
147/// RestorePlanMember
148///
149
150#[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///
165/// RestoreOrderingDependency
166///
167
168#[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///
176/// RestoreOrderingRelationship
177///
178
179#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
180#[serde(rename_all = "kebab-case")]
181pub enum RestoreOrderingRelationship {
182    ParentBeforeChild,
183}
184
185///
186/// RestorePlanner
187///
188
189pub struct RestorePlanner;
190
191impl RestorePlanner {
192    /// Build a no-mutation restore plan from the manifest and optional target mapping.
193    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///
232/// RestorePlanError
233///
234
235#[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
268// Validate a user-supplied restore mapping before applying it to the manifest.
269fn 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
293// Ensure mappings only reference members declared in the manifest.
294fn 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
316// Resolve source manifest members into target restore members.
317fn 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
360// Resolve all concrete verification checks that apply to one restore member role.
361fn 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
389// Return whether a verification check's role filter includes one member role.
390fn 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
394// Resolve one member's target canister, enforcing identity continuity.
395fn 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
417// Summarize identity and mapping decisions before ordering restore members.
418fn 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
453// Summarize snapshot provenance completeness before ordering restore members.
454fn 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
478// Summarize whether restore planning has the minimum metadata required to execute.
479fn 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
498// Summarize restore verification work declared by the manifest and members.
499fn 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
525// Summarize the concrete restore operations implied by a no-mutation plan.
526const 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
538// Topologically order members using manifest parent relationships.
539fn 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
568// Describe the topology dependency that controlled a member's restore ordering.
569fn 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
581// Summarize the dependency ordering metadata exposed in the restore plan.
582fn 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
600// Determine whether a member's in-group parent has already been emitted.
601fn 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
612// Validate textual principal fields used in mappings.
613fn 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}