1use 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#[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 #[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 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 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 pub fn authenticated_agent(&self) -> Result<Agent, IcpManagementCallError> {
152 self.build_authenticated_agent(Agent::builder())
153 }
154
155 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}