Skip to main content

canic_host/fleet_ensure/inventory/
mod.rs

1//! Module: fleet_ensure::inventory
2//!
3//! Responsibility: project last-converged and exact-current Ensure state for operator tooling.
4//! Does not own: historical install evidence, live observation, or topology decisions.
5//! Boundary: backup may consume the last converged topology; other commands bind plan and journal.
6
7use 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/// Last safely converged Fleet inventory retained for backup.
26#[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/// Environment-bound collection of exact terminal Fleet summaries.
34#[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/// Operator-facing identity projected from one validated terminal Fleet.
42#[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/// Terminal ensure inventory projected for current operator commands.
52#[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/// Current registry-shaped entry list retained by the sole ensure owner.
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct CurrentFleetRegistry {
63    pub entries: Vec<RegistryEntry>,
64}
65
66/// Current typed role/topology projection for operator target selection.
67#[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/// Typed current-inventory resolution failure.
76#[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    /// Return the only current Root for an operation whose maintained scope is singular.
139    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    /// Return the exact typed active Registry verified at terminal convergence.
155    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
184/// Discover every exact full-Fleet terminal Ensure result for one environment.
185pub 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 = &registry.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
277/// Resolve current operator targeting exclusively from a terminal ensure journal.
278pub 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
373/// Read the last converged topology even while an unapplied successor plan is retained.
374///
375/// This deliberately does not bind the current plan. Fleet Ensure publishes its nonterminal
376/// journal before effect-owned state changes and publishes validated terminal state before the
377/// converged journal. Current operator targeting must use [`resolve_current_fleet`] instead.
378pub 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
399/// Validate and project one candidate terminal state before it becomes public authority.
400pub(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}