Skip to main content

canic_host/
installed_deployment.rs

1use crate::{
2    icp::{IcpCli, IcpCommandError, existing_local_canister_candid_path},
3    install_root::{
4        InstallState, read_named_deployment_install_state,
5        read_named_deployment_install_state_from_root,
6    },
7    registry::{RegistryEntry, RegistryParseError, parse_registry_entries},
8    replica_query,
9};
10use std::{collections::BTreeMap, path::Path};
11use thiserror::Error as ThisError;
12
13///
14/// InstalledDeploymentRequest
15///
16
17#[derive(Clone, Debug, Eq, PartialEq)]
18pub struct InstalledDeploymentRequest {
19    pub deployment: String,
20    pub network: String,
21    pub icp: String,
22    pub detect_lost_local_root: bool,
23}
24
25///
26/// InstalledDeploymentResolution
27///
28
29#[derive(Clone, Debug, Eq, PartialEq)]
30pub struct InstalledDeploymentResolution {
31    pub source: InstalledDeploymentSource,
32    pub state: InstallState,
33    pub registry: InstalledDeploymentRegistry,
34    pub topology: ResolvedDeploymentTopology,
35}
36
37///
38/// InstalledDeploymentSource
39///
40
41#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub enum InstalledDeploymentSource {
43    LocalReplica,
44    IcpCli,
45}
46
47///
48/// InstalledDeploymentRegistry
49///
50
51#[derive(Clone, Debug, Eq, PartialEq)]
52pub struct InstalledDeploymentRegistry {
53    pub root_canister_id: String,
54    pub entries: Vec<RegistryEntry>,
55}
56
57///
58/// ResolvedDeploymentTopology
59///
60
61#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct ResolvedDeploymentTopology {
63    pub root_canister_id: String,
64    pub children_by_parent: BTreeMap<Option<String>, Vec<String>>,
65    pub roles_by_canister: BTreeMap<String, String>,
66}
67
68///
69/// InstalledDeploymentError
70///
71
72#[derive(Debug, ThisError)]
73pub enum InstalledDeploymentError {
74    #[error("deployment target {deployment} is not installed on network {network}")]
75    NoInstalledDeployment { network: String, deployment: String },
76
77    #[error("failed to read canic deployment state: {0}")]
78    InstallState(String),
79
80    #[error("local replica query failed: {0}")]
81    ReplicaQuery(String),
82
83    #[error("icp command failed: {command}\n{stderr}")]
84    IcpFailed { command: String, stderr: String },
85
86    #[error(
87        "deployment target {deployment} points to root {root}, but that canister is not present on network {network}"
88    )]
89    LostLocalDeployment {
90        deployment: String,
91        network: String,
92        root: String,
93    },
94
95    #[error(transparent)]
96    Registry(#[from] RegistryParseError),
97
98    #[error(transparent)]
99    Io(#[from] std::io::Error),
100}
101
102pub fn resolve_installed_deployment(
103    request: &InstalledDeploymentRequest,
104) -> Result<InstalledDeploymentResolution, InstalledDeploymentError> {
105    let state = read_installed_deployment_state(&request.network, &request.deployment)?;
106    let (source, registry_json) = query_registry(request, &state.root_canister_id)?;
107    installed_deployment_resolution(state, source, registry_json)
108}
109
110pub fn resolve_installed_deployment_from_root(
111    request: &InstalledDeploymentRequest,
112    icp_root: &Path,
113) -> Result<InstalledDeploymentResolution, InstalledDeploymentError> {
114    let state =
115        read_installed_deployment_state_from_root(&request.network, &request.deployment, icp_root)?;
116    let (source, registry_json) =
117        query_registry_from_root(request, &state.root_canister_id, icp_root)?;
118    installed_deployment_resolution(state, source, registry_json)
119}
120
121fn installed_deployment_resolution(
122    state: InstallState,
123    source: InstalledDeploymentSource,
124    registry_json: String,
125) -> Result<InstalledDeploymentResolution, InstalledDeploymentError> {
126    let entries = parse_registry_entries(&registry_json)?;
127    let registry = InstalledDeploymentRegistry {
128        root_canister_id: state.root_canister_id.clone(),
129        entries,
130    };
131    let topology = ResolvedDeploymentTopology::from_registry(&registry);
132    Ok(InstalledDeploymentResolution {
133        source,
134        state,
135        registry,
136        topology,
137    })
138}
139
140pub fn read_installed_deployment_state(
141    network: &str,
142    deployment: &str,
143) -> Result<InstallState, InstalledDeploymentError> {
144    read_named_deployment_install_state(network, deployment)
145        .map_err(|err| InstalledDeploymentError::InstallState(err.to_string()))?
146        .ok_or_else(|| InstalledDeploymentError::NoInstalledDeployment {
147            network: network.to_string(),
148            deployment: deployment.to_string(),
149        })
150}
151
152pub fn read_installed_deployment_state_from_root(
153    network: &str,
154    deployment: &str,
155    icp_root: &Path,
156) -> Result<InstallState, InstalledDeploymentError> {
157    read_named_deployment_install_state_from_root(icp_root, network, deployment)
158        .map_err(|err| InstalledDeploymentError::InstallState(err.to_string()))?
159        .ok_or_else(|| InstalledDeploymentError::NoInstalledDeployment {
160            network: network.to_string(),
161            deployment: deployment.to_string(),
162        })
163}
164
165impl ResolvedDeploymentTopology {
166    fn from_registry(registry: &InstalledDeploymentRegistry) -> Self {
167        let mut children_by_parent = BTreeMap::<Option<String>, Vec<String>>::new();
168        let mut roles_by_canister = BTreeMap::new();
169        for entry in &registry.entries {
170            children_by_parent
171                .entry(entry.parent_pid.clone())
172                .or_default()
173                .push(entry.pid.clone());
174            if let Some(role) = &entry.role {
175                roles_by_canister.insert(entry.pid.clone(), role.clone());
176            }
177        }
178        for children in children_by_parent.values_mut() {
179            children.sort();
180        }
181        Self {
182            root_canister_id: registry.root_canister_id.clone(),
183            children_by_parent,
184            roles_by_canister,
185        }
186    }
187}
188
189fn query_registry(
190    request: &InstalledDeploymentRequest,
191    root: &str,
192) -> Result<(InstalledDeploymentSource, String), InstalledDeploymentError> {
193    if replica_query::should_use_local_replica_query(Some(&request.network)) {
194        return replica_query::query_subnet_registry_json(Some(&request.network), root)
195            .map(|registry| (InstalledDeploymentSource::LocalReplica, registry))
196            .map_err(|err| local_registry_error(request, root, err.to_string()));
197    }
198
199    IcpCli::new(&request.icp, None, Some(request.network.clone()))
200        .canister_query_output(root, "canic_subnet_registry", Some("json"))
201        .map(|registry| (InstalledDeploymentSource::IcpCli, registry))
202        .map_err(installed_deployment_icp_error)
203}
204
205fn query_registry_from_root(
206    request: &InstalledDeploymentRequest,
207    root: &str,
208    icp_root: &Path,
209) -> Result<(InstalledDeploymentSource, String), InstalledDeploymentError> {
210    if replica_query::should_use_local_replica_query(Some(&request.network)) {
211        return replica_query::query_subnet_registry_json_from_root(
212            Some(&request.network),
213            root,
214            icp_root,
215        )
216        .map(|registry| (InstalledDeploymentSource::LocalReplica, registry))
217        .map_err(|err| local_registry_error(request, root, err.to_string()));
218    }
219
220    IcpCli::new(&request.icp, None, Some(request.network.clone()))
221        .with_cwd(icp_root)
222        .canister_query_output_with_candid(
223            root,
224            "canic_subnet_registry",
225            Some("json"),
226            existing_local_canister_candid_path(icp_root, &request.network, "root").as_deref(),
227        )
228        .map(|registry| (InstalledDeploymentSource::IcpCli, registry))
229        .map_err(installed_deployment_icp_error)
230}
231
232fn local_registry_error(
233    request: &InstalledDeploymentRequest,
234    root: &str,
235    error: String,
236) -> InstalledDeploymentError {
237    if request.detect_lost_local_root && is_canister_not_found_error(&error) {
238        return InstalledDeploymentError::LostLocalDeployment {
239            deployment: request.deployment.clone(),
240            network: request.network.clone(),
241            root: root.to_string(),
242        };
243    }
244    InstalledDeploymentError::ReplicaQuery(error)
245}
246
247fn is_canister_not_found_error(error: &str) -> bool {
248    error.contains("Canister ") && error.contains(" not found")
249}
250
251fn installed_deployment_icp_error(error: IcpCommandError) -> InstalledDeploymentError {
252    match error {
253        IcpCommandError::Io(err) => InstalledDeploymentError::Io(err),
254        IcpCommandError::Failed { command, stderr } => {
255            InstalledDeploymentError::IcpFailed { command, stderr }
256        }
257        IcpCommandError::Json {
258            command, output, ..
259        } => InstalledDeploymentError::IcpFailed {
260            command,
261            stderr: output,
262        },
263        error @ (IcpCommandError::MissingCli { .. }
264        | IcpCommandError::IncompatibleCliVersion { .. }) => InstalledDeploymentError::IcpFailed {
265            command: "icp --version".to_string(),
266            stderr: error.to_string(),
267        },
268        IcpCommandError::SnapshotIdUnavailable { output } => InstalledDeploymentError::IcpFailed {
269            command: "icp canister snapshot create".to_string(),
270            stderr: output,
271        },
272    }
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    // Ensure the resolved topology gives command code parent/role projections without reparsing.
280    #[test]
281    fn topology_indexes_registry_entries() {
282        let registry = InstalledDeploymentRegistry {
283            root_canister_id: "root-id".to_string(),
284            entries: vec![
285                RegistryEntry {
286                    pid: "child-b".to_string(),
287                    role: Some("worker".to_string()),
288                    kind: None,
289                    parent_pid: Some("root-id".to_string()),
290                    module_hash: None,
291                },
292                RegistryEntry {
293                    pid: "root-id".to_string(),
294                    role: Some("root".to_string()),
295                    kind: None,
296                    parent_pid: None,
297                    module_hash: None,
298                },
299                RegistryEntry {
300                    pid: "child-a".to_string(),
301                    role: Some("app".to_string()),
302                    kind: None,
303                    parent_pid: Some("root-id".to_string()),
304                    module_hash: None,
305                },
306            ],
307        };
308
309        let topology = ResolvedDeploymentTopology::from_registry(&registry);
310
311        assert_eq!(
312            topology
313                .children_by_parent
314                .get(&Some("root-id".to_string())),
315            Some(&vec!["child-a".to_string(), "child-b".to_string()])
316        );
317        assert_eq!(topology.roles_by_canister["child-a"], "app");
318        assert_eq!(topology.root_canister_id, "root-id");
319    }
320
321    // Ensure local replica missing-canister errors are recognized for lost fleet guidance.
322    #[test]
323    fn detects_local_canister_not_found_error() {
324        assert!(is_canister_not_found_error(
325            "local replica rejected query: code=3 message=Canister uxrrr-q7777-77774-qaaaq-cai not found"
326        ));
327        assert!(!is_canister_not_found_error(
328            "local replica rejected query: code=5 message=some other failure"
329        ));
330    }
331}