Skip to main content

canic_host/fleet_ensure/inventory/
mod.rs

1//! Module: fleet_ensure::inventory
2//!
3//! Responsibility: project terminal current-generation ensure state for operator tooling.
4//! Does not own: historical install evidence, live observation, or topology decisions.
5//! Boundary: only a terminal exact current ensure journal may become an operator inventory.
6
7use 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/// Current terminal Fleet inventory shared by backup and read-only operator commands.
18#[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/// Terminal ensure inventory projected for current operator commands.
26#[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/// Current registry-shaped entry list retained by the sole ensure owner.
35#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct CurrentFleetRegistry {
37    pub entries: Vec<RegistryEntry>,
38}
39
40/// Current typed role/topology projection for operator target selection.
41#[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/// Typed current-inventory resolution failure.
50#[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    /// Return the only current Root for an operation whose maintained scope is singular.
88    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    /// Return the exact typed active Registry verified at terminal convergence.
104    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
133/// Resolve current operator targeting exclusively from a terminal ensure journal.
134pub 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
193/// Read one terminal inventory without consulting deleted install plans or recovery state.
194pub 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
215/// Validate and project one candidate terminal state before it becomes public authority.
216pub(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}