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
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

# Unreleased

* feat: Projects can now depend on other `icp` projects vendored into them (e.g. as git submodules) via a top-level `dependencies:` block in `icp.yaml`. Each entry gives a local alias (`name`), a `path` to the dependency's project directory, and an optional `canisters` list selecting which of its canisters to expose. `icp deploy` deploys the whole dependency into the same environment and injects the selected dependency canister IDs into your canisters as `PUBLIC_CANISTER_ID:<name>:<canister>` environment variables. A dependency reached by the same directory through multiple paths is deployed once. Note: `:` is now reserved in canister names as the dependency namespace separator.
* feat: `icp canister delete` will now send the canister's remaining cycles to the caller

# v1.0.2
Expand Down
4 changes: 4 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

24 changes: 18 additions & 6 deletions crates/icp-cli/src/operations/binding_env_vars.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,22 +116,34 @@ pub(crate) async fn set_binding_env_vars_many(
.fail();
}

let binding_vars = canister_list
.iter()
.map(|(n, p)| (format!("PUBLIC_CANISTER_ID:{n}"), p.to_text()))
.collect::<Vec<(_, _)>>();

let mut futs = FuturesOrdered::new();
let progress_manager = ProgressManager::new(ProgressManagerSettings { hidden: debug });

for (cid, info) in target_canisters {
let pb = progress_manager.create_progress_bar(&info.name);
let canister_name = info.name.clone();

// Each canister receives only the ids it is wired to (its own project's
// canisters by their local names, plus any declared dependencies under
// their aliases), resolved to the ids that exist in this environment.
// A project without dependencies wires every canister to every sibling,
// reproducing the previous flat behavior.
let binding_vars: Vec<(String, String)> = info
.bindings
.iter()
.filter_map(|(env_name, referenced_key)| {
canister_list.get(referenced_key).map(|principal| {
(
format!("PUBLIC_CANISTER_ID:{env_name}"),
principal.to_text(),
)
})
})
.collect();

let settings_fn = {
let agent = agent.clone();
let pb = pb.clone();
let binding_vars = binding_vars.clone();

async move {
pb.set_message("Updating environment variables...");
Expand Down
4 changes: 4 additions & 0 deletions crates/icp-cli/src/operations/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,10 @@ pub(crate) async fn create_bundle(

let bundle_manifest = ProjectManifest {
canisters: canister_items,
// A bundle flattens every consolidated canister (including any pulled in
// from dependencies) into `canisters` as pre-built entries, so no external
// dependency references remain.
dependencies: vec![],
networks,
environments,
};
Expand Down
221 changes: 221 additions & 0 deletions crates/icp-cli/tests/dependency_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
use indoc::formatdoc;
use predicates::{prelude::PredicateBooleanExt, str::contains};

use crate::common::{ENVIRONMENT_RANDOM_PORT, NETWORK_RANDOM_PORT, TestContext, clients};
use icp::{fs::write_string, prelude::*};

mod common;

/// Deploying a project that declares a dependency should deploy the whole
/// dependency and wire canister IDs per project scope:
/// - the app's canister sees the exposed dependency canister under its alias,
/// - the dependency's canisters keep their standalone view (own names only).
#[tokio::test]
async fn deploy_with_dependency_injects_namespaced_ids() {
let ctx = TestContext::new();
let project_dir = ctx.create_project_dir("icp");

let wasm = ctx.make_asset("example_icp_mo.wasm");

// A self-contained vendored dependency project with two canisters.
let dep_dir = project_dir.join("vendor/openemail");
std::fs::create_dir_all(&dep_dir).expect("failed to create dependency dir");
let dep_manifest = 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"
"#};
write_string(&dep_dir.join("icp.yaml"), &dep_manifest)
.expect("failed to write dependency manifest");

// The app: one canister plus a dependency exposing only `openemail:backend`.
let pm = formatdoc! {r#"
canisters:
- name: backend
build:
steps:
- type: script
command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH"

dependencies:
- name: openemail
path: ./vendor/openemail
canisters: [backend]

{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(200 * TRILLION);

// Deploy the app and the whole dependency. The dependency canisters are keyed
// by their path relative to the project root.
ctx.icp()
.current_dir(&project_dir)
.args(["deploy", "--environment", "random-environment"])
.assert()
.success()
.stdout(contains("vendor/openemail:backend").and(contains("vendor/openemail:frontend")));

// The app's `backend` sees the exposed dependency canister under its alias.
ctx.icp()
.current_dir(&project_dir)
.args([
"canister",
"settings",
"show",
"backend",
"--environment",
"random-environment",
])
.assert()
.success()
.stdout(contains("PUBLIC_CANISTER_ID:openemail:backend"));

// The dependency's own `backend` keeps its standalone view: it sees `backend`
// (itself) and `frontend`, but not the parent's `openemail:` alias.
ctx.icp()
.current_dir(&project_dir)
.args([
"canister",
"settings",
"show",
"vendor/openemail:backend",
"--environment",
"random-environment",
])
.assert()
.success()
.stdout(
contains("PUBLIC_CANISTER_ID:backend")
.and(contains("PUBLIC_CANISTER_ID:frontend"))
.and(contains("PUBLIC_CANISTER_ID:openemail:backend").not()),
);
}

/// The "umbrella" layout: two independent sub-projects (`service-a`, `service-b`)
/// each depend on the same sibling `openemail` via `../openemail`, and the app
/// depends on both services. Because both edges resolve to the same directory,
/// openemail must be deployed exactly once and shared by both services.
#[tokio::test]
async fn deploy_with_shared_dependency_dedups_to_one_instance() {
let ctx = TestContext::new();
let app = ctx.create_project_dir("icp");
let wasm = ctx.make_asset("example_icp_mo.wasm");

let canister = |name: &str| {
formatdoc! {r#"
canisters:
- name: {name}
build:
steps:
- type: script
command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH"
"#}
};

// umbrella/openemail — the shared service.
let openemail = app.join("umbrella/openemail");
std::fs::create_dir_all(&openemail).expect("failed to create openemail dir");
write_string(&openemail.join("icp.yaml"), &canister("backend"))
.expect("failed to write openemail manifest");

// umbrella/service-a and umbrella/service-b each depend on ../openemail.
for svc in ["service-a", "service-b"] {
let dir = app.join(format!("umbrella/{svc}"));
std::fs::create_dir_all(&dir).expect("failed to create service dir");
let manifest = formatdoc! {r#"
{service}
dependencies:
- name: openemail
path: ../openemail
canisters: [backend]
"#, service = canister("service")};
write_string(&dir.join("icp.yaml"), &manifest).expect("failed to write service manifest");
}

// The app depends on both services.
let pm = formatdoc! {r#"
{frontend}
dependencies:
- name: service-a
path: ./umbrella/service-a
canisters: [service]
- name: service-b
path: ./umbrella/service-b
canisters: [service]

{NETWORK_RANDOM_PORT}
{ENVIRONMENT_RANDOM_PORT}
"#, frontend = canister("frontend")};
write_string(&app.join("icp.yaml"), &pm).expect("failed to write app manifest");

let _g = ctx.start_network_in(&app, "random-network").await;
ctx.ping_until_healthy(&app, "random-network");

clients::icp(&ctx, &app, Some("random-environment".to_string())).mint_cycles(500 * TRILLION);

// Deploy succeeds: the two edges to `umbrella/openemail` collapse to one
// instance. (Without de-dup, importing the same store key twice would error.)
ctx.icp()
.current_dir(&app)
.args(["deploy", "--environment", "random-environment"])
.assert()
.success();

// Capture the single shared openemail canister id.
let assert = ctx
.icp()
.current_dir(&app)
.args([
"canister",
"status",
"--environment",
"random-environment",
"umbrella/openemail:backend",
"--id-only",
])
.assert()
.success();
let openemail_id = String::from_utf8(assert.get_output().stdout.clone())
.expect("canister id should be valid utf-8")
.trim()
.to_string();
assert!(
!openemail_id.is_empty(),
"expected a shared openemail canister id"
);

// Both services' `openemail:backend` binding resolves to the SAME instance.
for svc in ["umbrella/service-a:service", "umbrella/service-b:service"] {
ctx.icp()
.current_dir(&app)
.args([
"canister",
"settings",
"show",
svc,
"--environment",
"random-environment",
])
.assert()
.success()
.stdout(
contains("PUBLIC_CANISTER_ID:openemail:backend")
.and(contains(openemail_id.clone())),
);
}
}
1 change: 1 addition & 0 deletions crates/icp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ num-bigint = { workspace = true }
num-integer = { workspace = true }
num-traits = { workspace = true }
p256 = { workspace = true }
pathdiff = { workspace = true }
pem = { workspace = true }
pkcs8 = { workspace = true }
rand = { workspace = true }
Expand Down
20 changes: 19 additions & 1 deletion crates/icp/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::{collections::HashMap, sync::Arc};
use std::{
collections::{BTreeMap, HashMap},
sync::Arc,
};

use async_trait::async_trait;
use indexmap::IndexMap;
Expand Down Expand Up @@ -105,6 +108,17 @@ pub struct Canister {
/// original recipe specifier string (e.g. `@dfinity/motoko@v4.0.0`).
/// `None` when the canister uses explicit build/sync instructions.
pub registry_recipe: Option<String>,

/// Canister-discovery wiring. Maps the name this canister reads in a
/// `PUBLIC_CANISTER_ID:<name>` environment variable to the store key of the
/// referenced canister. Computed during consolidation so each canister sees
/// the view its owning project expects: its own project's canisters under
/// their local names, plus any declared dependencies under their aliases
/// (`<alias>:<canister>`). For a project with no dependencies this maps every
/// canister's local name to itself, reproducing the flat "every canister sees
/// every sibling" behavior.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub bindings: BTreeMap<String, String>,
}

#[derive(Clone, Debug, PartialEq, Serialize)]
Expand Down Expand Up @@ -292,6 +306,7 @@ impl MockProjectLoader {
sync: SyncSteps::default(),
init_args: None,
registry_recipe: None,
bindings: BTreeMap::new(),
};

let local_network = Network {
Expand Down Expand Up @@ -375,6 +390,7 @@ impl MockProjectLoader {
sync: SyncSteps::default(),
init_args: None,
registry_recipe: None,
bindings: BTreeMap::new(),
};

let frontend_canister = Canister {
Expand All @@ -391,6 +407,7 @@ impl MockProjectLoader {
sync: SyncSteps::default(),
init_args: None,
registry_recipe: None,
bindings: BTreeMap::new(),
};

let database_canister = Canister {
Expand All @@ -407,6 +424,7 @@ impl MockProjectLoader {
sync: SyncSteps::default(),
init_args: None,
registry_recipe: None,
bindings: BTreeMap::new(),
};

// Create networks
Expand Down
Loading
Loading