Skip to main content

canic_host/icp/
management.rs

1//! Module: icp::management
2//!
3//! Responsibility: perform one typed management-canister update 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_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    /// Perform one typed management-canister update routed through the target
75    /// canister's exact effective canister ID.
76    pub(crate) fn management_canister_status_candid<I, O>(
77        &self,
78        effective_canister_id: Principal,
79        input: &I,
80    ) -> Result<O, IcpManagementCallError>
81    where
82        I: CandidType,
83        O: CandidType + DeserializeOwned,
84    {
85        let argument = candid::encode_one(input).map_err(IcpManagementCallError::CandidEncode)?;
86        let agent = self.management_agent()?;
87        let response = call_management_update(
88            &LiveAgentUpdateBoundary { agent: &agent },
89            effective_canister_id,
90            MANAGEMENT_CANISTER_STATUS,
91            argument,
92        )?;
93        candid::decode_one(&response).map_err(IcpManagementCallError::CandidResponse)
94    }
95
96    fn management_agent(&self) -> Result<Agent, IcpManagementCallError> {
97        let environment = self
98            .environment
99            .as_deref()
100            .ok_or(IcpManagementCallError::MissingEnvironment)?;
101        let network = self.network_status(environment)?;
102        let identity = self.exported_active_identity()?;
103        let agent = Agent::builder()
104            .with_url(&network.api_url)
105            .with_arc_identity(identity)
106            .with_ingress_expiry(MANAGEMENT_INGRESS_EXPIRY)
107            .build()
108            .map_err(IcpManagementCallError::AgentBuild)?;
109        let root_key = canic_core::cdk::utils::hash::decode_hex(&network.root_key)
110            .map_err(IcpManagementCallError::NetworkRootKey)?;
111        agent.set_root_key(root_key);
112        Ok(agent)
113    }
114
115    fn network_status(
116        &self,
117        environment: &str,
118    ) -> Result<IcpNetworkStatus, IcpManagementCallError> {
119        let mut command = self.command();
120        command.args(["network", "status", "--environment", environment, "--json"]);
121        run_json(&mut command).map_err(Into::into)
122    }
123
124    fn exported_active_identity(&self) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
125        let mut default_command = self.command();
126        default_command.args(["identity", "default"]);
127        let identity_name = run_output(&mut default_command)?;
128
129        let mut export_command = self.command();
130        export_command.args(["identity", "export", &identity_name]);
131        if let Some(password_file) = self.identity_password_file.as_deref() {
132            export_command.arg("--password-file").arg(password_file);
133        }
134        let mut pem = run_secret_output(&mut export_command)?;
135        let identity = parse_exported_identity(&pem);
136        pem.fill(0);
137        let identity = identity?;
138        let exported = identity
139            .sender()
140            .map_err(|message| IcpManagementCallError::ExportedIdentityPrincipal { message })?;
141        let active = self.identity_principal_text()?;
142        if exported.to_text() != active {
143            return Err(IcpManagementCallError::ExportedIdentityConflict {
144                active,
145                exported: exported.to_text(),
146            });
147        }
148        Ok(identity)
149    }
150}
151
152fn parse_exported_identity(pem: &[u8]) -> Result<Arc<dyn Identity>, IcpManagementCallError> {
153    if let Ok(identity) = BasicIdentity::from_pem(pem) {
154        return Ok(Arc::new(identity));
155    }
156    if let Ok(identity) = Secp256k1Identity::from_pem(pem) {
157        return Ok(Arc::new(identity));
158    }
159    if let Ok(identity) = Prime256v1Identity::from_pem(pem) {
160        return Ok(Arc::new(identity));
161    }
162    Err(IcpManagementCallError::UnsupportedExportedIdentity)
163}
164
165#[derive(Debug, Eq, PartialEq)]
166struct ManagementUpdateRequest {
167    argument: Vec<u8>,
168    canister_id: Principal,
169    effective_canister_id: Principal,
170    method: String,
171}
172
173trait AgentUpdateBoundary {
174    fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError>;
175}
176
177struct LiveAgentUpdateBoundary<'a> {
178    agent: &'a Agent,
179}
180
181impl AgentUpdateBoundary for LiveAgentUpdateBoundary<'_> {
182    fn update(&self, request: ManagementUpdateRequest) -> Result<Vec<u8>, IcpManagementCallError> {
183        let runtime = tokio::runtime::Builder::new_current_thread()
184            .enable_all()
185            .build()
186            .map_err(IcpManagementCallError::AsyncRuntime)?;
187        runtime
188            .block_on(
189                self.agent
190                    .update(&request.canister_id, request.method)
191                    .with_effective_canister_id(request.effective_canister_id)
192                    .with_arg(request.argument)
193                    .call_and_wait(),
194            )
195            .map_err(IcpManagementCallError::AgentCall)
196    }
197}
198
199fn call_management_update(
200    boundary: &impl AgentUpdateBoundary,
201    effective_canister_id: Principal,
202    method: &str,
203    argument: Vec<u8>,
204) -> Result<Vec<u8>, IcpManagementCallError> {
205    boundary.update(ManagementUpdateRequest {
206        argument,
207        canister_id: Principal::management_canister(),
208        effective_canister_id,
209        method: method.to_string(),
210    })
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use std::cell::RefCell;
217
218    struct RecordingAgentBoundary {
219        request: RefCell<Option<ManagementUpdateRequest>>,
220    }
221
222    impl AgentUpdateBoundary for RecordingAgentBoundary {
223        fn update(
224            &self,
225            request: ManagementUpdateRequest,
226        ) -> Result<Vec<u8>, IcpManagementCallError> {
227            self.request.replace(Some(request));
228            Ok(vec![1, 2, 3])
229        }
230    }
231
232    #[test]
233    fn management_status_routes_through_the_target_effective_canister_id() {
234        let target =
235            Principal::from_text("rrkah-fqaaa-aaaaa-aaaaq-cai").expect("effective canister ID");
236        let boundary = RecordingAgentBoundary {
237            request: RefCell::new(None),
238        };
239        let response =
240            call_management_update(&boundary, target, MANAGEMENT_CANISTER_STATUS, vec![7, 8, 9])
241                .expect("record routed management update");
242
243        assert_eq!(response, vec![1, 2, 3]);
244        assert_eq!(
245            boundary.request.into_inner(),
246            Some(ManagementUpdateRequest {
247                argument: vec![7, 8, 9],
248                canister_id: Principal::management_canister(),
249                effective_canister_id: target,
250                method: MANAGEMENT_CANISTER_STATUS.to_string(),
251            })
252        );
253    }
254}