Skip to main content

canic_host/
installed_deployment.rs

1use crate::{
2    icp::{IcpCli, IcpCommandError},
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(request, 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(request, state, source, registry_json)
119}
120
121fn installed_deployment_resolution(
122    _request: &InstalledDeploymentRequest,
123    state: InstallState,
124    source: InstalledDeploymentSource,
125    registry_json: String,
126) -> Result<InstalledDeploymentResolution, InstalledDeploymentError> {
127    let entries = parse_registry_entries(&registry_json)?;
128    let registry = InstalledDeploymentRegistry {
129        root_canister_id: state.root_canister_id.clone(),
130        entries,
131    };
132    let topology = ResolvedDeploymentTopology::from_registry(&registry);
133    Ok(InstalledDeploymentResolution {
134        source,
135        state,
136        registry,
137        topology,
138    })
139}
140
141pub fn read_installed_deployment_state(
142    network: &str,
143    deployment: &str,
144) -> Result<InstallState, InstalledDeploymentError> {
145    read_named_deployment_install_state(network, deployment)
146        .map_err(|err| InstalledDeploymentError::InstallState(err.to_string()))?
147        .ok_or_else(|| InstalledDeploymentError::NoInstalledDeployment {
148            network: network.to_string(),
149            deployment: deployment.to_string(),
150        })
151}
152
153pub fn read_installed_deployment_state_from_root(
154    network: &str,
155    deployment: &str,
156    icp_root: &Path,
157) -> Result<InstallState, InstalledDeploymentError> {
158    read_named_deployment_install_state_from_root(icp_root, network, deployment)
159        .map_err(|err| InstalledDeploymentError::InstallState(err.to_string()))?
160        .ok_or_else(|| InstalledDeploymentError::NoInstalledDeployment {
161            network: network.to_string(),
162            deployment: deployment.to_string(),
163        })
164}
165
166impl ResolvedDeploymentTopology {
167    fn from_registry(registry: &InstalledDeploymentRegistry) -> Self {
168        let mut children_by_parent = BTreeMap::<Option<String>, Vec<String>>::new();
169        let mut roles_by_canister = BTreeMap::new();
170        for entry in &registry.entries {
171            children_by_parent
172                .entry(entry.parent_pid.clone())
173                .or_default()
174                .push(entry.pid.clone());
175            if let Some(role) = &entry.role {
176                roles_by_canister.insert(entry.pid.clone(), role.clone());
177            }
178        }
179        for children in children_by_parent.values_mut() {
180            children.sort();
181        }
182        Self {
183            root_canister_id: registry.root_canister_id.clone(),
184            children_by_parent,
185            roles_by_canister,
186        }
187    }
188}
189
190fn query_registry(
191    request: &InstalledDeploymentRequest,
192    root: &str,
193) -> Result<(InstalledDeploymentSource, String), InstalledDeploymentError> {
194    if replica_query::should_use_local_replica_query(Some(&request.network)) {
195        return replica_query::query_subnet_registry_json(Some(&request.network), root)
196            .map(|registry| (InstalledDeploymentSource::LocalReplica, registry))
197            .map_err(|err| local_registry_error(request, root, err.to_string()));
198    }
199
200    IcpCli::new(&request.icp, None, Some(request.network.clone()))
201        .canister_query_output(root, "canic_subnet_registry", Some("json"))
202        .map(|registry| (InstalledDeploymentSource::IcpCli, registry))
203        .map_err(installed_deployment_icp_error)
204}
205
206fn query_registry_from_root(
207    request: &InstalledDeploymentRequest,
208    root: &str,
209    icp_root: &Path,
210) -> Result<(InstalledDeploymentSource, String), InstalledDeploymentError> {
211    if replica_query::should_use_local_replica_query(Some(&request.network)) {
212        return replica_query::query_subnet_registry_json_from_root(
213            Some(&request.network),
214            root,
215            icp_root,
216        )
217        .map(|registry| (InstalledDeploymentSource::LocalReplica, registry))
218        .map_err(|err| local_registry_error(request, root, err.to_string()));
219    }
220
221    IcpCli::new(&request.icp, None, Some(request.network.clone()))
222        .with_cwd(icp_root)
223        .canister_query_output(root, "canic_subnet_registry", Some("json"))
224        .map(|registry| (InstalledDeploymentSource::IcpCli, registry))
225        .map_err(installed_deployment_icp_error)
226}
227
228fn local_registry_error(
229    request: &InstalledDeploymentRequest,
230    root: &str,
231    error: String,
232) -> InstalledDeploymentError {
233    if request.detect_lost_local_root && is_canister_not_found_error(&error) {
234        return InstalledDeploymentError::LostLocalDeployment {
235            deployment: request.deployment.clone(),
236            network: request.network.clone(),
237            root: root.to_string(),
238        };
239    }
240    InstalledDeploymentError::ReplicaQuery(error)
241}
242
243fn is_canister_not_found_error(error: &str) -> bool {
244    error.contains("Canister ") && error.contains(" not found")
245}
246
247fn installed_deployment_icp_error(error: IcpCommandError) -> InstalledDeploymentError {
248    match error {
249        IcpCommandError::Io(err) => InstalledDeploymentError::Io(err),
250        IcpCommandError::Failed { command, stderr } => {
251            InstalledDeploymentError::IcpFailed { command, stderr }
252        }
253        IcpCommandError::Json {
254            command, output, ..
255        } => InstalledDeploymentError::IcpFailed {
256            command,
257            stderr: output,
258        },
259        IcpCommandError::SnapshotIdUnavailable { output } => InstalledDeploymentError::IcpFailed {
260            command: "icp canister snapshot create".to_string(),
261            stderr: output,
262        },
263    }
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    // Ensure the resolved topology gives command code parent/role projections without reparsing.
271    #[test]
272    fn topology_indexes_registry_entries() {
273        let registry = InstalledDeploymentRegistry {
274            root_canister_id: "root-id".to_string(),
275            entries: vec![
276                RegistryEntry {
277                    pid: "child-b".to_string(),
278                    role: Some("worker".to_string()),
279                    kind: None,
280                    parent_pid: Some("root-id".to_string()),
281                    module_hash: None,
282                },
283                RegistryEntry {
284                    pid: "root-id".to_string(),
285                    role: Some("root".to_string()),
286                    kind: None,
287                    parent_pid: None,
288                    module_hash: None,
289                },
290                RegistryEntry {
291                    pid: "child-a".to_string(),
292                    role: Some("app".to_string()),
293                    kind: None,
294                    parent_pid: Some("root-id".to_string()),
295                    module_hash: None,
296                },
297            ],
298        };
299
300        let topology = ResolvedDeploymentTopology::from_registry(&registry);
301
302        assert_eq!(
303            topology
304                .children_by_parent
305                .get(&Some("root-id".to_string())),
306            Some(&vec!["child-a".to_string(), "child-b".to_string()])
307        );
308        assert_eq!(topology.roles_by_canister["child-a"], "app");
309        assert_eq!(topology.root_canister_id, "root-id");
310    }
311
312    // Ensure local replica missing-canister errors are recognized for lost fleet guidance.
313    #[test]
314    fn detects_local_canister_not_found_error() {
315        assert!(is_canister_not_found_error(
316            "local replica rejected query: code=3 message=Canister uxrrr-q7777-77774-qaaaq-cai not found"
317        ));
318        assert!(!is_canister_not_found_error(
319            "local replica rejected query: code=5 message=some other failure"
320        ));
321    }
322}