canic_core/workflow/ic/
provision.rs

1// =============================================================================
2// PROVISIONING (ROOT ORCHESTRATOR HELPERS)
3// =============================================================================
4
5//! Provisioning helpers for creating, installing, and tearing down canisters.
6//!
7//! These routines bundle the multi-phase orchestration that root performs when
8//! scaling out the topology: reserving cycles, recording registry state,
9//! installing WASM modules, and cascading state updates to descendants.
10
11use crate::{
12    Error,
13    cdk::mgmt::CanisterInstallMode,
14    config::Config,
15    dto::{abi::v1::CanisterInitPayload, env::EnvView},
16    ops::{
17        adapter::directory::{app_directory_to_view, subnet_directory_to_view},
18        config::ConfigOps,
19        ic::{create_canister, delete_canister, deposit_cycles, get_cycles, uninstall_code},
20        runtime::{canister::install_code_with_extra_arg, env::EnvOps, wasm::WasmOps},
21        storage::{
22            directory::{AppDirectoryOps, SubnetDirectoryOps},
23            pool::PoolOps,
24            registry::SubnetRegistryOps,
25        },
26    },
27    workflow::{
28        directory::{RootAppDirectoryBuilder, RootSubnetDirectoryBuilder},
29        ic::IcError,
30        pool::pool_import_canister,
31        prelude::*,
32        snapshot::StateSnapshotBuilder,
33    },
34};
35use thiserror::Error as ThisError;
36
37pub(crate) fn build_nonroot_init_payload(
38    role: &CanisterRole,
39    parent_pid: Principal,
40) -> Result<CanisterInitPayload, Error> {
41    let env = EnvView {
42        prime_root_pid: Some(EnvOps::prime_root_pid()?),
43        subnet_role: Some(EnvOps::subnet_role()?),
44        subnet_pid: Some(EnvOps::subnet_pid()?),
45        root_pid: Some(EnvOps::root_pid()?),
46        canister_role: Some(role.clone()),
47        parent_pid: Some(parent_pid),
48    };
49
50    Ok(CanisterInitPayload::new(
51        env,
52        app_directory_to_view(AppDirectoryOps::export()),
53        subnet_directory_to_view(SubnetDirectoryOps::export()),
54    ))
55}
56
57///
58/// ProvisionError
59///
60
61#[derive(Debug, ThisError)]
62pub enum ProvisionError {
63    #[error("install failed for {pid}: {source}")]
64    InstallFailed { pid: Principal, source: Error },
65}
66
67impl From<ProvisionError> for Error {
68    fn from(err: ProvisionError) -> Self {
69        IcError::from(err).into()
70    }
71}
72
73//
74// ===========================================================================
75// DIRECTORY SYNC
76// ===========================================================================
77//
78
79/// Rebuild AppDirectory and SubnetDirectory from the registry,
80/// import them directly, and return a builder containing the sections to sync.
81///
82/// When `updated_role` is provided, only include the sections that list that role.
83pub(crate) async fn rebuild_directories_from_registry(
84    updated_role: Option<&CanisterRole>,
85) -> Result<StateSnapshotBuilder, Error> {
86    let cfg = Config::get()?;
87    let subnet_cfg = ConfigOps::current_subnet()?;
88
89    let include_app = updated_role.is_none_or(|role| cfg.app_directory.contains(role));
90    let include_subnet = updated_role.is_none_or(|role| subnet_cfg.subnet_directory.contains(role));
91
92    let mut builder = StateSnapshotBuilder::new();
93
94    if include_app {
95        let data = RootAppDirectoryBuilder::build_from_registry();
96        AppDirectoryOps::import(data.clone());
97        builder = builder.with_app_directory_view(app_directory_to_view(data));
98    }
99
100    if include_subnet {
101        let data = RootSubnetDirectoryBuilder::build_from_registry();
102        SubnetDirectoryOps::import(data.clone());
103        builder = builder.with_subnet_directory_view(subnet_directory_to_view(data));
104    }
105
106    Ok(builder)
107}
108
109//
110// ===========================================================================
111// HIGH-LEVEL FLOW
112// ===========================================================================
113//
114
115/// Create and install a new canister of the requested type beneath `parent`.
116///
117/// PHASES:
118/// 1. Allocate a canister ID and cycles (preferring the pool)
119/// 2. Install WASM + bootstrap initial state
120/// 3. Register canister in SubnetRegistry
121/// 4. Cascade topology + sync directories
122pub async fn create_and_install_canister(
123    role: &CanisterRole,
124    parent_pid: Principal,
125    extra_arg: Option<Vec<u8>>,
126) -> Result<Principal, Error> {
127    // must have WASM module registered
128    WasmOps::try_get(role)?;
129
130    // Phase 1: allocation
131    let (pid, source) = allocate_canister_with_source(role).await?;
132
133    // Phase 2: installation
134    if let Err(err) = install_canister(pid, role, parent_pid, extra_arg).await {
135        if source == AllocationSource::Pool {
136            if let Err(recycle_err) = pool_import_canister(pid).await {
137                log!(
138                    Topic::CanisterPool,
139                    Warn,
140                    "failed to recycle pool canister after install failure: {pid} ({recycle_err})"
141                );
142            }
143        } else if let Err(delete_err) = uninstall_and_delete_canister(pid).await {
144            log!(
145                Topic::CanisterLifecycle,
146                Warn,
147                "failed to delete canister after install failure: {pid} ({delete_err})"
148            );
149        }
150        return Err(ProvisionError::InstallFailed { pid, source: err }.into());
151    }
152
153    Ok(pid)
154}
155
156//
157// ===========================================================================
158// DELETION
159// ===========================================================================
160//
161
162/// Delete an existing canister.
163///
164/// PHASES:
165/// 0. Uninstall code
166/// 1. Delete via management canister
167/// 2. Remove from SubnetRegistry
168/// 3. Cascade topology
169/// 4. Sync directories
170pub async fn uninstall_and_delete_canister(pid: Principal) -> Result<(), Error> {
171    EnvOps::require_root()?;
172
173    // Phase 0: uninstall code
174    uninstall_code(pid).await?;
175
176    // Phase 1: delete the canister
177    delete_canister(pid).await?;
178
179    // Phase 2: remove registry record
180    let removed_entry = SubnetRegistryOps::remove(&pid);
181    match &removed_entry {
182        Some(c) => log!(
183            Topic::CanisterLifecycle,
184            Ok,
185            "🗑️ delete_canister: {} ({})",
186            pid,
187            c.role
188        ),
189        None => log!(
190            Topic::CanisterLifecycle,
191            Warn,
192            "🗑️ delete_canister: {pid} not in registry"
193        ),
194    }
195
196    Ok(())
197}
198
199//
200// ===========================================================================
201// PHASE 1 — ALLOCATION (Pool → Create)
202// ===========================================================================
203//
204
205/// Allocate a canister ID and ensure it meets the initial cycle target.
206///
207/// Reuses a canister from the pool if available; otherwise creates a new one.
208pub async fn allocate_canister(role: &CanisterRole) -> Result<Principal, Error> {
209    let (pid, _) = allocate_canister_with_source(role).await?;
210    Ok(pid)
211}
212
213#[derive(Clone, Copy, Debug, Eq, PartialEq)]
214enum AllocationSource {
215    Pool,
216    New,
217}
218
219async fn allocate_canister_with_source(
220    role: &CanisterRole,
221) -> Result<(Principal, AllocationSource), Error> {
222    // use ConfigOps for a clean, ops-layer config lookup
223    let cfg = ConfigOps::current_subnet_canister(role)?;
224    let target = cfg.initial_cycles;
225
226    // Reuse from pool
227    if let Some((pid, _)) = PoolOps::pop_ready() {
228        let mut current = get_cycles(pid).await?;
229
230        if current < target {
231            let missing = target.to_u128().saturating_sub(current.to_u128());
232            if missing > 0 {
233                deposit_cycles(pid, missing).await?;
234                current = Cycles::new(current.to_u128() + missing);
235
236                log!(
237                    Topic::CanisterPool,
238                    Ok,
239                    "⚡ allocate_canister: topped up {pid} by {} to meet target {}",
240                    Cycles::from(missing),
241                    target
242                );
243            }
244        }
245
246        log!(
247            Topic::CanisterPool,
248            Ok,
249            "⚡ allocate_canister: reusing {pid} from pool (current {current})"
250        );
251
252        return Ok((pid, AllocationSource::Pool));
253    }
254
255    // Create new canister
256    let pid = create_canister_with_configured_controllers(target).await?;
257    log!(
258        Topic::CanisterPool,
259        Info,
260        "⚡ allocate_canister: pool empty"
261    );
262
263    Ok((pid, AllocationSource::New))
264}
265
266/// Create a fresh canister on the IC with the configured controllers.
267async fn create_canister_with_configured_controllers(cycles: Cycles) -> Result<Principal, Error> {
268    let root = canister_self();
269    let mut controllers = Config::get()?.controllers.clone();
270    controllers.push(root); // root always controls
271
272    let pid = create_canister(controllers, cycles.clone()).await?;
273
274    log!(
275        Topic::CanisterLifecycle,
276        Ok,
277        "⚡ create_canister: {pid} ({cycles})"
278    );
279
280    Ok(pid)
281}
282
283//
284// ===========================================================================
285// PHASE 2 — INSTALLATION
286// ===========================================================================
287//
288
289/// Install WASM and initial state into a new canister.
290#[allow(clippy::cast_precision_loss)]
291async fn install_canister(
292    pid: Principal,
293    role: &CanisterRole,
294    parent_pid: Principal,
295    extra_arg: Option<Vec<u8>>,
296) -> Result<(), Error> {
297    // Fetch and register WASM
298    let wasm = WasmOps::try_get(role)?;
299
300    let payload = build_nonroot_init_payload(role, parent_pid)?;
301    let module_hash = wasm.module_hash();
302
303    // Register before install so init hooks can observe the registry; roll back on failure.
304    // otherwise if the init() tries to create a canister via root, it will panic
305    SubnetRegistryOps::register(pid, role, parent_pid, module_hash.clone())?;
306
307    if let Err(err) = install_code_with_extra_arg(
308        CanisterInstallMode::Install,
309        pid,
310        wasm.bytes(),
311        payload,
312        extra_arg,
313    )
314    .await
315    {
316        let removed = SubnetRegistryOps::remove(&pid);
317        if removed.is_none() {
318            log!(
319                Topic::CanisterLifecycle,
320                Warn,
321                "⚠️ install_canister rollback: {pid} missing from registry after failed install"
322            );
323        }
324
325        return Err(err);
326    }
327
328    log!(
329        Topic::CanisterLifecycle,
330        Ok,
331        "⚡ install_canister: {pid} ({role}, {:.2} KiB)",
332        wasm.len() as f64 / 1_024.0,
333    );
334
335    Ok(())
336}