Skip to main content

canic_host/
installed_fleet.rs

1use crate::{
2    icp::{IcpCli, IcpCommandError},
3    install_root::{InstallState, read_named_fleet_install_state},
4    registry::{RegistryEntry, RegistryParseError, parse_registry_entries},
5    replica_query,
6};
7use std::collections::BTreeMap;
8use thiserror::Error as ThisError;
9
10///
11/// InstalledFleetRequest
12///
13
14#[derive(Clone, Debug, Eq, PartialEq)]
15pub struct InstalledFleetRequest {
16    pub fleet: String,
17    pub network: String,
18    pub icp: String,
19    pub detect_lost_local_root: bool,
20}
21
22///
23/// InstalledFleetResolution
24///
25
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct InstalledFleetResolution {
28    pub source: InstalledFleetSource,
29    pub state: InstallState,
30    pub registry: InstalledFleetRegistry,
31    pub topology: ResolvedFleetTopology,
32}
33
34///
35/// InstalledFleetSource
36///
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq)]
39pub enum InstalledFleetSource {
40    LocalReplica,
41    IcpCli,
42}
43
44///
45/// InstalledFleetRegistry
46///
47
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct InstalledFleetRegistry {
50    pub root_canister_id: String,
51    pub entries: Vec<RegistryEntry>,
52}
53
54///
55/// ResolvedFleetTopology
56///
57
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct ResolvedFleetTopology {
60    pub root_canister_id: String,
61    pub children_by_parent: BTreeMap<Option<String>, Vec<String>>,
62    pub roles_by_canister: BTreeMap<String, String>,
63}
64
65///
66/// InstalledFleetError
67///
68
69#[derive(Debug, ThisError)]
70pub enum InstalledFleetError {
71    #[error("fleet {fleet} is not installed on network {network}")]
72    NoInstalledFleet { network: String, fleet: String },
73
74    #[error("failed to read canic fleet state: {0}")]
75    InstallState(String),
76
77    #[error("local replica query failed: {0}")]
78    ReplicaQuery(String),
79
80    #[error("icp command failed: {command}\n{stderr}")]
81    IcpFailed { command: String, stderr: String },
82
83    #[error(
84        "fleet {fleet} points to root {root}, but that canister is not present on network {network}"
85    )]
86    LostLocalFleet {
87        fleet: String,
88        network: String,
89        root: String,
90    },
91
92    #[error(transparent)]
93    Registry(#[from] RegistryParseError),
94
95    #[error(transparent)]
96    Io(#[from] std::io::Error),
97}
98
99pub fn resolve_installed_fleet(
100    request: &InstalledFleetRequest,
101) -> Result<InstalledFleetResolution, InstalledFleetError> {
102    let state = read_installed_fleet_state(&request.network, &request.fleet)?;
103    let (source, registry_json) = query_registry(request, &state.root_canister_id)?;
104    let entries = parse_registry_entries(&registry_json)?;
105    let registry = InstalledFleetRegistry {
106        root_canister_id: state.root_canister_id.clone(),
107        entries,
108    };
109    let topology = ResolvedFleetTopology::from_registry(&registry);
110    Ok(InstalledFleetResolution {
111        source,
112        state,
113        registry,
114        topology,
115    })
116}
117
118pub fn read_installed_fleet_state(
119    network: &str,
120    fleet: &str,
121) -> Result<InstallState, InstalledFleetError> {
122    read_named_fleet_install_state(network, fleet)
123        .map_err(|err| InstalledFleetError::InstallState(err.to_string()))?
124        .ok_or_else(|| InstalledFleetError::NoInstalledFleet {
125            network: network.to_string(),
126            fleet: fleet.to_string(),
127        })
128}
129
130impl ResolvedFleetTopology {
131    fn from_registry(registry: &InstalledFleetRegistry) -> Self {
132        let mut children_by_parent = BTreeMap::<Option<String>, Vec<String>>::new();
133        let mut roles_by_canister = BTreeMap::new();
134        for entry in &registry.entries {
135            children_by_parent
136                .entry(entry.parent_pid.clone())
137                .or_default()
138                .push(entry.pid.clone());
139            if let Some(role) = &entry.role {
140                roles_by_canister.insert(entry.pid.clone(), role.clone());
141            }
142        }
143        for children in children_by_parent.values_mut() {
144            children.sort();
145        }
146        Self {
147            root_canister_id: registry.root_canister_id.clone(),
148            children_by_parent,
149            roles_by_canister,
150        }
151    }
152}
153
154fn query_registry(
155    request: &InstalledFleetRequest,
156    root: &str,
157) -> Result<(InstalledFleetSource, String), InstalledFleetError> {
158    if replica_query::should_use_local_replica_query(Some(&request.network)) {
159        return replica_query::query_subnet_registry_json(Some(&request.network), root)
160            .map(|registry| (InstalledFleetSource::LocalReplica, registry))
161            .map_err(|err| local_registry_error(request, root, err.to_string()));
162    }
163
164    IcpCli::new(&request.icp, None, Some(request.network.clone()))
165        .canister_call_output(root, "canic_subnet_registry", Some("json"))
166        .map(|registry| (InstalledFleetSource::IcpCli, registry))
167        .map_err(installed_fleet_icp_error)
168}
169
170fn local_registry_error(
171    request: &InstalledFleetRequest,
172    root: &str,
173    error: String,
174) -> InstalledFleetError {
175    if request.detect_lost_local_root && is_canister_not_found_error(&error) {
176        return InstalledFleetError::LostLocalFleet {
177            fleet: request.fleet.clone(),
178            network: request.network.clone(),
179            root: root.to_string(),
180        };
181    }
182    InstalledFleetError::ReplicaQuery(error)
183}
184
185fn is_canister_not_found_error(error: &str) -> bool {
186    error.contains("Canister ") && error.contains(" not found")
187}
188
189fn installed_fleet_icp_error(error: IcpCommandError) -> InstalledFleetError {
190    match error {
191        IcpCommandError::Io(err) => InstalledFleetError::Io(err),
192        IcpCommandError::Failed { command, stderr } => {
193            InstalledFleetError::IcpFailed { command, stderr }
194        }
195        IcpCommandError::SnapshotIdUnavailable { output } => InstalledFleetError::IcpFailed {
196            command: "icp canister snapshot create".to_string(),
197            stderr: output,
198        },
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    // Ensure the resolved topology gives command code parent/role projections without reparsing.
207    #[test]
208    fn topology_indexes_registry_entries() {
209        let registry = InstalledFleetRegistry {
210            root_canister_id: "root-id".to_string(),
211            entries: vec![
212                RegistryEntry {
213                    pid: "child-b".to_string(),
214                    role: Some("worker".to_string()),
215                    kind: None,
216                    parent_pid: Some("root-id".to_string()),
217                    module_hash: None,
218                },
219                RegistryEntry {
220                    pid: "root-id".to_string(),
221                    role: Some("root".to_string()),
222                    kind: None,
223                    parent_pid: None,
224                    module_hash: None,
225                },
226                RegistryEntry {
227                    pid: "child-a".to_string(),
228                    role: Some("app".to_string()),
229                    kind: None,
230                    parent_pid: Some("root-id".to_string()),
231                    module_hash: None,
232                },
233            ],
234        };
235
236        let topology = ResolvedFleetTopology::from_registry(&registry);
237
238        assert_eq!(
239            topology
240                .children_by_parent
241                .get(&Some("root-id".to_string())),
242            Some(&vec!["child-a".to_string(), "child-b".to_string()])
243        );
244        assert_eq!(topology.roles_by_canister["child-a"], "app");
245        assert_eq!(topology.root_canister_id, "root-id");
246    }
247
248    // Ensure local replica missing-canister errors are recognized for lost fleet guidance.
249    #[test]
250    fn detects_local_canister_not_found_error() {
251        assert!(is_canister_not_found_error(
252            "local replica rejected query: code=3 message=Canister uxrrr-q7777-77774-qaaaq-cai not found"
253        ));
254        assert!(!is_canister_not_found_error(
255            "local replica rejected query: code=5 message=some other failure"
256        ));
257    }
258}