Skip to main content

canic_backup/discovery/
mod.rs

1//! Module: discovery
2//!
3//! Responsibility: convert registry observations into backup target sets.
4//! Does not own: snapshot IO, manifest validation, or persisted backup state.
5//! Boundary: prepares discovery projections consumed by backup planning.
6
7#[cfg(test)]
8mod tests;
9
10use crate::{
11    manifest::{
12        DeploymentMember, DeploymentSection, IdentityMode, SourceSnapshot, VerificationCheck,
13    },
14    registry::RegistryEntry,
15    topology::{TopologyHasher, TopologyRecord},
16};
17use std::collections::{BTreeMap, BTreeSet, VecDeque};
18use thiserror::Error as ThisError;
19
20///
21/// DiscoveredFleet
22///
23/// Registry-derived fleet snapshot before it is serialized into a manifest.
24/// Owned by backup discovery and consumed by manifest construction.
25///
26
27#[derive(Clone, Debug)]
28pub struct DiscoveredFleet {
29    pub topology_records: Vec<TopologyRecord>,
30    pub members: Vec<DiscoveredMember>,
31}
32
33impl DiscoveredFleet {
34    /// Convert discovered topology and member policy into a manifest fleet section.
35    pub fn into_deployment_section(self) -> Result<DeploymentSection, DiscoveryError> {
36        validate_discovered_members(&self.members)?;
37
38        let topology_hash = TopologyHasher::hash(&self.topology_records);
39        let members = self
40            .members
41            .into_iter()
42            .map(DiscoveredMember::into_deployment_member)
43            .collect();
44
45        Ok(DeploymentSection {
46            topology_hash_algorithm: topology_hash.algorithm,
47            topology_hash_input: topology_hash.input,
48            discovery_topology_hash: topology_hash.hash.clone(),
49            pre_snapshot_topology_hash: topology_hash.hash.clone(),
50            topology_hash: topology_hash.hash,
51            members,
52        })
53    }
54}
55
56///
57/// DiscoveredMember
58///
59/// One discovered deployment member with backup policy and snapshot metadata.
60/// Owned by backup discovery and projected into deployment manifest members.
61///
62
63#[derive(Clone, Debug)]
64pub struct DiscoveredMember {
65    pub role: String,
66    pub canister_id: String,
67    pub parent_canister_id: Option<String>,
68    pub subnet_canister_id: Option<String>,
69    pub controller_hint: Option<String>,
70    pub identity_mode: IdentityMode,
71    pub verification_checks: Vec<VerificationCheck>,
72    pub snapshot_plan: SnapshotPlan,
73}
74
75impl DiscoveredMember {
76    /// Project this discovery member into the manifest restore contract.
77    fn into_deployment_member(self) -> DeploymentMember {
78        DeploymentMember {
79            role: self.role,
80            canister_id: self.canister_id,
81            parent_canister_id: self.parent_canister_id,
82            subnet_canister_id: self.subnet_canister_id,
83            controller_hint: self.controller_hint,
84            identity_mode: self.identity_mode,
85            verification_checks: self.verification_checks,
86            source_snapshot: SourceSnapshot {
87                snapshot_id: self.snapshot_plan.snapshot_id,
88                module_hash: self.snapshot_plan.module_hash,
89                code_version: self.snapshot_plan.code_version,
90                artifact_path: self.snapshot_plan.artifact_path,
91                checksum_algorithm: self.snapshot_plan.checksum_algorithm,
92                checksum: self.snapshot_plan.checksum,
93            },
94        }
95    }
96}
97
98///
99/// SnapshotPlan
100///
101/// Source snapshot metadata planned for one discovered deployment member.
102/// Owned by backup discovery and copied into manifest source snapshots.
103///
104
105#[derive(Clone, Debug)]
106pub struct SnapshotPlan {
107    pub snapshot_id: String,
108    pub module_hash: Option<String>,
109    pub code_version: Option<String>,
110    pub artifact_path: String,
111    pub checksum_algorithm: String,
112    pub checksum: Option<String>,
113}
114
115///
116/// SnapshotTarget
117///
118/// Registry target selected for snapshot capture.
119/// Owned by backup discovery and consumed by snapshot planning.
120///
121
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct SnapshotTarget {
124    pub canister_id: String,
125    pub role: Option<String>,
126    pub parent_canister_id: Option<String>,
127    pub module_hash: Option<String>,
128}
129
130///
131/// DiscoveryError
132///
133/// Typed discovery failure returned while selecting backup targets.
134/// Owned by backup discovery and surfaced to snapshot and planning callers.
135///
136
137#[derive(Debug, ThisError)]
138pub enum DiscoveryError {
139    #[error("discovered fleet has no members")]
140    EmptyFleet,
141
142    #[error("duplicate discovered canister id {0}")]
143    DuplicateCanisterId(String),
144
145    #[error("discovered member {0} has no verification checks")]
146    MissingVerificationChecks(String),
147
148    #[error("registry JSON did not contain the requested canister {0}")]
149    CanisterNotInRegistry(String),
150
151    #[error(transparent)]
152    Json(#[from] serde_json::Error),
153}
154
155/// Resolve selected target and children from registry entries.
156pub fn targets_from_registry(
157    registry: &[RegistryEntry],
158    canister_id: &str,
159    recursive: bool,
160) -> Result<Vec<SnapshotTarget>, DiscoveryError> {
161    let by_pid = registry
162        .iter()
163        .map(|entry| (entry.pid.as_str(), entry))
164        .collect::<BTreeMap<_, _>>();
165
166    let root = by_pid
167        .get(canister_id)
168        .ok_or_else(|| DiscoveryError::CanisterNotInRegistry(canister_id.to_string()))?;
169
170    let mut targets = Vec::new();
171    let mut seen = BTreeSet::new();
172    targets.push(SnapshotTarget {
173        canister_id: root.pid.clone(),
174        role: root.role.clone(),
175        parent_canister_id: root.parent_pid.clone(),
176        module_hash: root.module_hash.clone(),
177    });
178    seen.insert(root.pid.clone());
179
180    let mut queue = VecDeque::from([root.pid.clone()]);
181    while let Some(parent) = queue.pop_front() {
182        for child in registry
183            .iter()
184            .filter(|entry| entry.parent_pid.as_deref() == Some(parent.as_str()))
185        {
186            if seen.insert(child.pid.clone()) {
187                targets.push(SnapshotTarget {
188                    canister_id: child.pid.clone(),
189                    role: child.role.clone(),
190                    parent_canister_id: child.parent_pid.clone(),
191                    module_hash: child.module_hash.clone(),
192                });
193                if recursive {
194                    queue.push_back(child.pid.clone());
195                }
196            }
197        }
198    }
199
200    Ok(targets)
201}
202
203// Validate discovery output before building a manifest projection.
204fn validate_discovered_members(members: &[DiscoveredMember]) -> Result<(), DiscoveryError> {
205    if members.is_empty() {
206        return Err(DiscoveryError::EmptyFleet);
207    }
208
209    let mut canister_ids = BTreeSet::new();
210    for member in members {
211        if !canister_ids.insert(member.canister_id.clone()) {
212            return Err(DiscoveryError::DuplicateCanisterId(
213                member.canister_id.clone(),
214            ));
215        }
216        if member.verification_checks.is_empty() {
217            return Err(DiscoveryError::MissingVerificationChecks(
218                member.canister_id.clone(),
219            ));
220        }
221    }
222
223    Ok(())
224}