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);
27pub const SNAPSHOT_RESPONSE_BYTES: usize = 2 * 1024 * 1024;
28
29#[derive(CandidType, Deserialize)]
30struct ManagementSnapshot {
31    id: Vec<u8>,
32}
33
34#[derive(Debug, Deserialize)]
35struct IcpNetworkStatus {
36    api_url: String,
37    root_key: String,
38}
39
40/// Typed failure while resolving or executing an effective-ID-correct
41/// management-canister call.
42#[derive(Debug, ThisError)]
43pub enum IcpManagementCallError {
44    #[error("management snapshot read for {canister} exceeded its deadline")]
45    SnapshotDeadline { canister: Principal },
46
47    #[error("management snapshot response has {actual} bytes; maximum is {maximum}")]
48    SnapshotResponseTooLarge { actual: usize, maximum: usize },
49
50    #[error("failed to encode the management-canister argument: {0}")]
51    CandidEncode(#[source] candid::Error),
52
53    #[error("failed to decode the management-canister response: {0}")]
54    CandidResponse(#[source] candid::Error),
55
56    #[error("failed to build the management-canister agent: {0}")]
57    AgentBuild(#[source] AgentError),
58
59    #[error("the effective-ID-correct management-canister call failed: {0}")]
60    AgentCall(#[source] AgentError),
61
62    #[error("failed to create the management-canister async runtime: {0}")]
63    AsyncRuntime(#[source] std::io::Error),
64
65    #[error(transparent)]
66    Icp(#[from] super::IcpCommandError),
67
68    #[error("ICP CLI reported an invalid network root key: {0}")]
69    NetworkRootKey(#[source] canic_core::cdk::utils::hash::DecodeHexError),
70
71    #[error("the selected ICP identity cannot be represented by a supported exported PEM")]
72    UnsupportedExportedIdentity,
73
74    #[error("failed to resolve the Principal of the exported ICP identity: {message}")]
75    ExportedIdentityPrincipal { message: String },
76
77    #[error(
78        "the exported ICP identity Principal {exported} conflicts with the active ICP identity {active}"
79    )]
80    ExportedIdentityConflict { active: String, exported: String },
81
82    #[error("an ICP environment is required for the management-canister call")]
83    MissingEnvironment,
84}
85
86impl IcpCli {
87    /// List exact snapshot IDs through the selected authenticated management boundary.
88    pub(crate) fn management_snapshot_ids(
89        &self,
90        canister_id: Principal,
91    ) -> Result<Vec<Vec<u8>>, IcpManagementCallError> {
92        self.measure_request(
93            crate::icp::IcpRequestKind::Update,
94            Some(&canister_id.to_text()),
95            Some("list_canister_snapshots"),
96            || {
97                let agent =
98                    self.authenticated_agent_with_response_limit(SNAPSHOT_RESPONSE_BYTES)?;
99                let runtime = tokio::runtime::Builder::new_current_thread()
100                    .enable_all()
101                    .build()
102                    .map_err(IcpManagementCallError::AsyncRuntime)?;
103                self.record_remote_call();
104                runtime.block_on(read_snapshot_ids(&agent, canister_id))
105            },
106        )
107    }
108
109    /// Delete exactly one snapshot after the caller has retained its reviewed intent.
110    pub(crate) fn delete_canister_snapshot(
111        &self,
112        canister_id: Principal,
113        snapshot_id: Vec<u8>,
114    ) -> Result<(), IcpManagementCallError> {
115        #[derive(CandidType)]
116        struct Request {
117            canister_id: Principal,
118            snapshot_id: Vec<u8>,
119        }
120        self.snapshot_update(
121            canister_id,
122            "delete_canister_snapshot",
123            &Request {
124                canister_id,
125                snapshot_id,
126            },
127        )
128    }
129
130    fn snapshot_update<I, O>(
131        &self,
132        canister_id: Principal,
133        method: &str,
134        input: &I,
135    ) -> Result<O, IcpManagementCallError>
136    where
137        I: CandidType,
138        O: for<'de> candid::utils::ArgumentDecoder<'de>,
139    {
140        self.measure_request(
141            crate::icp::IcpRequestKind::Update,
142            Some(&canister_id.to_text()),
143            Some(method),
144            || {
145                let argument =
146                    candid::encode_one(input).map_err(IcpManagementCallError::CandidEncode)?;
147                let agent = self.authenticated_agent()?;
148                self.record_remote_call();
149                let response = call_management_update(
150                    &LiveAgentUpdateBoundary { agent: &agent },
151                    canister_id,
152                    method,
153                    argument,
154                )?;
155                candid::decode_args(&response).map_err(IcpManagementCallError::CandidResponse)
156            },
157        )
158    }
159
160    /// Whether this command context is bound directly to one local replica.
161    #[must_use]
162    pub(crate) fn uses_direct_local_replica(&self) -> bool {
163        self.local_replica
164            .as_ref()
165            .is_some_and(|target| self.environment.as_deref() == Some(target.environment.as_str()))
166    }
167
168    /// Perform one typed management-canister update routed through the target
169    /// canister's exact effective canister ID.
170    pub(crate) fn management_canister_status_candid<I, O>(
171        &self,
172        effective_canister_id: Principal,
173        input: &I,
174    ) -> Result<O, IcpManagementCallError>
175    where
176        I: CandidType,
177        O: CandidType + DeserializeOwned,
178    {
179        self.measure_request(
180            crate::icp::IcpRequestKind::ManagementStatus,
181            Some(&effective_canister_id.to_text()),
182            Some(MANAGEMENT_CANISTER_STATUS),
183            || {
184                let argument =
185                    candid::encode_one(input).map_err(IcpManagementCallError::CandidEncode)?;
186                let agent = self.authenticated_agent()?;
187                self.record_remote_call();
188                let response = call_management_update(
189                    &LiveAgentUpdateBoundary { agent: &agent },
190                    effective_canister_id,
191                    MANAGEMENT_CANISTER_STATUS,
192                    argument,
193                )?;
194                candid::decode_one(&response).map_err(IcpManagementCallError::CandidResponse)
195            },
196        )
197    }
198
199    /// Clear code and stable state while preserving the exact canister ID and cycles.
200    /// The caller must retain the reviewed uninstall intent before invoking this effect.
201    pub(crate) fn uninstall_canister(
202        &self,
203        canister_id: Principal,
204    ) -> Result<(), IcpManagementCallError> {
205        #[derive(CandidType)]
206        struct UninstallRequest {
207            canister_id: Principal,
208            sender_canister_version: Option<u64>,
209        }
210        self.measure_request(
211            crate::icp::IcpRequestKind::Update,
212            Some(&canister_id.to_text()),
213            Some("uninstall_code"),
214            || {
215                let argument = candid::encode_one(UninstallRequest {
216                    canister_id,
217                    sender_canister_version: None,
218                })
219                .map_err(IcpManagementCallError::CandidEncode)?;
220                let agent = self.authenticated_agent()?;
221                self.record_remote_call();
222                let response = call_management_update(
223                    &LiveAgentUpdateBoundary { agent: &agent },
224                    canister_id,
225                    "uninstall_code",
226                    argument,
227                )?;
228                candid::decode_args::<()>(&response).map_err(IcpManagementCallError::CandidResponse)
229            },
230        )
231    }
232
233    /// Resolve an agent bound to the selected ICP environment and verified active identity.
234    ///
235    /// The caller owns reviewed effect authority and durable intent before using it.
236    pub fn authenticated_agent(&self) -> Result<Agent, IcpManagementCallError> {
237        self.build_authenticated_agent(Agent::builder())
238    }
239
240    /// Resolve the same selected identity and network with bounded HTTP response bodies.
241    pub(crate) fn authenticated_agent_with_response_limit(
242        &self,
243        maximum: usize,
244    ) -> Result<Agent, IcpManagementCallError> {
245        self.build_authenticated_agent(Agent::builder().with_max_response_body_size(maximum))
246    }
247
248    pub(super) fn build_authenticated_agent(
249        &self,
250        builder: AgentBuilder,
251    ) -> Result<Agent, IcpManagementCallError> {
252        let environment = self
253            .environment
254            .as_deref()
255            .ok_or(IcpManagementCallError::MissingEnvironment)?;
256        let network = self.network_status(environment)?;
257        let identity = self.exported_active_identity()?;
258        let builder = builder
259            .with_url(&network.api_url)
260            .with_arc_identity(identity)
261            .with_ingress_expiry(MANAGEMENT_INGRESS_EXPIRY);
262        let agent = builder
263            .build()
264            .map_err(IcpManagementCallError::AgentBuild)?;
265        let root_key = canic_core::cdk::utils::hash::decode_hex(&network.root_key)
266            .map_err(IcpManagementCallError::NetworkRootKey)?;
267        agent.set_root_key(root_key);
268        Ok(agent)
269    }
270
271    fn network_status(
272        &self,
273        environment: &str,
274    ) -> Result<IcpNetworkStatus, IcpManagementCallError> {
275        if let Some(target) = &self.local_replica
276            && environment == target.environment
277        {
278            return Ok(IcpNetworkStatus {
279                api_url: target.url.clone(),
280                root_key: target.root_key.clone(),
281            });
282        }
283        let mut command = self.request_command();
284        command.args(["network", "status", "--environment", environment, "--json"]);
285        run_json(&mut command, self).map_err(Into::into)
286    }
287
288    fn exported_active_identity(&self) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
289        let identity_name = self.selected_identity_name()?;
290
291        let mut export_command = self.request_command();
292        export_command.args(["identity", "export", &identity_name]);
293        if let Some(password_file) = self.identity_password_file.as_deref() {
294            export_command.arg("--password-file").arg(password_file);
295        }
296        let mut pem = run_secret_output(&mut export_command, self)?;
297        let identity = parse_exported_identity(&pem);
298        pem.fill(0);
299        let identity = identity?;
300        let exported = identity
301            .sender()
302            .map_err(|message| IcpManagementCallError::ExportedIdentityPrincipal { message })?;
303        let active = self.identity_principal_text()?;
304        if exported.to_text() != active {
305            return Err(IcpManagementCallError::ExportedIdentityConflict {
306                active,
307                exported: exported.to_text(),
308            });
309        }
310        Ok(identity)
311    }
312}
313
314/// One bounded snapshot inventory call on an already authenticated, network-bound agent.
315pub async fn read_snapshot_ids(
316    agent: &Agent,
317    canister: Principal,
318) -> Result<Vec<Vec<u8>>, IcpManagementCallError> {
319    #[derive(CandidType)]
320    struct Request {
321        canister_id: Principal,
322    }
323    let argument = candid::encode_one(Request {
324        canister_id: canister,
325    })
326    .map_err(IcpManagementCallError::CandidEncode)?;
327    let bytes = tokio::time::timeout(
328        Duration::from_secs(45),
329        agent
330            .update(&Principal::management_canister(), "list_canister_snapshots")
331            .with_effective_canister_id(canister)
332            .with_arg(argument)
333            .call_and_wait(),
334    )
335    .await
336    .map_err(|_| IcpManagementCallError::SnapshotDeadline { canister })?
337    .map_err(IcpManagementCallError::AgentCall)?;
338    decode_snapshot_ids(&bytes)
339}
340
341fn decode_snapshot_ids(bytes: &[u8]) -> Result<Vec<Vec<u8>>, IcpManagementCallError> {
342    if bytes.len() > SNAPSHOT_RESPONSE_BYTES {
343        return Err(IcpManagementCallError::SnapshotResponseTooLarge {
344            actual: bytes.len(),
345            maximum: SNAPSHOT_RESPONSE_BYTES,
346        });
347    }
348    let mut config = candid::de::DecoderConfig::new();
349    config
350        .set_decoding_quota(SNAPSHOT_RESPONSE_BYTES * 64)
351        .set_skipping_quota(SNAPSHOT_RESPONSE_BYTES * 64);
352    let snapshots: Vec<ManagementSnapshot> = candid::utils::decode_one_with_config(bytes, &config)
353        .map_err(IcpManagementCallError::CandidResponse)?;
354    Ok(snapshots.into_iter().map(|snapshot| snapshot.id).collect())
355}
356
357fn parse_exported_identity(pem: &[u8]) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
358    if let Ok(identity) = BasicIdentity::from_pem(pem) {
359        return Ok(Arc::new(identity));
360    }
361    if let Ok(identity) = Secp256k1Identity::from_pem(pem) {
362        return Ok(Arc::new(identity));
363    }
364    if let Ok(identity) = Prime256v1Identity::from_pem(pem) {
365        return Ok(Arc::new(identity));
366    }
367    Err(IcpManagementCallError::UnsupportedExportedIdentity)
368}
369
370#[derive(Debug, Eq, PartialEq)]
371struct ManagementUpdateRequest {
372    argument: Vec<u8>,
373    canister_id: Principal,
374    effective_canister_id: Principal,
375    method: String,
376}
377
378trait AgentUpdateBoundary {
379    fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError>;
380}
381
382struct LiveAgentUpdateBoundary<'a> {
383    agent: &'a Agent,
384}
385
386impl AgentUpdateBoundary for LiveAgentUpdateBoundary<'_> {
387    fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError> {
388        let runtime = tokio::runtime::Builder::new_current_thread()
389            .enable_all()
390            .build()
391            .map_err(IcpManagementCallError::AsyncRuntime)?;
392        runtime
393            .block_on(
394                self.agent
395                    .update(&request.canister_id, request.method)
396                    .with_effective_canister_id(request.effective_canister_id)
397                    .with_arg(request.argument)
398                    .call_and_wait(),
399            )
400            .map_err(IcpManagementCallError::AgentCall)
401    }
402}
403
404fn call_management_update(
405    boundary: &impl AgentUpdateBoundary,
406    effective_canister_id: Principal,
407    method: &str,
408    argument: Vec<u8>,
409) -> Result<Vec<u8>, IcpManagementCallError> {
410    boundary.update(ManagementUpdateRequest {
411        argument,
412        canister_id: Principal::management_canister(),
413        effective_canister_id,
414        method: method.to_string(),
415    })
416}
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421    use crate::icp::LocalReplicaTarget;
422    use std::cell::RefCell;
423
424    #[test]
425    fn snapshot_inventory_decoder_preserves_ids_and_refuses_oversized_or_malformed_replies() {
426        let bytes = candid::encode_one(vec![ManagementSnapshot { id: vec![1, 2, 3] }]).unwrap();
427        assert_eq!(decode_snapshot_ids(&bytes).unwrap(), [vec![1, 2, 3]]);
428        assert!(matches!(
429            decode_snapshot_ids(&vec![0; SNAPSHOT_RESPONSE_BYTES + 1]),
430            Err(IcpManagementCallError::SnapshotResponseTooLarge { .. })
431        ));
432        assert!(matches!(
433            decode_snapshot_ids(b"DIDL"),
434            Err(IcpManagementCallError::CandidResponse(_))
435        ));
436    }
437
438    struct RecordingAgentBoundary {
439        request: RefCell<Option<ManagementUpdateRequest>>,
440    }
441
442    impl AgentUpdateBoundary for RecordingAgentBoundary {
443        fn update(
444            &self,
445            request: ManagementUpdateRequest,
446        ) -> Result<Vec<u8>, IcpManagementCallError> {
447            self.request.replace(Some(request));
448            Ok(vec![1, 2, 3])
449        }
450    }
451
452    #[test]
453    fn management_status_routes_through_the_target_effective_canister_id() {
454        let target =
455            Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").expect("effective canister ID");
456        let boundary = RecordingAgentBoundary {
457            request: RefCell::new(None),
458        };
459        let response =
460            call_management_update(&boundary, target, MANAGEMENT_CANISTER_STATUS, vec![7, 8, 9])
461                .expect("record routed management update");
462
463        assert_eq!(response, vec![1, 2, 3]);
464        assert_eq!(
465            boundary.request.into_inner(),
466            Some(ManagementUpdateRequest {
467                argument: vec![7, 8, 9],
468                canister_id: Principal::management_canister(),
469                effective_canister_id: target,
470                method: MANAGEMENT_CANISTER_STATUS.to_string(),
471            })
472        );
473    }
474
475    #[test]
476    fn explicit_local_replica_owns_management_network_resolution() {
477        let target = LocalReplicaTarget {
478            environment: "local-qualification".into(),
479            root_key: "010203".to_string(),
480            url: "http://127.0.0.1:4943/".to_string(),
481        };
482        let icp = IcpCli::new("missing-icp", Some(target.environment.clone()))
483            .with_local_replica(Some(target.clone()));
484
485        let status = icp
486            .network_status(&target.environment)
487            .expect("resolve explicit local replica without invoking ICP CLI");
488
489        assert_eq!(status.api_url, target.url);
490        assert_eq!(status.root_key, target.root_key);
491        assert!(icp.uses_direct_local_replica());
492        let mut direct = std::process::Command::new("icp");
493        icp.add_target_args(&mut direct);
494        assert_eq!(
495            direct.get_args().collect::<Vec<_>>(),
496            ["-n", &target.url, "-k", &target.root_key].map(std::ffi::OsStr::new)
497        );
498        let other = IcpCli::new("missing-icp", Some("ic".into())).with_local_replica(Some(target));
499        assert!(!other.uses_direct_local_replica());
500        let mut named = std::process::Command::new("icp");
501        other.add_target_args(&mut named);
502        assert_eq!(
503            named.get_args().collect::<Vec<_>>(),
504            ["-e", "ic"].map(std::ffi::OsStr::new)
505        );
506    }
507}