Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
66 changes: 66 additions & 0 deletions crates/icp-cli/src/commands/canister/link.rs
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
adamspofford-dfinity marked this conversation as resolved.
#[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?;
Comment thread
adamspofford-dfinity marked this conversation as resolved.
}

ctx.set_canister_id_for_env(&args.name, args.principal, &environment)
.await?;
Comment thread
adamspofford-dfinity marked this conversation as resolved.

info!(
"Linked canister '{}' to ID {} in environment '{}'",
args.name,
args.principal,
environment.name()
);

Ok(())
}
2 changes: 2 additions & 0 deletions crates/icp-cli/src/commands/canister/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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),
Expand Down
4 changes: 4 additions & 0 deletions crates/icp-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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?
}
Expand Down
158 changes: 158 additions & 0 deletions crates/icp-cli/tests/canister_link_tests.rs
Original file line number Diff line number Diff line change
@@ -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}"
);
}
22 changes: 22 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Comment thread
adamspofford-dfinity marked this conversation as resolved.

**Usage:** `icp canister link [OPTIONS] <NAME> <PRINCIPAL>`

###### **Arguments:**

* `<NAME>` — Name of the project canister to associate the ID with. Must be declared in the target environment
* `<PRINCIPAL>` — Principal of the existing canister to link

###### **Options:**

* `-e`, `--environment <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.
Expand Down
Loading