Skip to content

Commit 032cc49

Browse files
committed
copilot
1 parent c6097d2 commit 032cc49

8 files changed

Lines changed: 178 additions & 33 deletions

File tree

‎crates/icp-sync-plugin/sync-plugin.wit‎

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,19 @@ interface types {
5151
/// defined directly in the app root has no subproject prefix and appears
5252
/// as its bare local name, e.g. "backend".
5353
///
54-
/// Every canister in the same subproject as the canister being synced is
55-
/// additionally listed under its bare local name (a duplicate entry with
56-
/// the same `id`), so a plugin can address a sibling by the same local
57-
/// name the manifest uses. A bare name always means the sibling, so an
58-
/// app-root canister sharing that local name is not listed.
54+
/// Every canister the subproject being synced can name for itself is
55+
/// additionally listed under that name (a duplicate entry with the same
56+
/// `id`): its own canisters under their bare local names, and the
57+
/// canisters of subprojects nested below it under the
58+
/// `subproject:local` key those would have if it were the app root —
59+
/// e.g. for "services/crm:backend", the canister
60+
/// "services/crm/vendor/ledger:ledger" is also listed as
61+
/// "vendor/ledger:ledger". These are the keys the subproject's own
62+
/// manifest uses, so a plugin addresses the same canister by the same
63+
/// name whether the subproject is deployed standalone or vendored into
64+
/// a workspace. Such a name always means what the subproject means by
65+
/// it, so a canister elsewhere in the workspace whose key is spelled
66+
/// the same way is not listed under it.
5967
name: string,
6068
/// Textual principal the name resolves to for this environment.
6169
id: string,
@@ -71,10 +79,10 @@ interface types {
7179
/// permitted, whether or not it also appears in `canisters`.
7280
host,
7381
/// A canister from the `canisters` list, identified by name, spelled
74-
/// exactly as it appears in `sync-exec-input.canister-ids` — a bare
75-
/// local name for a canister in the same subproject, or a
76-
/// `subproject:local` key otherwise. The host resolves it against that
77-
/// mapping table.
82+
/// exactly as it appears in `sync-exec-input.canister-ids` — for a
83+
/// canister the synced canister's own subproject names, the name that
84+
/// subproject uses; otherwise the app-root-relative `subproject:local`
85+
/// key. The host resolves it against that mapping table.
7886
name(string),
7987
}
8088

‎crates/icp/src/canister/sync/plugin.rs‎

Lines changed: 145 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -158,31 +158,60 @@ pub(super) async fn sync(
158158
}
159159

160160
/// The canister ID table exposed to a sync plugin: every named canister in the
161-
/// project, plus — for canisters in the same subproject as the one being synced
162-
/// — a duplicate entry under the bare local name. A store key is
163-
/// `<subproject>:<local>` for a canister in a subproject and a bare local name
164-
/// for a canister defined directly in the app root (see the WIT
165-
/// `canister-id-entry` docs), so the syncing canister's namespace is the prefix
166-
/// of its own key.
161+
/// project, plus — for every canister in the subproject the synced canister
162+
/// belongs to, or in a subproject nested below it — a duplicate entry under the
163+
/// name that subproject itself uses. A store key is `<subproject>:<local>` for a
164+
/// canister in a subproject and a bare local name for a canister defined
165+
/// directly in the app root (see the WIT `canister-id-entry` docs), so the
166+
/// syncing canister's namespace is the prefix of its own key.
167167
///
168-
/// A local name never contains a colon but a subproject directory may, so keys
169-
/// split on their *last* colon. The bare-name aliases take precedence over an
170-
/// app-root canister of the same local name: a plugin resolving a bare name is
171-
/// naming what the syncing canister's own manifest calls it.
168+
/// The aliases exist so a subproject's manifest and plugins keep working when it
169+
/// is vendored into a workspace: both the step's `canisters:` list and the name a
170+
/// plugin passes back as a call target are written where the subproject's own
171+
/// names apply, but store keys are relative to the app root, which moves. See
172+
/// [`member_relative_alias`] for the names produced.
173+
///
174+
/// The aliases take precedence over a canister elsewhere in the workspace whose
175+
/// store key happens to be spelled the same way: a plugin resolving such a name
176+
/// is naming what its own subproject calls it.
172177
fn exposed_canister_ids(params: &Params) -> BTreeMap<String, Principal> {
173-
let syncing_namespace = params.name.rsplit_once(':').map(|(namespace, _)| namespace);
178+
// A canister in the app root is in the subproject the store keys are already
179+
// relative to, so its names need no translation.
180+
let Some((syncing_namespace, _)) = params.name.rsplit_once(':') else {
181+
return params.canister_ids.clone();
182+
};
174183

175184
let mut table = params.canister_ids.clone();
176185
for (key, id) in &params.canister_ids {
177-
if let Some((namespace, local)) = key.rsplit_once(':')
178-
&& Some(namespace) == syncing_namespace
179-
{
180-
table.insert(local.to_owned(), *id);
186+
if let Some(alias) = member_relative_alias(syncing_namespace, key) {
187+
table.insert(alias.to_owned(), *id);
181188
}
182189
}
183190
table
184191
}
185192

193+
/// The name a canister has *within* the subproject at `namespace`: its store key
194+
/// with that subproject's prefix removed. A canister of the subproject itself
195+
/// comes back under its bare local name; one belonging to a subproject nested
196+
/// below it comes back under the `<path>:<local>` key it would have if that
197+
/// subproject were the app root. `None` for a canister the subproject has no name
198+
/// of its own for.
199+
///
200+
/// A local name never contains a colon but a subproject directory may, so a key
201+
/// splits on its *last* colon.
202+
fn member_relative_alias<'a>(namespace: &str, key: &'a str) -> Option<&'a str> {
203+
let (key_namespace, _) = key.rsplit_once(':')?;
204+
let rest = key.strip_prefix(namespace)?;
205+
match key_namespace == namespace {
206+
// The colon separating the subproject from a local name of its own.
207+
true => rest.strip_prefix(':'),
208+
// Otherwise the key's subproject must sit *below* this one. Demanding
209+
// the path separator is what keeps `services/crm-legacy:backend` out of
210+
// `services/crm`, which it merely shares a spelling prefix with.
211+
false => rest.strip_prefix('/'),
212+
}
213+
}
214+
186215
/// Resolve the step's `canisters` list into a [`CallableCanisters`] enforcement
187216
/// set. Each listed name is looked up in `canister_ids`; a name that does not
188217
/// resolve is a manifest error.
@@ -293,6 +322,84 @@ mod tests {
293322
assert_eq!(table.get("backend"), Some(&backend));
294323
}
295324

