1use crate::{
2 manifest::{FleetMember, FleetSection, IdentityMode, SourceSnapshot, VerificationCheck},
3 topology::{TopologyHasher, TopologyRecord},
4};
5use std::collections::BTreeSet;
6use thiserror::Error as ThisError;
7
8#[derive(Clone, Debug)]
13pub struct DiscoveredFleet {
14 pub topology_records: Vec<TopologyRecord>,
15 pub members: Vec<DiscoveredMember>,
16}
17
18impl DiscoveredFleet {
19 pub fn into_fleet_section(self) -> Result<FleetSection, DiscoveryError> {
21 validate_discovered_members(&self.members)?;
22
23 let topology_hash = TopologyHasher::hash(&self.topology_records);
24 let members = self
25 .members
26 .into_iter()
27 .map(DiscoveredMember::into_fleet_member)
28 .collect();
29
30 Ok(FleetSection {
31 topology_hash_algorithm: topology_hash.algorithm,
32 topology_hash_input: topology_hash.input,
33 discovery_topology_hash: topology_hash.hash.clone(),
34 pre_snapshot_topology_hash: topology_hash.hash.clone(),
35 topology_hash: topology_hash.hash,
36 members,
37 })
38 }
39}
40
41#[derive(Clone, Debug)]
46pub struct DiscoveredMember {
47 pub role: String,
48 pub canister_id: String,
49 pub parent_canister_id: Option<String>,
50 pub subnet_canister_id: Option<String>,
51 pub controller_hint: Option<String>,
52 pub identity_mode: IdentityMode,
53 pub restore_group: u16,
54 pub verification_class: String,
55 pub verification_checks: Vec<VerificationCheck>,
56 pub snapshot_plan: SnapshotPlan,
57}
58
59impl DiscoveredMember {
60 fn into_fleet_member(self) -> FleetMember {
62 FleetMember {
63 role: self.role,
64 canister_id: self.canister_id,
65 parent_canister_id: self.parent_canister_id,
66 subnet_canister_id: self.subnet_canister_id,
67 controller_hint: self.controller_hint,
68 identity_mode: self.identity_mode,
69 restore_group: self.restore_group,
70 verification_class: self.verification_class,
71 verification_checks: self.verification_checks,
72 source_snapshot: SourceSnapshot {
73 snapshot_id: self.snapshot_plan.snapshot_id,
74 module_hash: self.snapshot_plan.module_hash,
75 wasm_hash: self.snapshot_plan.wasm_hash,
76 code_version: self.snapshot_plan.code_version,
77 artifact_path: self.snapshot_plan.artifact_path,
78 checksum_algorithm: self.snapshot_plan.checksum_algorithm,
79 },
80 }
81 }
82}
83
84#[derive(Clone, Debug)]
89pub struct SnapshotPlan {
90 pub snapshot_id: String,
91 pub module_hash: Option<String>,
92 pub wasm_hash: Option<String>,
93 pub code_version: Option<String>,
94 pub artifact_path: String,
95 pub checksum_algorithm: String,
96}
97
98#[derive(Debug, ThisError)]
103pub enum DiscoveryError {
104 #[error("discovered fleet has no members")]
105 EmptyFleet,
106
107 #[error("duplicate discovered canister id {0}")]
108 DuplicateCanisterId(String),
109
110 #[error("discovered member {0} has no verification checks")]
111 MissingVerificationChecks(String),
112}
113
114fn validate_discovered_members(members: &[DiscoveredMember]) -> Result<(), DiscoveryError> {
116 if members.is_empty() {
117 return Err(DiscoveryError::EmptyFleet);
118 }
119
120 let mut canister_ids = BTreeSet::new();
121 for member in members {
122 if !canister_ids.insert(member.canister_id.clone()) {
123 return Err(DiscoveryError::DuplicateCanisterId(
124 member.canister_id.clone(),
125 ));
126 }
127 if member.verification_checks.is_empty() {
128 return Err(DiscoveryError::MissingVerificationChecks(
129 member.canister_id.clone(),
130 ));
131 }
132 }
133
134 Ok(())
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use candid::Principal;
141
142 const ROOT: Principal = Principal::from_slice(&[]);
143 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
144
145 fn p(id: u8) -> Principal {
147 Principal::from_slice(&[id; 29])
148 }
149
150 #[test]
152 fn discovery_projects_to_valid_fleet_section() {
153 let fleet = DiscoveredFleet {
154 topology_records: vec![
155 topology_record(ROOT, None, "root"),
156 topology_record(p(2), Some(ROOT), "app"),
157 ],
158 members: vec![
159 discovered_member("root", ROOT.to_string(), None),
160 discovered_member("app", p(2).to_string(), Some(ROOT.to_string())),
161 ],
162 };
163
164 let section = fleet
165 .into_fleet_section()
166 .expect("discovery should project");
167
168 section.validate().expect("fleet section should validate");
169 assert_eq!(section.discovery_topology_hash, section.topology_hash);
170 assert_eq!(section.members.len(), 2);
171 }
172
173 #[test]
175 fn discovery_rejects_duplicate_canisters() {
176 let fleet = DiscoveredFleet {
177 topology_records: vec![topology_record(ROOT, None, "root")],
178 members: vec![
179 discovered_member("root", ROOT.to_string(), None),
180 discovered_member("root", ROOT.to_string(), None),
181 ],
182 };
183
184 let err = fleet
185 .into_fleet_section()
186 .expect_err("duplicate canisters should fail");
187
188 assert!(matches!(err, DiscoveryError::DuplicateCanisterId(_)));
189 }
190
191 #[test]
193 fn discovery_requires_verification_checks() {
194 let mut member = discovered_member("root", ROOT.to_string(), None);
195 member.verification_checks.clear();
196 let fleet = DiscoveredFleet {
197 topology_records: vec![topology_record(ROOT, None, "root")],
198 members: vec![member],
199 };
200
201 let err = fleet
202 .into_fleet_section()
203 .expect_err("missing verification should fail");
204
205 assert!(matches!(err, DiscoveryError::MissingVerificationChecks(_)));
206 }
207
208 fn topology_record(
210 pid: Principal,
211 parent_pid: Option<Principal>,
212 role: &str,
213 ) -> TopologyRecord {
214 TopologyRecord {
215 pid,
216 parent_pid,
217 role: role.to_string(),
218 module_hash: None,
219 }
220 }
221
222 fn discovered_member(
224 role: &str,
225 canister_id: String,
226 parent_canister_id: Option<String>,
227 ) -> DiscoveredMember {
228 DiscoveredMember {
229 role: role.to_string(),
230 canister_id,
231 parent_canister_id,
232 subnet_canister_id: None,
233 controller_hint: Some(ROOT.to_string()),
234 identity_mode: IdentityMode::Fixed,
235 restore_group: 1,
236 verification_class: "basic".to_string(),
237 verification_checks: vec![VerificationCheck {
238 kind: "call".to_string(),
239 method: Some("canic_ready".to_string()),
240 roles: Vec::new(),
241 }],
242 snapshot_plan: SnapshotPlan {
243 snapshot_id: format!("snap-{role}"),
244 module_hash: Some(HASH.to_string()),
245 wasm_hash: Some(HASH.to_string()),
246 code_version: Some("v0.30.0".to_string()),
247 artifact_path: format!("artifacts/{role}"),
248 checksum_algorithm: "sha256".to_string(),
249 },
250 }
251 }
252}