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`].
3439const 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 ) ]
3750pub 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.
5774pub 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.
7998pub 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`.
101124pub 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+ }
0 commit comments