Skip to main content

canic_host/icp/
management.rs

1//! Module: icp::management
2//!
3//! Responsibility: perform typed management updates with the target
4//! canister as the HTTP effective canister ID.
5//! Does not own: install policy, durable effect intent, or effect reconciliation.
6//! Boundary: the selected ICP environment and identity are resolved through the
7//! maintained ICP CLI, while `ic-agent` owns the correctly routed call.
8
9use std::{sync::Arc, time::Duration};
10
11use candid::{CandidType, Principal};
12use ic_agent::{
13    Agent, AgentError, Identity,
14    identity::{BasicIdentity, Prime256v1Identity, Secp256k1Identity},
15};
16use serde::{Deserialize, de::DeserializeOwned};
17use thiserror::Error as ThisError;
18
19use super::{
20    model::{IcpCli, LOCAL_ICP_TARGET},
21    run::{run_json, run_output, run_secret_output},
22};
23
24const MANAGEMENT_CANISTER_STATUS: &str = "canister_status";
25const MANAGEMENT_INGRESS_EXPIRY: Duration = Duration::from_mins(4);
26
27#[derive(Debug, Deserialize)]
28struct IcpNetworkStatus {
29    api_url: String,
30    root_key: String,
31}
32
33/// Typed failure while resolving or executing an effective-ID-correct
34/// management-canister call.
35#[derive(Debug, ThisError)]
36pub enum IcpManagementCallError {
37    #[error("failed to encode the management-canister argument: {0}")]
38    CandidEncode(#[source] candid::Error),
39
40    #[error("failed to decode the management-canister response: {0}")]
41    CandidResponse(#[source] candid::Error),
42
43    #[error("failed to build the management-canister agent: {0}")]
44    AgentBuild(#[source] AgentError),
45
46    #[error("the effective-ID-correct management-canister call failed: {0}")]
47    AgentCall(#[source] AgentError),
48
49    #[error("failed to create the management-canister async runtime: {0}")]
50    AsyncRuntime(#[source] std::io::Error),
51
52    #[error(transparent)]
53    Icp(#[from] super::IcpCommandError),
54
55    #[error("ICP CLI reported an invalid network root key: {0}")]
56    NetworkRootKey(#[source] canic_core::cdk::utils::hash::DecodeHexError),
57
58    #[error("the selected ICP identity cannot be represented by a supported exported PEM")]
59    UnsupportedExportedIdentity,
60
61    #[error("failed to resolve the Principal of the exported ICP identity: {message}")]
62    ExportedIdentityPrincipal { message: String },
63
64    #[error(
65        "the exported ICP identity Principal {exported} conflicts with the active ICP identity {active}"
66    )]
67    ExportedIdentityConflict { active: String, exported: String },
68
69    #[error("an ICP environment is required for the management-canister call")]
70    MissingEnvironment,
71}
72
73impl IcpCli {
74    /// Whether this command context is bound directly to one local replica.
75    #[must_use]
76    pub(crate) fn uses_direct_local_replica(&self) -> bool {
77        self.environment.as_deref() == Some(LOCAL_ICP_TARGET) && self.local_replica.is_some()
78    }
79
80    /// Perform one typed management-canister update routed through the target
81    /// canister's exact effective canister ID.
82    pub(crate) fn management_canister_status_candid<I, O>(
83        &self,
84        effective_canister_id: Principal,
85        input: &I,
86    ) -> Result<O, IcpManagementCallError>
87    where
88        I: CandidType,
89        O: CandidType + DeserializeOwned,
90    {
91        let argument = candid::encode_one(input).map_err(IcpManagementCallError::CandidEncode)?;
92        let agent = self.authenticated_agent()?;
93        self.record_remote_call();
94        let response = call_management_update(
95            &LiveAgentUpdateBoundary { agent: &agent },
96            effective_canister_id,
97            MANAGEMENT_CANISTER_STATUS,
98            argument,
99        )?;
100        candid::decode_one(&response).map_err(IcpManagementCallError::CandidResponse)
101    }
102
103    /// Resolve an agent bound to the selected ICP environment and verified active identity.
104    ///
105    /// The caller owns reviewed effect authority and durable intent before using it.
106    pub fn authenticated_agent(&self) -> Result<Agent, IcpManagementCallError> {
107        let environment = self
108            .environment
109            .as_deref()
110            .ok_or(IcpManagementCallError::MissingEnvironment)?;
111        let network = self.network_status(environment)?;
112        let identity = self.exported_active_identity()?;
113        let agent = Agent::builder()
114            .with_url(&network.api_url)
115            .with_arc_identity(identity)
116            .with_ingress_expiry(MANAGEMENT_INGRESS_EXPIRY)
117            .build()
118            .map_err(IcpManagementCallError::AgentBuild)?;
119        let root_key = canic_core::cdk::utils::hash::decode_hex(&network.root_key)
120            .map_err(IcpManagementCallError::NetworkRootKey)?;
121        agent.set_root_key(root_key);
122        Ok(agent)
123    }
124
125    fn network_status(
126        &self,
127        environment: &str,
128    ) -> Result<IcpNetworkStatus, IcpManagementCallError> {
129        if environment == LOCAL_ICP_TARGET
130            && let Some(target) = &self.local_replica
131        {
132            return Ok(IcpNetworkStatus {
133                api_url: target.url.clone(),
134                root_key: target.root_key.clone(),
135            });
136        }
137        let mut command = self.command();
138        command.args(["network", "status", "--environment", environment, "--json"]);
139        run_json(&mut command).map_err(Into::into)
140    }
141
142    fn exported_active_identity(&self) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
143        let mut default_command = self.command();
144        default_command.args(["identity", "default"]);
145        let identity_name = run_output(&mut default_command)?;
146
147        let mut export_command = self.command();
148        export_command.args(["identity", "export", &identity_name]);
149        if let Some(password_file) = self.identity_password_file.as_deref() {
150            export_command.arg("--password-file").arg(password_file);
151        }
152        let mut pem = run_secret_output(&mut export_command)?;
153        let identity = parse_exported_identity(&pem);
154        pem.fill(0);
155        let identity = identity?;
156        let exported = identity
157            .sender()
158            .map_err(|message| IcpManagementCallError::ExportedIdentityPrincipal { message })?;
159        let active = self.identity_principal_text()?;
160        if exported.to_text() != active {
161            return Err(IcpManagementCallError::ExportedIdentityConflict {
162                active,
163                exported: exported.to_text(),
164            });
165        }
166        Ok(identity)
167    }
168}
169
170fn parse_exported_identity(pem: &[u8]) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
171    if let Ok(identity) = BasicIdentity::from_pem(pem) {
172        return Ok(Arc::new(identity));
173    }
174    if let Ok(identity) = Secp256k1Identity::from_pem(pem) {
175        return Ok(Arc::new(identity));
176    }
177    if let Ok(identity) = Prime256v1Identity::from_pem(pem) {
178        return Ok(Arc::new(identity));
179    }
180    Err(IcpManagementCallError::UnsupportedExportedIdentity)
181}
182
183#[derive(Debug, Eq, PartialEq)]
184struct ManagementUpdateRequest {
185    argument: Vec<u8>,
186    canister_id: Principal,
187    effective_canister_id: Principal,
188    method: String,
189}
190
191trait AgentUpdateBoundary {
192    fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError>;
193}
194
195struct LiveAgentUpdateBoundary<'a> {
196    agent: &'a Agent,
197}
198
199impl AgentUpdateBoundary for LiveAgentUpdateBoundary<'_> {
200    fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError> {
201        let runtime = tokio::runtime::Builder::new_current_thread()
202            .enable_all()
203            .build()
204            .map_err(IcpManagementCallError::AsyncRuntime)?;
205        runtime
206            .block_on(
207                self.agent
208                    .update(&request.canister_id, request.method)
209                    .with_effective_canister_id(request.effective_canister_id)
210                    .with_arg(request.argument)
211                    .call_and_wait(),
212            )
213            .map_err(IcpManagementCallError::AgentCall)
214    }
215}
216
217fn call_management_update(
218    boundary: &impl AgentUpdateBoundary,
219    effective_canister_id: Principal,
220    method: &str,
221    argument: Vec<u8>,
222) -> Result<Vec<u8>, IcpManagementCallError> {
223    boundary.update(ManagementUpdateRequest {
224        argument,
225        canister_id: Principal::management_canister(),
226        effective_canister_id,
227        method: method.to_string(),
228    })
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::icp::LocalReplicaTarget;
235    use std::cell::RefCell;
236
237    struct RecordingAgentBoundary {
238        request: RefCell<Option<ManagementUpdateRequest>>,
239    }
240
241    impl AgentUpdateBoundary for RecordingAgentBoundary {
242        fn update(
243            &self,
244            request: ManagementUpdateRequest,
245        ) -> Result<Vec<u8>, IcpManagementCallError> {
246            self.request.replace(Some(request));
247            Ok(vec![1, 2, 3])
248        }
249    }
250
251    #[test]
252    fn management_status_routes_through_the_target_effective_canister_id() {
253        let target =
254            Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").expect("effective canister ID");
255        let boundary = RecordingAgentBoundary {
256            request: RefCell::new(None),
257        };
258        let response =
259            call_management_update(&boundary, target, MANAGEMENT_CANISTER_STATUS, vec![7, 8, 9])
260                .expect("record routed management update");
261
262        assert_eq!(response, vec![1, 2, 3]);
263        assert_eq!(
264            boundary.request.into_inner(),
265            Some(ManagementUpdateRequest {
266                argument: vec![7, 8, 9],
267                canister_id: Principal::management_canister(),
268                effective_canister_id: target,
269                method: MANAGEMENT_CANISTER_STATUS.to_string(),
270            })
271        );
272    }
273
274    #[test]
275    fn explicit_local_replica_owns_management_network_resolution() {
276        let target = LocalReplicaTarget {
277            root_key: "010203".to_string(),
278            url: "http://127.0.0.1:4943/".to_string(),
279        };
280        let icp = IcpCli::new("missing-icp", Some(LOCAL_ICP_TARGET.to_string()))
281            .with_local_replica(Some(target.clone()));
282
283        let status = icp
284            .network_status(LOCAL_ICP_TARGET)
285            .expect("resolve explicit local replica without invoking ICP CLI");
286
287        assert_eq!(status.api_url, target.url);
288        assert_eq!(status.root_key, target.root_key);
289    }
290}