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    agent::AgentBuilder,
15    identity::{BasicIdentity, Prime256v1Identity, Secp256k1Identity},
16};
17use serde::{Deserialize, de::DeserializeOwned};
18use thiserror::Error as ThisError;
19
20use super::{
21    model::IcpCli,
22    run::{run_json, run_secret_output},
23};
24
25const MANAGEMENT_CANISTER_STATUS: &str = "canister_status";
26const MANAGEMENT_INGRESS_EXPIRY: Duration = Duration::from_mins(4);
27
28#[derive(Debug, Deserialize)]
29struct IcpNetworkStatus {
30    api_url: String,
31    root_key: String,
32}
33
34/// Typed failure while resolving or executing an effective-ID-correct
35/// management-canister call.
36#[derive(Debug, ThisError)]
37pub enum IcpManagementCallError {
38    #[error("failed to encode the management-canister argument: {0}")]
39    CandidEncode(#[source] candid::Error),
40
41    #[error("failed to decode the management-canister response: {0}")]
42    CandidResponse(#[source] candid::Error),
43
44    #[error("failed to build the management-canister agent: {0}")]
45    AgentBuild(#[source] AgentError),
46
47    #[error("the effective-ID-correct management-canister call failed: {0}")]
48    AgentCall(#[source] AgentError),
49
50    #[error("failed to create the management-canister async runtime: {0}")]
51    AsyncRuntime(#[source] std::io::Error),
52
53    #[error(transparent)]
54    Icp(#[from] super::IcpCommandError),
55
56    #[error("ICP CLI reported an invalid network root key: {0}")]
57    NetworkRootKey(#[source] canic_core::cdk::utils::hash::DecodeHexError),
58
59    #[error("the selected ICP identity cannot be represented by a supported exported PEM")]
60    UnsupportedExportedIdentity,
61
62    #[error("failed to resolve the Principal of the exported ICP identity: {message}")]
63    ExportedIdentityPrincipal { message: String },
64
65    #[error(
66        "the exported ICP identity Principal {exported} conflicts with the active ICP identity {active}"
67    )]
68    ExportedIdentityConflict { active: String, exported: String },
69
70    #[error("an ICP environment is required for the management-canister call")]
71    MissingEnvironment,
72}
73
74impl IcpCli {
75    /// Whether this command context is bound directly to one local replica.
76    #[must_use]
77    pub(crate) fn uses_direct_local_replica(&self) -> bool {
78        self.local_replica
79            .as_ref()
80            .is_some_and(|target| self.environment.as_deref() == Some(target.environment.as_str()))
81    }
82
83    /// Perform one typed management-canister update routed through the target
84    /// canister's exact effective canister ID.
85    pub(crate) fn management_canister_status_candid<I, O>(
86        &self,
87        effective_canister_id: Principal,
88        input: &I,
89    ) -> Result<O, IcpManagementCallError>
90    where
91        I: CandidType,
92        O: CandidType + DeserializeOwned,
93    {
94        self.measure_request(
95            crate::icp::IcpRequestKind::ManagementStatus,
96            Some(&effective_canister_id.to_text()),
97            Some(MANAGEMENT_CANISTER_STATUS),
98            || {
99                let argument =
100                    candid::encode_one(input).map_err(IcpManagementCallError::CandidEncode)?;
101                let agent = self.authenticated_agent()?;
102                self.record_remote_call();
103                let response = call_management_update(
104                    &LiveAgentUpdateBoundary { agent: &agent },
105                    effective_canister_id,
106                    MANAGEMENT_CANISTER_STATUS,
107                    argument,
108                )?;
109                candid::decode_one(&response).map_err(IcpManagementCallError::CandidResponse)
110            },
111        )
112    }
113
114    /// Clear code and stable state while preserving the exact canister ID and cycles.
115    /// The caller must retain the reviewed uninstall intent before invoking this effect.
116    pub(crate) fn uninstall_canister(
117        &self,
118        canister_id: Principal,
119    ) -> Result<(), IcpManagementCallError> {
120        #[derive(CandidType)]
121        struct UninstallRequest {
122            canister_id: Principal,
123            sender_canister_version: Option<u64>,
124        }
125        self.measure_request(
126            crate::icp::IcpRequestKind::Update,
127            Some(&canister_id.to_text()),
128            Some("uninstall_code"),
129            || {
130                let argument = candid::encode_one(UninstallRequest {
131                    canister_id,
132                    sender_canister_version: None,
133                })
134                .map_err(IcpManagementCallError::CandidEncode)?;
135                let agent = self.authenticated_agent()?;
136                self.record_remote_call();
137                let response = call_management_update(
138                    &LiveAgentUpdateBoundary { agent: &agent },
139                    canister_id,
140                    "uninstall_code",
141                    argument,
142                )?;
143                candid::decode_args::<()>(&response).map_err(IcpManagementCallError::CandidResponse)
144            },
145        )
146    }
147
148    /// Resolve an agent bound to the selected ICP environment and verified active identity.
149    ///
150    /// The caller owns reviewed effect authority and durable intent before using it.
151    pub fn authenticated_agent(&self) -> Result<Agent, IcpManagementCallError> {
152        self.build_authenticated_agent(Agent::builder())
153    }
154
155    /// Resolve the same selected identity and network with bounded HTTP response bodies.
156    pub(crate) fn authenticated_agent_with_response_limit(
157        &self,
158        maximum: usize,
159    ) -> Result<Agent, IcpManagementCallError> {
160        self.build_authenticated_agent(Agent::builder().with_max_response_body_size(maximum))
161    }
162
163    pub(super) fn build_authenticated_agent(
164        &self,
165        builder: AgentBuilder,
166    ) -> Result<Agent, IcpManagementCallError> {
167        let environment = self
168            .environment
169            .as_deref()
170            .ok_or(IcpManagementCallError::MissingEnvironment)?;
171        let network = self.network_status(environment)?;
172        let identity = self.exported_active_identity()?;
173        let builder = builder
174            .with_url(&network.api_url)
175            .with_arc_identity(identity)
176            .with_ingress_expiry(MANAGEMENT_INGRESS_EXPIRY);
177        let agent = builder
178            .build()
179            .map_err(IcpManagementCallError::AgentBuild)?;
180        let root_key = canic_core::cdk::utils::hash::decode_hex(&network.root_key)
181            .map_err(IcpManagementCallError::NetworkRootKey)?;
182        agent.set_root_key(root_key);
183        Ok(agent)
184    }
185
186    fn network_status(
187        &self,
188        environment: &str,
189    ) -> Result<IcpNetworkStatus, IcpManagementCallError> {
190        if let Some(target) = &self.local_replica
191            && environment == target.environment
192        {
193            return Ok(IcpNetworkStatus {
194                api_url: target.url.clone(),
195                root_key: target.root_key.clone(),
196            });
197        }
198        let mut command = self.request_command();
199        command.args(["network", "status", "--environment", environment, "--json"]);
200        run_json(&mut command, self).map_err(Into::into)
201    }
202
203    fn exported_active_identity(&self) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
204        let identity_name = self.selected_identity_name()?;
205
206        let mut export_command = self.request_command();
207        export_command.args(["identity", "export", &identity_name]);
208        if let Some(password_file) = self.identity_password_file.as_deref() {
209            export_command.arg("--password-file").arg(password_file);
210        }
211        let mut pem = run_secret_output(&mut export_command, self)?;
212        let identity = parse_exported_identity(&pem);
213        pem.fill(0);
214        let identity = identity?;
215        let exported = identity
216            .sender()
217            .map_err(|message| IcpManagementCallError::ExportedIdentityPrincipal { message })?;
218        let active = self.identity_principal_text()?;
219        if exported.to_text() != active {
220            return Err(IcpManagementCallError::ExportedIdentityConflict {
221                active,
222                exported: exported.to_text(),
223            });
224        }
225        Ok(identity)
226    }
227}
228
229fn parse_exported_identity(pem: &[u8]) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
230    if let Ok(identity) = BasicIdentity::from_pem(pem) {
231        return Ok(Arc::new(identity));
232    }
233    if let Ok(identity) = Secp256k1Identity::from_pem(pem) {
234        return Ok(Arc::new(identity));
235    }
236    if let Ok(identity) = Prime256v1Identity::from_pem(pem) {
237        return Ok(Arc::new(identity));
238    }
239    Err(IcpManagementCallError::UnsupportedExportedIdentity)
240}
241
242#[derive(Debug, Eq, PartialEq)]
243struct ManagementUpdateRequest {
244    argument: Vec<u8>,
245    canister_id: Principal,
246    effective_canister_id: Principal,
247    method: String,
248}
249
250trait AgentUpdateBoundary {
251    fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError>;
252}
253
254struct LiveAgentUpdateBoundary<'a> {
255    agent: &'a Agent,
256}
257
258impl AgentUpdateBoundary for LiveAgentUpdateBoundary<'_> {
259    fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError> {
260        let runtime = tokio::runtime::Builder::new_current_thread()
261            .enable_all()
262            .build()
263            .map_err(IcpManagementCallError::AsyncRuntime)?;
264        runtime
265            .block_on(
266                self.agent
267                    .update(&request.canister_id, request.method)
268                    .with_effective_canister_id(request.effective_canister_id)
269                    .with_arg(request.argument)
270                    .call_and_wait(),
271            )
272            .map_err(IcpManagementCallError::AgentCall)
273    }
274}
275
276fn call_management_update(
277    boundary: &impl AgentUpdateBoundary,
278    effective_canister_id: Principal,
279    method: &str,
280    argument: Vec<u8>,
281) -> Result<Vec<u8>, IcpManagementCallError> {
282    boundary.update(ManagementUpdateRequest {
283        argument,
284        canister_id: Principal::management_canister(),
285        effective_canister_id,
286        method: method.to_string(),
287    })
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::icp::LocalReplicaTarget;
294    use std::cell::RefCell;
295
296    struct RecordingAgentBoundary {
297        request: RefCell<Option<ManagementUpdateRequest>>,
298    }
299
300    impl AgentUpdateBoundary for RecordingAgentBoundary {
301        fn update(
302            &self,
303            request: ManagementUpdateRequest,
304        ) -> Result<Vec<u8>, IcpManagementCallError> {
305            self.request.replace(Some(request));
306            Ok(vec![1, 2, 3])
307        }
308    }
309
310    #[test]
311    fn management_status_routes_through_the_target_effective_canister_id() {
312        let target =
313            Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").expect("effective canister ID");
314        let boundary = RecordingAgentBoundary {
315            request: RefCell::new(None),
316        };
317        let response =
318            call_management_update(&boundary, target, MANAGEMENT_CANISTER_STATUS, vec![7, 8, 9])
319                .expect("record routed management update");
320
321        assert_eq!(response, vec![1, 2, 3]);
322        assert_eq!(
323            boundary.request.into_inner(),
324            Some(ManagementUpdateRequest {
325                argument: vec![7, 8, 9],
326                canister_id: Principal::management_canister(),
327                effective_canister_id: target,
328                method: MANAGEMENT_CANISTER_STATUS.to_string(),
329            })
330        );
331    }
332
333    #[test]
334    fn explicit_local_replica_owns_management_network_resolution() {
335        let target = LocalReplicaTarget {
336            environment: "local-qualification".into(),
337            root_key: "010203".to_string(),
338            url: "http://127.0.0.1:4943/".to_string(),
339        };
340        let icp = IcpCli::new("missing-icp", Some(target.environment.clone()))
341            .with_local_replica(Some(target.clone()));
342
343        let status = icp
344            .network_status(&target.environment)
345            .expect("resolve explicit local replica without invoking ICP CLI");
346
347        assert_eq!(status.api_url, target.url);
348        assert_eq!(status.root_key, target.root_key);
349        assert!(icp.uses_direct_local_replica());
350        let mut direct = std::process::Command::new("icp");
351        icp.add_target_args(&mut direct);
352        assert_eq!(
353            direct.get_args().collect::<Vec<_>>(),
354            ["-n", &target.url, "-k", &target.root_key].map(std::ffi::OsStr::new)
355        );
356        let other = IcpCli::new("missing-icp", Some("ic".into())).with_local_replica(Some(target));
357        assert!(!other.uses_direct_local_replica());
358        let mut named = std::process::Command::new("icp");
359        other.add_target_args(&mut named);
360        assert_eq!(
361            named.get_args().collect::<Vec<_>>(),
362            ["-e", "ic"].map(std::ffi::OsStr::new)
363        );
364    }
365}