325+
/// A canister of a subproject nested below the syncing canister's own is
326+
/// exposed under the key that subproject has relative to it — the very key
327+
/// its manifest and plugins use when it is built standalone, so vendoring it
328+
/// into a workspace leaves both spellings working.
329+
#[test]
330+
fn exposed_ids_add_member_relative_names_for_nested_subprojects() {
331+
let ledger = principal(1);
332+
let deep = principal(2);
333+
let params = params_named(
334+
"services/crm:backend",
335+
&[
336+
("services/crm:backend", principal(9)),
337+
("services/crm/vendor/ledger:ledger", ledger),
338+
("services/crm/vendor/ledger/vendor/util:util", deep),
339+
],
340+
);
341+
342+
let table = exposed_canister_ids(&params);
343+
344+
assert_eq!(table.get("vendor/ledger:ledger"), Some(&ledger));
345+
// Nesting is not limited to one level: the whole subtree below the
346+
// syncing canister's subproject is renamed relative to it.
347+
assert_eq!(table.get("vendor/ledger/vendor/util:util"), Some(&deep));
348+
// The workspace-absolute keys remain.
349+
assert_eq!(
350+
table.get("services/crm/vendor/ledger:ledger"),
351+
Some(&ledger)
352+
);
353+
}
354+
355+
/// A subproject whose path merely starts with the same characters is not
356+
/// nested below the syncing canister's, so it contributes no alias.
357+
#[test]
358+
fn exposed_ids_ignore_a_subproject_sharing_a_spelling_prefix() {
359+
let legacy = principal(1);
360+
let params = params_named(
361+
"services/crm:backend",
362+
&[
363+
("services/crm:backend", principal(9)),
364+
("services/crm-legacy:backend", legacy),
365+
],
366+
);
367+
368+
let table = exposed_canister_ids(&params);
369+
370+
assert_eq!(table.get("services/crm-legacy:backend"), Some(&legacy));
371+
// Its local name belongs to the syncing canister, not to it.
372+
assert_eq!(table.get("backend"), Some(&principal(9)));
373+
assert_eq!(table.get("-legacy:backend"), None);
374+
}
375+
376+
/// A member-relative alias wins over a workspace canister whose store key is
377+
/// spelled the same way, for the same reason a bare sibling name does: the
378+
/// name is being read where the subproject's own names apply.
379+
#[test]
380+
fn exposed_ids_member_relative_alias_overrides_a_root_dependency_key() {
381+
let root_ledger = principal(1);
382+
let own_ledger = principal(2);
383+
let params = params_named(
384+
"services/crm:backend",
385+
&[
386+
("services/crm:backend", principal(9)),
387+
("services/crm/vendor/ledger:ledger", own_ledger),
388+
("vendor/ledger:ledger", root_ledger),
389+
],
390+
);
391+
392+
let table = exposed_canister_ids(&params);
393+
394+
assert_eq!(table.get("vendor/ledger:ledger"), Some(&own_ledger));
395+
// The root's own dependency is still reachable, by its store key.
396+
assert_eq!(
397+
table.get("services/crm/vendor/ledger:ledger"),
398+
Some(&own_ledger)
399+
);
400+
assert!(!table.values().any(|id| *id == root_ledger));
401+
}
402+
296403
/// An app-root canister sharing a local name with a sibling of the syncing
297404
/// canister does not keep the bare name: the syncing subproject's own
298405
/// canister is what that name means to the plugin.
@@ -323,18 +430,41 @@ mod tests {
323430
fn exposed_ids_split_subproject_prefix_at_the_last_colon() {
324431
let backend = principal(1);
325432
let frontend = principal(2);
433+
let nested = principal(3);
326434
let params = params_named(
327435
"services/odd:name:backend",
328436
&[
329437
("services/odd:name:backend", backend),
330438
("services/odd:name:frontend", frontend),
439+
("services/odd:name/vendor/ledger:ledger", nested),
331440
],
332441
);
333442

334443
let table = exposed_canister_ids(&params);
335444

336445
assert_eq!(table.get("backend"), Some(&backend));
337446
assert_eq!(table.get("frontend"), Some(&frontend));
447+
assert_eq!(table.get("vendor/ledger:ledger"), Some(&nested));
448+
}
449+
450+
/// The mirror image: a directory whose *name* contains a colon is not a
451+
/// subproject nested below the part before it. `services/odd:name` holds one
452+
/// directory named `odd:name`, so from `services/odd` it is nothing at all.
453+
#[test]
454+
fn exposed_ids_do_not_read_a_colon_in_a_directory_name_as_nesting() {
455+
let odd = principal(1);
456+
let params = params_named(
457+
"services/odd:backend",
458+
&[
459+
("services/odd:backend", principal(9)),
460+
("services/odd:name:frontend", odd),
461+
],
462+
);
463+
464+
let table = exposed_canister_ids(&params);
465+
466+
assert_eq!(table.get("services/odd:name:frontend"), Some(&odd));
467+
assert_eq!(table.get("name:frontend"), None);
338468
}
339469

340470
/// A single-project layout keys canisters by bare local name already, so no

‎crates/icp/src/manifest/adapter/plugin.rs‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -261,11 +261,14 @@ pub struct Adapter {
261261

262262
/// Canisters this plugin may call, or read metadata from, in addition to
263263
/// the canister being synced. Each entry is a canister name resolved against
264-
/// the project's canister ID table for the environment being synced (e.g.
265-
/// `backend`, or a namespaced subproject canister such as
266-
/// `services/open-crm:backend`). The plugin picks a target per request via
267-
/// the `call-target` in its `canister-call` or `canister-metadata-section`
268-
/// request; a target not listed here is rejected by the host.
264+
/// the project's canister ID table for the environment being synced, written
265+
/// as this project spells it: a bare local name for one of its own canisters
266+
/// (e.g. `backend`), or a `<subproject>:<canister>` key for a canister of
267+
/// something it depends on (e.g. `vendor/ledger:ledger`). The same spellings
268+
/// hold when the project is a workspace member, so vendoring it does not
269+
/// change them. The plugin picks a target per request via the `call-target`
270+
/// in its `canister-call` or `canister-metadata-section` request; a target
271+
/// not listed here is rejected by the host.
269272
pub canisters: Option<Vec<String>>,
270273
}
271274

‎docs/concepts/sync-plugins.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ The authoritative interface, including all record fields, lives in [`sync-plugin
7878
| `proxy-canister-id` | Textual principal of the proxy canister if one was configured via `--proxy`, otherwise absent |
7979
| `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 |
8080

81-
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 defined in a subproject. 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.
81+
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 defined in a subproject.
82+
83+
Every canister the subproject being synced can name for itself is additionally listed under that name, so a plugin looks a canister up by the name that subproject's own manifest uses. For a canister in `services/crm`, that means its siblings under their bare local names, and the canisters of its own dependencies under keys relative to it — `services/crm/vendor/ledger:ledger` is also listed as `vendor/ledger:ledger`. Those are the names the subproject uses when it is deployed on its own, so a plugin written against them keeps working once the subproject is vendored into a workspace. Such a name always means what the subproject means by it: a canister elsewhere in the workspace whose key is spelled the same way is not listed under it for that sync.
8284

8385
The manifest declares directories and files together under `files:`; the host splits them into these two lists by what is on disk, so a plugin never has to say up front which an entry will turn out to be.
8486

‎docs/guides/writing-sync-plugins.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export!(Plugin);
8585
A few things to note:
8686

8787
- **You encode the arguments.** `arg` is raw Candid bytes. Encode with `candid::Encode!`; decode any response (`Vec<u8>`) with `candid::Decode!`.
88-
- **You choose the target.** `target: CallTarget::Host` reaches the canister being synced. To call another canister, declare it in the manifest's [`canisters:`](../reference/configuration.md#plugin-sync) list and address it with `CallTarget::Name("ledger".into())` — the name matches the entries in `input.canister_ids`. Names are the only way to reach another canister; the host resolves them per environment. The host rejects a target you did not declare.
88+
- **You choose the target.** `target: CallTarget::Host` reaches the canister being synced. To call another canister, declare it in the manifest's [`canisters:`](../reference/configuration.md#plugin-sync) list and address it with `CallTarget::Name("ledger".into())` — the name matches the entries in `input.canister_ids`. Names are the only way to reach another canister; the host resolves them per environment. The host rejects a target you did not declare. A name is always the one the plugin's own project uses, so hardcoding it stays correct when that project is vendored into a workspace as a subproject.
8989
- **`direct` and `cycles` control proxy routing.** With `direct: false`, update calls go through the [proxy canister](proxy-canister.md) when one is configured, and `cycles` can fund the forwarded call. With `direct: true`, the call always goes straight to the target. See [The Plugin Interface](../concepts/sync-plugins.md#the-plugin-interface) for the full semantics.
9090

9191
## Read Canister Metadata

‎docs/reference/configuration.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ A plugin receives every `fields:` value as a string. Numbers and booleans need n
207207

208208
A canister name in `canisters:` is the same name you use elsewhere in the project — a bare local name for a sibling canister, or a namespaced `subproject:canister` key for a canister defined in a subproject. A name that does not resolve to a known canister for the environment fails the sync step.
209209

210+
Names are always written from this project's point of view, so they keep working when the project is vendored into a workspace as a subproject: a plugin on a canister in `services/crm` reaches a canister of its own `vendor/ledger` dependency as `vendor/ledger:ledger` either way, even though the workspace keys that canister `services/crm/vendor/ledger:ledger`. Both spellings resolve; if a canister elsewhere in the workspace happens to be keyed `vendor/ledger:ledger`, the name means your own.
211+
210212
The plugin runs in a WASI sandbox: it can call update and query methods on the canister being synced (and any canister listed in `canisters:`), read those canisters' metadata sections, and read the declared `files`, but cannot open network sockets, spawn subprocesses, or write to disk. See [Sync Plugins](../concepts/sync-plugins.md) for the mechanism and [Writing a Sync Plugin](../guides/writing-sync-plugins.md) to author one.
211213

212214
## Recipes

0 commit comments

Comments
 (0)