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_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/// Last safely converged Fleet inventory retained for backup.
26
27#[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/// Environment-bound collection of exact terminal Fleet summaries.
35#[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/// Operator-facing identity projected from one validated terminal Fleet.
43#[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/// Terminal ensure inventory projected for current operator commands.
53#[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/// Current registry-shaped entry list retained by the sole ensure owner.
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct CurrentFleetRegistry {
64    pub entries: Vec<RegistryEntry>,
65}
66
67/// Current typed role/topology projection for operator target selection.
68#[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/// Typed current-inventory resolution failure.
77#[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    /// Return the only current Root for an operation whose maintained scope is singular.
140    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    /// Return the exact typed active Registry verified at terminal convergence.
156    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
186/// Discover every exact full-Fleet terminal Ensure result for one environment.
187pub 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 = &registry.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
279/// Resolve current operator targeting exclusively from a terminal ensure journal.
280pub 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
375/// Read the last converged topology even while an unapplied successor plan is retained.
376///
377/// This deliberately does not bind the current plan. Fleet Ensure publishes its nonterminal
378/// journal before effect-owned state changes and publishes validated terminal state before the
379/// converged journal. Current operator targeting must use [`resolve_current_fleet`] instead.
380pub 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
401/// Validate and project one candidate terminal state before it becomes public authority.
402pub(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}