diff --git a/crates/icp-cli/src/operations/sync.rs b/crates/icp-cli/src/operations/sync.rs index 18c77efcc..77ea04174 100644 --- a/crates/icp-cli/src/operations/sync.rs +++ b/crates/icp-cli/src/operations/sync.rs @@ -59,6 +59,7 @@ async fn sync_canister( &Params { path: canister_path.clone(), cid: canister_id, + name: canister_info.name.clone(), environment: environment.to_owned(), network: network.to_owned(), canister_ids: canister_ids.clone(), diff --git a/crates/icp-sync-plugin/DESIGN.md b/crates/icp-sync-plugin/DESIGN.md index dd54eb68f..2a5787b6e 100644 --- a/crates/icp-sync-plugin/DESIGN.md +++ b/crates/icp-sync-plugin/DESIGN.md @@ -31,6 +31,11 @@ docs; the *reasons* behind those choices are recorded here. from `sync-exec-input.canister-id`. There is deliberately no field for a different target, so the single-canister restriction is *structural* rather than a policy the plugin could bypass. +- **`sync-exec-input` carries the canister ID table** — `canister-ids` exposes + the project's name→principal map for the environment, so a plugin can resolve + canister names it knows about. It is informational only: `canister-call` + still targets the canister being synced, so the table grants no ability to + call other canisters. - **Filesystem access via WASI, not a host import** — plugins use standard language APIs (`std::fs`); the host preopens the declared `dirs` read-only. No bespoke `read-file`/`list-dir` import is needed. @@ -62,21 +67,14 @@ crates/icp-sync-plugin/ Public function: ```rust -pub fn run_plugin( - wasm_path: Utf8PathBuf, - base_dir: Utf8PathBuf, - dirs: Vec, - files: Vec, - target_canister_id: Principal, - agent: Agent, - proxy: Option, - identity_principal: Principal, - environment: String, - compute_limit_secs: u64, - stdio: Option>, -) -> Result, RunPluginError> +pub fn run_plugin(invocation: PluginInvocation) -> Result, RunPluginError> ``` +`PluginInvocation` bundles the inputs: `wasm_path`, `base_dir`, `dirs`, `files`, +`target_canister_id` (the canister being synced), `agent`, `proxy`, +`identity_principal`, `environment`, `compute_limit_secs`, and the exposed +`canister_ids` table, plus `stdio`. + `dirs` and `files` are the manifest-relative path strings, straight from the adapter. The runtime owns *all* filesystem access anchored at `base_dir`: it preopens each `dir` from `base_dir.join(dir)` and reads each `file` from @@ -188,7 +186,9 @@ pub struct Adapter { ### `crates/icp/src/canister/sync/plugin.rs` Resolves the wasm (local read or remote HTTP fetch into the package cache), -verifies sha256, then calls `icp_sync_plugin::run_plugin(...)`, forwarding the -manifest's `dirs`/`files` strings unchanged. The runtime — not the CLI — opens -those paths and enforces the path-safety checks, so the CLI no longer touches -the plugin's input files itself. +verifies sha256, builds the exposed canister ID table, then calls +`icp_sync_plugin::run_plugin(...)` with a `PluginInvocation`. The runtime — not +the CLI — opens the declared paths and enforces the path-safety checks, so the +CLI no longer touches the plugin's input files itself. `exposed_canister_ids` +adds a bare-local-name duplicate for every canister in the same subproject as +the one being synced. diff --git a/crates/icp-sync-plugin/src/lib.rs b/crates/icp-sync-plugin/src/lib.rs index dfffddc4c..84c5f4e41 100644 --- a/crates/icp-sync-plugin/src/lib.rs +++ b/crates/icp-sync-plugin/src/lib.rs @@ -2,5 +2,6 @@ mod path; mod runtime; pub use runtime::{ - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, RunPluginError, run_plugin, + DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, PluginInvocation, RunPluginError, + run_plugin, }; diff --git a/crates/icp-sync-plugin/src/runtime.rs b/crates/icp-sync-plugin/src/runtime.rs index 27091fc34..50406b383 100644 --- a/crates/icp-sync-plugin/src/runtime.rs +++ b/crates/icp-sync-plugin/src/runtime.rs @@ -1,4 +1,5 @@ // Host-side Component Model runtime for sync plugins. +use std::collections::BTreeMap; use std::pin::Pin; use std::sync::Arc; use std::sync::Mutex as StdMutex; @@ -38,9 +39,8 @@ use wasmtime_wasi::{DirPerms, FilePerms}; // module so their generated type names don't collide. `run_plugin` reads the // interface version from the component's own metadata (see `detect_plugin_abi`) // and drives it through the matching module, so plugins built against either -// interface load. The two interfaces are currently structurally identical; the -// split exists so later breaking changes to the current interface can land -// without dropping support for already-built plugins. +// interface load. The split exists so breaking changes to the current interface +// can land without dropping support for already-built plugins. mod v2 { wasmtime::component::bindgen!({ world: "sync-plugin", @@ -55,7 +55,7 @@ mod v1 { }); } -use v2::icp::sync_plugin::types::CallType; +use v2::icp::sync_plugin::types::{CallType, CanisterIdEntry}; // HostState holds everything the plugin's import functions need. struct HostState { @@ -328,20 +328,53 @@ fn detect_plugin_abi( } } -#[allow(clippy::too_many_arguments)] -pub fn run_plugin( - wasm_path: Utf8PathBuf, - base_dir: Utf8PathBuf, - dirs: Vec, - files: Vec, - target_canister_id: Principal, - agent: Agent, - proxy: Option, - identity_principal: Principal, - environment: String, - compute_limit_secs: u64, - stdio: Option>, -) -> Result, RunPluginError> { +/// Everything [`run_plugin`] needs to load and drive one sync plugin. +#[derive(Debug)] +pub struct PluginInvocation { + /// On-disk path to the plugin's wasm component. + pub wasm_path: Utf8PathBuf, + /// Directory the declared `dirs`/`files` are anchored at (the canister dir). + pub base_dir: Utf8PathBuf, + /// Manifest-relative directories to preopen read-only. + pub dirs: Vec, + /// Manifest-relative files to read and pass inline. + pub files: Vec, + /// The canister being synced. + pub target_canister_id: Principal, + /// Agent used for canister calls. + pub agent: Agent, + /// Proxy canister to route update calls through, if configured. + pub proxy: Option, + /// Signing identity principal, surfaced to the plugin. + pub identity_principal: Principal, + /// Name of the environment being synced. + pub environment: String, + /// Pure-wasm compute-time budget in seconds. + pub compute_limit_secs: u64, + /// The project's canister ID table for this environment, as exposed to the + /// plugin. Same-project canisters appear both under their fully-qualified + /// key and their bare local name (see the WIT `canister-id-entry` docs). + pub canister_ids: BTreeMap, + /// Channel for live rolling-view output, if any. + pub stdio: Option>, +} + +pub fn run_plugin(invocation: PluginInvocation) -> Result, RunPluginError> { + let PluginInvocation { + wasm_path, + base_dir, + dirs, + files, + target_canister_id, + agent, + proxy, + identity_principal, + environment, + compute_limit_secs, + canister_ids, + stdio, + } = invocation; + let mut config = Config::new(); config.wasm_component_model(true); config.max_wasm_stack(MAX_WASM_STACK); @@ -485,6 +518,13 @@ pub fn run_plugin( .collect(), identity_principal: identity_text, proxy_canister_id: proxy_text, + canister_ids: canister_ids + .into_iter() + .map(|(name, id)| CanisterIdEntry { + name, + id: id.to_text(), + }) + .collect(), }; plugin.call_exec(&mut store, &input) } @@ -702,25 +742,34 @@ mod tests { Principal::anonymous() } + /// A [`PluginInvocation`] with test-friendly defaults: anonymous canister + /// and identity, no proxy, an empty canister ID table, the default compute + /// limit, and the current directory as the base. Individual tests override + /// the few fields they care about. + fn invocation(wasm_path: &str, environment: &str) -> PluginInvocation { + PluginInvocation { + wasm_path: wasm_path.into(), + base_dir: ".".into(), + dirs: vec![], + files: vec![], + target_canister_id: anon(), + agent: dummy_agent(), + proxy: None, + identity_principal: anon(), + environment: environment.to_string(), + compute_limit_secs: DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, + canister_ids: BTreeMap::new(), + stdio: None, + } + } + // ------------------------------------------------------------------------- // Error-path tests — no fixture WASM needed // ------------------------------------------------------------------------- #[test] fn load_component_error_on_missing_file() { - let result = run_plugin( - "nonexistent.wasm".into(), - ".".into(), - vec![], - vec![], - anon(), - dummy_agent(), - None, - anon(), - "test".to_string(), - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, - None, - ); + let result = run_plugin(invocation("nonexistent.wasm", "test")); assert!(matches!(result, Err(RunPluginError::LoadComponent { .. }))); } @@ -746,20 +795,12 @@ mod tests { let Some(wasm_path) = option_env!("TEST_PLUGIN_WASM") else { return; }; - let result = run_plugin( - wasm_path.into(), - ".".into(), - vec!["nonexistent_dir".to_string()], - vec![], - anon(), - dummy_agent(), - None, - anon(), - "test".to_string(), - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, - None, - ); - assert!(matches!(result, Err(RunPluginError::PreopenDir { .. }))); + let mut inv = invocation(wasm_path, "test"); + inv.dirs = vec!["nonexistent_dir".to_string()]; + assert!(matches!( + run_plugin(inv), + Err(RunPluginError::PreopenDir { .. }) + )); } #[cfg(unix)] @@ -774,20 +815,13 @@ mod tests { std::fs::create_dir_all(base.join("real")).expect("create real dir"); symlink(base.join("real"), base.join("link")).expect("create symlink"); - let result = run_plugin( - wasm_path.into(), - base.to_path_buf(), - vec!["link".to_string()], - vec![], - anon(), - dummy_agent(), - None, - anon(), - "test".to_string(), - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, - None, - ); - assert!(matches!(result, Err(RunPluginError::SymlinkDir { .. }))); + let mut inv = invocation(wasm_path, "test"); + inv.base_dir = base.to_path_buf(); + inv.dirs = vec!["link".to_string()]; + assert!(matches!( + run_plugin(inv), + Err(RunPluginError::SymlinkDir { .. }) + )); } #[test] @@ -795,20 +829,12 @@ mod tests { let Some(wasm_path) = option_env!("TEST_PLUGIN_WASM") else { return; }; - let result = run_plugin( - wasm_path.into(), - ".".into(), - vec![], - vec!["nonexistent_file.txt".to_string()], - anon(), - dummy_agent(), - None, - anon(), - "test".to_string(), - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, - None, - ); - assert!(matches!(result, Err(RunPluginError::ReadFile { .. }))); + let mut inv = invocation(wasm_path, "test"); + inv.files = vec!["nonexistent_file.txt".to_string()]; + assert!(matches!( + run_plugin(inv), + Err(RunPluginError::ReadFile { .. }) + )); } #[cfg(unix)] @@ -823,20 +849,13 @@ mod tests { std::fs::write(base.join("real.txt"), b"data").expect("write real file"); symlink(base.join("real.txt"), base.join("link.txt")).expect("create symlink"); - let result = run_plugin( - wasm_path.into(), - base.to_path_buf(), - vec![], - vec!["link.txt".to_string()], - anon(), - dummy_agent(), - None, - anon(), - "test".to_string(), - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, - None, - ); - assert!(matches!(result, Err(RunPluginError::SymlinkFile { .. }))); + let mut inv = invocation(wasm_path, "test"); + inv.base_dir = base.to_path_buf(); + inv.files = vec!["link.txt".to_string()]; + assert!(matches!( + run_plugin(inv), + Err(RunPluginError::SymlinkFile { .. }) + )); } #[test] @@ -844,20 +863,7 @@ mod tests { let Some(wasm_path) = option_env!("TEST_PLUGIN_WASM") else { return; }; - let result = run_plugin( - wasm_path.into(), - ".".into(), - vec![], - vec![], - anon(), - dummy_agent(), - None, - anon(), - "ok".to_string(), - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, - None, - ); - assert!(result.is_ok()); + assert!(run_plugin(invocation(wasm_path, "ok")).is_ok()); } #[test] @@ -865,21 +871,8 @@ mod tests { let Some(wasm_path) = option_env!("TEST_PLUGIN_WASM") else { return; }; - let result = run_plugin( - wasm_path.into(), - ".".into(), - vec![], - vec![], - anon(), - dummy_agent(), - None, - anon(), - "error".to_string(), - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, - None, - ); assert!(matches!( - result, + run_plugin(invocation(wasm_path, "error")), Err(RunPluginError::PluginFailed { ref message }) if message == "deliberate failure" )); } @@ -891,20 +884,9 @@ mod tests { }; // The "spin" fixture busy-loops forever; a 1-second limit keeps the // test fast while still exercising the epoch-interruption trap. - let result = run_plugin( - wasm_path.into(), - ".".into(), - vec![], - vec![], - anon(), - dummy_agent(), - None, - anon(), - "spin".to_string(), - 1, - None, - ); - let err = result.expect_err("spinning plugin should hit the compute limit"); + let mut inv = invocation(wasm_path, "spin"); + inv.compute_limit_secs = 1; + let err = run_plugin(inv).expect_err("spinning plugin should hit the compute limit"); // The trap surfaces through the CallExec source chain, so walk it and // assert the message names both the limit and the override env var. let mut chain = err.to_string(); @@ -926,19 +908,9 @@ mod tests { }; let (tx, mut rx) = tokio::sync::mpsc::channel::(16); let result = tokio::task::block_in_place(|| { - run_plugin( - wasm_path.into(), - ".".into(), - vec![], - vec![], - anon(), - dummy_agent(), - None, - anon(), - "print".to_string(), - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, - Some(tx), - ) + let mut inv = invocation(wasm_path, "print"); + inv.stdio = Some(tx); + run_plugin(inv) }); assert!(result.is_ok()); let msg = rx.try_recv().expect("expected stdout message on channel"); @@ -952,36 +924,10 @@ mod tests { let Some(wasm_path) = option_env!("TEST_PLUGIN_V1_WASM") else { return; }; - let result = run_plugin( - wasm_path.into(), - ".".into(), - vec![], - vec![], - anon(), - dummy_agent(), - None, - anon(), - "ok".to_string(), - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, - None, - ); - assert!(result.is_ok()); + assert!(run_plugin(invocation(wasm_path, "ok")).is_ok()); // Its error surface flows through the same machinery as v0.2.0 plugins. - let result = run_plugin( - wasm_path.into(), - ".".into(), - vec![], - vec![], - anon(), - dummy_agent(), - None, - anon(), - "error".to_string(), - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, - None, - ); assert!(matches!( - result, + run_plugin(invocation(wasm_path, "error")), Err(RunPluginError::PluginFailed { ref message }) if message == "deliberate v1 failure" )); } @@ -993,19 +939,9 @@ mod tests { }; let (tx, mut rx) = tokio::sync::mpsc::channel::(16); let result = tokio::task::block_in_place(|| { - run_plugin( - wasm_path.into(), - ".".into(), - vec![], - vec![], - anon(), - dummy_agent(), - None, - anon(), - "hello".to_string(), - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, - Some(tx), - ) + let mut inv = invocation(wasm_path, "hello"); + inv.stdio = Some(tx); + run_plugin(inv) }); let lines = result.expect("plugin should succeed"); assert_eq!(lines, vec!["hello".to_string()]); diff --git a/crates/icp-sync-plugin/sync-plugin.wit b/crates/icp-sync-plugin/sync-plugin.wit index 9e7ffb297..e201568b5 100644 --- a/crates/icp-sync-plugin/sync-plugin.wit +++ b/crates/icp-sync-plugin/sync-plugin.wit @@ -14,6 +14,25 @@ interface types { content: string, } + /// An entry in the project's canister ID mapping table: a canister name + /// and the textual principal it resolves to in the environment being synced. + record canister-id-entry { + /// The canister's fully-qualified project key: the subproject it belongs + /// to (a path relative to the app root) joined to its local name by a + /// single colon, e.g. "services/open-accounts:backend". A canister + /// defined directly in the app root has no subproject prefix and appears + /// as its bare local name, e.g. "backend". + /// + /// Every canister in the same subproject as the canister being synced is + /// additionally listed under its bare local name (a duplicate entry with + /// the same `id`), so a plugin can address a sibling by the same local + /// name the manifest uses. A bare name always means the sibling, so an + /// app-root canister sharing that local name is not listed. + name: string, + /// Textual principal the name resolves to for this environment. + id: string, + } + /// Input passed by the runtime to the plugin's exec() export. record sync-exec-input { /// Textual principal of the canister being synced. @@ -32,6 +51,12 @@ interface types { /// Textual principal of the proxy canister, if one was configured via /// `--proxy`. None when no proxy is in use. proxy-canister-id: option, + /// Name→principal mapping for every named canister in the project for + /// the environment being synced, sorted by name. Informational: the + /// plugin may use it to resolve canister names it knows about. Being + /// listed here does not let the plugin call a canister — the + /// `canister-call` import always targets the canister being synced. + canister-ids: list, } /// A request to call a method on the target canister. @@ -59,7 +84,7 @@ interface types { /// The complete interface of a sync plugin. world sync-plugin { - use types.{sync-exec-input, canister-call-request, file-input}; + use types.{sync-exec-input, canister-call-request, canister-id-entry, file-input}; // ------------------------------------------------------------------------- // Host functions (imports) — provided by icp-cli, called by the plugin diff --git a/crates/icp/src/canister/sync/mod.rs b/crates/icp/src/canister/sync/mod.rs index 519a75b71..93dfdd0bd 100644 --- a/crates/icp/src/canister/sync/mod.rs +++ b/crates/icp/src/canister/sync/mod.rs @@ -19,6 +19,10 @@ use script::{HostScripts, ScriptInvocation, ScriptRunError, ScriptRunner}; pub struct Params { pub path: PathBuf, pub cid: Principal, + /// Fully-qualified store key of the canister being synced (e.g. `backend`, + /// or `services/open-crm:backend` for a dependency canister). Its namespace + /// prefix identifies which other canisters are in the same subproject. + pub name: String, /// Name of the environment being synced (e.g. "local", "production"). /// Passed to sync plugin steps via `SyncExecInput`. pub environment: String, @@ -165,6 +169,7 @@ mod tests { let params = Params { path: "/work/backend".into(), cid, + name: "backend".to_owned(), environment: "production".to_owned(), network: "ic".to_owned(), canister_ids: BTreeMap::from([( diff --git a/crates/icp/src/canister/sync/plugin.rs b/crates/icp/src/canister/sync/plugin.rs index 97056d64d..22f749724 100644 --- a/crates/icp/src/canister/sync/plugin.rs +++ b/crates/icp/src/canister/sync/plugin.rs @@ -1,8 +1,11 @@ +use std::collections::BTreeMap; + use camino::Utf8PathBuf; use candid::Principal; use ic_agent::Agent; use icp_sync_plugin::{ - DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, RunPluginError, run_plugin, + DEFAULT_PLUGIN_COMPUTE_LIMIT_SECS, PLUGIN_COMPUTE_LIMIT_ENV, PluginInvocation, RunPluginError, + run_plugin, }; use snafu::prelude::*; use tokio::sync::mpsc::Sender; @@ -91,7 +94,10 @@ pub(super) async fn sync( let dirs: Vec = adapter.dirs.clone().unwrap_or_default(); let files: Vec = adapter.files.clone().unwrap_or_default(); - // 3. Run the plugin (blocking call — signal Tokio that this thread will block). + // 3. Build the canister ID table exposed to the plugin. + let canister_ids = exposed_canister_ids(params); + + // 4. Run the plugin (blocking call — signal Tokio that this thread will block). let identity_principal = agent .get_principal() .map_err(|err| PluginError::GetIdentityPrincipal { err })?; @@ -101,23 +107,49 @@ pub(super) async fn sync( let stdio_clone = stdio.clone(); tokio::task::block_in_place(|| { - run_plugin( + run_plugin(PluginInvocation { wasm_path, base_dir, dirs, files, - params.cid, - agent_clone, + target_canister_id: params.cid, + agent: agent_clone, proxy, identity_principal, - environment_owned, + environment: environment_owned, compute_limit_secs, - stdio_clone, - ) + canister_ids, + stdio: stdio_clone, + }) }) .context(RunSnafu) } +/// The canister ID table exposed to a sync plugin: every named canister in the +/// project, plus — for canisters in the same subproject as the one being synced +/// — a duplicate entry under the bare local name. A store key is +/// `:` for a dependency canister and a bare local name for a +/// canister defined directly in the app root (see the WIT `canister-id-entry` +/// docs), so the syncing canister's namespace is the prefix of its own key. +/// +/// A local name never contains a colon but a subproject directory may, so keys +/// split on their *last* colon. The bare-name aliases take precedence over an +/// app-root canister of the same local name: a plugin resolving a bare name is +/// naming what the syncing canister's own manifest calls it. +fn exposed_canister_ids(params: &Params) -> BTreeMap { + let syncing_namespace = params.name.rsplit_once(':').map(|(namespace, _)| namespace); + + let mut table = params.canister_ids.clone(); + for (key, id) in ¶ms.canister_ids { + if let Some((namespace, local)) = key.rsplit_once(':') + && Some(namespace) == syncing_namespace + { + table.insert(local.to_owned(), *id); + } + } + table +} + #[cfg(test)] mod tests { use super::*; @@ -140,4 +172,108 @@ mod tests { ); } } + + fn principal(byte: u8) -> Principal { + Principal::from_slice(&[byte; 4]) + } + + fn params_named(name: &str, ids: &[(&str, Principal)]) -> Params { + Params { + path: "/work".into(), + cid: principal(0), + name: name.to_owned(), + environment: "demo".to_owned(), + network: "ic".to_owned(), + canister_ids: ids.iter().map(|(n, p)| ((*n).to_owned(), *p)).collect(), + proxy: None, + } + } + + /// Canisters sharing the syncing canister's subproject are additionally + /// exposed under their bare local name; canisters in other subprojects are + /// not. + #[test] + fn exposed_ids_add_bare_names_for_same_subproject() { + let backend = principal(1); + let frontend = principal(2); + let foreign = principal(3); + let params = params_named( + "services/open-accounts:backend", + &[ + ("services/open-accounts:backend", backend), + ("services/open-accounts:frontend", frontend), + ("services/open-crm:backend", foreign), + ], + ); + + let table = exposed_canister_ids(¶ms); + + // Same-subproject canisters gain a bare-local duplicate... + assert_eq!(table.get("backend"), Some(&backend)); + assert_eq!(table.get("frontend"), Some(&frontend)); + // ...while the fully-qualified keys are still present for everyone. + assert_eq!( + table.get("services/open-accounts:frontend"), + Some(&frontend) + ); + assert_eq!(table.get("services/open-crm:backend"), Some(&foreign)); + // The other subproject's canister is not reachable by a bare name; the + // bare "backend" belongs to the syncing canister's own subproject. + assert_eq!(table.get("backend"), Some(&backend)); + } + + /// An app-root canister sharing a local name with a sibling of the syncing + /// canister does not keep the bare name: the syncing subproject's own + /// canister is what that name means to the plugin. + #[test] + fn exposed_ids_sibling_alias_overrides_the_app_root_name() { + let root_backend = principal(1); + let sibling_backend = principal(2); + let params = params_named( + "services/open-accounts:frontend", + &[ + ("backend", root_backend), + ("services/open-accounts:backend", sibling_backend), + ("services/open-accounts:frontend", principal(3)), + ], + ); + + let table = exposed_canister_ids(¶ms); + + assert_eq!(table.get("backend"), Some(&sibling_backend)); + // The app-root canister's only key was that bare name, so it drops out + // of the table entirely rather than answering to a sibling's name. + assert!(!table.values().any(|id| *id == root_backend)); + } + + /// A subproject directory may itself contain a colon, so keys are split on + /// their last one — the same rule bundling uses. + #[test] + fn exposed_ids_split_subproject_prefix_at_the_last_colon() { + let backend = principal(1); + let frontend = principal(2); + let params = params_named( + "services/odd:name:backend", + &[ + ("services/odd:name:backend", backend), + ("services/odd:name:frontend", frontend), + ], + ); + + let table = exposed_canister_ids(¶ms); + + assert_eq!(table.get("backend"), Some(&backend)); + assert_eq!(table.get("frontend"), Some(&frontend)); + } + + /// A single-project layout keys canisters by bare local name already, so no + /// duplicates are added. + #[test] + fn exposed_ids_unchanged_without_a_subproject() { + let backend = principal(1); + let params = params_named("backend", &[("backend", backend)]); + let table = exposed_canister_ids(¶ms); + assert_eq!(table.len(), 1); + assert_eq!(table.get("backend"), Some(&backend)); + } } diff --git a/crates/icp/src/canister/sync/script.rs b/crates/icp/src/canister/sync/script.rs index 7c9d741d7..e26d73171 100644 --- a/crates/icp/src/canister/sync/script.rs +++ b/crates/icp/src/canister/sync/script.rs @@ -145,6 +145,7 @@ mod tests { Params { path: "/work/backend".into(), cid: principal(1), + name: "backend".to_owned(), environment: "production".to_owned(), network: "ic".to_owned(), canister_ids: canister_ids diff --git a/docs/concepts/sync-plugins.md b/docs/concepts/sync-plugins.md index f8310deea..a7a6eb3f9 100644 --- a/docs/concepts/sync-plugins.md +++ b/docs/concepts/sync-plugins.md @@ -3,7 +3,7 @@ title: Sync Plugins description: How sync plugins extend the sync phase with sandboxed WebAssembly components that run arbitrary post-deployment logic against a canister. --- -A **sync plugin** is a WebAssembly component that runs during the [sync phase](build-deploy-sync.md#sync-phase) to perform arbitrary post-deployment work against a single canister. icp-cli loads the plugin into a sandboxed [wasmtime](https://wasmtime.dev/) WASI runtime, hands it the ID of the canister being synced, and lets it make canister calls and read declared files — nothing more. +A **sync plugin** is a WebAssembly component that runs during the [sync phase](build-deploy-sync.md#sync-phase) to perform arbitrary post-deployment work against a single canister. icp-cli loads the plugin into a sandboxed [wasmtime](https://wasmtime.dev/) WASI runtime, hands it the ID of the canister being synced (plus the project's canister ID table), and lets it make canister calls and read declared files — nothing more. You declare a sync plugin in your manifest with a `plugin` sync step. For the exact manifest fields, see [Plugin Sync in the Configuration Reference](../reference/configuration.md#plugin-sync). To author your own plugin, see [Writing a Sync Plugin](../guides/writing-sync-plugins.md). @@ -35,6 +35,7 @@ icp sync ├─ exec(sync-exec-input) called │ canister-id = │ identity-principal = + │ canister-ids = │ dirs / files = what you declared in the manifest │ └─ plugin makes canister-call(...) to the target canister (× N) @@ -68,6 +69,9 @@ The authoritative interface, including all record fields, lives in [`sync-plugin | `files` | The files you declared in `files:`, each as a `(name, content)` pair read by the host | | `identity-principal` | Textual principal of the signing identity used for canister calls | | `proxy-canister-id` | Textual principal of the proxy canister if one was configured via `--proxy`, otherwise absent | +| `canister-ids` | The project's canister ID table for this environment — each entry a canister name and the principal it resolves to. Informational; being listed here does not grant permission to call a canister | + +Each `canister-ids` entry's name is the canister's fully-qualified project key: a bare local name for a canister defined in the app root, or a `subproject:canister` key for a canister that came from a dependency. Canisters in the same subproject as the one being synced are additionally listed under their bare local name, so a plugin can look up a sibling by the name that subproject's manifest uses. A bare name always means the sibling: if an app-root canister has the same local name, it is not listed for that sync. ### Calling the canister — `canister-call`