From f05b5a5b2213963680050b7eea477565341208a2 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 8 Jul 2026 21:46:38 -0400 Subject: [PATCH 1/8] feat(manifest): add dependencies block to project manifest Introduce a top-level `dependencies:` block in icp.yaml for declaring another icp project this project depends on. Each entry has a local alias (`name`), a `path` to the dependency's project dir, and an optional `canisters` exposure subset (omit = expose all). This commit adds only the manifest type + schema; consolidation and deploy behavior follow in later commits. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/icp-cli/src/operations/bundle.rs | 4 + crates/icp/src/manifest/dependency.rs | 170 ++++++++++++++++++++++++ crates/icp/src/manifest/mod.rs | 2 + crates/icp/src/manifest/project.rs | 20 ++- docs/schemas/icp-yaml-schema.json | 54 +++++++- 5 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 crates/icp/src/manifest/dependency.rs diff --git a/crates/icp-cli/src/operations/bundle.rs b/crates/icp-cli/src/operations/bundle.rs index c725ec8d..565949f3 100644 --- a/crates/icp-cli/src/operations/bundle.rs +++ b/crates/icp-cli/src/operations/bundle.rs @@ -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, }; diff --git a/crates/icp/src/manifest/dependency.rs b/crates/icp/src/manifest/dependency.rs new file mode 100644 index 00000000..f0928925 --- /dev/null +++ b/crates/icp/src/manifest/dependency.rs @@ -0,0 +1,170 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; + +use super::environment::CanisterSelection; + +/// Raw form of a dependency entry as written in `icp.yaml`. +/// +/// `canisters` follows the same convention as an environment's canister +/// selection: omitted means "all", an empty list means "none". +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct DependencyInner { + pub name: String, + pub path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canisters: Option>, +} + +/// Declares a dependency on another `icp` project vendored into this one +/// (typically as a git submodule). +/// +/// Running `icp deploy` deploys the dependency's canisters into the parent's +/// environment and injects the *selected* dependency canisters' IDs into the +/// parent's canisters as `PUBLIC_CANISTER_ID::` environment +/// variables. +#[derive(Clone, Debug, PartialEq, JsonSchema)] +pub struct DependencyManifest { + /// Local alias for the dependency project. Namespaces the dependency's + /// canister IDs when they are exposed to the parent's canisters as + /// `PUBLIC_CANISTER_ID::` environment variables. Must be + /// unique among dependencies, must not collide with a local canister name, + /// and must not contain `:`. + pub name: String, + + /// Path to the directory containing the dependency's `icp.yaml`, resolved + /// relative to this manifest's project directory. + pub path: String, + + /// Which of the dependency's canisters to **expose** to the parent as + /// `PUBLIC_CANISTER_ID` environment variables. Defaults to all. This is an + /// exposure filter only: the whole dependency is always deployed, because a + /// dependency's canisters may call each other. + #[schemars(with = "Option>")] + pub canisters: CanisterSelection, +} + +impl From for DependencyManifest { + fn from(v: DependencyInner) -> Self { + let DependencyInner { + name, + path, + canisters, + } = v; + + let canisters = match canisters { + Some(cs) => match cs.is_empty() { + true => CanisterSelection::None, + false => CanisterSelection::Named(cs), + }, + None => CanisterSelection::Everything, + }; + + Self { + name, + path, + canisters, + } + } +} + +impl<'de> Deserialize<'de> for DependencyManifest { + fn deserialize>(d: D) -> Result { + let inner: DependencyInner = Deserialize::deserialize(d)?; + Ok(inner.into()) + } +} + +impl From<&DependencyManifest> for DependencyInner { + fn from(dep: &DependencyManifest) -> Self { + let canisters = match &dep.canisters { + CanisterSelection::Everything => None, + CanisterSelection::Named(names) => Some(names.clone()), + CanisterSelection::None => Some(vec![]), + }; + + DependencyInner { + name: dep.name.clone(), + path: dep.path.clone(), + canisters, + } + } +} + +impl Serialize for DependencyManifest { + fn serialize(&self, serializer: S) -> Result { + DependencyInner::from(self).serialize(serializer) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn expose_all_when_canisters_omitted() { + assert_eq!( + serde_yaml::from_str::( + r#" + name: openemail + path: ./vendor/openemail + "# + ) + .expect("failed to deserialize DependencyManifest"), + DependencyManifest { + name: "openemail".to_string(), + path: "./vendor/openemail".to_string(), + canisters: CanisterSelection::Everything, + }, + ); + } + + #[test] + fn named_subset() { + assert_eq!( + serde_yaml::from_str::( + r#" + name: openemail + path: ./vendor/openemail + canisters: [backend] + "# + ) + .expect("failed to deserialize DependencyManifest"), + DependencyManifest { + name: "openemail".to_string(), + path: "./vendor/openemail".to_string(), + canisters: CanisterSelection::Named(vec!["backend".to_string()]), + }, + ); + } + + #[test] + fn empty_list_exposes_none() { + assert_eq!( + serde_yaml::from_str::( + r#" + name: openemail + path: ./vendor/openemail + canisters: [] + "# + ) + .expect("failed to deserialize DependencyManifest") + .canisters, + CanisterSelection::None, + ); + } + + #[test] + fn unknown_field_rejected() { + assert!( + serde_yaml::from_str::( + r#" + name: openemail + path: ./vendor/openemail + bogus: true + "# + ) + .is_err() + ); + } +} diff --git a/crates/icp/src/manifest/mod.rs b/crates/icp/src/manifest/mod.rs index fc2fa202..fbbd7fa1 100644 --- a/crates/icp/src/manifest/mod.rs +++ b/crates/icp/src/manifest/mod.rs @@ -9,6 +9,7 @@ use crate::prelude::*; pub(crate) mod adapter; pub(crate) mod canister; +pub(crate) mod dependency; pub(crate) mod environment; pub(crate) mod network; pub(crate) mod project; @@ -22,6 +23,7 @@ pub use { ArgsFormat, BuildStep, BuildSteps, CanisterManifest, Instructions, ManifestInitArgs, SyncStep, SyncSteps, }, + dependency::DependencyManifest, environment::EnvironmentManifest, network::{ManagedMode, Mode, NetworkManifest}, project::ProjectManifest, diff --git a/crates/icp/src/manifest/project.rs b/crates/icp/src/manifest/project.rs index 310b57f9..c08c5274 100644 --- a/crates/icp/src/manifest/project.rs +++ b/crates/icp/src/manifest/project.rs @@ -2,7 +2,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::manifest::{ - Item, canister::CanisterManifest, environment::EnvironmentManifest, network::NetworkManifest, + Item, canister::CanisterManifest, dependency::DependencyManifest, + environment::EnvironmentManifest, network::NetworkManifest, }; #[derive(Debug, PartialEq, JsonSchema, Deserialize, Serialize)] @@ -11,6 +12,13 @@ pub struct ProjectManifest { #[serde(default)] pub canisters: Vec>, + /// Other `icp` projects this project depends on. Their canisters are + /// deployed into this project's environment and their IDs are injected into + /// this project's canisters as `PUBLIC_CANISTER_ID::` + /// environment variables. + #[serde(default)] + pub dependencies: Vec>, + #[serde(default)] pub networks: Vec>, @@ -111,6 +119,7 @@ mod tests { assert_eq!( serde_yaml::from_str::(r#""#).unwrap(), ProjectManifest { + dependencies: vec![], canisters: vec![], networks: vec![], environments: vec![], @@ -130,6 +139,7 @@ mod tests { command: dosomething.sh "#}), ProjectManifest { + dependencies: vec![], canisters: vec![Item::Manifest(CanisterManifest { name: "my-canister".to_string(), settings: Settings::default(), @@ -211,6 +221,7 @@ mod tests { command: dosomething.sh "#}), ProjectManifest { + dependencies: vec![], canisters: vec![Item::Manifest(CanisterManifest { name: "my-canister".to_string(), settings: Settings::default(), @@ -245,6 +256,7 @@ mod tests { - canisters/* "#}), ProjectManifest { + dependencies: vec![], canisters: vec![ Item::Manifest(CanisterManifest { name: "my-canister".to_string(), @@ -278,6 +290,7 @@ mod tests { mode: managed "#}), ProjectManifest { + dependencies: vec![], canisters: vec![], networks: vec![Item::Manifest(NetworkManifest { name: "my-network".to_string(), @@ -309,6 +322,7 @@ mod tests { canisters: [my-canister] "#}), ProjectManifest { + dependencies: vec![], canisters: vec![], networks: vec![], environments: vec![Item::Manifest(EnvironmentManifest { @@ -332,6 +346,7 @@ mod tests { canisters: [my-canister] "#}), ProjectManifest { + dependencies: vec![], canisters: vec![], networks: vec![], environments: vec![Item::Manifest(EnvironmentManifest { @@ -357,6 +372,7 @@ mod tests { - name: environment-3 "#}), ProjectManifest { + dependencies: vec![], canisters: vec![], networks: vec![], environments: vec![ @@ -399,6 +415,7 @@ mod tests { compute_allocation: 2 "#}), ProjectManifest { + dependencies: vec![], canisters: vec![], networks: vec![], environments: vec![Item::Manifest(EnvironmentManifest { @@ -440,6 +457,7 @@ mod tests { format: hex "#}), ProjectManifest { + dependencies: vec![], canisters: vec![], networks: vec![], environments: vec![Item::Manifest(EnvironmentManifest { diff --git a/docs/schemas/icp-yaml-schema.json b/docs/schemas/icp-yaml-schema.json index 34697af4..039746ea 100644 --- a/docs/schemas/icp-yaml-schema.json +++ b/docs/schemas/icp-yaml-schema.json @@ -300,6 +300,34 @@ ], "description": "An amount of cycles.\n\nDeserializes from a number or a string with suffixes (k, m, b, t) and optional underscore separators." }, + "DependencyManifest": { + "description": "Declares a dependency on another `icp` project vendored into this one\n(typically as a git submodule).\n\nRunning `icp deploy` deploys the dependency's canisters into the parent's\nenvironment and injects the *selected* dependency canisters' IDs into the\nparent's canisters as `PUBLIC_CANISTER_ID::` environment\nvariables.", + "properties": { + "canisters": { + "description": "Which of the dependency's canisters to **expose** to the parent as\n`PUBLIC_CANISTER_ID` environment variables. Defaults to all. This is an\nexposure filter only: the whole dependency is always deployed, because a\ndependency's canisters may call each other.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "name": { + "description": "Local alias for the dependency project. Namespaces the dependency's\ncanister IDs when they are exposed to the parent's canisters as\n`PUBLIC_CANISTER_ID::` environment variables. Must be\nunique among dependencies, must not collide with a local canister name,\nand must not contain `:`.", + "type": "string" + }, + "path": { + "description": "Path to the directory containing the dependency's `icp.yaml`, resolved\nrelative to this manifest's project directory.", + "type": "string" + } + }, + "required": [ + "name", + "path" + ], + "type": "object" + }, "DurationAmount": { "anyOf": [ { @@ -412,12 +440,24 @@ "type": "string" }, { - "$ref": "#/$defs/NetworkManifest", + "$ref": "#/$defs/DependencyManifest", "description": "The manifest" } ] }, "Item3": { + "anyOf": [ + { + "description": "Path to a manifest", + "type": "string" + }, + { + "$ref": "#/$defs/NetworkManifest", + "description": "The manifest" + } + ] + }, + "Item4": { "anyOf": [ { "description": "Path to a manifest", @@ -984,17 +1024,25 @@ }, "type": "array" }, + "dependencies": { + "default": [], + "description": "Other `icp` projects this project depends on. Their canisters are\ndeployed into this project's environment and their IDs are injected into\nthis project's canisters as `PUBLIC_CANISTER_ID::`\nenvironment variables.", + "items": { + "$ref": "#/$defs/Item2" + }, + "type": "array" + }, "environments": { "default": [], "items": { - "$ref": "#/$defs/Item3" + "$ref": "#/$defs/Item4" }, "type": "array" }, "networks": { "default": [], "items": { - "$ref": "#/$defs/Item2" + "$ref": "#/$defs/Item3" }, "type": "array" } From e42897d05baf60521ebd09239acdec514db02447 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 8 Jul 2026 21:58:35 -0400 Subject: [PATCH 2/8] feat(project): consolidate dependency projects into the canonical project Extend consolidate_manifest to import declared dependency projects: - Recursively load each dependency's icp.yaml and import ALL of its canisters (deploy-all), keyed by their app-root-relative path (e.g. `umbrella/openemail:backend`). The `canisters` field is an exposure filter for env vars only, not a deployment filter. - De-duplicate instances by canonicalized directory, so a diamond dependency (e.g. two sibling submodules both depending on `../openemail`) deploys once. - Detect dependency cycles. - Compute per-project `PUBLIC_CANISTER_ID` bindings on each Canister so a dependency's canisters keep their standalone view (own siblings by bare name) while the parent sees only the exposed dependency canisters under `:`. - Reserve `:` in canister names and dependency aliases; validate alias uniqueness and alias/canister collisions. - Translate dependency canisters' name-based controller refs to store keys. The canonical `Canister` gains a `bindings` map consumed by the deploy step (next commit). For a project with no dependencies, bindings map every canister to itself, preserving today's flat env-var behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 4 + crates/icp/Cargo.toml | 1 + crates/icp/src/lib.rs | 20 +- crates/icp/src/manifest/project.rs | 2 +- crates/icp/src/project.rs | 819 ++++++++++++++++++++++++++--- docs/schemas/icp-yaml-schema.json | 20 +- 6 files changed, 779 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d1a4ce44..1fc708f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3695,6 +3695,7 @@ dependencies = [ "num-integer", "num-traits", "p256", + "pathdiff", "pem", "pkcs8", "rand 0.10.1", @@ -5269,6 +5270,9 @@ name = "pathdiff" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +dependencies = [ + "camino", +] [[package]] name = "pbkdf2" diff --git a/crates/icp/Cargo.toml b/crates/icp/Cargo.toml index c0355bcb..004c53e6 100644 --- a/crates/icp/Cargo.toml +++ b/crates/icp/Cargo.toml @@ -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 } diff --git a/crates/icp/src/lib.rs b/crates/icp/src/lib.rs index d5ad70cf..efaf7162 100644 --- a/crates/icp/src/lib.rs +++ b/crates/icp/src/lib.rs @@ -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; @@ -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, + + /// Canister-discovery wiring. Maps the name this canister reads in a + /// `PUBLIC_CANISTER_ID:` 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 + /// (`:`). 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, } #[derive(Clone, Debug, PartialEq, Serialize)] @@ -292,6 +306,7 @@ impl MockProjectLoader { sync: SyncSteps::default(), init_args: None, registry_recipe: None, + bindings: BTreeMap::new(), }; let local_network = Network { @@ -375,6 +390,7 @@ impl MockProjectLoader { sync: SyncSteps::default(), init_args: None, registry_recipe: None, + bindings: BTreeMap::new(), }; let frontend_canister = Canister { @@ -391,6 +407,7 @@ impl MockProjectLoader { sync: SyncSteps::default(), init_args: None, registry_recipe: None, + bindings: BTreeMap::new(), }; let database_canister = Canister { @@ -407,6 +424,7 @@ impl MockProjectLoader { sync: SyncSteps::default(), init_args: None, registry_recipe: None, + bindings: BTreeMap::new(), }; // Create networks diff --git a/crates/icp/src/manifest/project.rs b/crates/icp/src/manifest/project.rs index c08c5274..12c5cded 100644 --- a/crates/icp/src/manifest/project.rs +++ b/crates/icp/src/manifest/project.rs @@ -17,7 +17,7 @@ pub struct ProjectManifest { /// this project's canisters as `PUBLIC_CANISTER_ID::` /// environment variables. #[serde(default)] - pub dependencies: Vec>, + pub dependencies: Vec, #[serde(default)] pub networks: Vec>, diff --git a/crates/icp/src/project.rs b/crates/icp/src/project.rs index 3b8bcf6b..9ebddf7f 100644 --- a/crates/icp/src/project.rs +++ b/crates/icp/src/project.rs @@ -1,4 +1,4 @@ -use std::collections::{HashMap, HashSet, hash_map::Entry}; +use std::collections::{BTreeMap, HashMap, HashSet, hash_map::Entry}; use indexmap::{IndexMap, map::Entry as IndexEntry}; @@ -6,13 +6,13 @@ use snafu::prelude::*; use crate::{ Canister, Environment, InitArgs, Network, Project, - canister::recipe, + canister::{ControllerRef, recipe}, context::IC_ROOT_KEY, fs, manifest::{ - ArgsFormat, CANISTER_MANIFEST, CanisterManifest, EnvironmentManifest, Item, - LoadManifestFromPathError, ManifestInitArgs, NetworkManifest, ProjectManifest, - ProjectRootLocateError, + ArgsFormat, CANISTER_MANIFEST, CanisterManifest, DependencyManifest, EnvironmentManifest, + Item, LoadManifestFromPathError, ManifestInitArgs, NetworkManifest, PROJECT_MANIFEST, + ProjectManifest, ProjectRootLocateError, canister::{Instructions, SyncSteps}, environment::CanisterSelection, load_manifest_from_path, @@ -105,6 +105,44 @@ pub enum ConsolidateManifestError { controller: String, }, + #[snafu(display( + "canister name '{name}' is invalid: ':' is reserved as the dependency namespace separator" + ))] + InvalidCanisterName { name: String }, + + #[snafu(display( + "dependency alias '{alias}' is invalid: ':' is reserved as the dependency namespace separator" + ))] + InvalidDependencyAlias { alias: String }, + + #[snafu(display("project declares two dependencies with the same alias '{alias}'"))] + DuplicateDependencyAlias { alias: String }, + + #[snafu(display( + "dependency alias '{alias}' collides with a canister of the same name in the same project" + ))] + DependencyAliasCollision { alias: String }, + + #[snafu(display("could not find a project manifest for dependency '{alias}' at: '{path}'"))] + DependencyNotFound { alias: String, path: String }, + + #[snafu(display("failed to canonicalize path for dependency '{alias}' at: '{path}'"))] + DependencyCanonicalize { alias: String, path: String }, + + #[snafu(display("failed to load project manifest for dependency '{alias}'"))] + LoadDependencyManifest { + source: LoadManifestFromPathError, + alias: String, + }, + + #[snafu(display( + "dependency '{alias}' selects canister '{canister}', which the dependency does not declare" + ))] + UnknownDependencyCanister { alias: String, canister: String }, + + #[snafu(display("dependency cycle detected: {chain}"))] + CircularDependency { chain: String }, + #[snafu(transparent)] Environment { source: EnvironmentError }, } @@ -152,24 +190,19 @@ fn is_glob(s: &str) -> bool { s.contains('*') || s.contains('?') || s.contains('[') || s.contains('{') } -/// Turns the ProjectManifest into a Project struct -/// - Adds the default Networks -/// - Adds the default Environment -/// - Validates the manifest to make sure that: -/// - There are no duplicates -/// - All the environments have networks -/// - All the referenced canisters exist -/// - All the recipes have been resolved -pub async fn consolidate_manifest( +/// Builds the canonical canisters declared directly in one project manifest, +/// resolving glob/path/inline entries, recipes, and init-args relative to +/// `pdir`. Returns `(local name, canister dir, canister)` with empty bindings; +/// callers assign store keys and bindings. Does not check for duplicate names +/// across projects — that is the caller's responsibility (via the global map). +async fn build_manifest_canisters( pdir: &Path, + manifest_canisters: &[Item], recipe_resolver: &dyn recipe::Resolve, - m: &ProjectManifest, -) -> Result { - // Canisters. IndexMap (not HashMap) so the order from the project manifest is preserved - // through to consumers like `icp project bundle`, which needs reproducible output. - let mut canisters: IndexMap = IndexMap::new(); +) -> Result, ConsolidateManifestError> { + let mut result: Vec<(String, PathBuf, Canister)> = Vec::new(); - for i in &m.canisters { + for i in manifest_canisters { let ms = match i { Item::Path(pattern) => { let is_glob_pattern = is_glob(pattern); @@ -179,7 +212,6 @@ pub async fn consolidate_manifest( // Glob pattern true => { - // Resolve glob let paths = glob::glob(pdir.join(pattern).as_str()).context(GlobParseSnafu)?; @@ -217,40 +249,34 @@ pub async fn consolidate_manifest( }; let mut ms = vec![]; - for p in paths { ms.push(( - // - // Canister root p.to_owned(), - // - // Canister manifest load_manifest_from_path::(&p.join(CANISTER_MANIFEST)) .await .context(LoadCanisterSnafu)?, )); } - ms } - Item::Manifest(m) => vec![( - // - // Canister root - pdir.to_owned(), - // - // Canister manifest - m.to_owned(), - )], + Item::Manifest(m) => vec![(pdir.to_owned(), m.to_owned())], }; for (cdir, m) in ms { + // `:` is reserved as the dependency namespace separator in store keys + // and `PUBLIC_CANISTER_ID:` env vars. + if m.name.contains(':') { + return InvalidCanisterNameSnafu { + name: m.name.clone(), + } + .fail(); + } + let registry_recipe = match &m.instructions { Instructions::BuildSync { .. } => None, Instructions::Recipe { recipe } => match &recipe.recipe_type { - crate::manifest::recipe::RecipeType::Registry { .. } => { - Some(recipe.recipe_type.to_string()) - } + RecipeType::Registry { .. } => Some(recipe.recipe_type.to_string()), _ => None, }, }; @@ -279,42 +305,343 @@ pub async fn consolidate_manifest( } }; - // Check for duplicates - match canisters.entry(m.name.to_owned()) { - // Duplicate - IndexEntry::Occupied(e) => { - return DuplicateSnafu { - kind: "canister".to_string(), - name: e.key().to_owned(), + let init_args = m + .init_args + .as_ref() + .map(|mia| resolve_manifest_init_args(mia, &cdir, &m.name)) + .transpose()?; + + result.push(( + m.name.clone(), + cdir, + Canister { + name: m.name.clone(), + settings: m.settings.clone(), + build, + sync, + init_args, + registry_recipe, + bindings: BTreeMap::new(), + }, + )); + } + } + + Ok(result) +} + +/// A dependency instance's own canisters, as `(local name, full store key)`. +/// Returned by [`import_dependency`] and cached per canonical path so diamond +/// dependencies reuse the same instance. +type ImportedInstance = Vec<(String, String)>; + +/// Canonicalize a dependency root (resolving symlinks and `..`) for use as a +/// de-dup / cycle-detection identity. +fn canonicalize_dep(alias: &str, dep_root: &Path) -> Result { + let build_err = || { + DependencyCanonicalizeSnafu { + alias: alias.to_owned(), + path: dep_root.to_string(), + } + .build() + }; + let canon = dunce::canonicalize(dep_root.as_std_path()).map_err(|_| build_err())?; + PathBuf::try_from(canon).map_err(|_| build_err()) +} + +/// Store-key prefix for a dependency instance: its canonical directory relative +/// to the canonical app root, forward-slash separated so keys are stable across +/// platforms and independent of how each edge spells the path. +fn relative_prefix(app_root_canonical: &Path, dep_canonical: &Path) -> String { + let rel = pathdiff::diff_utf8_paths(dep_canonical, app_root_canonical) + .unwrap_or_else(|| dep_canonical.to_owned()); + rel.as_str().replace('\\', "/") +} + +/// Rewrite `CanisterName` controller references from a dependency's local +/// canister names to their store keys, so global controller validation and +/// deploy-time id lookup operate uniformly on store keys. +fn translate_controllers(canister: &mut Canister, local_to_key: &BTreeMap) { + if let Some(controllers) = &mut canister.settings.controllers { + for cref in controllers.iter_mut() { + if let ControllerRef::CanisterName(name) = cref + && let Some(key) = local_to_key.get(name) + { + *name = key.clone(); + } + } + } +} + +/// Compute the `PUBLIC_CANISTER_ID` env-var wiring for canisters in one project +/// scope: its own canisters by local name, plus each dependency's exposed +/// canisters under `:`. +fn compute_bindings( + own: &[(String, String)], + edges: &[(String, Vec<(String, String)>)], +) -> BTreeMap { + let mut bindings = BTreeMap::new(); + for (local, key) in own { + bindings.insert(local.clone(), key.clone()); + } + for (alias, exposed) in edges { + for (dep_local, key) in exposed { + bindings.insert(format!("{alias}:{dep_local}"), key.clone()); + } + } + bindings +} + +/// Select which of a dependency instance's own canisters are exposed to the +/// parent, per the dependency's `canisters` selection. +fn select_exposed( + inst: &ImportedInstance, + selection: &CanisterSelection, + alias: &str, +) -> Result, ConsolidateManifestError> { + match selection { + CanisterSelection::Everything => Ok(inst.clone()), + CanisterSelection::None => Ok(vec![]), + CanisterSelection::Named(names) => { + let mut out = Vec::new(); + for name in names { + match inst.iter().find(|(local, _)| local == name) { + Some(pair) => out.push(pair.clone()), + None => { + return UnknownDependencyCanisterSnafu { + alias: alias.to_owned(), + canister: name.clone(), + } + .fail(); } - .fail(); } + } + Ok(out) + } + } +} - // Ok - IndexEntry::Vacant(e) => { - let init_args = m - .init_args - .as_ref() - .map(|mia| resolve_manifest_init_args(mia, &cdir, &m.name)) - .transpose()?; - - e.insert(( - // - // Canister root - cdir, - // - // Canister - Canister { - name: m.name.to_owned(), - settings: m.settings.to_owned(), - build, - sync, - init_args, - registry_recipe, - }, - )); +/// Validate the dependency aliases declared in one project scope: no `:`, no +/// collision with a local canister name, and no duplicate alias. +fn validate_dependency_aliases( + deps: &[DependencyManifest], + own_canister_names: &HashSet, +) -> Result<(), ConsolidateManifestError> { + let mut seen: HashSet<&str> = HashSet::new(); + for d in deps { + if d.name.contains(':') { + return InvalidDependencyAliasSnafu { + alias: d.name.clone(), + } + .fail(); + } + if own_canister_names.contains(&d.name) { + return DependencyAliasCollisionSnafu { + alias: d.name.clone(), + } + .fail(); + } + if !seen.insert(&d.name) { + return DuplicateDependencyAliasSnafu { + alias: d.name.clone(), + } + .fail(); + } + } + Ok(()) +} + +/// Recursively import a dependency's canisters into `canisters`, keyed by their +/// app-root-relative store keys. De-duplicates instances by canonical path +/// (diamond dependencies deploy once) and detects cycles. Returns the imported +/// instance's prefix and its own canisters. +async fn import_dependency( + app_root_canonical: &Path, + parent_dir: &Path, + dep: &DependencyManifest, + recipe_resolver: &dyn recipe::Resolve, + canisters: &mut IndexMap, + registry: &mut HashMap, + stack: &mut Vec, +) -> Result { + let dep_root = parent_dir.join(&dep.path); + let manifest_path = dep_root.join(PROJECT_MANIFEST); + if !manifest_path.is_file() { + return DependencyNotFoundSnafu { + alias: dep.name.clone(), + path: dep_root.to_string(), + } + .fail(); + } + + let canonical = canonicalize_dep(&dep.name, &dep_root)?; + + // Cycle detection. + if stack.contains(&canonical) { + let mut chain: Vec = stack.iter().map(|p| p.to_string()).collect(); + chain.push(canonical.to_string()); + return CircularDependencySnafu { + chain: chain.join(" -> "), + } + .fail(); + } + + // Diamond de-dup: same resolved directory means the same instance. + if let Some(inst) = registry.get(&canonical) { + return Ok(inst.clone()); + } + + stack.push(canonical.clone()); + + let prefix = relative_prefix(app_root_canonical, &canonical); + + let dep_manifest: ProjectManifest = + load_manifest_from_path(&manifest_path) + .await + .context(LoadDependencyManifestSnafu { + alias: dep.name.clone(), + })?; + + // Build the dependency's own canisters and key them under the prefix. All of + // them are imported (deploy-all); the `canisters` exposure subset is applied + // by the caller when wiring env vars. + let built = + build_manifest_canisters(&dep_root, &dep_manifest.canisters, recipe_resolver).await?; + + let mut own: Vec<(String, String)> = Vec::new(); + let mut local_to_key: BTreeMap = BTreeMap::new(); + for (local, cdir, mut canister) in built { + let store_key = format!("{prefix}:{local}"); + canister.name = store_key.clone(); + own.push((local.clone(), store_key.clone())); + local_to_key.insert(local.clone(), store_key.clone()); + match canisters.entry(store_key.clone()) { + IndexEntry::Occupied(_) => { + return DuplicateSnafu { + kind: "canister".to_string(), + name: store_key, } + .fail(); } + IndexEntry::Vacant(e) => { + e.insert((cdir, canister)); + } + } + } + + // Now that every sibling's store key is known, translate the dependency's + // controller references (local sibling name -> store key). + for (_, key) in &own { + if let Some((_, canister)) = canisters.get_mut(key) { + translate_controllers(canister, &local_to_key); + } + } + + // Recurse into the dependency's own dependencies. + let own_names: HashSet = own.iter().map(|(l, _)| l.clone()).collect(); + validate_dependency_aliases(&dep_manifest.dependencies, &own_names)?; + + let mut edges: Vec<(String, Vec<(String, String)>)> = Vec::new(); + for nested in &dep_manifest.dependencies { + let inst = Box::pin(import_dependency( + app_root_canonical, + &dep_root, + nested, + recipe_resolver, + canisters, + registry, + stack, + )) + .await?; + let exposed = select_exposed(&inst, &nested.canisters, &nested.name)?; + edges.push((nested.name.clone(), exposed)); + } + + // Assign env-var bindings for this instance's own canisters. + let bindings = compute_bindings(&own, &edges); + for (_, key) in &own { + if let Some((_, canister)) = canisters.get_mut(key) { + canister.bindings = bindings.clone(); + } + } + + stack.pop(); + registry.insert(canonical, own.clone()); + Ok(own) +} + +/// Turns the ProjectManifest into a Project struct +/// - Adds the default Networks +/// - Adds the default Environment +/// - Imports any dependency projects' canisters +/// - Validates the manifest to make sure that: +/// - There are no duplicates +/// - All the environments have networks +/// - All the referenced canisters exist +/// - All the recipes have been resolved +pub async fn consolidate_manifest( + pdir: &Path, + recipe_resolver: &dyn recipe::Resolve, + m: &ProjectManifest, +) -> Result { + // Canisters. IndexMap (not HashMap) so the order from the project manifest is preserved + // through to consumers like `icp project bundle`, which needs reproducible output. + let mut canisters: IndexMap = IndexMap::new(); + + // Canonical app root, used to derive stable, order-independent store-key + // prefixes for imported dependency canisters. + let app_root_canonical = + canonicalize_dep("", pdir).unwrap_or_else(|_| pdir.to_owned()); + + // This project's own canisters, keyed by their bare local names. + let app_built = build_manifest_canisters(pdir, &m.canisters, recipe_resolver).await?; + let mut app_own: Vec<(String, String)> = Vec::new(); + for (local, cdir, canister) in app_built { + app_own.push((local.clone(), local.clone())); + match canisters.entry(local.clone()) { + IndexEntry::Occupied(e) => { + return DuplicateSnafu { + kind: "canister".to_string(), + name: e.key().to_owned(), + } + .fail(); + } + IndexEntry::Vacant(e) => { + e.insert((cdir, canister)); + } + } + } + + // Import dependency projects. Each dependency is deployed in full and keyed + // under its app-root-relative path; diamonds (the same directory reached via + // multiple edges) resolve to a single instance. + let mut registry: HashMap = HashMap::new(); + let mut stack: Vec = Vec::new(); + let app_own_names: HashSet = app_own.iter().map(|(l, _)| l.clone()).collect(); + validate_dependency_aliases(&m.dependencies, &app_own_names)?; + + let mut app_edges: Vec<(String, Vec<(String, String)>)> = Vec::new(); + for dep in &m.dependencies { + let inst = import_dependency( + &app_root_canonical, + pdir, + dep, + recipe_resolver, + &mut canisters, + &mut registry, + &mut stack, + ) + .await?; + let exposed = select_exposed(&inst, &dep.canisters, &dep.name)?; + app_edges.push((dep.name.clone(), exposed)); + } + + // Assign env-var bindings for this project's own canisters (own canisters by + // local name plus each dependency's exposed canisters under `:`). + let app_bindings = compute_bindings(&app_own, &app_edges); + for (_, key) in &app_own { + if let Some((_, canister)) = canisters.get_mut(key) { + canister.bindings = app_bindings.clone(); } } @@ -585,3 +912,357 @@ pub async fn consolidate_manifest( environments, }) } + +#[cfg(test)] +mod dependency_tests { + use super::*; + use crate::canister::recipe::{RecipeContext, Resolve, ResolveError}; + use crate::manifest::canister::{BuildSteps, SyncSteps}; + use crate::manifest::recipe::Recipe; + use camino_tempfile::Utf8TempDir; + + /// Recipes are never used in these tests; every canister is pre-built. + struct PanicResolver; + + #[async_trait::async_trait] + impl Resolve for PanicResolver { + async fn resolve( + &self, + _recipe: &Recipe, + _context: &RecipeContext, + ) -> Result<(BuildSteps, SyncSteps), ResolveError> { + panic!("recipe resolver should not be called in dependency tests"); + } + } + + fn write(dir: &Path, rel: &str, contents: &str) { + let p = dir.join(rel); + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, contents).unwrap(); + } + + /// A minimal `icp.yaml` body declaring the given pre-built canisters, + /// followed by a raw `dependencies:` block (may be empty). + fn manifest(canisters: &[&str], deps: &str) -> String { + let mut s = String::new(); + if canisters.is_empty() { + s.push_str("canisters: []\n"); + } else { + s.push_str("canisters:\n"); + for c in canisters { + s.push_str(&format!( + " - name: {c}\n build:\n steps:\n - type: pre-built\n path: {c}.wasm\n" + )); + } + } + s.push_str(deps); + s + } + + async fn consolidate(pdir: &Path) -> Result { + let m: ProjectManifest = load_manifest_from_path(&pdir.join(PROJECT_MANIFEST)) + .await + .expect("failed to parse project manifest"); + consolidate_manifest(pdir, &PanicResolver, &m).await + } + + fn bindings_of<'a>(p: &'a Project, key: &str) -> &'a BTreeMap { + &p.canisters + .get(key) + .unwrap_or_else(|| { + panic!( + "canister '{key}' not found; have {:?}", + p.canisters.keys().collect::>() + ) + }) + .1 + .bindings + } + + #[tokio::test] + async fn single_project_bindings_are_self_and_siblings() { + let tmp = Utf8TempDir::new().unwrap(); + write( + tmp.path(), + "icp.yaml", + &manifest(&["backend", "frontend"], ""), + ); + + let p = consolidate(tmp.path()).await.unwrap(); + + // Flat behavior preserved: every canister maps every sibling (incl. self) + // to itself. + let expected = BTreeMap::from([ + ("backend".to_string(), "backend".to_string()), + ("frontend".to_string(), "frontend".to_string()), + ]); + assert_eq!(bindings_of(&p, "backend"), &expected); + assert_eq!(bindings_of(&p, "frontend"), &expected); + } + + #[tokio::test] + async fn dependency_import_and_exposure_subset() { + let tmp = Utf8TempDir::new().unwrap(); + // Dependency nested inside the app (mirrors a submodule under the app). + write( + tmp.path(), + "openemail/icp.yaml", + &manifest(&["backend", "frontend"], ""), + ); + write( + tmp.path(), + "icp.yaml", + &manifest( + &["backend"], + "dependencies:\n - name: openemail\n path: ./openemail\n canisters: [backend]\n", + ), + ); + + let p = consolidate(tmp.path()).await.unwrap(); + + // The whole dependency is deployed (both canisters imported), keyed by path. + assert!(p.canisters.contains_key("backend")); + assert!(p.canisters.contains_key("openemail:backend")); + assert!(p.canisters.contains_key("openemail:frontend")); + + // App's own canister sees itself and only the *exposed* dependency canister. + assert_eq!( + bindings_of(&p, "backend"), + &BTreeMap::from([ + ("backend".to_string(), "backend".to_string()), + ( + "openemail:backend".to_string(), + "openemail:backend".to_string() + ), + ]) + ); + + // The dependency's own canisters keep their standalone view (bare names). + assert_eq!( + bindings_of(&p, "openemail:backend"), + &BTreeMap::from([ + ("backend".to_string(), "openemail:backend".to_string()), + ("frontend".to_string(), "openemail:frontend".to_string()), + ]) + ); + } + + #[tokio::test] + async fn diamond_dedups_to_single_instance() { + let tmp = Utf8TempDir::new().unwrap(); + // umbrella layout: service-a and service-b both depend on ../openemail. + write( + tmp.path(), + "umbrella/openemail/icp.yaml", + &manifest(&["backend"], ""), + ); + write( + tmp.path(), + "umbrella/service-a/icp.yaml", + &manifest( + &["backend"], + "dependencies:\n - name: openemail\n path: ../openemail\n", + ), + ); + write( + tmp.path(), + "umbrella/service-b/icp.yaml", + &manifest( + &["backend"], + "dependencies:\n - name: openemail\n path: ../openemail\n", + ), + ); + write( + tmp.path(), + "icp.yaml", + &manifest( + &[], + "dependencies:\n - name: service-a\n path: ./umbrella/service-a\n - name: service-b\n path: ./umbrella/service-b\n", + ), + ); + + let p = consolidate(tmp.path()).await.unwrap(); + + // openemail is imported exactly once despite two edges reaching it. + let openemail_keys: Vec<_> = p + .canisters + .keys() + .filter(|k| k.contains("openemail")) + .collect(); + assert_eq!( + openemail_keys, + vec![&"umbrella/openemail:backend".to_string()], + "expected a single shared openemail instance" + ); + + // Both services' code reads `openemail:backend`, resolving to the one instance. + assert_eq!( + bindings_of(&p, "umbrella/service-a:backend").get("openemail:backend"), + Some(&"umbrella/openemail:backend".to_string()) + ); + assert_eq!( + bindings_of(&p, "umbrella/service-b:backend").get("openemail:backend"), + Some(&"umbrella/openemail:backend".to_string()) + ); + } + + #[tokio::test] + async fn cycle_is_detected() { + let tmp = Utf8TempDir::new().unwrap(); + write( + tmp.path(), + "icp.yaml", + &manifest(&[], "dependencies:\n - name: a\n path: ./a\n"), + ); + write( + tmp.path(), + "a/icp.yaml", + &manifest(&["x"], "dependencies:\n - name: b\n path: ../b\n"), + ); + write( + tmp.path(), + "b/icp.yaml", + &manifest(&["y"], "dependencies:\n - name: a\n path: ../a\n"), + ); + + let err = consolidate(tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ConsolidateManifestError::CircularDependency { .. }), + "expected CircularDependency, got {err:?}" + ); + } + + #[tokio::test] + async fn alias_colliding_with_canister_name_is_rejected() { + let tmp = Utf8TempDir::new().unwrap(); + write( + tmp.path(), + "openemail/icp.yaml", + &manifest(&["backend"], ""), + ); + write( + tmp.path(), + "icp.yaml", + &manifest( + &["openemail"], + "dependencies:\n - name: openemail\n path: ./openemail\n", + ), + ); + + let err = consolidate(tmp.path()).await.unwrap_err(); + assert!( + matches!( + err, + ConsolidateManifestError::DependencyAliasCollision { .. } + ), + "got {err:?}" + ); + } + + #[tokio::test] + async fn duplicate_alias_is_rejected() { + let tmp = Utf8TempDir::new().unwrap(); + write(tmp.path(), "one/icp.yaml", &manifest(&["backend"], "")); + write(tmp.path(), "two/icp.yaml", &manifest(&["backend"], "")); + write( + tmp.path(), + "icp.yaml", + &manifest( + &[], + "dependencies:\n - name: dup\n path: ./one\n - name: dup\n path: ./two\n", + ), + ); + + let err = consolidate(tmp.path()).await.unwrap_err(); + assert!( + matches!( + err, + ConsolidateManifestError::DuplicateDependencyAlias { .. } + ), + "got {err:?}" + ); + } + + #[tokio::test] + async fn colon_in_canister_name_is_rejected() { + let tmp = Utf8TempDir::new().unwrap(); + write(tmp.path(), "icp.yaml", &manifest(&["foo:bar"], "")); + + let err = consolidate(tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ConsolidateManifestError::InvalidCanisterName { .. }), + "got {err:?}" + ); + } + + #[tokio::test] + async fn unknown_exposed_canister_is_rejected() { + let tmp = Utf8TempDir::new().unwrap(); + write( + tmp.path(), + "openemail/icp.yaml", + &manifest(&["backend"], ""), + ); + write( + tmp.path(), + "icp.yaml", + &manifest( + &[], + "dependencies:\n - name: openemail\n path: ./openemail\n canisters: [nope]\n", + ), + ); + + let err = consolidate(tmp.path()).await.unwrap_err(); + assert!( + matches!( + err, + ConsolidateManifestError::UnknownDependencyCanister { .. } + ), + "got {err:?}" + ); + } + + #[tokio::test] + async fn missing_dependency_path_is_rejected() { + let tmp = Utf8TempDir::new().unwrap(); + write( + tmp.path(), + "icp.yaml", + &manifest( + &[], + "dependencies:\n - name: openemail\n path: ./does-not-exist\n", + ), + ); + + let err = consolidate(tmp.path()).await.unwrap_err(); + assert!( + matches!(err, ConsolidateManifestError::DependencyNotFound { .. }), + "got {err:?}" + ); + } + + #[tokio::test] + async fn imported_canisters_appear_in_implicit_environments() { + let tmp = Utf8TempDir::new().unwrap(); + write( + tmp.path(), + "openemail/icp.yaml", + &manifest(&["backend"], ""), + ); + write( + tmp.path(), + "icp.yaml", + &manifest( + &["backend"], + "dependencies:\n - name: openemail\n path: ./openemail\n", + ), + ); + + let p = consolidate(tmp.path()).await.unwrap(); + + // Deploy-all: the implicit `local` environment includes the dependency. + let local = p.environments.get("local").unwrap(); + assert!(local.canisters.contains_key("backend")); + assert!(local.canisters.contains_key("openemail:backend")); + } +} diff --git a/docs/schemas/icp-yaml-schema.json b/docs/schemas/icp-yaml-schema.json index 039746ea..d1945c8d 100644 --- a/docs/schemas/icp-yaml-schema.json +++ b/docs/schemas/icp-yaml-schema.json @@ -434,18 +434,6 @@ ] }, "Item2": { - "anyOf": [ - { - "description": "Path to a manifest", - "type": "string" - }, - { - "$ref": "#/$defs/DependencyManifest", - "description": "The manifest" - } - ] - }, - "Item3": { "anyOf": [ { "description": "Path to a manifest", @@ -457,7 +445,7 @@ } ] }, - "Item4": { + "Item3": { "anyOf": [ { "description": "Path to a manifest", @@ -1028,21 +1016,21 @@ "default": [], "description": "Other `icp` projects this project depends on. Their canisters are\ndeployed into this project's environment and their IDs are injected into\nthis project's canisters as `PUBLIC_CANISTER_ID::`\nenvironment variables.", "items": { - "$ref": "#/$defs/Item2" + "$ref": "#/$defs/DependencyManifest" }, "type": "array" }, "environments": { "default": [], "items": { - "$ref": "#/$defs/Item4" + "$ref": "#/$defs/Item3" }, "type": "array" }, "networks": { "default": [], "items": { - "$ref": "#/$defs/Item3" + "$ref": "#/$defs/Item2" }, "type": "array" } From 263b0db9dd322f8c1458fd53e3a5af9b82d89adb Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 8 Jul 2026 22:01:29 -0400 Subject: [PATCH 3/8] feat(deploy): inject per-project canister-id env vars; make artifact store namespaced-name safe - set_binding_env_vars_many now derives each canister's PUBLIC_CANISTER_ID env vars from its own `bindings` map instead of a single flat set, so a dependency's canisters keep the view their project expects and the parent sees only the exposed dependency canisters. Bindings are filtered to ids present in the environment; a dependency-free project is unaffected (every canister still sees every sibling). - The artifact store percent-encodes `/`, `\`, `:`, `%` in canister names so namespaced dependency canister keys (e.g. `vendor/openemail:backend`) are valid filenames on all platforms. Plain names are unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/operations/binding_env_vars.rs | 24 ++++++--- crates/icp/src/store_artifact.rs | 54 ++++++++++++++++++- 2 files changed, 71 insertions(+), 7 deletions(-) diff --git a/crates/icp-cli/src/operations/binding_env_vars.rs b/crates/icp-cli/src/operations/binding_env_vars.rs index 88eff87f..a2118f19 100644 --- a/crates/icp-cli/src/operations/binding_env_vars.rs +++ b/crates/icp-cli/src/operations/binding_env_vars.rs @@ -116,11 +116,6 @@ 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::>(); - let mut futs = FuturesOrdered::new(); let progress_manager = ProgressManager::new(ProgressManagerSettings { hidden: debug }); @@ -128,10 +123,27 @@ pub(crate) async fn set_binding_env_vars_many( 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..."); diff --git a/crates/icp/src/store_artifact.rs b/crates/icp/src/store_artifact.rs index 602b84ad..8c2894f8 100644 --- a/crates/icp/src/store_artifact.rs +++ b/crates/icp/src/store_artifact.rs @@ -53,9 +53,30 @@ struct ArtifactPaths { dir: PathBuf, } +/// Encode a canister name into a single filename-safe segment. +/// +/// Canister names may be namespaced store keys containing `/` and `:` (imported +/// dependency canisters, e.g. `vendor/openemail:backend`), which are not valid +/// filename characters on every platform. Percent-encoding the unsafe set keeps +/// the mapping reversible and collision-free; plain names (alphanumeric/`-`/`_`/`.`) +/// are left unchanged, so existing artifact filenames are unaffected. +fn sanitize_artifact_name(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + for c in name.chars() { + match c { + '%' => out.push_str("%25"), + '/' => out.push_str("%2F"), + '\\' => out.push_str("%5C"), + ':' => out.push_str("%3A"), + _ => out.push(c), + } + } + out +} + impl ArtifactPaths { fn artifact_by_name(&self, name: &str) -> PathBuf { - self.dir.join(name) + self.dir.join(sanitize_artifact_name(name)) } } @@ -119,6 +140,37 @@ impl Access for ArtifactStore { } } +#[cfg(test)] +mod tests { + use super::sanitize_artifact_name; + + #[test] + fn plain_names_unchanged() { + assert_eq!(sanitize_artifact_name("backend"), "backend"); + assert_eq!( + sanitize_artifact_name("my-canister_1.wasm"), + "my-canister_1.wasm" + ); + } + + #[test] + fn namespaced_names_are_filename_safe() { + let s = sanitize_artifact_name("vendor/openemail:backend"); + assert_eq!(s, "vendor%2Fopenemail%3Abackend"); + assert!(!s.contains('/')); + assert!(!s.contains(':')); + } + + #[test] + fn encoding_is_injective() { + // `%` is itself encoded, so a literal "%2F" never collides with "/". + assert_ne!( + sanitize_artifact_name("a%2Fb"), + sanitize_artifact_name("a/b") + ); + } +} + #[cfg(test)] /// In-memory mock implementation of `Access`. pub(crate) struct MockInMemoryArtifactStore { From 4f747e8ac06f772c572f64229a6351b2a5bcf16c Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 8 Jul 2026 22:05:42 -0400 Subject: [PATCH 4/8] fix(url): fall back to principal host for namespaced canister URLs A dependency canister's namespaced name (e.g. `dep:backend`, `vendor/dep:backend`) is not a valid DNS label, so it cannot get a friendly `..` subdomain. Skip the friendly host for such names and use the principal host, avoiding a malformed/incorrect URL when printing deployed canister URLs. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/icp/src/network/custom_domains.rs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/crates/icp/src/network/custom_domains.rs b/crates/icp/src/network/custom_domains.rs index 9938a070..d16c1cf5 100644 --- a/crates/icp/src/network/custom_domains.rs +++ b/crates/icp/src/network/custom_domains.rs @@ -81,11 +81,16 @@ pub fn canister_gateway_url( let domain = gateway_domain(http_gateway_url); let mut url = http_gateway_url.clone(); match (friendly, domain) { - (Some((canister_name, env_name)), Some(domain)) => { + // A namespaced dependency canister (e.g. `dep:backend` or `vendor/dep:backend`) + // is not a valid DNS label, so it can't get a friendly subdomain; those fall + // through to the principal host below. + (Some((canister_name, env_name)), Some(domain)) + if !canister_name.contains([':', '/', '\\']) => + { url.set_host(Some(&format!("{canister_name}.{env_name}.{domain}"))) .expect("friendly domain should be a valid host"); } - (None, Some(domain)) => { + (_, Some(domain)) => { url.set_host(Some(&format!("{canister_id}.{domain}"))) .expect("principal domain should be a valid host"); } @@ -181,6 +186,19 @@ mod tests { assert_eq!(url.as_str(), "http://backend.local.localhost:8000/"); } + #[test] + fn canister_gateway_url_namespaced_name_falls_back_to_principal() { + let base: Url = "http://localhost:8000".parse().unwrap(); + let cid = Principal::from_text("bkyz2-fmaaa-aaaaa-qaaaq-cai").unwrap(); + // A namespaced dependency canister (e.g. `dep:backend`) is not a valid DNS + // label, so the friendly host is rejected and we fall back to the principal. + let url = canister_gateway_url(&base, cid, Some(("dep:backend", "local"))); + assert_eq!( + url.as_str(), + "http://bkyz2-fmaaa-aaaaa-qaaaq-cai.localhost:8000/" + ); + } + #[test] fn canister_gateway_url_without_friendly_domain() { let base: Url = "http://localhost:8000".parse().unwrap(); From e184be8a708356ad415970e70ef19ced1515aec2 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Wed, 8 Jul 2026 22:41:49 -0400 Subject: [PATCH 5/8] docs(deps): example, integration test, and docs for project dependencies - Add examples/icp-project-dependency demonstrating an app that vendors an `openemail` dependency and exposes its `backend` canister. - Add an integration test that deploys an app + dependency to a managed network and asserts the app sees PUBLIC_CANISTER_ID:openemail:backend while the dependency's canisters keep their standalone view. - Document dependency projects in the canister-discovery guide and CHANGELOG. - Move the store_artifact test module to the end of the file (clippy). Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + crates/icp-cli/tests/dependency_tests.rs | 107 ++++++++++++++++++ crates/icp/src/store_artifact.rs | 59 +++++----- docs/concepts/canister-discovery.md | 28 +++++ examples/icp-project-dependency/README.md | 56 +++++++++ examples/icp-project-dependency/icp.yaml | 28 +++++ .../vendor/openemail/icp.yaml | 23 ++++ 7 files changed, 271 insertions(+), 31 deletions(-) create mode 100644 crates/icp-cli/tests/dependency_tests.rs create mode 100644 examples/icp-project-dependency/README.md create mode 100644 examples/icp-project-dependency/icp.yaml create mode 100644 examples/icp-project-dependency/vendor/openemail/icp.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b5ee1e0..1ccdba60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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::` 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 diff --git a/crates/icp-cli/tests/dependency_tests.rs b/crates/icp-cli/tests/dependency_tests.rs new file mode 100644 index 00000000..3ef779d2 --- /dev/null +++ b/crates/icp-cli/tests/dependency_tests.rs @@ -0,0 +1,107 @@ +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()), + ); +} diff --git a/crates/icp/src/store_artifact.rs b/crates/icp/src/store_artifact.rs index 8c2894f8..000554d6 100644 --- a/crates/icp/src/store_artifact.rs +++ b/crates/icp/src/store_artifact.rs @@ -140,37 +140,6 @@ impl Access for ArtifactStore { } } -#[cfg(test)] -mod tests { - use super::sanitize_artifact_name; - - #[test] - fn plain_names_unchanged() { - assert_eq!(sanitize_artifact_name("backend"), "backend"); - assert_eq!( - sanitize_artifact_name("my-canister_1.wasm"), - "my-canister_1.wasm" - ); - } - - #[test] - fn namespaced_names_are_filename_safe() { - let s = sanitize_artifact_name("vendor/openemail:backend"); - assert_eq!(s, "vendor%2Fopenemail%3Abackend"); - assert!(!s.contains('/')); - assert!(!s.contains(':')); - } - - #[test] - fn encoding_is_injective() { - // `%` is itself encoded, so a literal "%2F" never collides with "/". - assert_ne!( - sanitize_artifact_name("a%2Fb"), - sanitize_artifact_name("a/b") - ); - } -} - #[cfg(test)] /// In-memory mock implementation of `Access`. pub(crate) struct MockInMemoryArtifactStore { @@ -214,3 +183,31 @@ impl Access for MockInMemoryArtifactStore { } } } + +#[cfg(test)] +mod tests { + use super::sanitize_artifact_name; + + #[test] + fn plain_names_unchanged() { + assert_eq!(sanitize_artifact_name("backend"), "backend"); + assert_eq!( + sanitize_artifact_name("my-canister_1.wasm"), + "my-canister_1.wasm" + ); + } + + #[test] + fn namespaced_names_are_filename_safe() { + let s = sanitize_artifact_name("vendor/openemail:backend"); + assert_eq!(s, "vendor%2Fopenemail%3Abackend"); + assert!(!s.contains('/')); + assert!(!s.contains(':')); + } + + #[test] + fn encoding_is_injective() { + // `%` is itself encoded, so a literal "%2F" never collides with "/". + assert_ne!(sanitize_artifact_name("a%2Fb"), sanitize_artifact_name("a/b")); + } +} diff --git a/docs/concepts/canister-discovery.md b/docs/concepts/canister-discovery.md index 04bcdb38..7efca72d 100644 --- a/docs/concepts/canister-discovery.md +++ b/docs/concepts/canister-discovery.md @@ -133,6 +133,34 @@ If you prefer not to use canister environment variables: 1. **Init arguments** — Pass canister IDs as initialization parameters 2. **Configuration** — Store IDs in canister state during setup +## Dependency Projects + +A project can depend on another `icp` project vendored into it (for example as a git submodule) by declaring it under a top-level `dependencies:` block: + +```yaml +dependencies: + - name: openemail # local alias + path: ./vendor/openemail # directory containing the dependency's icp.yaml + canisters: [backend] # which of its canisters to expose (omit for all) +``` + +Running `icp deploy` deploys **all** of the dependency's canisters into the same environment (a dependency's canisters may call each other, so the whole dependency is always deployed) and injects the **selected** dependency canister IDs into your canisters under the alias: + +``` +PUBLIC_CANISTER_ID:openemail:backend → +``` + +Canister IDs are injected per project scope, so vendored code behaves the same whether deployed on its own or as a dependency: + +- Your project's canisters see their own canisters by name (`PUBLIC_CANISTER_ID:backend`) plus the exposed dependency canisters under the alias (`PUBLIC_CANISTER_ID:openemail:backend`). +- The dependency's canisters see only their own canisters, exactly as they would when deployed standalone (`PUBLIC_CANISTER_ID:backend`, `PUBLIC_CANISTER_ID:frontend`). + +Because two projects may each declare a canister with the same name, imported dependency canisters are keyed by their path relative to the project root (for example `vendor/openemail:backend`). Use that name to address a dependency canister directly, e.g. `icp canister status "vendor/openemail:backend"`. A dependency reached by the same directory through multiple paths (a shared dependency) is deployed once. + +> **Note:** `:` is reserved in canister names as the dependency namespace separator. + +See the [project-dependency example](https://github.com/dfinity/icp-cli/tree/main/examples/icp-project-dependency) for a complete setup. + ## Custom Canister Environment Variables Beyond automatic `PUBLIC_CANISTER_ID:*` variables, you can define custom canister environment variables in `icp.yaml`. See the [Environment Variables Reference](../reference/environment-variables.md#custom-variables) for configuration syntax. diff --git a/examples/icp-project-dependency/README.md b/examples/icp-project-dependency/README.md new file mode 100644 index 00000000..bf21f381 --- /dev/null +++ b/examples/icp-project-dependency/README.md @@ -0,0 +1,56 @@ +# Project Dependency Example + +This example shows how an `icp` project can depend on **another** `icp` project +that is vendored into it (typically as a git submodule), so both are developed in +one source tree and deployed together. + +## Layout + +``` +icp-project-dependency/ + icp.yaml # the "app" — has a `backend` canister and a dependency + vendor/ + openemail/ # a self-contained icp project (would be a git submodule) + icp.yaml # has `backend` and `frontend` canisters +``` + +The app declares: + +```yaml +dependencies: + - name: openemail + path: ./vendor/openemail + canisters: [backend] +``` + +## What `icp deploy` does + +Running `icp deploy` in this directory: + +1. Deploys the app's canisters **and all** of `openemail`'s canisters + (`backend` and `frontend`) into the same environment. The whole dependency is + always deployed because a dependency's canisters may call each other. +2. Injects the **selected** dependency canister IDs into the app's canisters as + environment variables. Here the app's `backend` receives + `PUBLIC_CANISTER_ID:openemail:backend`. + +Canister-ID environment variables are set per project scope: + +- The app's canisters see their own canisters by name + (`PUBLIC_CANISTER_ID:backend`) plus the exposed dependency canisters under the + alias (`PUBLIC_CANISTER_ID:openemail:backend`). +- `openemail`'s canisters see exactly what they would see when deployed + standalone: `PUBLIC_CANISTER_ID:backend` and `PUBLIC_CANISTER_ID:frontend`, + each resolving to openemail's own canisters — so vendored code behaves + identically whether deployed on its own or as a dependency. + +## Store keys + +Because the app and openemail both define a `backend` canister, imported +dependency canisters are keyed by their path relative to the project root, e.g. +`vendor/openemail:backend`. Use these names to address dependency canisters +directly: + +```bash +icp canister status "vendor/openemail:backend" +``` diff --git a/examples/icp-project-dependency/icp.yaml b/examples/icp-project-dependency/icp.yaml new file mode 100644 index 00000000..94457f82 --- /dev/null +++ b/examples/icp-project-dependency/icp.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/icp-yaml-schema.json +# +# This example shows how one icp project can depend on another icp project that +# is vendored into it (typically as a git submodule). See ./vendor/openemail for +# the self-contained dependency project. +# +# `icp deploy` here deploys this project's canisters AND all of openemail's +# canisters into the same environment, and injects the selected openemail +# canister IDs into this project's canisters as environment variables named +# `PUBLIC_CANISTER_ID:openemail:`. + +canisters: + - name: backend + build: + steps: + - type: pre-built + path: ../icp-pre-built/dist/hello_world.wasm + sha256: 17a05e36278cd04c7ae6d3d3226c136267b9df7525a0657521405e22ec96be7a + +dependencies: + # Declare a dependency on the vendored `openemail` project. + # name: local alias — namespaces its canister IDs (PUBLIC_CANISTER_ID:openemail:) + # path: directory containing openemail's icp.yaml + # canisters: which of openemail's canisters to expose to this project's canisters + # (omit to expose all). The whole dependency is always deployed regardless. + - name: openemail + path: ./vendor/openemail + canisters: [backend] diff --git a/examples/icp-project-dependency/vendor/openemail/icp.yaml b/examples/icp-project-dependency/vendor/openemail/icp.yaml new file mode 100644 index 00000000..bd564827 --- /dev/null +++ b/examples/icp-project-dependency/vendor/openemail/icp.yaml @@ -0,0 +1,23 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/icp-yaml-schema.json +# +# `openemail` is a self-contained icp project. It can be developed and deployed +# on its own (`icp deploy` from this directory), and it is also consumed as a +# dependency by the parent project one level up (../../icp.yaml). +# +# In the real workflow this directory would be a git submodule so its source can +# be iterated in place while the parent app is developed. + +canisters: + - name: backend + build: + steps: + - type: pre-built + path: ../../../icp-pre-built/dist/hello_world.wasm + sha256: 17a05e36278cd04c7ae6d3d3226c136267b9df7525a0657521405e22ec96be7a + + - name: frontend + build: + steps: + - type: pre-built + path: ../../../icp-pre-built/dist/hello_world.wasm + sha256: 17a05e36278cd04c7ae6d3d3226c136267b9df7525a0657521405e22ec96be7a From 8bf818ae90847c9bad7e16f03bf1f85f983c85ee Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 9 Jul 2026 09:52:34 -0400 Subject: [PATCH 6/8] test(deps): example and integration test for shared ("umbrella") dependencies Cover the layout where two independent sub-projects (service-a, service-b) each depend on the same sibling `openemail` via `../openemail`, and an app depends on both services. - Add examples/icp-project-dependency-shared demonstrating the umbrella layout. - Add an integration test that deploys the app + both services + openemail to a managed network and asserts openemail is deployed once and both services' `PUBLIC_CANISTER_ID:openemail:backend` resolve to the same shared canister id. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/icp-cli/tests/dependency_tests.rs | 114 ++++++++++++++++++ .../icp-project-dependency-shared/README.md | 65 ++++++++++ .../icp-project-dependency-shared/icp.yaml | 28 +++++ .../umbrella/openemail/icp.yaml | 12 ++ .../umbrella/service-a/icp.yaml | 17 +++ .../umbrella/service-b/icp.yaml | 18 +++ 6 files changed, 254 insertions(+) create mode 100644 examples/icp-project-dependency-shared/README.md create mode 100644 examples/icp-project-dependency-shared/icp.yaml create mode 100644 examples/icp-project-dependency-shared/umbrella/openemail/icp.yaml create mode 100644 examples/icp-project-dependency-shared/umbrella/service-a/icp.yaml create mode 100644 examples/icp-project-dependency-shared/umbrella/service-b/icp.yaml diff --git a/crates/icp-cli/tests/dependency_tests.rs b/crates/icp-cli/tests/dependency_tests.rs index 3ef779d2..573a53cf 100644 --- a/crates/icp-cli/tests/dependency_tests.rs +++ b/crates/icp-cli/tests/dependency_tests.rs @@ -105,3 +105,117 @@ async fn deploy_with_dependency_injects_namespaced_ids() { .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())), + ); + } +} diff --git a/examples/icp-project-dependency-shared/README.md b/examples/icp-project-dependency-shared/README.md new file mode 100644 index 00000000..dcef554e --- /dev/null +++ b/examples/icp-project-dependency-shared/README.md @@ -0,0 +1,65 @@ +# Shared Dependency ("Umbrella") Example + +This example shows a **shared** dependency: two independent sub-projects each +depend on the same third project, and it is deployed **once** and shared. + +## Layout + +``` +icp-project-dependency-shared/ + icp.yaml # the "app" — depends on both services + umbrella/ # bundle of sibling projects (a git submodule in practice) + service-a/ + icp.yaml # depends on ../openemail + service-b/ + icp.yaml # depends on ../openemail + openemail/ + icp.yaml # the shared service +``` + +`service-a` and `service-b` are self-contained `icp` projects — each can be +deployed on its own — and each declares: + +```yaml +dependencies: + - name: openemail + path: ../openemail + canisters: [backend] +``` + +The app depends on both services: + +```yaml +dependencies: + - name: service-a + path: ./umbrella/service-a + canisters: [service] + - name: service-b + path: ./umbrella/service-b + canisters: [service] +``` + +## Shared instance + +Both `service-a` and `service-b` reach `openemail` through `../openemail`, which +resolves to the **same** directory (`umbrella/openemail`). A dependency's identity +is its resolved source directory, so openemail is imported **once**: + +Running `icp deploy` produces these canisters: + +- `frontend` (the app) +- `umbrella/service-a:service` +- `umbrella/service-b:service` +- `umbrella/openemail:backend` — a **single** shared instance + +Both services' canisters read `PUBLIC_CANISTER_ID:openemail:backend`, and both +resolve to that one shared canister. If the two services instead vendored +separate copies of openemail (different directories), they would get isolated +instances. + +Inspect the result with: + +```bash +icp project show +icp canister status "umbrella/openemail:backend" +``` diff --git a/examples/icp-project-dependency-shared/icp.yaml b/examples/icp-project-dependency-shared/icp.yaml new file mode 100644 index 00000000..5d687747 --- /dev/null +++ b/examples/icp-project-dependency-shared/icp.yaml @@ -0,0 +1,28 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/icp-yaml-schema.json +# +# Shared-dependency ("umbrella") example. +# +# `umbrella/` bundles three sibling projects — `service-a`, `service-b`, and +# `openemail` — as it would appear when vendored as a git submodule. Both +# `service-a` and `service-b` depend on `openemail` via `../openemail`. +# +# This app depends on both services. Because both service edges resolve to the +# SAME `umbrella/openemail` directory, openemail is deployed exactly once and +# shared: `service-a`'s and `service-b`'s canisters both discover the one +# openemail instance through `PUBLIC_CANISTER_ID:openemail:backend`. + +canisters: + - name: frontend + build: + steps: + - type: pre-built + path: ../icp-pre-built/dist/hello_world.wasm + sha256: 17a05e36278cd04c7ae6d3d3226c136267b9df7525a0657521405e22ec96be7a + +dependencies: + - name: service-a + path: ./umbrella/service-a + canisters: [service] + - name: service-b + path: ./umbrella/service-b + canisters: [service] diff --git a/examples/icp-project-dependency-shared/umbrella/openemail/icp.yaml b/examples/icp-project-dependency-shared/umbrella/openemail/icp.yaml new file mode 100644 index 00000000..efaace4e --- /dev/null +++ b/examples/icp-project-dependency-shared/umbrella/openemail/icp.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/icp-yaml-schema.json +# +# A self-contained shared service, consumed by both sibling projects +# `../service-a` and `../service-b`. + +canisters: + - name: backend + build: + steps: + - type: pre-built + path: ../../../icp-pre-built/dist/hello_world.wasm + sha256: 17a05e36278cd04c7ae6d3d3226c136267b9df7525a0657521405e22ec96be7a diff --git a/examples/icp-project-dependency-shared/umbrella/service-a/icp.yaml b/examples/icp-project-dependency-shared/umbrella/service-a/icp.yaml new file mode 100644 index 00000000..ed7a67b7 --- /dev/null +++ b/examples/icp-project-dependency-shared/umbrella/service-a/icp.yaml @@ -0,0 +1,17 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/icp-yaml-schema.json +# +# A self-contained service that depends on the sibling `openemail` project. +# Deployable on its own (`icp deploy` here) and reused by the parent app. + +canisters: + - name: service + build: + steps: + - type: pre-built + path: ../../../icp-pre-built/dist/hello_world.wasm + sha256: 17a05e36278cd04c7ae6d3d3226c136267b9df7525a0657521405e22ec96be7a + +dependencies: + - name: openemail + path: ../openemail + canisters: [backend] diff --git a/examples/icp-project-dependency-shared/umbrella/service-b/icp.yaml b/examples/icp-project-dependency-shared/umbrella/service-b/icp.yaml new file mode 100644 index 00000000..5a5b8a63 --- /dev/null +++ b/examples/icp-project-dependency-shared/umbrella/service-b/icp.yaml @@ -0,0 +1,18 @@ +# yaml-language-server: $schema=https://github.com/dfinity/icp-cli/raw/refs/tags/v0.1.0/docs/schemas/icp-yaml-schema.json +# +# A second self-contained service that also depends on the sibling `openemail` +# project. When both services are deployed together (via the parent app), they +# share a single openemail instance. + +canisters: + - name: service + build: + steps: + - type: pre-built + path: ../../../icp-pre-built/dist/hello_world.wasm + sha256: 17a05e36278cd04c7ae6d3d3226c136267b9df7525a0657521405e22ec96be7a + +dependencies: + - name: openemail + path: ../openemail + canisters: [backend] From fd450dafd51060395580b809a3dd95be3c5458d9 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 9 Jul 2026 09:55:56 -0400 Subject: [PATCH 7/8] chore: fmt --- crates/icp/src/store_artifact.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/icp/src/store_artifact.rs b/crates/icp/src/store_artifact.rs index 000554d6..0f2a715d 100644 --- a/crates/icp/src/store_artifact.rs +++ b/crates/icp/src/store_artifact.rs @@ -208,6 +208,9 @@ mod tests { #[test] fn encoding_is_injective() { // `%` is itself encoded, so a literal "%2F" never collides with "/". - assert_ne!(sanitize_artifact_name("a%2Fb"), sanitize_artifact_name("a/b")); + assert_ne!( + sanitize_artifact_name("a%2Fb"), + sanitize_artifact_name("a/b") + ); } } From 7354b0e1b561a93b19dc4670811766e1c43cd843 Mon Sep 17 00:00:00 2001 From: Linwei Shang Date: Thu, 9 Jul 2026 10:29:23 -0400 Subject: [PATCH 8/8] fix(deps): upgrade crossbeam-epoch fo RUSTSEC-2026-0204 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1fc708f2..4fb9f6a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1621,9 +1621,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ]