diff --git a/CHANGELOG.md b/CHANGELOG.md index da294325d..609a13a91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Unreleased +* feat: `script` sync steps now receive `ICP_CLI_ENVIRONMENT`, `ICP_CLI_NETWORK`, `ICP_CLI_CID` (the current canister's principal), and `ICP_CLI_CID_` (every canister's principal) as environment variables. * fix: `icp canister call` with both `--json` and `-o hex` no longer prints both kinds of output at once. * fix: `icp` no longer picks up a stale inherited `$PWD` when launched as a subprocess via `chdir(2)` + `execve` (e.g. from a test harness). The logical `$PWD` path is now validated against `getcwd()` by inode before use, preserving symlink-aware project root discovery while ignoring stale values. diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index e20ccdb57..611fa3870 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -11,6 +11,7 @@ use icp::{ }; use icp_canister_interfaces::candid_ui::MAINNET_CANDID_UI_CID; use serde::Serialize; +use std::collections::BTreeMap; use tracing::info; use crate::{ @@ -362,12 +363,20 @@ 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:"); + let canister_ids: BTreeMap = ctx + .ids_by_environment(&environment_selection) + .await? + .into_iter() + .collect(); + let pkg_cache = ctx.dirs.package_cache()?; sync_many( ctx.syncer.clone(), agent.clone(), sync_canisters, environment_selection.name().to_owned(), + env.network.name.clone(), + canister_ids, args.proxy, ctx.debug, &pkg_cache, diff --git a/crates/icp-cli/src/commands/sync.rs b/crates/icp-cli/src/commands/sync.rs index 5f3c9fdd5..ee4360e74 100644 --- a/crates/icp-cli/src/commands/sync.rs +++ b/crates/icp-cli/src/commands/sync.rs @@ -3,6 +3,7 @@ use clap::Args; use futures::future::try_join_all; use icp::context::{CanisterSelection, Context, EnvironmentSelection}; use icp::identity::IdentitySelection; +use std::collections::BTreeMap; use tracing::info; use crate::{ @@ -82,12 +83,20 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E info!("Syncing canisters:"); + let canister_ids: BTreeMap = ctx + .ids_by_environment(&environment_selection) + .await? + .into_iter() + .collect(); + let pkg_cache = ctx.dirs.package_cache()?; sync_many( ctx.syncer.clone(), agent, sync_canisters, environment_selection.name().to_owned(), + env.network.name.clone(), + canister_ids, args.proxy, ctx.debug, &pkg_cache, diff --git a/crates/icp-cli/src/operations/sync.rs b/crates/icp-cli/src/operations/sync.rs index e0f6d4227..9431f7939 100644 --- a/crates/icp-cli/src/operations/sync.rs +++ b/crates/icp-cli/src/operations/sync.rs @@ -8,6 +8,7 @@ use icp::{ prelude::PathBuf, }; use snafu::prelude::*; +use std::collections::BTreeMap; use std::sync::Arc; use tracing::error; @@ -35,6 +36,8 @@ async fn sync_canister( canister_id: Principal, canister_info: &Canister, environment: &str, + network: &str, + canister_ids: &BTreeMap, proxy: Option, pb: &mut MultiStepProgressBar, pkg_cache: &PackageCache, @@ -56,6 +59,8 @@ async fn sync_canister( path: canister_path.clone(), cid: canister_id, environment: environment.to_owned(), + network: network.to_owned(), + canister_ids: canister_ids.clone(), proxy, }, agent, @@ -79,6 +84,8 @@ pub(crate) async fn sync_many( agent: Agent, canisters: Vec<(Principal, PathBuf, Canister)>, environment: String, + network: String, + canister_ids: BTreeMap, proxy: Option, debug: bool, pkg_cache: &PackageCache, @@ -93,6 +100,8 @@ pub(crate) async fn sync_many( let agent = agent.clone(); let syncer = syncer.clone(); let environment = environment.clone(); + let network = network.clone(); + let canister_ids = canister_ids.clone(); async move { // Define the sync logic @@ -103,6 +112,8 @@ pub(crate) async fn sync_many( cid, &canister_info, &environment, + &network, + &canister_ids, proxy, &mut pb, pkg_cache, diff --git a/crates/icp-cli/tests/deploy_tests.rs b/crates/icp-cli/tests/deploy_tests.rs index 7f8affd08..6ac9e3ba0 100644 --- a/crates/icp-cli/tests/deploy_tests.rs +++ b/crates/icp-cli/tests/deploy_tests.rs @@ -13,6 +13,7 @@ use crate::common::{ use icp::{ fs::{create_dir_all, write_string}, prelude::*, + store_id::IdMapping, }; mod common; @@ -1038,3 +1039,89 @@ async fn deploy_through_proxy() { .success() .stdout(contains("Status: Running").and(contains(&proxy_cid))); } + +#[tokio::test] +async fn deploy_sync_script_icp_env_vars() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + // canister-a verifies env/network vars during deploy; canister-b verifies cross-canister + // CID visibility during the explicit sync step. + let pm = formatdoc! {r#" + canisters: + - name: canister-a + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + sync: + steps: + - type: script + command: echo "ENV=$ICP_CLI_ENVIRONMENT NET=$ICP_CLI_NETWORK CID=$ICP_CLI_CID B_CID=$ICP_CLI_CID_CANISTER_B" + - name: canister-b + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + sync: + steps: + - type: script + command: echo "B_SEES_A=$ICP_CLI_CID_CANISTER_A" + + {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); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "--debug", + "deploy", + "--subnet", + common::SUBNET_ID, + "--environment", + "random-environment", + ]) + .assert() + .success() + .stderr(contains("ENV=random-environment")) + .stderr(contains("NET=random-network")); + + // Read the assigned canister IDs and verify CID vars and cross-canister visibility. + let id_mapping: IdMapping = icp::fs::json::load( + &project_dir + .join(".icp") + .join("cache") + .join("mappings") + .join("random-environment.ids.json"), + ) + .expect("failed to read ID mapping"); + + let cid_a = id_mapping + .get("canister-a") + .expect("canister-a ID not found") + .to_text(); + + let cid_b = id_mapping + .get("canister-b") + .expect("canister-b ID not found") + .to_text(); + + ctx.icp() + .current_dir(&project_dir) + .args(["--debug", "sync", "--environment", "random-environment"]) + .assert() + .success() + .stderr(contains(format!("CID={cid_a}"))) + .stderr(contains(format!("B_CID={cid_b}"))) + .stderr(contains(format!("B_SEES_A={cid_a}"))); +} diff --git a/crates/icp-cli/tests/sync_tests.rs b/crates/icp-cli/tests/sync_tests.rs index 69beff427..4d0c9efbb 100644 --- a/crates/icp-cli/tests/sync_tests.rs +++ b/crates/icp-cli/tests/sync_tests.rs @@ -540,6 +540,89 @@ async fn sync_plugin_registers_seed_data() { ); } +#[tokio::test] +async fn sync_script_icp_env_vars() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + // canister-a verifies all four env vars; canister-b verifies cross-canister CID visibility. + let pm = formatdoc! {r#" + canisters: + - name: canister-a + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + sync: + steps: + - type: script + command: echo "ENV=$ICP_CLI_ENVIRONMENT NET=$ICP_CLI_NETWORK CID=$ICP_CLI_CID B_CID=$ICP_CLI_CID_CANISTER_B" + - name: canister-b + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + sync: + steps: + - type: script + command: echo "B_SEES_A=$ICP_CLI_CID_CANISTER_A" + + {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); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--subnet", + common::SUBNET_ID, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + let id_mapping: IdMapping = icp::fs::json::load( + &project_dir + .join(".icp") + .join("cache") + .join("mappings") + .join("random-environment.ids.json"), + ) + .expect("failed to read ID mapping"); + + let cid_a = id_mapping + .get("canister-a") + .expect("canister-a ID not found") + .to_text(); + + let cid_b = id_mapping + .get("canister-b") + .expect("canister-b ID not found") + .to_text(); + + ctx.icp() + .current_dir(&project_dir) + .args(["--debug", "sync", "--environment", "random-environment"]) + .assert() + .success() + .stderr(contains("ENV=random-environment")) + .stderr(contains("NET=random-network")) + .stderr(contains(format!("CID={cid_a}"))) + .stderr(contains(format!("B_CID={cid_b}"))) + .stderr(contains(format!("B_SEES_A={cid_a}"))); +} + #[tokio::test] async fn sync_plugin_routes_through_proxy() { let ctx = TestContext::new(); diff --git a/crates/icp/src/canister/sync/mod.rs b/crates/icp/src/canister/sync/mod.rs index 10bb53fb6..480ff2bdb 100644 --- a/crates/icp/src/canister/sync/mod.rs +++ b/crates/icp/src/canister/sync/mod.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use async_trait::async_trait; use candid::Principal; use ic_agent::Agent; @@ -18,6 +20,10 @@ pub struct Params { /// Name of the environment being synced (e.g. "local", "production"). /// Passed to sync plugin steps via `SyncExecInput`. pub environment: String, + /// Name of the network (e.g. "local", "ic"). + pub network: String, + /// IDs of all named canisters in the project for this environment. + pub canister_ids: BTreeMap, /// Proxy canister to route calls through, if `--proxy` was passed. pub proxy: Option, } diff --git a/crates/icp/src/canister/sync/script.rs b/crates/icp/src/canister/sync/script.rs index 89fdfc73d..1043e8777 100644 --- a/crates/icp/src/canister/sync/script.rs +++ b/crates/icp/src/canister/sync/script.rs @@ -11,5 +11,21 @@ pub(super) async fn sync( params: &Params, stdio: Option>, ) -> Result<(), ScriptError> { - execute(adapter, params.path.as_ref(), &[], stdio).await + let mut envs: Vec<(String, String)> = vec![ + ("ICP_CLI_ENVIRONMENT".to_owned(), params.environment.clone()), + ("ICP_CLI_NETWORK".to_owned(), params.network.clone()), + ("ICP_CLI_CID".to_owned(), params.cid.to_text()), + ]; + for (name, id) in ¶ms.canister_ids { + let key = format!( + "ICP_CLI_CID_{}", + name.to_uppercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) + .collect::() + ); + envs.push((key, id.to_text())); + } + let env_refs: Vec<(&str, &str)> = envs.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + execute(adapter, params.path.as_ref(), &env_refs, stdio).await } diff --git a/docs/concepts/build-deploy-sync.md b/docs/concepts/build-deploy-sync.md index 0b6b4868b..468d35a81 100644 --- a/docs/concepts/build-deploy-sync.md +++ b/docs/concepts/build-deploy-sync.md @@ -124,6 +124,29 @@ sync: dir: dist ``` +### Script Sync Steps + +You can also run arbitrary shell commands in sync steps: + +```yaml +sync: + steps: + - type: script + commands: + - my-tool upload --canister "$ICP_CLI_CID" --env "$ICP_CLI_ENVIRONMENT" +``` + +### Environment Variables + +Script sync steps have access to: + +- `ICP_CLI_ENVIRONMENT` — The current environment name (e.g. `local`, `staging`) +- `ICP_CLI_NETWORK` — The current network name (e.g. `local`, `ic`) +- `ICP_CLI_CID` — The canister ID of the canister being synced +- `ICP_CLI_CID_` — The canister ID of every canister with a registered ID in the current environment (name uppercased, non-alphanumeric characters replaced with `_`) + +See [Environment Variables Reference](../reference/environment-variables.md#sync-script-variables) for full details. + ### When Sync Runs - Automatically after `icp deploy` diff --git a/docs/guides/creating-recipes.md b/docs/guides/creating-recipes.md index 6e3b63f14..d3a45e6a1 100644 --- a/docs/guides/creating-recipes.md +++ b/docs/guides/creating-recipes.md @@ -146,6 +146,23 @@ canisters: Use `{{#if}}` with `{{else}}` for defaults, refer to the examples above. +## Environment Variables + +Recipe scripts have access to runtime environment variables set by icp-cli. + +**Build script steps** receive: + +- `ICP_WASM_OUTPUT_PATH` — Where to write the compiled WASM file + +**Sync script steps** receive: + +- `ICP_CLI_ENVIRONMENT` — The current environment name (e.g. `local`, `staging`) +- `ICP_CLI_NETWORK` — The current network name (e.g. `local`, `ic`) +- `ICP_CLI_CID` — The canister ID of the canister being synced +- `ICP_CLI_CID_` — The canister ID of every canister with a registered ID in the current environment + +See [Environment Variables Reference](../reference/environment-variables.md) for full details. + ## Testing Recipes Test your recipe by viewing the expanded configuration: diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index e772d9d39..0085b17b0 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -27,6 +27,45 @@ build: The script also runs with the **canister directory as the current working directory**, so relative paths in your build commands resolve from there. +## Sync Script Variables + +During `script` sync steps, icp-cli sets the following environment variables: + +### `ICP_CLI_ENVIRONMENT` + +The name of the current environment (e.g. `local`, `staging`, `production`). + +### `ICP_CLI_NETWORK` + +The name of the current network (e.g. `local`, `ic`). + +### `ICP_CLI_CID` + +The canister ID (principal) of the canister being synced. + +### `ICP_CLI_CID_` + +The canister ID (principal) of every canister with a registered ID in the current environment, one variable per canister. `` is the canister name uppercased with any non-alphanumeric character replaced by `_`. + +For example, a project with canisters `backend` and `my-frontend` produces: + +``` +ICP_CLI_CID_BACKEND=bkyz2-fmaaa-aaaaa-qaaaq-cai +ICP_CLI_CID_MY_FRONTEND=bd3sg-teaaa-aaaaa-qaaba-cai +``` + +**Example:** +```yaml +sync: + steps: + - type: script + commands: + - echo "Syncing canister $ICP_CLI_CID on $ICP_CLI_NETWORK" + - my-tool upload --canister "$ICP_CLI_CID" --backend "$ICP_CLI_CID_BACKEND" +``` + +Like build scripts, sync scripts run with the **canister directory as the current working directory**. + ## CLI Configuration Variables ### `ICP_ENVIRONMENT`