Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion crates/icp-cli/src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,10 +359,17 @@
// canister is its only controller. Sync steps (e.g. asset uploads to a frontend
// canister) will fail because the user's identity lacks the required permissions.
// The fix is to make a proxy call to the frontend canister's `grant_permission`
// method to permit the user identity to upload assets directly before syncing.

Check warning on line 362 in crates/icp-cli/src/commands/deploy.rs

View workflow job for this annotation

GitHub Actions / fmt:required

Diff in /home/runner/work/icp-cli/icp-cli/crates/icp-cli/src/commands/deploy.rs
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())

Check warning on line 369 in crates/icp-cli/src/commands/deploy.rs

View workflow job for this annotation

GitHub Actions / fmt:required

Diff in /home/runner/work/icp-cli/icp-cli/crates/icp-cli/src/commands/deploy.rs
.unwrap_or_default();

sync_many(ctx.syncer.clone(), agent.clone(), sync_canisters, canister_ids, ctx.debug).await?;
}

// Print URLs for deployed canisters
Expand Down
10 changes: 9 additions & 1 deletion crates/icp-cli/src/commands/sync.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
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::{
Expand Down Expand Up @@ -74,10 +75,17 @@
info!("No canisters have sync steps configured");
return Ok(());
}

Check warning on line 78 in crates/icp-cli/src/commands/sync.rs

View workflow job for this annotation

GitHub Actions / fmt:required

Diff in /home/runner/work/icp-cli/icp-cli/crates/icp-cli/src/commands/sync.rs
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(())
}
3 changes: 3 additions & 0 deletions crates/icp-cli/src/operations/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ pub(crate) async fn create_bundle(
dir: new_dir,
}));
}
SyncStep::Call(adapter) => {
bundle_sync_steps.push(SyncStep::Call(adapter.clone()));
}
}
}

Expand Down
11 changes: 9 additions & 2 deletions crates/icp-cli/src/operations/sync.rs
Original file line number Diff line number Diff line change
@@ -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::{
Expand All @@ -6,7 +10,6 @@ use icp::{
prelude::PathBuf,
};
use snafu::prelude::*;
use std::sync::Arc;
use tracing::error;

use crate::progress::{MultiStepProgressBar, ProgressManager, ProgressManagerSettings};
Expand All @@ -32,6 +35,7 @@ async fn sync_canister(
canister_path: PathBuf,
canister_id: Principal,
canister_info: &Canister,
canister_ids: &HashMap<String, Principal>,
pb: &mut MultiStepProgressBar,
) -> Result<(), SynchronizeError> {
let step_count = canister_info.sync.steps.len();
Expand All @@ -50,6 +54,7 @@ async fn sync_canister(
&Params {
path: canister_path.clone(),
cid: canister_id,
canister_ids: canister_ids.clone(),
},
agent,
Some(tx),
Expand All @@ -70,6 +75,7 @@ pub(crate) async fn sync_many(
syncer: Arc<dyn Synchronize>,
agent: Agent,
canisters: Vec<(Principal, PathBuf, Canister)>,
canister_ids: HashMap<String, Principal>,
debug: bool,
) -> Result<(), SyncOperationError> {
let mut futs = FuturesOrdered::new();
Expand All @@ -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
Expand Down
75 changes: 75 additions & 0 deletions crates/icp-cli/tests/bundle_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
60 changes: 60 additions & 0 deletions crates/icp-cli/tests/deploy_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
112 changes: 112 additions & 0 deletions crates/icp/src/canister/sync/call.rs
Original file line number Diff line number Diff line change
@@ -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,

Check warning on line 25 in crates/icp/src/canister/sync/call.rs

View workflow job for this annotation

GitHub Actions / fmt:required

Diff in /home/runner/work/icp-cli/icp-cli/crates/icp/src/canister/sync/call.rs
},

#[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,
},

Check warning on line 43 in crates/icp/src/canister/sync/call.rs

View workflow job for this annotation

GitHub Actions / fmt:required

Diff in /home/runner/work/icp-cli/icp-cli/crates/icp/src/canister/sync/call.rs
}

pub(super) async fn sync(adapter: &Adapter, params: &Params, agent: &Agent) -> Result<(), CallError> {
let cid = params
.canister_ids
.get(&adapter.canister)
.copied()

Check warning on line 50 in crates/icp/src/canister/sync/call.rs

View workflow job for this annotation

GitHub Actions / fmt:required

Diff in /home/runner/work/icp-cli/icp-cli/crates/icp/src/canister/sync/call.rs
.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, &params.path, &adapter.canister, &adapter.method)?

Check warning on line 55 in crates/icp/src/canister/sync/call.rs

View workflow job for this annotation

GitHub Actions / fmt:required

Diff in /home/runner/work/icp-cli/icp-cli/crates/icp/src/canister/sync/call.rs
.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<InitArgs, CallError> {

Check failure on line 81 in crates/icp/src/canister/sync/call.rs

View workflow job for this annotation

GitHub Actions / lint:required

the `Err`-variant returned from this function is very large
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);

Check warning on line 88 in crates/icp/src/canister/sync/call.rs

View workflow job for this annotation

GitHub Actions / fmt:required

Diff in /home/runner/work/icp-cli/icp-cli/crates/icp/src/canister/sync/call.rs
match format {
ArgsFormat::Bin => {
let bytes = fs::read(&file_path).context(ReadArgsFileSnafu { canister, method })?;
Ok(InitArgs::Binary(bytes))
}
fmt => {
let content =

Check warning on line 95 in crates/icp/src/canister/sync/call.rs

View workflow job for this annotation

GitHub Actions / fmt:required

Diff in /home/runner/work/icp-cli/icp-cli/crates/icp/src/canister/sync/call.rs
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(),
}),
},
}
}
Loading
Loading