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