diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c5b1821..9aa6e538 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ bump. Currently experimental: project bundling, project dependencies # Unreleased +* feat: `icp canister link` assigns an existing canister principal to a project canister * feat: `icp canister create --with-icp` (not supported in `icp deploy`) uses the CMC to create canisters. Only needed for deploying to restricted system subnets. * feat: `icp deploy --no-create` will error if any canisters do not exist, rather than creating them. diff --git a/crates/icp-cli/src/commands/canister/link.rs b/crates/icp-cli/src/commands/canister/link.rs new file mode 100644 index 00000000..d7823785 --- /dev/null +++ b/crates/icp-cli/src/commands/canister/link.rs @@ -0,0 +1,66 @@ +use anyhow::bail; +use candid::Principal; +use clap::Args; +use icp::context::{Context, EnvironmentSelection}; +use tracing::info; + +use crate::options::EnvironmentOpt; + +/// Link an existing canister to the project by recording its ID in the canister ID store. +/// +/// This associates an already-deployed canister with a name declared in the target +/// environment, without creating a new canister. It is the inverse of the record that +/// `icp canister create` writes automatically. +#[derive(Debug, Args)] +pub(crate) struct LinkArgs { + /// Name of the project canister to associate the ID with. + /// Must be declared in the target environment. + pub(crate) name: String, + + /// Principal of the existing canister to link. + pub(crate) principal: Principal, + + #[command(flatten)] + pub(crate) environment: EnvironmentOpt, + + /// Overwrite an ID already recorded for this canister. + #[arg(long)] + pub(crate) force: bool, +} + +pub(crate) async fn exec(ctx: &Context, args: &LinkArgs) -> Result<(), anyhow::Error> { + let environment: EnvironmentSelection = args.environment.clone().into(); + + // A principal must map to at most one canister within an environment; linking an + // ID already claimed by another canister would create an ambiguous mapping. + let existing = ctx.ids_by_environment(&environment).await?; + if let Some((owner, _)) = existing + .iter() + .find(|(name, id)| **id == args.principal && *name != &args.name) + { + bail!( + "canister ID {} is already linked to '{owner}' in environment '{}'", + args.principal, + environment.name() + ); + } + + // Replacing an existing entry requires clearing it first; the id store refuses + // to register a name that is already mapped. + if args.force { + ctx.remove_canister_id_for_env(&args.name, &environment) + .await?; + } + + ctx.set_canister_id_for_env(&args.name, args.principal, &environment) + .await?; + + info!( + "Linked canister '{}' to ID {} in environment '{}'", + args.name, + args.principal, + environment.name() + ); + + Ok(()) +} diff --git a/crates/icp-cli/src/commands/canister/mod.rs b/crates/icp-cli/src/commands/canister/mod.rs index e53f11e9..191d7d5c 100644 --- a/crates/icp-cli/src/commands/canister/mod.rs +++ b/crates/icp-cli/src/commands/canister/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod call; pub(crate) mod create; pub(crate) mod delete; pub(crate) mod install; +pub(crate) mod link; pub(crate) mod list; pub(crate) mod logs; pub(crate) mod metadata; @@ -23,6 +24,7 @@ pub(crate) enum Command { Create(create::CreateArgs), Delete(delete::DeleteArgs), Install(install::InstallArgs), + Link(link::LinkArgs), List(list::ListArgs), Logs(logs::LogsArgs), Metadata(metadata::MetadataArgs), diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index 2fe92e54..ce26a9ca 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -228,6 +228,10 @@ async fn dispatch(ctx: &icp::context::Context, command: Command) -> Result<(), E commands::canister::install::exec(ctx, &args).await? } + commands::canister::Command::Link(args) => { + commands::canister::link::exec(ctx, &args).await? + } + commands::canister::Command::List(args) => { commands::canister::list::exec(ctx, &args).await? } diff --git a/crates/icp-cli/tests/canister_link_tests.rs b/crates/icp-cli/tests/canister_link_tests.rs new file mode 100644 index 00000000..f19660fb --- /dev/null +++ b/crates/icp-cli/tests/canister_link_tests.rs @@ -0,0 +1,158 @@ +use indoc::formatdoc; +use predicates::str::contains; + +use crate::common::{ENVIRONMENT_RANDOM_PORT, NETWORK_RANDOM_PORT, TestContext}; +use icp::{fs::write_string, prelude::*}; + +mod common; + +/// A well-formed canister principal used as the link target. `link` never contacts a +/// network, so the canister does not need to actually exist. +const LINKED_ID: &str = "rrkah-fqaaa-aaaaa-aaaaq-cai"; + +fn write_manifest(project_dir: &Path) { + let pm = formatdoc! {r#" + canisters: + - name: my-canister + build: + steps: + - type: script + command: echo hi + - name: other-canister + build: + steps: + - type: script + command: echo hi + + {NETWORK_RANDOM_PORT} + {ENVIRONMENT_RANDOM_PORT} + "#}; + + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); +} + +fn mapping_path(project_dir: &Path) -> PathBuf { + project_dir + .join(".icp") + .join("cache") + .join("mappings") + .join("random-environment.ids.json") +} + +#[tokio::test] +async fn canister_link_records_id() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + write_manifest(&project_dir); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "link", + "my-canister", + LINKED_ID, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + let path = mapping_path(&project_dir); + assert!(path.exists(), "ID mapping file should exist at {path}"); + + let mapping = icp::fs::read_to_string(&path).expect("failed to read mapping file"); + assert!( + mapping.contains(LINKED_ID), + "mapping should contain the linked ID, got: {mapping}" + ); +} + +#[tokio::test] +async fn canister_link_unknown_name_fails() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + write_manifest(&project_dir); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "link", + "not-a-canister", + LINKED_ID, + "--environment", + "random-environment", + ]) + .assert() + .failure(); +} + +#[tokio::test] +async fn canister_link_duplicate_principal_fails() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + write_manifest(&project_dir); + + let link = |name: &str| { + let mut cmd = ctx.icp(); + cmd.current_dir(&project_dir).args([ + "canister", + "link", + name, + LINKED_ID, + "--environment", + "random-environment", + ]); + cmd + }; + + link("my-canister").assert().success(); + + // The same principal cannot be linked to a second canister. + link("other-canister") + .assert() + .failure() + .stderr(contains("already linked to 'my-canister'")); +} + +#[tokio::test] +async fn canister_link_existing_requires_force() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + write_manifest(&project_dir); + + let link = |id: &str, force: bool| { + let mut cmd = ctx.icp(); + cmd.current_dir(&project_dir).args([ + "canister", + "link", + "my-canister", + id, + "--environment", + "random-environment", + ]); + if force { + cmd.arg("--force"); + } + cmd + }; + + link(LINKED_ID, false).assert().success(); + + // A second link to a different ID must fail without --force. + let other_id = "ryjl3-tyaaa-aaaaa-aaaba-cai"; + link(other_id, false) + .assert() + .failure() + .stderr(contains("already registered")); + + // With --force it overwrites the recorded ID. + link(other_id, true).assert().success(); + + let mapping = icp::fs::read_to_string(&mapping_path(&project_dir)).expect("read mapping"); + assert!( + mapping.contains(other_id) && !mapping.contains(LINKED_ID), + "mapping should hold the forced ID only, got: {mapping}" + ); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c5d6966e..8fd377ce 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -14,6 +14,7 @@ This document contains the help content for the `icp` command-line program. * [`icp canister create`↴](#icp-canister-create) * [`icp canister delete`↴](#icp-canister-delete) * [`icp canister install`↴](#icp-canister-install) +* [`icp canister link`↴](#icp-canister-link) * [`icp canister list`↴](#icp-canister-list) * [`icp canister logs`↴](#icp-canister-logs) * [`icp canister metadata`↴](#icp-canister-metadata) @@ -137,6 +138,7 @@ Perform canister operations against a network * `create` — Create a canister on a network * `delete` — Delete a canister from a network * `install` — Install a built WASM to a canister on a network +* `link` — Link an existing canister to the project by recording its ID in the canister ID store * `list` — List the canisters in an environment * `logs` — Fetch and display canister logs * `metadata` — Read a metadata section from a canister @@ -342,6 +344,26 @@ Install a built WASM to a canister on a network +## `icp canister link` + +Link an existing canister to the project by recording its ID in the canister ID store. + +This associates an already-deployed canister with a name declared in the target environment, without creating a new canister. It is the inverse of the record that `icp canister create` writes automatically. + +**Usage:** `icp canister link [OPTIONS] ` + +###### **Arguments:** + +* `` — Name of the project canister to associate the ID with. Must be declared in the target environment +* `` — Principal of the existing canister to link + +###### **Options:** + +* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--force` — Overwrite an ID already recorded for this canister + + + ## `icp canister list` List the canisters in an environment.