Skip to content

Commit 853f409

Browse files
fix(icp): address review — chunk cleanup, controllers, sync phases, docs, tests
- install_wasm: clear chunk store on every failure in the chunked path (best-effort), preserving the original error over any cleanup error; split out upload_and_install_chunked. - deploy docs: state create is direct/cycles-free, suitable for CloudEngine-style subnets only; cycles-ledger/CMC funding is out of scope (use the binary's operations::create). - deploy_bundle example: restructure into create-all / install-all / sync-all phases so the full name->id map is known before controllers, install and sync; resolve+retain manifest controllers and append the deployer without duplicates (From<Settings> drops controllers). - project docs: custom Resolve impls are internal-only (recipe input types are crate-private); external consumers should use NoRecipes. - deploy: extract pure needs_chunked_install and add unit tests for the chunking-threshold boundaries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 71a97d5 commit 853f409

3 files changed

Lines changed: 220 additions & 45 deletions

File tree

crates/icp/examples/deploy_bundle.rs

Lines changed: 94 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,9 @@
2323
use std::collections::BTreeMap;
2424
use std::sync::Arc;
2525

26-
use candid::Principal;
27-
use ic_agent::{Identity, identity::AnonymousIdentity};
28-
use ic_management_canister_types::CanisterSettings;
26+
use candid::{Encode, Principal};
27+
use ic_agent::{Agent, Identity, identity::AnonymousIdentity};
28+
use ic_management_canister_types::{CanisterId, CanisterSettings, UpdateSettingsArgs};
2929

3030
use icp::agent::{Create, Creator};
3131
use icp::canister::sync::{Params, Syncer, Synchronize};
@@ -36,9 +36,12 @@ use icp::package::PackageCache;
3636
use icp::prelude::PathBuf;
3737
use icp::project::load_project;
3838

39-
/// Deploy every canister of `environment` in the project at `project_dir`:
40-
/// load + validate the manifest, then for each canister create it, install its
41-
/// wasm, and run its sync steps.
39+
/// Deploy every canister of `environment` in the project at `project_dir` in
40+
/// three explicit phases — create ALL, install ALL, sync ALL — mirroring the
41+
/// CLI's ordering in `commands/deploy.rs`. Doing every `create_canister` up
42+
/// front means the full name -> id map exists before we resolve controllers,
43+
/// install code, or sync, so named-canister references always resolve and each
44+
/// sync step receives the COMPLETE `Params::canister_ids`.
4245
#[allow(clippy::too_many_arguments)]
4346
async fn deploy_bundle(
4447
project_dir: &PathBuf,
@@ -56,37 +59,58 @@ async fn deploy_bundle(
5659
.ok_or_else(|| format!("environment '{environment}' not found in project"))?;
5760

5861
let agent = Creator.create(identity, network_url).await?;
59-
let controller = agent
62+
let deployer = agent
6063
.get_principal()
6164
.map_err(Box::<dyn std::error::Error>::from)?;
6265
let pkg_cache = PackageCache::new(cache_root)?;
6366
let syncer = Syncer;
6467

65-
// Ids of everything we deploy, so sync steps can wire canister references.
68+
// Ids of everything we deploy, so controller refs and sync steps can wire
69+
// canister-name references to concrete principals.
6670
let mut canister_ids: BTreeMap<String, Principal> = BTreeMap::new();
6771

68-
for (name, (canister_dir, canister)) in &env.canisters {
69-
let wasm_bytes = load_prebuilt_wasm(canister, canister_dir, &pkg_cache).await?;
70-
71-
// 2. Deploy the wasm: create the canister, then install code.
72-
// Controllers/allocations come from the manifest; we add ourselves as
73-
// a controller so later upgrades and asset syncs are permitted.
74-
let mut settings: CanisterSettings = canister.settings.clone().into();
75-
settings.controllers = Some(vec![controller]);
72+
// Phase (a): create ALL canisters, building the full name -> id map.
73+
//
74+
// NOTE: `From<Settings> for CanisterSettings` hard-codes `controllers: None`,
75+
// silently dropping the manifest's declared controllers. We therefore create
76+
// with allocations only (the deployer becomes the sole controller by default)
77+
// and re-apply the configured controllers in a second pass below, once every
78+
// canister id is known — a named-canister controller can only be resolved
79+
// after the canister it names has been created.
80+
for (name, (_canister_dir, canister)) in &env.canisters {
81+
let settings: CanisterSettings = canister.settings.clone().into();
7682
let cid = deploy::create_canister_on_subnet(&agent, subnet, settings).await?;
7783
canister_ids.insert(name.clone(), cid);
84+
}
85+
86+
// Phase (a, controllers): with the full map in hand, resolve each canister's
87+
// configured controllers (principals + named-canister refs) and append the
88+
// deployer without duplicates, then apply them via `update_settings`.
89+
for (name, (_canister_dir, canister)) in &env.canisters {
90+
let cid = canister_ids[name];
91+
let controllers = resolve_controllers(canister, &canister_ids, deployer)?;
92+
set_controllers(&agent, cid, controllers).await?;
93+
}
7894

95+
// Phase (b): install ALL wasms.
96+
for (name, (canister_dir, canister)) in &env.canisters {
97+
let cid = canister_ids[name];
98+
let wasm_bytes = load_prebuilt_wasm(canister, canister_dir, &pkg_cache).await?;
7999
let mode = deploy::resolve_install_mode(&agent, cid).await?;
80100
let init_args = canister
81101
.init_args
82102
.as_ref()
83103
.map(|ia| ia.to_bytes())
84104
.transpose()?;
85105
deploy::install_wasm(&agent, cid, &wasm_bytes, mode, init_args.as_deref()).await?;
106+
}
86107

87-
// 3. Sync assets via the wasm plugin. An asset canister declares a
88-
// `plugin` sync step pointing at the directory to upload; the syncer
89-
// resolves the plugin wasm and runs it against the live canister.
108+
// Phase (c): sync ALL asset canisters. An asset canister declares a `plugin`
109+
// sync step pointing at the directory to upload; the syncer resolves the
110+
// plugin wasm and runs it against the live canister. Each step gets the
111+
// COMPLETE `canister_ids` map so cross-canister references resolve.
112+
for (name, (canister_dir, canister)) in &env.canisters {
113+
let cid = canister_ids[name];
90114
for step in &canister.sync.steps {
91115
let params = Params {
92116
path: canister_dir.clone(),
@@ -98,13 +122,63 @@ async fn deploy_bundle(
98122
};
99123
syncer.sync(step, &params, &agent, None, &pkg_cache).await?;
100124
}
101-
102125
println!("deployed {name} -> {cid}");
103126
}
104127

105128
Ok(())
106129
}
107130

131+
/// Resolve a canister's manifest-declared controllers (principals and
132+
/// named-canister references) against the full `canister_ids` map, then append
133+
/// `deployer` without duplicates so later upgrades and asset syncs are permitted.
134+
///
135+
/// This exists because `From<Settings> for CanisterSettings` drops controllers,
136+
/// so retaining the manifest's configured controllers is the caller's job.
137+
fn resolve_controllers(
138+
canister: &icp::Canister,
139+
canister_ids: &BTreeMap<String, Principal>,
140+
deployer: Principal,
141+
) -> Result<Vec<Principal>, Box<dyn std::error::Error>> {
142+
let refs = canister.settings.controllers.as_deref().unwrap_or_default();
143+
let (mut resolved, unresolved) = icp::canister::resolve_controllers(refs, canister_ids);
144+
if !unresolved.is_empty() {
145+
return Err(format!(
146+
"canister '{}' declares controller(s) not created in this deployment: {unresolved:?}",
147+
canister.name
148+
)
149+
.into());
150+
}
151+
if !resolved.contains(&deployer) {
152+
resolved.push(deployer);
153+
}
154+
Ok(resolved)
155+
}
156+
157+
/// Apply `controllers` to `cid` via the management canister's `update_settings`.
158+
/// Required because the create call can't carry controllers (see
159+
/// [`resolve_controllers`]): `From<Settings> for CanisterSettings` drops them.
160+
async fn set_controllers(
161+
agent: &Agent,
162+
cid: Principal,
163+
controllers: Vec<Principal>,
164+
) -> Result<(), Box<dyn std::error::Error>> {
165+
let args = UpdateSettingsArgs {
166+
canister_id: CanisterId::from(cid),
167+
settings: CanisterSettings {
168+
controllers: Some(controllers),
169+
..Default::default()
170+
},
171+
sender_canister_version: None,
172+
};
173+
agent
174+
.update(&Principal::management_canister(), "update_settings")
175+
.with_arg(Encode!(&args)?)
176+
.with_effective_canister_id(cid)
177+
.call_and_wait()
178+
.await?;
179+
Ok(())
180+
}
181+
108182
/// Resolve a canister's prebuilt wasm to bytes. Marketplace bundles ship
109183
/// prebuilt modules, so we look for a `pre-built` build step and read the file
110184
/// (local path or cached download) it points at.

crates/icp/src/deploy.rs

Lines changed: 117 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,16 @@
33
//!
44
//! This is a **minimal, self-contained public surface** intended for external
55
//! consumers (for example a backend service deploying prebuilt marketplace app
6-
//! bundles) that need to create canisters and install code from Rust. It does
7-
//! *not* replicate the binary's cycles-ledger / cycles-minting-canister / proxy
8-
//! funding paths (see [`crate`]'s sibling `icp-cli` `operations::create`); it
9-
//! calls the management canister directly, which is what a local replica or a
10-
//! cloud-engine subnet expects — the caller (or the subnet) provides the cycles.
6+
//! bundles) that need to create canisters and install code from Rust.
7+
//!
8+
//! Scope: it performs **direct** management-canister `create_canister` calls
9+
//! with **no cycles attached**. That only works on subnets that permit
10+
//! cycles-free creation — i.e. CloudEngine-style engine subnets, which are the
11+
//! intended consumer. It is **not** a general local-replica / cycles-ledger /
12+
//! cycles-minting-canister flow: subnets that require cycles-ledger or CMC
13+
//! funding to create a canister are out of scope for this API. Use the `icp`
14+
//! binary's `operations::create` (cycles ledger / CMC / proxy funding) for
15+
//! those.
1116
//!
1217
//! The `icp` binary's own `operations::{create,install}` layer could converge
1318
//! onto these functions later; today it carries extra machinery (progress bars,
@@ -33,6 +38,14 @@ const CHUNK_SIZE: usize = 1024 * 1024;
3338
/// deciding whether the install message fits under [`CHUNK_THRESHOLD`].
3439
const ENCODING_OVERHEAD: usize = 500;
3540

41+
/// Decide whether an install carrying `wasm_len` bytes of module and `arg_len`
42+
/// bytes of init args must use the chunked-install flow: `true` when the encoded
43+
/// `install_code` message would exceed the 2 MiB [`CHUNK_THRESHOLD`], `false`
44+
/// when it fits in a single message. Pure so the boundary is unit-testable.
45+
fn needs_chunked_install(wasm_len: usize, arg_len: usize) -> bool {
46+
wasm_len + arg_len + ENCODING_OVERHEAD > CHUNK_THRESHOLD
47+
}
48+
3649
#[derive(Debug, Snafu)]
3750
pub enum DeployError {
3851
#[snafu(display("failed to encode candid arguments"))]
@@ -54,6 +67,10 @@ pub enum DeployError {
5467
/// call targeting `subnet`: the first principal in the subnet's canister-id
5568
/// ranges. `create_canister` has no natural target canister of its own, so the
5669
/// agent needs an effective id that routes the request to the intended subnet.
70+
///
71+
/// This is meant for the direct, cycles-free creation flow against
72+
/// CloudEngine-style engine subnets (see the [module docs](self)); it is not a
73+
/// cycles-ledger / CMC creation helper.
5774
pub async fn effective_canister_id_for_subnet(
5875
agent: &Agent,
5976
subnet: Principal,
@@ -67,15 +84,17 @@ pub async fn effective_canister_id_for_subnet(
6784
Ok(start)
6885
}
6986

70-
/// Create a canister via the management canister, routing the request to the
71-
/// subnet that owns `effective_canister_id` (any principal within the target
72-
/// subnet's id range — see [`effective_canister_id_for_subnet`]). `settings`
73-
/// carries the controllers, compute/memory allocation, etc.
87+
/// Create a canister via a **direct** management-canister `create_canister`
88+
/// call, routing the request to the subnet that owns `effective_canister_id`
89+
/// (any principal within the target subnet's id range — see
90+
/// [`effective_canister_id_for_subnet`]). `settings` carries the controllers,
91+
/// compute/memory allocation, etc.
7492
///
75-
/// No cycles are attached here, so this is appropriate for local replicas and
76-
/// cloud-engine subnets that provision cycles for created canisters. Funding a
77-
/// mainnet creation (cycles ledger / CMC) is intentionally out of scope for this
78-
/// minimal surface.
93+
/// **No cycles are attached.** This only succeeds on subnets that permit
94+
/// cycles-free creation — CloudEngine-style engine subnets, the intended
95+
/// consumer of this API. Subnets that require cycles-ledger or CMC funding
96+
/// (e.g. mainnet) are out of scope here; use the `icp` binary's
97+
/// `operations::create` for those.
7998
pub async fn create_canister(
8099
agent: &Agent,
81100
effective_canister_id: Principal,
@@ -98,6 +117,10 @@ pub async fn create_canister(
98117

99118
/// Convenience wrapper over [`create_canister`] that resolves the effective
100119
/// canister id from `subnet` for you.
120+
///
121+
/// Same scope as [`create_canister`]: direct, cycles-free creation suitable for
122+
/// CloudEngine-style engine subnets only. Subnets requiring cycles-ledger / CMC
123+
/// funding are out of scope — use the `icp` binary's `operations::create`.
101124
pub async fn create_canister_on_subnet(
102125
agent: &Agent,
103126
subnet: Principal,
@@ -160,9 +183,7 @@ pub async fn install_wasm(
160183
.map(|a| a.to_vec())
161184
.unwrap_or_else(|| Encode!().expect("encoding empty candid args cannot fail"));
162185

163-
let total_install_size = wasm.len() + arg.len() + ENCODING_OVERHEAD;
164-
165-
if total_install_size <= CHUNK_THRESHOLD {
186+
if !needs_chunked_install(wasm.len(), arg.len()) {
166187
let install_args = InstallCodeArgs {
167188
mode,
168189
canister_id: cid,
@@ -175,9 +196,34 @@ pub async fn install_wasm(
175196
}
176197

177198
// Large module: clear any stale chunks, upload the wasm in chunks, then
178-
// install by hash.
199+
// install by hash. Anything from here on can leave chunks charged to the
200+
// canister, so the chunk store is cleared again on *every* outcome below —
201+
// not just success — while the original error (if any) is preserved.
179202
clear_chunk_store(agent, canister_id, cid).await?;
180203

204+
let install_result = upload_and_install_chunked(agent, canister_id, cid, wasm, mode, arg).await;
205+
206+
// Free the chunk store regardless of the outcome. A cleanup failure must not
207+
// mask an upload/install error, so on `Err` we drop the (best-effort) clear
208+
// result and return the original error; on `Ok` we surface a clear failure.
209+
let clear_result = clear_chunk_store(agent, canister_id, cid).await;
210+
match install_result {
211+
Ok(()) => clear_result,
212+
Err(e) => Err(e),
213+
}
214+
}
215+
216+
/// Upload `wasm` to `canister_id`'s chunk store in [`CHUNK_SIZE`] pieces, then
217+
/// `install_chunked_code` by hash. Split out of [`install_wasm`] so the caller
218+
/// can run chunk-store cleanup around it on every outcome.
219+
async fn upload_and_install_chunked(
220+
agent: &Agent,
221+
canister_id: Principal,
222+
cid: CanisterId,
223+
wasm: &[u8],
224+
mode: CanisterInstallMode,
225+
arg: Vec<u8>,
226+
) -> Result<(), DeployError> {
181227
let mut chunk_hashes: Vec<ChunkHash> = Vec::new();
182228
for chunk in wasm.chunks(CHUNK_SIZE) {
183229
let upload_args = UploadChunkArgs {
@@ -204,12 +250,7 @@ pub async fn install_wasm(
204250
arg,
205251
sender_canister_version: None,
206252
};
207-
let install_result = mgmt_call(agent, canister_id, "install_chunked_code", &chunked_args).await;
208-
209-
// Free the chunk store regardless of the install outcome, preferring to
210-
// surface the original install error.
211-
let clear_result = clear_chunk_store(agent, canister_id, cid).await;
212-
install_result.and(clear_result)
253+
mgmt_call(agent, canister_id, "install_chunked_code", &chunked_args).await
213254
}
214255

215256
/// Encode `arg`, send it as an update to the management canister for `method`,
@@ -243,3 +284,56 @@ async fn clear_chunk_store(
243284
)
244285
.await
245286
}
287+
288+
#[cfg(test)]
289+
mod tests {
290+
use super::*;
291+
292+
// The install flow itself (upload_chunk / install_code / clear_chunk_store
293+
// ordering and the failure-cleanup / error-precedence path in `install_wasm`)
294+
// talks to the management canister through `ic_agent::Agent`, which has no
295+
// lightweight fake to inject here, so that path needs a live-replica
296+
// integration test rather than a unit test. What is unit-testable without a
297+
// network is the pure chunking-threshold decision, covered below.
298+
299+
#[test]
300+
fn small_install_fits_single_message() {
301+
assert!(!needs_chunked_install(0, 0));
302+
assert!(!needs_chunked_install(1024, 0));
303+
assert!(!needs_chunked_install(1024, 1024));
304+
}
305+
306+
#[test]
307+
fn threshold_boundary_is_inclusive() {
308+
// wasm + arg + overhead == CHUNK_THRESHOLD still fits in one message.
309+
let wasm_at_limit = CHUNK_THRESHOLD - ENCODING_OVERHEAD;
310+
assert!(!needs_chunked_install(wasm_at_limit, 0));
311+
// One byte over the limit tips into the chunked flow.
312+
assert!(needs_chunked_install(wasm_at_limit + 1, 0));
313+
}
314+
315+
#[test]
316+
fn init_args_can_push_over_the_threshold() {
317+
let wasm_len = CHUNK_THRESHOLD - ENCODING_OVERHEAD - 10;
318+
// Wasm alone fits...
319+
assert!(!needs_chunked_install(wasm_len, 0));
320+
// ...but adding enough init-arg bytes to exceed the limit forces chunking.
321+
assert!(needs_chunked_install(wasm_len, 11));
322+
}
323+
324+
#[test]
325+
fn large_module_requires_chunking() {
326+
assert!(needs_chunked_install(8 * 1024 * 1024, 0));
327+
}
328+
329+
#[test]
330+
fn chunk_size_is_within_the_spec_per_chunk_limit() {
331+
// Each uploaded chunk must stay under the 1 MiB per-chunk spec limit, and
332+
// a module just over the single-message threshold must split into >1 chunk.
333+
const { assert!(CHUNK_SIZE <= 1024 * 1024) };
334+
let wasm = vec![0u8; CHUNK_THRESHOLD + 1];
335+
let chunks = wasm.chunks(CHUNK_SIZE).count();
336+
assert!(chunks > 1);
337+
assert!(wasm.chunks(CHUNK_SIZE).all(|c| c.len() <= CHUNK_SIZE));
338+
}
339+
}

crates/icp/src/project.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -854,6 +854,11 @@ fn build_environment_canisters(
854854
/// Suitable for consumers that deploy only prebuilt bundles (no `@scope/name`
855855
/// registry-recipe canisters), so a project manifest can be loaded and validated
856856
/// with no recipe machinery wired up. This is what [`load_project`] uses.
857+
///
858+
/// This is the intended [`recipe::Resolve`] for **external** consumers. Writing
859+
/// a custom `Resolve` is currently internal-only: [`recipe::Resolve::resolve`]
860+
/// takes crate-private recipe input types (`manifest::recipe::Recipe`), which are
861+
/// not exported, so out-of-crate code cannot implement the trait meaningfully.
857862
pub struct NoRecipes;
858863

859864
#[async_trait::async_trait]
@@ -878,8 +883,10 @@ impl recipe::Resolve for NoRecipes {
878883
/// collaborators by hand.
879884
///
880885
/// Recipe canisters (`@scope/name` registry references) are not supported here —
881-
/// see [`NoRecipes`]. Consumers that need them can call [`consolidate_manifest`]
882-
/// directly with their own [`recipe::Resolve`] implementation.
886+
/// see [`NoRecipes`]. Resolving recipes requires a [`recipe::Resolve`] whose
887+
/// input types are crate-private, so that path is internal-only today; external
888+
/// consumers should ship prebuilt bundles and use [`NoRecipes`] (as this
889+
/// function does).
883890
pub async fn load_project(dir: &Path) -> Result<Project, crate::ProjectLoadError> {
884891
let m = load_manifest_from_path::<ProjectManifest>(&dir.join(PROJECT_MANIFEST))
885892
.await

0 commit comments

Comments
 (0)