1use crate::{
8 fleet_ensure::{
9 model::{
10 DesiredCanisterKind, FleetEnsureCompletion, FleetEnsurePlan, FleetEnsurePlanScope,
11 },
12 ops::{EnsurePaths, EnsureStateError, read_journal, read_plan, read_state},
13 },
14 network::{NetworkIdentityError, resolve_canonical_network_id_from_root},
15 registry::RegistryEntry,
16};
17use canic_contracts::ids::{AppId, CanonicalNetworkId, FleetId, FleetName};
18use std::{
19 collections::{BTreeMap, BTreeSet},
20 fs, io,
21 path::{Path, PathBuf},
22};
23use thiserror::Error as ThisError;
24
25#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct CurrentFleetInventory {
29 pub active_registry: Option<canic_contracts::dto::fleet_registry::FleetRegistry>,
30 pub entries: Vec<RegistryEntry>,
31 pub roots: Vec<String>,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct CurrentFleetDiscovery {
37 pub canonical_network_id: CanonicalNetworkId,
38 pub environment: String,
39 pub fleets: Vec<CurrentFleetSummary>,
40}
41
42#[derive(Clone, Debug, Eq, PartialEq)]
44pub struct CurrentFleetSummary {
45 pub app: AppId,
46 pub canonical_network_id: CanonicalNetworkId,
47 pub coordinator: String,
48 pub fleet: FleetName,
49 pub fleet_id: FleetId,
50}
51
52#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct CurrentFleetResolution {
55 pub active_registry: Option<canic_contracts::dto::fleet_registry::FleetRegistry>,
56 pub plan: FleetEnsurePlan,
57 pub registry: CurrentFleetRegistry,
58 pub topology: CurrentFleetTopology,
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct CurrentFleetRegistry {
64 pub entries: Vec<RegistryEntry>,
65}
66
67#[derive(Clone, Debug, Eq, PartialEq)]
69pub struct CurrentFleetTopology {
70 pub coordinator_canister_id: String,
71 pub fleet_subnet_root_canister_ids: Vec<String>,
72 pub children_by_parent: BTreeMap<Option<String>, Vec<String>>,
73 pub roles_by_canister: BTreeMap<String, String>,
74}
75
76#[derive(Debug, ThisError)]
78pub enum CurrentFleetInventoryError {
79 #[error("Fleet {fleet} has no terminal current ensure operation in environment {environment}")]
80 NotConverged { environment: String, fleet: String },
81
82 #[error("current Fleet topology references missing parent {parent} for {canister}")]
83 MissingParent { canister: String, parent: String },
84
85 #[error("current Fleet topology has no retained Principal for {0}")]
86 MissingPrincipal(String),
87
88 #[error("current Fleet topology has no exact Coordinator")]
89 MissingCoordinator,
90
91 #[error("current Fleet topology retains one Principal under more than one role")]
92 DuplicatePrincipal,
93
94 #[error("current Fleet {fleet} has no exact retained ensure plan")]
95 MissingPlan { fleet: String },
96
97 #[error("current Fleet {fleet} terminal journal and retained plan authority conflict")]
98 PlanAuthorityConflict { fleet: String },
99
100 #[error(
101 "current Fleet {fleet} belongs to canonical network {actual}, not selected network {expected}"
102 )]
103 NetworkAuthorityConflict {
104 actual: CanonicalNetworkId,
105 expected: CanonicalNetworkId,
106 fleet: String,
107 },
108
109 #[error("current Fleet {fleet} has no unique typed active Registry authority")]
110 ProtocolAuthorityMissing { fleet: String },
111
112 #[error("current Fleet {fleet} typed Registry authority conflicts with terminal topology")]
113 ProtocolAuthorityConflict { fleet: String },
114
115 #[error("current Fleet {fleet} has {root_count} Roots; select one exact Root principal")]
116 AmbiguousFleetSubnetRoot { fleet: String, root_count: usize },
117
118 #[error("failed to read current Fleet discovery directory {}: {source}", path.display())]
119 DiscoveryRead {
120 path: PathBuf,
121 #[source]
122 source: io::Error,
123 },
124
125 #[error("current Fleet discovery path is not a safe directory: {}", path.display())]
126 UnsafeDiscoveryPath { path: PathBuf },
127
128 #[error("current Fleet discovery repeats {field} authority {value}")]
129 DuplicateDiscoveryAuthority { field: &'static str, value: String },
130
131 #[error(transparent)]
132 Network(#[from] NetworkIdentityError),
133
134 #[error(transparent)]
135 State(#[from] EnsureStateError),
136}
137
138impl CurrentFleetTopology {
139 pub fn unique_fleet_subnet_root<'a>(
141 &'a self,
142 fleet: &str,
143 ) -> Result<&'a str, CurrentFleetInventoryError> {
144 match self.fleet_subnet_root_canister_ids.as_slice() {
145 [root] => Ok(root),
146 roots => Err(CurrentFleetInventoryError::AmbiguousFleetSubnetRoot {
147 fleet: fleet.to_string(),
148 root_count: roots.len(),
149 }),
150 }
151 }
152}
153
154impl CurrentFleetResolution {
155 pub fn initial_active_registry(
157 &self,
158 fleet: &str,
159 ) -> Result<&canic_contracts::dto::fleet_registry::FleetRegistry, CurrentFleetInventoryError>
160 {
161 let registry = self.active_registry.as_ref().ok_or_else(|| {
162 CurrentFleetInventoryError::ProtocolAuthorityMissing {
163 fleet: fleet.to_string(),
164 }
165 })?;
166 let mut registry_roots = registry
167 .fleet_subnet_roots
168 .iter()
169 .filter(|root| {
170 root.status != canic_contracts::dto::fleet_registry::FleetSubnetRootStatus::Removed
171 })
172 .map(|root| root.fleet_subnet_root.to_text())
173 .collect::<Vec<_>>();
174 registry_roots.sort_unstable();
175 if registry.authority.binding.coordinator.to_text() != self.topology.coordinator_canister_id
176 || registry_roots != self.topology.fleet_subnet_root_canister_ids
177 {
178 return Err(CurrentFleetInventoryError::ProtocolAuthorityConflict {
179 fleet: fleet.to_string(),
180 });
181 }
182 Ok(registry)
183 }
184}
185
186pub fn discover_current_fleets(
188 root: &Path,
189 environment: &str,
190) -> Result<CurrentFleetDiscovery, CurrentFleetInventoryError> {
191 let canonical_network_id = resolve_canonical_network_id_from_root(root, environment)?;
192 let directory = root.join(".canic").join("fleet-ensure").join(environment);
193 let Some(entries) = read_discovery_directory(&directory)? else {
194 return Ok(CurrentFleetDiscovery {
195 canonical_network_id,
196 environment: environment.to_string(),
197 fleets: Vec::new(),
198 });
199 };
200 let mut coordinators = BTreeSet::new();
201 let mut fleet_ids = BTreeSet::new();
202 let mut fleets = Vec::new();
203 for entry in entries {
204 let entry = entry.map_err(|source| CurrentFleetInventoryError::DiscoveryRead {
205 path: directory.clone(),
206 source,
207 })?;
208 let path = entry.path();
209 let file_type =
210 entry
211 .file_type()
212 .map_err(|source| CurrentFleetInventoryError::DiscoveryRead {
213 path: path.clone(),
214 source,
215 })?;
216 if file_type.is_symlink() {
217 return Err(CurrentFleetInventoryError::UnsafeDiscoveryPath { path });
218 }
219 if !file_type.is_dir() {
220 continue;
221 }
222 let fleet = entry
223 .file_name()
224 .into_string()
225 .ok()
226 .and_then(|name| name.parse::<FleetName>().ok())
227 .ok_or(CurrentFleetInventoryError::UnsafeDiscoveryPath { path })?;
228 let paths = EnsurePaths::under(root, environment, fleet.as_str());
229 let Some(journal) = read_journal(&paths)? else {
230 continue;
231 };
232 if journal.completion != FleetEnsureCompletion::Converged {
233 continue;
234 }
235 let plan = matching_terminal_plan(&paths, environment, fleet.as_str(), &journal)?;
236 if plan.scope != FleetEnsurePlanScope::Full {
237 continue;
238 }
239 let current = resolve_current_fleet(root, environment, fleet.as_str())?;
240 let registry = current.initial_active_registry(fleet.as_str())?;
241 let binding = ®istry.authority.binding;
242 let actual_network_id = binding.fleet.fleet.canonical_network_id;
243 if actual_network_id != canonical_network_id {
244 return Err(CurrentFleetInventoryError::NetworkAuthorityConflict {
245 actual: actual_network_id,
246 expected: canonical_network_id,
247 fleet: fleet.to_string(),
248 });
249 }
250 if !fleet_ids.insert(binding.fleet.fleet.fleet_id) {
251 return Err(CurrentFleetInventoryError::DuplicateDiscoveryAuthority {
252 field: "Fleet ID",
253 value: binding.fleet.fleet.fleet_id.to_string(),
254 });
255 }
256 let coordinator = binding.coordinator.to_text();
257 if !coordinators.insert(coordinator.clone()) {
258 return Err(CurrentFleetInventoryError::DuplicateDiscoveryAuthority {
259 field: "Coordinator",
260 value: coordinator,
261 });
262 }
263 fleets.push(CurrentFleetSummary {
264 app: binding.fleet.app.clone(),
265 canonical_network_id: actual_network_id,
266 coordinator,
267 fleet,
268 fleet_id: binding.fleet.fleet.fleet_id,
269 });
270 }
271 fleets.sort_by(|left, right| left.fleet.cmp(&right.fleet));
272 Ok(CurrentFleetDiscovery {
273 canonical_network_id,
274 environment: environment.to_string(),
275 fleets,
276 })
277}
278
279pub fn resolve_current_fleet(
281 root: &Path,
282 environment: &str,
283 fleet: &str,
284) -> Result<CurrentFleetResolution, CurrentFleetInventoryError> {
285 let inventory = read_last_converged_fleet_inventory(root, environment, fleet)?;
286 let paths = EnsurePaths::under(root, environment, fleet);
287 let journal =
288 read_journal(&paths)?.ok_or_else(|| CurrentFleetInventoryError::NotConverged {
289 environment: environment.to_string(),
290 fleet: fleet.to_string(),
291 })?;
292 let plan = matching_terminal_plan(&paths, environment, fleet, &journal)?;
293 let coordinator_canister_id = inventory
294 .entries
295 .iter()
296 .find(|entry| entry.role.as_deref() == Some("fleet_coordinator"))
297 .map(|entry| entry.pid.clone())
298 .ok_or(CurrentFleetInventoryError::MissingCoordinator)?;
299 let mut children_by_parent = BTreeMap::<Option<String>, Vec<String>>::new();
300 let mut roles_by_canister = BTreeMap::new();
301 for entry in &inventory.entries {
302 children_by_parent
303 .entry(entry.parent_pid.clone())
304 .or_default()
305 .push(entry.pid.clone());
306 if let Some(role) = &entry.role {
307 roles_by_canister.insert(entry.pid.clone(), role.clone());
308 }
309 }
310 for children in children_by_parent.values_mut() {
311 children.sort_unstable();
312 }
313 Ok(CurrentFleetResolution {
314 active_registry: inventory.active_registry,
315 plan,
316 registry: CurrentFleetRegistry {
317 entries: inventory.entries,
318 },
319 topology: CurrentFleetTopology {
320 coordinator_canister_id,
321 fleet_subnet_root_canister_ids: inventory.roots,
322 children_by_parent,
323 roles_by_canister,
324 },
325 })
326}
327
328fn matching_terminal_plan(
329 paths: &EnsurePaths,
330 environment: &str,
331 fleet: &str,
332 journal: &crate::fleet_ensure::model::FleetEnsureJournalRecord,
333) -> Result<FleetEnsurePlan, CurrentFleetInventoryError> {
334 let plan = read_plan(paths)?.ok_or_else(|| CurrentFleetInventoryError::MissingPlan {
335 fleet: fleet.to_string(),
336 })?;
337 if plan.plan_sha256 != journal.plan_sha256
338 || plan.environment != environment
339 || plan.fleet != fleet
340 || plan.operation_id != journal.operation_id
341 {
342 return Err(CurrentFleetInventoryError::PlanAuthorityConflict {
343 fleet: fleet.to_string(),
344 });
345 }
346 Ok(plan)
347}
348
349fn read_discovery_directory(
350 path: &Path,
351) -> Result<Option<fs::ReadDir>, CurrentFleetInventoryError> {
352 let metadata = match fs::symlink_metadata(path) {
353 Ok(metadata) => metadata,
354 Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(None),
355 Err(source) => {
356 return Err(CurrentFleetInventoryError::DiscoveryRead {
357 path: path.to_path_buf(),
358 source,
359 });
360 }
361 };
362 if metadata.file_type().is_symlink() || !metadata.is_dir() {
363 return Err(CurrentFleetInventoryError::UnsafeDiscoveryPath {
364 path: path.to_path_buf(),
365 });
366 }
367 fs::read_dir(path)
368 .map(Some)
369 .map_err(|source| CurrentFleetInventoryError::DiscoveryRead {
370 path: path.to_path_buf(),
371 source,
372 })
373}
374
375pub fn read_last_converged_fleet_inventory(
381 root: &Path,
382 environment: &str,
383 fleet: &str,
384) -> Result<CurrentFleetInventory, CurrentFleetInventoryError> {
385 let paths = EnsurePaths::under(root, environment, fleet);
386 let journal =
387 read_journal(&paths)?.ok_or_else(|| CurrentFleetInventoryError::NotConverged {
388 environment: environment.to_string(),
389 fleet: fleet.to_string(),
390 })?;
391 if journal.completion != FleetEnsureCompletion::Converged {
392 return Err(CurrentFleetInventoryError::NotConverged {
393 environment: environment.to_string(),
394 fleet: fleet.to_string(),
395 });
396 }
397 let state = read_state(&paths, fleet)?;
398 project_current_fleet_inventory(&state)
399}
400
401pub(super) fn project_current_fleet_inventory(
403 state: &crate::fleet_ensure::model::FleetEnsureStateRecord,
404) -> Result<CurrentFleetInventory, CurrentFleetInventoryError> {
405 let mut entries = Vec::with_capacity(state.topology.len());
406 let mut roots = Vec::new();
407 for (name, topology) in &state.topology {
408 let principal = state
409 .principals
410 .get(name)
411 .ok_or_else(|| CurrentFleetInventoryError::MissingPrincipal(name.clone()))?;
412 let parent_pid = topology
413 .parent
414 .as_ref()
415 .map(|parent| {
416 state.principals.get(parent).cloned().ok_or_else(|| {
417 CurrentFleetInventoryError::MissingParent {
418 canister: name.clone(),
419 parent: parent.clone(),
420 }
421 })
422 })
423 .transpose()?;
424 if topology.kind == DesiredCanisterKind::Root {
425 roots.push(principal.clone());
426 }
427 entries.push(RegistryEntry {
428 module_hash: topology.module_hash.clone(),
429 parent_pid,
430 pid: principal.clone(),
431 protocol_binding: topology.protocol_binding.clone(),
432 role: topology
433 .role
434 .clone()
435 .or_else(|| Some(default_role(topology.kind, name))),
436 });
437 }
438 entries.sort_by(|left, right| left.pid.cmp(&right.pid));
439 if entries.windows(2).any(|pair| pair[0].pid == pair[1].pid) {
440 return Err(CurrentFleetInventoryError::DuplicatePrincipal);
441 }
442 roots.sort();
443 if let Some(registry) = &state.active_registry {
444 let coordinator = entries
445 .iter()
446 .filter(|entry| entry.role.as_deref() == Some("fleet_coordinator"))
447 .map(|entry| entry.pid.as_str())
448 .collect::<Vec<_>>();
449 let mut registry_roots = registry
450 .fleet_subnet_roots
451 .iter()
452 .filter(|root| {
453 root.status != canic_contracts::dto::fleet_registry::FleetSubnetRootStatus::Removed
454 })
455 .map(|root| root.fleet_subnet_root.to_text())
456 .collect::<Vec<_>>();
457 registry_roots.sort_unstable();
458 if coordinator.as_slice() != [registry.authority.binding.coordinator.to_text()]
459 || registry_roots != roots
460 {
461 return Err(CurrentFleetInventoryError::ProtocolAuthorityConflict {
462 fleet: state.fleet.clone(),
463 });
464 }
465 }
466 Ok(CurrentFleetInventory {
467 active_registry: state.active_registry.clone(),
468 entries,
469 roots,
470 })
471}
472
473fn default_role(kind: DesiredCanisterKind, name: &str) -> String {
474 match kind {
475 DesiredCanisterKind::Coordinator => "fleet_coordinator".to_string(),
476 DesiredCanisterKind::Root => "root".to_string(),
477 DesiredCanisterKind::Store => "wasm_store".to_string(),
478 DesiredCanisterKind::Pool => "canister_pool_asset".to_string(),
479 DesiredCanisterKind::Auxiliary | DesiredCanisterKind::Component => name.to_string(),
480 }
481}