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)]
89#[expect(
90    clippy::struct_excessive_bools,
91    reason = "restore summaries intentionally expose machine-readable readiness flags"
92)]
93pub struct RestoreSnapshotSummary {
94    pub all_members_have_module_hash: bool,
95    pub all_members_have_wasm_hash: bool,
96    pub all_members_have_code_version: bool,
97    pub all_members_have_checksum: bool,
98    pub members_with_module_hash: usize,
99    pub members_with_wasm_hash: usize,
100    pub members_with_code_version: usize,
101    pub members_with_checksum: usize,
102}
103
104///
105/// RestoreVerificationSummary
106///
107
108#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
109pub struct RestoreVerificationSummary {
110    pub verification_required: bool,
111    pub all_members_have_checks: bool,
112    pub fleet_checks: usize,
113    pub member_check_groups: usize,
114    pub member_checks: usize,
115    pub members_with_checks: usize,
116    pub total_checks: usize,
117}
118
119///
120/// RestoreReadinessSummary
121///
122
123#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
124pub struct RestoreReadinessSummary {
125    pub ready: bool,
126    pub reasons: Vec<String>,
127}
128
129///
130/// RestoreOperationSummary
131///
132
133#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
134pub struct RestoreOperationSummary {
135    pub planned_snapshot_uploads: usize,
136    pub planned_snapshot_loads: usize,
137    pub planned_verification_checks: usize,
138    pub planned_operations: usize,
139}
140
141///
142/// RestoreOrderingSummary
143///
144
145#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
146pub struct RestoreOrderingSummary {
147    pub ordered_members: usize,
148    pub dependency_free_members: usize,
149    pub parent_edges: usize,
150}
151
152///
153/// RestorePlanMember
154///
155
156#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
157pub struct RestorePlanMember {
158    pub source_canister: String,
159    pub target_canister: String,
160    pub role: String,
161    pub parent_source_canister: Option<String>,
162    pub parent_target_canister: Option<String>,
163    pub ordering_dependency: Option<RestoreOrderingDependency>,
164    pub member_order: usize,
165    pub identity_mode: IdentityMode,
166    pub verification_checks: Vec<VerificationCheck>,
167    pub source_snapshot: SourceSnapshot,
168}
169
170///
171/// RestoreOrderingDependency
172///
173
174#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
175pub struct RestoreOrderingDependency {
176    pub source_canister: String,
177    pub target_canister: String,
178    pub relationship: RestoreOrderingRelationship,
179}
180
181///
182/// RestoreOrderingRelationship
183///
184
185#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
186#[serde(rename_all = "kebab-case")]
187pub enum RestoreOrderingRelationship {
188    ParentBeforeChild,
189}
190
191///
192/// RestorePlanner
193///
194
195pub struct RestorePlanner;
196
197impl RestorePlanner {
198    /// Build a no-mutation restore plan from the manifest and optional target mapping.
199    pub fn plan(
200        manifest: &FleetBackupManifest,
201        mapping: Option<&RestoreMapping>,
202    ) -> Result<RestorePlan, RestorePlanError> {
203        manifest.validate()?;
204        if let Some(mapping) = mapping {
205            validate_mapping(mapping)?;
206            validate_mapping_sources(manifest, mapping)?;
207        }
208
209        let members = resolve_members(manifest, mapping)?;
210        let identity_summary = restore_identity_summary(&members, mapping.is_some());
211        let snapshot_summary = restore_snapshot_summary(&members);
212        let verification_summary = restore_verification_summary(manifest, &members);
213        let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
214        let members = order_members(members)?;
215        let ordering_summary = restore_ordering_summary(&members);
216        let operation_summary =
217            restore_operation_summary(manifest.fleet.members.len(), &verification_summary);
218
219        Ok(RestorePlan {
220            backup_id: manifest.backup_id.clone(),
221            source_environment: manifest.source.environment.clone(),
222            source_root_canister: manifest.source.root_canister.clone(),
223            topology_hash: manifest.fleet.topology_hash.clone(),
224            member_count: manifest.fleet.members.len(),
225            identity_summary,
226            snapshot_summary,
227            verification_summary,
228            readiness_summary,
229            operation_summary,
230            ordering_summary,
231            fleet_verification_checks: manifest.verification.fleet_checks.clone(),
232            members,
233        })
234    }
235}
236
237///
238/// RestorePlanError
239///
240
241#[derive(Debug, ThisError)]
242pub enum RestorePlanError {
243    #[error(transparent)]
244    InvalidManifest(#[from] ManifestValidationError),
245
246    #[error("field {field} must be a valid principal: {value}")]
247    InvalidPrincipal { field: &'static str, value: String },
248
249    #[error("mapping contains duplicate source canister {0}")]
250    DuplicateMappingSource(String),
251
252    #[error("mapping contains duplicate target canister {0}")]
253    DuplicateMappingTarget(String),
254
255    #[error("mapping references unknown source canister {0}")]
256    UnknownMappingSource(String),
257
258    #[error("mapping is missing source canister {0}")]
259    MissingMappingSource(String),
260
261    #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
262    FixedIdentityRemap {
263        source_canister: String,
264        target_canister: String,
265    },
266
267    #[error("restore plan contains duplicate target canister {0}")]
268    DuplicatePlanTarget(String),
269
270    #[error("restore plan contains a parent cycle or unresolved dependency")]
271    RestoreOrderCycle,
272}
273
274// Validate a user-supplied restore mapping before applying it to the manifest.
275fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
276    let mut sources = BTreeSet::new();
277    let mut targets = BTreeSet::new();
278
279    for entry in &mapping.members {
280        validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
281        validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
282
283        if !sources.insert(entry.source_canister.clone()) {
284            return Err(RestorePlanError::DuplicateMappingSource(
285                entry.source_canister.clone(),
286            ));
287        }
288
289        if !targets.insert(entry.target_canister.clone()) {
290            return Err(RestorePlanError::DuplicateMappingTarget(
291                entry.target_canister.clone(),
292            ));
293        }
294    }
295
296    Ok(())
297}
298
299// Ensure mappings only reference members declared in the manifest.
300fn validate_mapping_sources(
301    manifest: &FleetBackupManifest,
302    mapping: &RestoreMapping,
303) -> Result<(), RestorePlanError> {
304    let sources = manifest
305        .fleet
306        .members
307        .iter()
308        .map(|member| member.canister_id.as_str())
309        .collect::<BTreeSet<_>>();
310
311    for entry in &mapping.members {
312        if !sources.contains(entry.source_canister.as_str()) {
313            return Err(RestorePlanError::UnknownMappingSource(
314                entry.source_canister.clone(),
315            ));
316        }
317    }
318
319    Ok(())
320}
321
322// Resolve source manifest members into target restore members.
323fn resolve_members(
324    manifest: &FleetBackupManifest,
325    mapping: Option<&RestoreMapping>,
326) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
327    let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
328    let mut targets = BTreeSet::new();
329    let mut source_to_target = std::collections::BTreeMap::new();
330
331    for member in &manifest.fleet.members {
332        let target = resolve_target(member, mapping)?;
333        if !targets.insert(target.clone()) {
334            return Err(RestorePlanError::DuplicatePlanTarget(target));
335        }
336
337        source_to_target.insert(member.canister_id.clone(), target.clone());
338        plan_members.push(RestorePlanMember {
339            source_canister: member.canister_id.clone(),
340            target_canister: target,
341            role: member.role.clone(),
342            parent_source_canister: member.parent_canister_id.clone(),
343            parent_target_canister: None,
344            ordering_dependency: None,
345            member_order: 0,
346            identity_mode: member.identity_mode.clone(),
347            verification_checks: concrete_member_verification_checks(
348                member,
349                &manifest.verification,
350            ),
351            source_snapshot: member.source_snapshot.clone(),
352        });
353    }
354
355    for member in &mut plan_members {
356        member.parent_target_canister = member
357            .parent_source_canister
358            .as_ref()
359            .and_then(|parent| source_to_target.get(parent))
360            .cloned();
361    }
362
363    Ok(plan_members)
364}
365
366// Resolve all concrete verification checks that apply to one restore member role.
367fn concrete_member_verification_checks(
368    member: &FleetMember,
369    verification: &VerificationPlan,
370) -> Vec<VerificationCheck> {
371    let mut checks = member
372        .verification_checks
373        .iter()
374        .filter(|check| verification_check_applies_to_role(check, &member.role))
375        .cloned()
376        .collect::<Vec<_>>();
377
378    for group in &verification.member_checks {
379        if group.role != member.role {
380            continue;
381        }
382
383        checks.extend(
384            group
385                .checks
386                .iter()
387                .filter(|check| verification_check_applies_to_role(check, &member.role))
388                .cloned(),
389        );
390    }
391
392    checks
393}
394
395// Return whether a verification check's role filter includes one member role.
396fn verification_check_applies_to_role(check: &VerificationCheck, role: &str) -> bool {
397    check.roles.is_empty() || check.roles.iter().any(|check_role| check_role == role)
398}
399
400// Resolve one member's target canister, enforcing identity continuity.
401fn resolve_target(
402    member: &FleetMember,
403    mapping: Option<&RestoreMapping>,
404) -> Result<String, RestorePlanError> {
405    let target = match mapping {
406        Some(mapping) => mapping
407            .target_for(&member.canister_id)
408            .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
409            .to_string(),
410        None => member.canister_id.clone(),
411    };
412
413    if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
414        return Err(RestorePlanError::FixedIdentityRemap {
415            source_canister: member.canister_id.clone(),
416            target_canister: target,
417        });
418    }
419
420    Ok(target)
421}
422
423// Summarize identity and mapping decisions before ordering restore members.
424fn restore_identity_summary(
425    members: &[RestorePlanMember],
426    mapping_supplied: bool,
427) -> RestoreIdentitySummary {
428    let mut summary = RestoreIdentitySummary {
429        mapping_supplied,
430        all_sources_mapped: false,
431        fixed_members: 0,
432        relocatable_members: 0,
433        in_place_members: 0,
434        mapped_members: 0,
435        remapped_members: 0,
436    };
437
438    for member in members {
439        match member.identity_mode {
440            IdentityMode::Fixed => summary.fixed_members += 1,
441            IdentityMode::Relocatable => summary.relocatable_members += 1,
442        }
443
444        if member.source_canister == member.target_canister {
445            summary.in_place_members += 1;
446        } else {
447            summary.remapped_members += 1;
448        }
449        if mapping_supplied {
450            summary.mapped_members += 1;
451        }
452    }
453
454    summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
455
456    summary
457}
458
459// Summarize snapshot provenance completeness before ordering restore members.
460fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
461    let members_with_module_hash = members
462        .iter()
463        .filter(|member| member.source_snapshot.module_hash.is_some())
464        .count();
465    let members_with_wasm_hash = members
466        .iter()
467        .filter(|member| member.source_snapshot.wasm_hash.is_some())
468        .count();
469    let members_with_code_version = members
470        .iter()
471        .filter(|member| member.source_snapshot.code_version.is_some())
472        .count();
473    let members_with_checksum = members
474        .iter()
475        .filter(|member| member.source_snapshot.checksum.is_some())
476        .count();
477
478    RestoreSnapshotSummary {
479        all_members_have_module_hash: members_with_module_hash == members.len(),
480        all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
481        all_members_have_code_version: members_with_code_version == members.len(),
482        all_members_have_checksum: members_with_checksum == members.len(),
483        members_with_module_hash,
484        members_with_wasm_hash,
485        members_with_code_version,
486        members_with_checksum,
487    }
488}
489
490// Summarize whether restore planning has the minimum metadata required to execute.
491fn restore_readiness_summary(
492    snapshot: &RestoreSnapshotSummary,
493    verification: &RestoreVerificationSummary,
494) -> RestoreReadinessSummary {
495    let mut reasons = Vec::new();
496
497    if !snapshot.all_members_have_checksum {
498        reasons.push("missing-snapshot-checksum".to_string());
499    }
500    if !verification.all_members_have_checks {
501        reasons.push("missing-verification-checks".to_string());
502    }
503
504    RestoreReadinessSummary {
505        ready: reasons.is_empty(),
506        reasons,
507    }
508}
509
510// Summarize restore verification work declared by the manifest and members.
511fn restore_verification_summary(
512    manifest: &FleetBackupManifest,
513    members: &[RestorePlanMember],
514) -> RestoreVerificationSummary {
515    let fleet_checks = manifest.verification.fleet_checks.len();
516    let member_check_groups = manifest.verification.member_checks.len();
517    let member_checks = members
518        .iter()
519        .map(|member| member.verification_checks.len())
520        .sum::<usize>();
521    let members_with_checks = members
522        .iter()
523        .filter(|member| !member.verification_checks.is_empty())
524        .count();
525
526    RestoreVerificationSummary {
527        verification_required: true,
528        all_members_have_checks: members_with_checks == members.len(),
529        fleet_checks,
530        member_check_groups,
531        member_checks,
532        members_with_checks,
533        total_checks: fleet_checks + member_checks,
534    }
535}
536
537// Summarize the concrete restore operations implied by a no-mutation plan.
538const fn restore_operation_summary(
539    member_count: usize,
540    verification_summary: &RestoreVerificationSummary,
541) -> RestoreOperationSummary {
542    RestoreOperationSummary {
543        planned_snapshot_uploads: member_count,
544        planned_snapshot_loads: member_count,
545        planned_verification_checks: verification_summary.total_checks,
546        planned_operations: member_count + member_count + verification_summary.total_checks,
547    }
548}
549
550// Topologically order members using manifest parent relationships.
551fn order_members(
552    members: Vec<RestorePlanMember>,
553) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
554    let mut remaining = members;
555    let group_sources = remaining
556        .iter()
557        .map(|member| member.source_canister.clone())
558        .collect::<BTreeSet<_>>();
559    let mut emitted = BTreeSet::new();
560    let mut ordered = Vec::with_capacity(remaining.len());
561
562    while !remaining.is_empty() {
563        let Some(index) = remaining
564            .iter()
565            .position(|member| parent_satisfied(member, &group_sources, &emitted))
566        else {
567            return Err(RestorePlanError::RestoreOrderCycle);
568        };
569
570        let mut member = remaining.remove(index);
571        member.member_order = ordered.len();
572        member.ordering_dependency = ordering_dependency(&member);
573        emitted.insert(member.source_canister.clone());
574        ordered.push(member);
575    }
576
577    Ok(ordered)
578}
579
580// Describe the topology dependency that controlled a member's restore ordering.
581fn ordering_dependency(member: &RestorePlanMember) -> Option<RestoreOrderingDependency> {
582    let parent_source = member.parent_source_canister.as_ref()?;
583    let parent_target = member.parent_target_canister.as_ref()?;
584    let relationship = RestoreOrderingRelationship::ParentBeforeChild;
585
586    Some(RestoreOrderingDependency {
587        source_canister: parent_source.clone(),
588        target_canister: parent_target.clone(),
589        relationship,
590    })
591}
592
593// Summarize the dependency ordering metadata exposed in the restore plan.
594fn restore_ordering_summary(members: &[RestorePlanMember]) -> RestoreOrderingSummary {
595    let mut summary = RestoreOrderingSummary {
596        ordered_members: members.len(),
597        dependency_free_members: 0,
598        parent_edges: 0,
599    };
600
601    for member in members {
602        if member.ordering_dependency.is_some() {
603            summary.parent_edges += 1;
604        } else {
605            summary.dependency_free_members += 1;
606        }
607    }
608
609    summary
610}
611
612// Determine whether a member's in-group parent has already been emitted.
613fn parent_satisfied(
614    member: &RestorePlanMember,
615    group_sources: &BTreeSet<String>,
616    emitted: &BTreeSet<String>,
617) -> bool {
618    match &member.parent_source_canister {
619        Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
620        _ => true,
621    }
622}
623
624// Validate textual principal fields used in mappings.
625fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
626    Principal::from_str(value)
627        .map(|_| ())
628        .map_err(|_| RestorePlanError::InvalidPrincipal {
629            field,
630            value: value.to_string(),
631        })
632}