canic_host/fleet_ensure/inventory/
mod.rs1use crate::{
8 fleet_ensure::{
9 model::{DesiredCanisterKind, FleetEnsureCompletion, FleetEnsurePlan},
10 ops::{EnsurePaths, EnsureStateError, read_journal, read_plan, read_state},
11 },
12 registry::RegistryEntry,
13};
14use std::{collections::BTreeMap, path::Path};
15use thiserror::Error as ThisError;
16
17#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct CurrentFleetInventory {
20 pub active_registry: Option<canic_core::dto::fleet_registry::FleetRegistry>,
21 pub entries: Vec<RegistryEntry>,
22 pub roots: Vec<String>,
23}
24
25#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct CurrentFleetResolution {
28 pub active_registry: Option<canic_core::dto::fleet_registry::FleetRegistry>,
29 pub plan: FleetEnsurePlan,
30 pub registry: CurrentFleetRegistry,
31 pub topology: CurrentFleetTopology,
32}
33
34#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct CurrentFleetRegistry {
37 pub entries: Vec<RegistryEntry>,
38}
39
40#[derive(Clone, Debug, Eq, PartialEq)]
42pub struct CurrentFleetTopology {
43 pub coordinator_canister_id: String,
44 pub fleet_subnet_root_canister_ids: Vec<String>,
45 pub children_by_parent: BTreeMap<Option<String>, Vec<String>>,
46 pub roles_by_canister: BTreeMap<String, String>,
47}
48
49#[derive(Debug, ThisError)]
51pub enum CurrentFleetInventoryError {
52 #[error("Fleet {fleet} has no terminal current ensure operation in environment {environment}")]
53 NotConverged { environment: String, fleet: String },
54
55 #[error("current Fleet topology references missing parent {parent} for {canister}")]
56 MissingParent { canister: String, parent: String },
57
58 #[error("current Fleet topology has no retained Principal for {0}")]
59 MissingPrincipal(String),
60
61 #[error("current Fleet topology has no exact Coordinator")]
62 MissingCoordinator,
63
64 #[error("current Fleet topology retains one Principal under more than one role")]
65 DuplicatePrincipal,
66
67 #[error("current Fleet {fleet} has no exact retained ensure plan")]
68 MissingPlan { fleet: String },
69
70 #[error("current Fleet {fleet} terminal journal and retained plan authority conflict")]
71 PlanAuthorityConflict { fleet: String },
72
73 #[error("current Fleet {fleet} has no unique typed active Registry authority")]
74 ProtocolAuthorityMissing { fleet: String },
75
76 #[error("current Fleet {fleet} typed Registry authority conflicts with terminal topology")]
77 ProtocolAuthorityConflict { fleet: String },
78
79 #[error("current Fleet {fleet} has {root_count} Roots; select one exact Root principal")]
80 AmbiguousFleetSubnetRoot { fleet: String, root_count: usize },
81
82 #[error(transparent)]
83 State(#[from] EnsureStateError),
84}
85
86impl CurrentFleetTopology {
87 pub fn unique_fleet_subnet_root<'a>(
89 &'a self,
90 fleet: &str,
91 ) -> Result<&'a str, CurrentFleetInventoryError> {
92 match self.fleet_subnet_root_canister_ids.as_slice() {
93 [root] => Ok(root),
94 roots => Err(CurrentFleetInventoryError::AmbiguousFleetSubnetRoot {
95 fleet: fleet.to_string(),
96 root_count: roots.len(),
97 }),
98 }
99 }
100}
101
102impl CurrentFleetResolution {
103 pub fn initial_active_registry(
105 &self,
106 fleet: &str,
107 ) -> Result<&canic_core::dto::fleet_registry::FleetRegistry, CurrentFleetInventoryError> {
108 let registry = self.active_registry.as_ref().ok_or_else(|| {
109 CurrentFleetInventoryError::ProtocolAuthorityMissing {
110 fleet: fleet.to_string(),
111 }
112 })?;
113 let mut registry_roots = registry
114 .fleet_subnet_roots
115 .iter()
116 .filter(|root| {
117 root.status != canic_core::dto::fleet_registry::FleetSubnetRootStatus::Removed
118 })
119 .map(|root| root.fleet_subnet_root.to_text())
120 .collect::<Vec<_>>();
121 registry_roots.sort_unstable();
122 if registry.authority.binding.coordinator.to_text() != self.topology.coordinator_canister_id
123 || registry_roots != self.topology.fleet_subnet_root_canister_ids
124 {
125 return Err(CurrentFleetInventoryError::ProtocolAuthorityConflict {
126 fleet: fleet.to_string(),
127 });
128 }
129 Ok(registry)
130 }
131}
132
133pub fn resolve_current_fleet(
135 root: &Path,
136 environment: &str,
137 fleet: &str,
138) -> Result<CurrentFleetResolution, CurrentFleetInventoryError> {
139 let inventory = read_current_fleet_inventory(root, environment, fleet)?;
140 let paths = EnsurePaths::under(root, environment, fleet);
141 let journal =
142 read_journal(&paths)?.ok_or_else(|| CurrentFleetInventoryError::NotConverged {
143 environment: environment.to_string(),
144 fleet: fleet.to_string(),
145 })?;
146 let plan = read_plan(&paths)?.ok_or_else(|| CurrentFleetInventoryError::MissingPlan {
147 fleet: fleet.to_string(),
148 })?;
149 if plan.plan_sha256 != journal.plan_sha256
150 || plan.environment != environment
151 || plan.fleet != fleet
152 || plan.operation_id != journal.operation_id
153 {
154 return Err(CurrentFleetInventoryError::PlanAuthorityConflict {
155 fleet: fleet.to_string(),
156 });
157 }
158 let coordinator_canister_id = inventory
159 .entries
160 .iter()
161 .find(|entry| entry.role.as_deref() == Some("fleet_coordinator"))
162 .map(|entry| entry.pid.clone())
163 .ok_or(CurrentFleetInventoryError::MissingCoordinator)?;
164 let mut children_by_parent = BTreeMap::<Option<String>, Vec<String>>::new();
165 let mut roles_by_canister = BTreeMap::new();
166 for entry in &inventory.entries {
167 children_by_parent
168 .entry(entry.parent_pid.clone())
169 .or_default()
170 .push(entry.pid.clone());
171 if let Some(role) = &entry.role {
172 roles_by_canister.insert(entry.pid.clone(), role.clone());
173 }
174 }
175 for children in children_by_parent.values_mut() {
176 children.sort_unstable();
177 }
178 Ok(CurrentFleetResolution {
179 active_registry: inventory.active_registry,
180 plan,
181 registry: CurrentFleetRegistry {
182 entries: inventory.entries,
183 },
184 topology: CurrentFleetTopology {
185 coordinator_canister_id,
186 fleet_subnet_root_canister_ids: inventory.roots,
187 children_by_parent,
188 roles_by_canister,
189 },
190 })
191}
192
193pub fn read_current_fleet_inventory(
195 root: &Path,
196 environment: &str,
197 fleet: &str,
198) -> Result<CurrentFleetInventory, CurrentFleetInventoryError> {
199 let paths = EnsurePaths::under(root, environment, fleet);
200 let journal =
201 read_journal(&paths)?.ok_or_else(|| CurrentFleetInventoryError::NotConverged {
202 environment: environment.to_string(),
203 fleet: fleet.to_string(),
204 })?;
205 if journal.completion != FleetEnsureCompletion::Converged {
206 return Err(CurrentFleetInventoryError::NotConverged {
207 environment: environment.to_string(),
208 fleet: fleet.to_string(),
209 });
210 }
211 let state = read_state(&paths, fleet)?;
212 project_current_fleet_inventory(&state)
213}
214
215pub(super) fn project_current_fleet_inventory(
217 state: &crate::fleet_ensure::model::FleetEnsureStateRecord,
218) -> Result<CurrentFleetInventory, CurrentFleetInventoryError> {
219 let mut entries = Vec::with_capacity(state.topology.len());
220 let mut roots = Vec::new();
221 for (name, topology) in &state.topology {
222 let principal = state
223 .principals
224 .get(name)
225 .ok_or_else(|| CurrentFleetInventoryError::MissingPrincipal(name.clone()))?;
226 let parent_pid = topology
227 .parent
228 .as_ref()
229 .map(|parent| {
230 state.principals.get(parent).cloned().ok_or_else(|| {
231 CurrentFleetInventoryError::MissingParent {
232 canister: name.clone(),
233 parent: parent.clone(),
234 }
235 })
236 })
237 .transpose()?;
238 if topology.kind == DesiredCanisterKind::Root {
239 roots.push(principal.clone());
240 }
241 entries.push(RegistryEntry {
242 module_hash: topology.module_hash.clone(),
243 parent_pid,
244 pid: principal.clone(),
245 protocol_binding: topology.protocol_binding.clone(),
246 role: topology
247 .role
248 .clone()
249 .or_else(|| Some(default_role(topology.kind, name))),
250 });
251 }
252 entries.sort_by(|left, right| left.pid.cmp(&right.pid));
253 if entries.windows(2).any(|pair| pair[0].pid == pair[1].pid) {
254 return Err(CurrentFleetInventoryError::DuplicatePrincipal);
255 }
256 roots.sort();
257 if let Some(registry) = &state.active_registry {
258 let coordinator = entries
259 .iter()
260 .filter(|entry| entry.role.as_deref() == Some("fleet_coordinator"))
261 .map(|entry| entry.pid.as_str())
262 .collect::<Vec<_>>();
263 let mut registry_roots = registry
264 .fleet_subnet_roots
265 .iter()
266 .filter(|root| {
267 root.status != canic_core::dto::fleet_registry::FleetSubnetRootStatus::Removed
268 })
269 .map(|root| root.fleet_subnet_root.to_text())
270 .collect::<Vec<_>>();
271 registry_roots.sort_unstable();
272 if coordinator.as_slice() != [registry.authority.binding.coordinator.to_text()]
273 || registry_roots != roots
274 {
275 return Err(CurrentFleetInventoryError::ProtocolAuthorityConflict {
276 fleet: state.fleet.clone(),
277 });
278 }
279 }
280 Ok(CurrentFleetInventory {
281 active_registry: state.active_registry.clone(),
282 entries,
283 roots,
284 })
285}
286
287fn default_role(kind: DesiredCanisterKind, name: &str) -> String {
288 match kind {
289 DesiredCanisterKind::Coordinator => "fleet_coordinator".to_string(),
290 DesiredCanisterKind::Root => "root".to_string(),
291 DesiredCanisterKind::Store => "wasm_store".to_string(),
292 DesiredCanisterKind::Pool => "canister_pool_asset".to_string(),
293 DesiredCanisterKind::Auxiliary | DesiredCanisterKind::Component => name.to_string(),
294 }
295}