diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index deb36de88..038a2ed5c 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -362,7 +362,14 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: // method to permit the user identity to upload assets directly before syncing. info!("Syncing canisters:"); - sync_many(ctx.syncer.clone(), agent.clone(), sync_canisters, ctx.debug).await?; + let is_cache = matches!(env.network.configuration, NetworkConfiguration::Managed { .. }); + let canister_ids = ctx + .ids + .lookup_by_environment(is_cache, &env.name) + .map(|m| m.into_iter().collect()) + .unwrap_or_default(); + + sync_many(ctx.syncer.clone(), agent.clone(), sync_canisters, canister_ids, ctx.debug).await?; } // Print URLs for deployed canisters diff --git a/crates/icp-cli/src/commands/sync.rs b/crates/icp-cli/src/commands/sync.rs index 7e4663285..8dc1748de 100644 --- a/crates/icp-cli/src/commands/sync.rs +++ b/crates/icp-cli/src/commands/sync.rs @@ -2,6 +2,7 @@ use clap::Args; use futures::future::try_join_all; use icp::context::{CanisterSelection, Context, EnvironmentSelection}; use icp::identity::IdentitySelection; +use icp::network::Configuration as NetworkConfiguration; use tracing::info; use crate::{ @@ -77,7 +78,14 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E info!("Syncing canisters:"); - sync_many(ctx.syncer.clone(), agent, sync_canisters, ctx.debug).await?; + let is_cache = matches!(env.network.configuration, NetworkConfiguration::Managed { .. }); + let canister_ids = ctx + .ids + .lookup_by_environment(is_cache, &env.name) + .map(|m| m.into_iter().collect()) + .unwrap_or_default(); + + sync_many(ctx.syncer.clone(), agent, sync_canisters, canister_ids, ctx.debug).await?; Ok(()) } diff --git a/crates/icp-cli/src/operations/bundle.rs b/crates/icp-cli/src/operations/bundle.rs index 95726e8c0..7f8010440 100644 --- a/crates/icp-cli/src/operations/bundle.rs +++ b/crates/icp-cli/src/operations/bundle.rs @@ -139,6 +139,9 @@ pub(crate) async fn create_bundle( dir: new_dir, })); } + SyncStep::Call(adapter) => { + bundle_sync_steps.push(SyncStep::Call(adapter.clone())); + } } } diff --git a/crates/icp-cli/src/operations/sync.rs b/crates/icp-cli/src/operations/sync.rs index feb407055..2deea47cf 100644 --- a/crates/icp-cli/src/operations/sync.rs +++ b/crates/icp-cli/src/operations/sync.rs @@ -1,3 +1,7 @@ +use std::sync::Arc; + +use std::collections::HashMap; + use futures::{StreamExt, stream::FuturesOrdered}; use ic_agent::{Agent, export::Principal}; use icp::{ @@ -6,7 +10,6 @@ use icp::{ prelude::PathBuf, }; use snafu::prelude::*; -use std::sync::Arc; use tracing::error; use crate::progress::{MultiStepProgressBar, ProgressManager, ProgressManagerSettings}; @@ -32,6 +35,7 @@ async fn sync_canister( canister_path: PathBuf, canister_id: Principal, canister_info: &Canister, + canister_ids: &HashMap, pb: &mut MultiStepProgressBar, ) -> Result<(), SynchronizeError> { let step_count = canister_info.sync.steps.len(); @@ -50,6 +54,7 @@ async fn sync_canister( &Params { path: canister_path.clone(), cid: canister_id, + canister_ids: canister_ids.clone(), }, agent, Some(tx), @@ -70,6 +75,7 @@ pub(crate) async fn sync_many( syncer: Arc, agent: Agent, canisters: Vec<(Principal, PathBuf, Canister)>, + canister_ids: HashMap, debug: bool, ) -> Result<(), SyncOperationError> { let mut futs = FuturesOrdered::new(); @@ -81,11 +87,12 @@ pub(crate) async fn sync_many( let fut = { let agent = agent.clone(); let syncer = syncer.clone(); + let canister_ids = canister_ids.clone(); async move { // Define the sync logic let sync_result = - sync_canister(&syncer, &agent, canister_path, cid, &canister_info, &mut pb) + sync_canister(&syncer, &agent, canister_path, cid, &canister_info, &canister_ids, &mut pb) .await; // Execute with progress tracking for final state diff --git a/crates/icp-cli/tests/bundle_tests.rs b/crates/icp-cli/tests/bundle_tests.rs index 80f879cd2..e6521928c 100644 --- a/crates/icp-cli/tests/bundle_tests.rs +++ b/crates/icp-cli/tests/bundle_tests.rs @@ -207,6 +207,81 @@ async fn bundle_and_deploy() { ); } +/// Bundle a project whose canister has a call sync step. +/// Verifies that the call step is preserved verbatim in the bundled manifest. +#[test] +fn bundle_preserves_call_sync_step() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + let pm = formatdoc! {r#" + canisters: + - name: backend + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + - name: frontend + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + sync: + steps: + - type: call + canister: backend + method: greet + args: '("world")' + + {NETWORK_RANDOM_PORT} + {ENVIRONMENT_RANDOM_PORT} + "#}; + + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); + + let bundle_path = project_dir.join("bundle.tar.gz"); + + ctx.icp() + .current_dir(&project_dir) + .args(["project", "bundle", "--output", bundle_path.as_str()]) + .assert() + .success(); + + let bundle_bytes = fs::read(bundle_path.as_std_path()).expect("failed to read bundle"); + let gz = GzDecoder::new(BufReader::new(bundle_bytes.as_slice())); + let mut archive = Archive::new(gz); + let mut manifest_yaml = String::new(); + + for entry in archive.entries().expect("failed to read archive entries") { + let mut entry = entry.expect("failed to read archive entry"); + let path = entry + .path() + .expect("failed to get entry path") + .to_string_lossy() + .into_owned(); + if path == "icp.yaml" { + entry + .read_to_string(&mut manifest_yaml) + .expect("failed to read icp.yaml"); + } + } + + assert!(!manifest_yaml.is_empty(), "icp.yaml not found in bundle"); + assert!( + manifest_yaml.contains("type: call"), + "bundled manifest should preserve call sync step" + ); + assert!( + manifest_yaml.contains("canister: backend"), + "bundled manifest should preserve call target canister" + ); + assert!( + manifest_yaml.contains("method: greet"), + "bundled manifest should preserve call method name" + ); +} + /// Projects with script sync steps must be rejected with a clear error. #[test] fn bundle_rejects_script_sync_step() { diff --git a/crates/icp-cli/tests/deploy_tests.rs b/crates/icp-cli/tests/deploy_tests.rs index 7f8affd08..57cfd107a 100644 --- a/crates/icp-cli/tests/deploy_tests.rs +++ b/crates/icp-cli/tests/deploy_tests.rs @@ -1038,3 +1038,63 @@ async fn deploy_through_proxy() { .success() .stdout(contains("Status: Running").and(contains(&proxy_cid))); } + +/// Deploy two canisters where one has a call sync step that invokes a method on the other. +/// Verifies that the call step runs successfully during deploy and standalone sync. +#[tokio::test] +async fn deploy_with_call_sync_step() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + let pm = formatdoc! {r#" + canisters: + - name: callee + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + - name: caller + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + sync: + steps: + - type: call + canister: callee + method: greet + args: '("world")' + + {NETWORK_RANDOM_PORT} + {ENVIRONMENT_RANDOM_PORT} + "#}; + + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); + + let _g = ctx.start_network_in(&project_dir, "random-network").await; + ctx.ping_until_healthy(&project_dir, "random-network"); + + clients::icp(&ctx, &project_dir, Some("random-environment".to_string())) + .mint_cycles(10 * TRILLION); + + // Deploy runs the call sync step as part of the deploy flow. + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--subnet", + common::SUBNET_ID, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Standalone sync also runs the call step. + ctx.icp() + .current_dir(&project_dir) + .args(["sync", "caller", "--environment", "random-environment"]) + .assert() + .success(); +} diff --git a/crates/icp/src/canister/sync/call.rs b/crates/icp/src/canister/sync/call.rs new file mode 100644 index 000000000..31f76dcc5 --- /dev/null +++ b/crates/icp/src/canister/sync/call.rs @@ -0,0 +1,112 @@ +use candid::Encode; +use ic_agent::Agent; +use snafu::prelude::*; + +use crate::{ + InitArgs, InitArgsToBytesError, fs, + manifest::{ + adapter::call::Adapter, + canister::{ArgsFormat, ManifestInitArgs}, + }, + prelude::*, +}; + +use super::Params; + +#[derive(Debug, Snafu)] +pub enum CallError { + #[snafu(display("canister '{name}' not found in the current environment"))] + CanisterNotFound { name: String }, + + #[snafu(display("failed to read args file for call to {canister}.{method}"))] + ReadArgsFile { + canister: String, + method: String, + source: fs::IoError, + }, + + #[snafu(display("cannot use 'bin' format with an inline value for call to {canister}.{method}"))] + BinFormatInlineArgs { canister: String, method: String }, + + #[snafu(display("failed to encode args for call to {canister}.{method}"))] + EncodeArgs { + canister: String, + method: String, + source: InitArgsToBytesError, + }, + + #[snafu(display("call to {canister}.{method} failed"))] + Call { + canister: String, + method: String, + source: ic_agent::AgentError, + }, +} + +pub(super) async fn sync(adapter: &Adapter, params: &Params, agent: &Agent) -> Result<(), CallError> { + let cid = params + .canister_ids + .get(&adapter.canister) + .copied() + .ok_or_else(|| CanisterNotFoundSnafu { name: &adapter.canister }.build())?; + + let arg_bytes = match &adapter.args { + None => Encode!().expect("empty Candid encoding cannot fail"), + Some(manifest_args) => resolve_args(manifest_args, ¶ms.path, &adapter.canister, &adapter.method)? + .to_bytes() + .context(EncodeArgsSnafu { + canister: &adapter.canister, + method: &adapter.method, + })?, + }; + + agent + .update(&cid, &adapter.method) + .with_arg(arg_bytes) + .call_and_wait() + .await + .context(CallSnafu { + canister: &adapter.canister, + method: &adapter.method, + })?; + + Ok(()) +} + +fn resolve_args( + manifest_args: &ManifestInitArgs, + base_path: &Path, + canister: &str, + method: &str, +) -> Result { + match manifest_args { + ManifestInitArgs::String(content) => Ok(InitArgs::Text { + content: content.trim().to_owned(), + format: ArgsFormat::Candid, + }), + ManifestInitArgs::Path { path, format } => { + let file_path = base_path.join(path); + match format { + ArgsFormat::Bin => { + let bytes = fs::read(&file_path).context(ReadArgsFileSnafu { canister, method })?; + Ok(InitArgs::Binary(bytes)) + } + fmt => { + let content = + fs::read_to_string(&file_path).context(ReadArgsFileSnafu { canister, method })?; + Ok(InitArgs::Text { + content: content.trim().to_owned(), + format: fmt.clone(), + }) + } + } + } + ManifestInitArgs::Value { value, format } => match format { + ArgsFormat::Bin => BinFormatInlineArgsSnafu { canister, method }.fail(), + fmt => Ok(InitArgs::Text { + content: value.trim().to_owned(), + format: fmt.clone(), + }), + }, + } +} diff --git a/crates/icp/src/canister/sync/mod.rs b/crates/icp/src/canister/sync/mod.rs index adc06ec69..59fd87620 100644 --- a/crates/icp/src/canister/sync/mod.rs +++ b/crates/icp/src/canister/sync/mod.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use async_trait::async_trait; use candid::Principal; use ic_agent::Agent; @@ -8,11 +10,13 @@ use crate::manifest::canister::SyncStep; use crate::prelude::*; mod assets; +mod call; mod script; pub struct Params { pub path: PathBuf, pub cid: Principal, + pub canister_ids: HashMap, } #[derive(Debug, Snafu)] @@ -22,6 +26,9 @@ pub enum SynchronizeError { #[snafu(transparent)] Assets { source: assets::AssetsError }, + + #[snafu(transparent)] + Call { source: call::CallError }, } #[async_trait] @@ -49,6 +56,7 @@ impl Synchronize for Syncer { match step { SyncStep::Assets(adapter) => Ok(assets::sync(adapter, params, agent).await?), SyncStep::Script(adapter) => Ok(script::sync(adapter, params, stdio).await?), + SyncStep::Call(adapter) => Ok(call::sync(adapter, params, agent).await?), } } } diff --git a/crates/icp/src/manifest/adapter/call.rs b/crates/icp/src/manifest/adapter/call.rs new file mode 100644 index 000000000..d8b28a593 --- /dev/null +++ b/crates/icp/src/manifest/adapter/call.rs @@ -0,0 +1,26 @@ +use std::fmt; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::manifest::canister::ManifestInitArgs; + +/// Configuration for a canister call sync step. +#[derive(Clone, Debug, Deserialize, PartialEq, JsonSchema, Serialize)] +pub struct Adapter { + /// Name of the canister in the current project to call. + pub canister: String, + + /// Name of the canister method to invoke. + pub method: String, + + /// Arguments to pass to the method call. + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option, +} + +impl fmt::Display for Adapter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "(canister: {}, method: {})", self.canister, self.method) + } +} diff --git a/crates/icp/src/manifest/adapter/mod.rs b/crates/icp/src/manifest/adapter/mod.rs index 99c66b732..fbfbe9f7b 100644 --- a/crates/icp/src/manifest/adapter/mod.rs +++ b/crates/icp/src/manifest/adapter/mod.rs @@ -1,3 +1,4 @@ pub mod assets; +pub mod call; pub mod prebuilt; pub mod script; diff --git a/crates/icp/src/manifest/canister.rs b/crates/icp/src/manifest/canister.rs index 98d1aa0a4..9bff46e58 100644 --- a/crates/icp/src/manifest/canister.rs +++ b/crates/icp/src/manifest/canister.rs @@ -317,6 +317,9 @@ pub enum SyncStep { /// Represents syncing of an assets canister Assets(adapter::assets::Adapter), + + /// Performs a canister call as part of the sync process. + Call(adapter::call::Adapter), } impl fmt::Display for SyncStep { @@ -327,6 +330,7 @@ impl fmt::Display for SyncStep { match self { SyncStep::Script(v) => format!("script {v}"), SyncStep::Assets(v) => format!("assets {v}"), + SyncStep::Call(v) => format!("call {v}"), } ) } @@ -348,6 +352,7 @@ mod tests { manifest::{ adapter::{ assets, + call, prebuilt::{self, RemoteSource, SourceField}, script, }, @@ -757,6 +762,81 @@ mod tests { ); } + #[test] + fn call_sync_step() { + assert_eq!( + validate_canister_yaml(indoc! {r#" + name: my-canister + build: + steps: + - type: script + command: dosomething.sh + sync: + steps: + - type: call + canister: other-canister + method: initialize + args: "(42)" + "#}), + CanisterManifest { + name: "my-canister".to_string(), + settings: Settings::default(), + init_args: None, + instructions: Instructions::BuildSync { + build: BuildSteps { + steps: vec![BuildStep::Script(script::Adapter { + command: script::CommandField::Command("dosomething.sh".to_string()), + })] + }, + sync: Some(SyncSteps { + steps: vec![SyncStep::Call(call::Adapter { + canister: "other-canister".to_string(), + method: "initialize".to_string(), + args: Some(ManifestInitArgs::String("(42)".to_string())), + })] + }), + }, + }, + ); + } + + #[test] + fn call_sync_step_no_args() { + assert_eq!( + validate_canister_yaml(indoc! {r#" + name: my-canister + build: + steps: + - type: script + command: dosomething.sh + sync: + steps: + - type: call + canister: other-canister + method: initialize + "#}), + CanisterManifest { + name: "my-canister".to_string(), + settings: Settings::default(), + init_args: None, + instructions: Instructions::BuildSync { + build: BuildSteps { + steps: vec![BuildStep::Script(script::Adapter { + command: script::CommandField::Command("dosomething.sh".to_string()), + })] + }, + sync: Some(SyncSteps { + steps: vec![SyncStep::Call(call::Adapter { + canister: "other-canister".to_string(), + method: "initialize".to_string(), + args: None, + })] + }), + }, + }, + ); + } + #[test] fn manifest_init_args_path() { let ia: ManifestInitArgs = serde_yaml::from_str(indoc! {r#" diff --git a/docs/schemas/canister-yaml-schema.json b/docs/schemas/canister-yaml-schema.json index 441a67c4d..69128e4f5 100644 --- a/docs/schemas/canister-yaml-schema.json +++ b/docs/schemas/canister-yaml-schema.json @@ -89,6 +89,35 @@ ], "type": "object" }, + "Adapter4": { + "description": "Configuration for a canister call sync step.", + "properties": { + "args": { + "anyOf": [ + { + "$ref": "#/$defs/ManifestInitArgs" + }, + { + "type": "null" + } + ], + "description": "Arguments to pass to the method call." + }, + "canister": { + "description": "Name of the canister in the current project to call.", + "type": "string" + }, + "method": { + "description": "Name of the canister method to invoke.", + "type": "string" + } + }, + "required": [ + "canister", + "method" + ], + "type": "object" + }, "ArgsFormat": { "description": "Format specifier for canister call/install args content.", "oneOf": [ @@ -448,6 +477,20 @@ "type" ], "type": "object" + }, + { + "$ref": "#/$defs/Adapter4", + "description": "Performs a canister call as part of the sync process.", + "properties": { + "type": { + "const": "call", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" } ] }, diff --git a/docs/schemas/icp-yaml-schema.json b/docs/schemas/icp-yaml-schema.json index be895ec6e..314e7a5af 100644 --- a/docs/schemas/icp-yaml-schema.json +++ b/docs/schemas/icp-yaml-schema.json @@ -89,6 +89,35 @@ ], "type": "object" }, + "Adapter4": { + "description": "Configuration for a canister call sync step.", + "properties": { + "args": { + "anyOf": [ + { + "$ref": "#/$defs/ManifestInitArgs" + }, + { + "type": "null" + } + ], + "description": "Arguments to pass to the method call." + }, + "canister": { + "description": "Name of the canister in the current project to call.", + "type": "string" + }, + "method": { + "description": "Name of the canister method to invoke.", + "type": "string" + } + }, + "required": [ + "canister", + "method" + ], + "type": "object" + }, "ArgsFormat": { "description": "Format specifier for canister call/install args content.", "oneOf": [ @@ -927,6 +956,20 @@ "type" ], "type": "object" + }, + { + "$ref": "#/$defs/Adapter4", + "description": "Performs a canister call as part of the sync process.", + "properties": { + "type": { + "const": "call", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" } ] },