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,
21    run::{run_json, 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.local_replica
78            .as_ref()
79            .is_some_and(|target| self.environment.as_deref() == Some(target.environment.as_str()))
80    }
81
82    /// Perform one typed management-canister update routed through the target
83    /// canister's exact effective canister ID.
84    pub(crate) fn management_canister_status_candid<I, O>(
85        &self,
86        effective_canister_id: Principal,
87        input: &I,
88    ) -> Result<O, IcpManagementCallError>
89    where
90        I: CandidType,
91        O: CandidType + DeserializeOwned,
92    {
93        self.measure_request(
94            crate::icp::IcpRequestKind::ManagementStatus,
95            Some(&effective_canister_id.to_text()),
96            Some(MANAGEMENT_CANISTER_STATUS),
97            || {
98                let argument =
99                    candid::encode_one(input).map_err(IcpManagementCallError::CandidEncode)?;
100                let agent = self.authenticated_agent()?;
101                self.record_remote_call();
102                let response = call_management_update(
103                    &LiveAgentUpdateBoundary { agent: &agent },
104                    effective_canister_id,
105                    MANAGEMENT_CANISTER_STATUS,
106                    argument,
107                )?;
108                candid::decode_one(&response).map_err(IcpManagementCallError::CandidResponse)
109            },
110        )
111    }
112
113    /// Resolve an agent bound to the selected ICP environment and verified active identity.
114    ///
115    /// The caller owns reviewed effect authority and durable intent before using it.
116    pub fn authenticated_agent(&self) -> Result<Agent, IcpManagementCallError> {
117        self.build_authenticated_agent(None)
118    }
119
120    /// Resolve the same selected identity and network with bounded HTTP response bodies.
121    pub(crate) fn authenticated_agent_with_response_limit(
122        &self,
123        maximum: usize,
124    ) -> Result<Agent, IcpManagementCallError> {
125        self.build_authenticated_agent(Some(maximum))
126    }
127
128    fn build_authenticated_agent(
129        &self,
130        maximum: Option<usize>,
131    ) -> Result<Agent, IcpManagementCallError> {
132        let environment = self
133            .environment
134            .as_deref()
135            .ok_or(IcpManagementCallError::MissingEnvironment)?;
136        let network = self.network_status(environment)?;
137        let identity = self.exported_active_identity()?;
138        let mut builder = Agent::builder()
139            .with_url(&network.api_url)
140            .with_arc_identity(identity)
141            .with_ingress_expiry(MANAGEMENT_INGRESS_EXPIRY);
142        if let Some(maximum) = maximum {
143            builder = builder.with_max_response_body_size(maximum);
144        }
145        let agent = builder
146            .build()
147            .map_err(IcpManagementCallError::AgentBuild)?;
148        let root_key = canic_core::cdk::utils::hash::decode_hex(&network.root_key)
149            .map_err(IcpManagementCallError::NetworkRootKey)?;
150        agent.set_root_key(root_key);
151        Ok(agent)
152    }
153
154    fn network_status(
155        &self,
156        environment: &str,
157    ) -> Result<IcpNetworkStatus, IcpManagementCallError> {
158        if let Some(target) = &self.local_replica
159            && environment == target.environment
160        {
161            return Ok(IcpNetworkStatus {
162                api_url: target.url.clone(),
163                root_key: target.root_key.clone(),
164            });
165        }
166        let mut command = self.request_command();
167        command.args(["network", "status", "--environment", environment, "--json"]);
168        run_json(&mut command, self).map_err(Into::into)
169    }
170
171    fn exported_active_identity(&self) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
172        let identity_name = self.selected_identity_name()?;
173
174        let mut export_command = self.request_command();
175        export_command.args(["identity", "export", &identity_name]);
176        if let Some(password_file) = self.identity_password_file.as_deref() {
177            export_command.arg("--password-file").arg(password_file);
178        }
179        let mut pem = run_secret_output(&mut export_command, self)?;
180        let identity = parse_exported_identity(&pem);
181        pem.fill(0);
182        let identity = identity?;
183        let exported = identity
184            .sender()
185            .map_err(|message| IcpManagementCallError::ExportedIdentityPrincipal { message })?;
186        let active = self.identity_principal_text()?;
187        if exported.to_text() != active {
188            return Err(IcpManagementCallError::ExportedIdentityConflict {
189                active,
190                exported: exported.to_text(),
191            });
192        }
193        Ok(identity)
194    }
195}
196
197fn parse_exported_identity(pem: &[u8]) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
198    if let Ok(identity) = BasicIdentity::from_pem(pem) {
199        return Ok(Arc::new(identity));
200    }
201    if let Ok(identity) = Secp256k1Identity::from_pem(pem) {
202        return Ok(Arc::new(identity));
203    }
204    if let Ok(identity) = Prime256v1Identity::from_pem(pem) {
205        return Ok(Arc::new(identity));
206    }
207    Err(IcpManagementCallError::UnsupportedExportedIdentity)
208}
209
210#[derive(Debug, Eq, PartialEq)]
211struct ManagementUpdateRequest {
212    argument: Vec<u8>,
213    canister_id: Principal,
214    effective_canister_id: Principal,
215    method: String,
216}
217
218trait AgentUpdateBoundary {
219    fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError>;
220}
221
222struct LiveAgentUpdateBoundary<'a> {
223    agent: &'a Agent,
224}
225
226impl AgentUpdateBoundary for LiveAgentUpdateBoundary<'_> {
227    fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError> {
228        let runtime = tokio::runtime::Builder::new_current_thread()
229            .enable_all()
230            .build()
231            .map_err(IcpManagementCallError::AsyncRuntime)?;
232        runtime
233            .block_on(
234                self.agent
235                    .update(&request.canister_id, request.method)
236                    .with_effective_canister_id(request.effective_canister_id)
237                    .with_arg(request.argument)
238                    .call_and_wait(),
239            )
240            .map_err(IcpManagementCallError::AgentCall)
241    }
242}
243
244fn call_management_update(
245    boundary: &impl AgentUpdateBoundary,
246    effective_canister_id: Principal,
247    method: &str,
248    argument: Vec<u8>,
249) -> Result<Vec<u8>, IcpManagementCallError> {
250    boundary.update(ManagementUpdateRequest {
251        argument,
252        canister_id: Principal::management_canister(),
253        effective_canister_id,
254        method: method.to_string(),
255    })
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::icp::LocalReplicaTarget;
262    use std::cell::RefCell;
263
264    struct RecordingAgentBoundary {
265        request: RefCell<Option<ManagementUpdateRequest>>,
266    }
267
268    impl AgentUpdateBoundary for RecordingAgentBoundary {
269        fn update(
270            &self,
271            request: ManagementUpdateRequest,
272        ) -> Result<Vec<u8>, IcpManagementCallError> {
273            self.request.replace(Some(request));
274            Ok(vec![1, 2, 3])
275        }
276    }
277
278    #[test]
279    fn management_status_routes_through_the_target_effective_canister_id() {
280        let target =
281            Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").expect("effective canister ID");
282        let boundary = RecordingAgentBoundary {
283            request: RefCell::new(None),
284        };
285        let response =
286            call_management_update(&boundary, target, MANAGEMENT_CANISTER_STATUS, vec![7, 8, 9])
287                .expect("record routed management update");
288
289        assert_eq!(response, vec![1, 2, 3]);
290        assert_eq!(
291            boundary.request.into_inner(),
292            Some(ManagementUpdateRequest {
293                argument: vec![7, 8, 9],
294                canister_id: Principal::management_canister(),
295                effective_canister_id: target,
296                method: MANAGEMENT_CANISTER_STATUS.to_string(),
297            })
298        );
299    }
300
301    #[test]
302    fn explicit_local_replica_owns_management_network_resolution() {
303        let target = LocalReplicaTarget {
304            environment: "local-qualification".into(),
305            root_key: "010203".to_string(),
306            url: "http://127.0.0.1:4943/".to_string(),
307        };
308        let icp = IcpCli::new("missing-icp", Some(target.environment.clone()))
309            .with_local_replica(Some(target.clone()));
310
311        let status = icp
312            .network_status(&target.environment)
313            .expect("resolve explicit local replica without invoking ICP CLI");
314
315        assert_eq!(status.api_url, target.url);
316        assert_eq!(status.root_key, target.root_key);
317        assert!(icp.uses_direct_local_replica());
318        let mut direct = std::process::Command::new("icp");
319        icp.add_target_args(&mut direct);
320        assert_eq!(
321            direct.get_args().collect::<Vec<_>>(),
322            ["-n", &target.url, "-k", &target.root_key].map(std::ffi::OsStr::new)
323        );
324        let other = IcpCli::new("missing-icp", Some("ic".into())).with_local_replica(Some(target));
325        assert!(!other.uses_direct_local_replica());
326        let mut named = std::process::Command::new("icp");
327        other.add_target_args(&mut named);
328        assert_eq!(
329            named.get_args().collect::<Vec<_>>(),
330            ["-e", "ic"].map(std::ffi::OsStr::new)
331        );
332    }
333}