canic_core/ops/
reserve.rs

1//! Reserve pool lifecycle helpers.
2//!
3//! The root canister maintains a pool of empty or decommissioned canisters
4//! that can be quickly reassigned when scaling.
5//!
6//! INVARIANTS:
7//! - Reserve canisters are NOT part of topology
8//! - Reserve canisters have NO parent
9//! - Root is the sole controller
10//! - Importing a canister is destructive (code + controllers wiped)
11//! - Registry metadata is informational only while in reserve
12
13pub use crate::ops::storage::reserve::{CanisterReserveEntry, CanisterReserveView};
14
15use crate::{
16    Error, ThisError,
17    cdk::{
18        api::canister_self,
19        futures::spawn,
20        mgmt::{CanisterSettings, UpdateSettingsArgs},
21        types::Principal,
22    },
23    config::{Config, schema::SubnetConfig},
24    log::Topic,
25    ops::{
26        OPS_RESERVE_CHECK_INTERVAL, OPS_RESERVE_INIT_DELAY, OpsError,
27        config::ConfigOps,
28        ic::{
29            get_cycles,
30            mgmt::{create_canister, uninstall_code},
31            timer::{TimerId, TimerOps},
32            update_settings,
33        },
34        prelude::*,
35        storage::{reserve::CanisterReserveStorageOps, topology::SubnetCanisterRegistryOps},
36    },
37    types::{Cycles, TC},
38};
39use candid::CandidType;
40use serde::Deserialize;
41use std::cell::RefCell;
42
43///
44/// ReserveOpsError
45///
46
47#[derive(Debug, ThisError)]
48pub enum ReserveOpsError {
49    #[error("reserve entry missing for {pid}")]
50    ReserveEntryMissing { pid: Principal },
51
52    #[error("missing module hash for reserve entry {pid}")]
53    MissingModuleHash { pid: Principal },
54
55    #[error("missing type for reserve entry {pid}")]
56    MissingType { pid: Principal },
57}
58
59impl From<ReserveOpsError> for Error {
60    fn from(err: ReserveOpsError) -> Self {
61        OpsError::from(err).into()
62    }
63}
64
65//
66// ADMIN API
67//
68
69///
70/// ReserveAdminCommand
71///
72
73#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
74pub enum ReserveAdminCommand {
75    CreateEmpty,
76    Recycle { pid: Principal },
77    Import { pid: Principal },
78}
79
80///
81/// ReserveAdminResponse
82///
83
84#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
85pub enum ReserveAdminResponse {
86    Created { pid: Principal },
87    Recycled,
88    Imported,
89}
90
91//
92// TIMER
93//
94
95thread_local! {
96    static TIMER: RefCell<Option<TimerId>> = const { RefCell::new(None) };
97}
98
99/// Default cycles allocated to freshly created reserve canisters.
100const RESERVE_CANISTER_CYCLES: u128 = 5 * TC;
101
102//
103// INTERNAL HELPERS
104//
105
106/// Controller set for all reserve canisters (root-only).
107fn reserve_controllers() -> Vec<Principal> {
108    let mut controllers = Config::try_get()
109        .map(|cfg| cfg.controllers.clone())
110        .unwrap_or_default();
111    let root = canister_self();
112
113    if !controllers.contains(&root) {
114        controllers.push(root);
115    }
116
117    controllers
118}
119
120/// Reset a canister into a clean reserve state.
121async fn reset_into_reserve(pid: Principal) -> Result<Cycles, Error> {
122    uninstall_code(pid).await?;
123
124    update_settings(&UpdateSettingsArgs {
125        canister_id: pid,
126        settings: CanisterSettings {
127            controllers: Some(reserve_controllers()),
128            ..Default::default()
129        },
130    })
131    .await?;
132
133    get_cycles(pid).await
134}
135
136///
137/// ReserveOps
138///
139
140pub struct ReserveOps;
141
142impl ReserveOps {
143    /// Starts periodic reserve maintenance. Safe to call multiple times.
144    pub fn start() {
145        TIMER.with_borrow_mut(|slot| {
146            if slot.is_some() {
147                return;
148            }
149
150            let Some(_cfg) = Self::enabled_subnet_config() else {
151                return;
152            };
153
154            let id = TimerOps::set(OPS_RESERVE_INIT_DELAY, "reserve:init", async {
155                let _ = Self::check();
156
157                let interval_id = TimerOps::set_interval(
158                    OPS_RESERVE_CHECK_INTERVAL,
159                    "reserve:interval",
160                    || async {
161                        let _ = Self::check();
162                    },
163                );
164
165                TIMER.with_borrow_mut(|slot| *slot = Some(interval_id));
166            });
167
168            *slot = Some(id);
169        });
170    }
171
172    /// Stops reserve maintenance callbacks.
173    pub fn stop() {
174        TIMER.with_borrow_mut(|slot| {
175            if let Some(id) = slot.take() {
176                TimerOps::clear(id);
177            }
178        });
179    }
180
181    /// Ensures the reserve contains the minimum required entries.
182    #[must_use]
183    pub fn check() -> u64 {
184        let subnet_cfg = match ConfigOps::current_subnet() {
185            Ok(cfg) => cfg,
186            Err(e) => {
187                log!(
188                    Topic::CanisterReserve,
189                    Warn,
190                    "cannot read subnet config: {e:?}"
191                );
192                return 0;
193            }
194        };
195
196        let min_size: u64 = subnet_cfg.reserve.minimum_size.into();
197        let reserve_size = CanisterReserveStorageOps::len();
198
199        if reserve_size < min_size {
200            let missing = (min_size - reserve_size).min(10);
201
202            log!(
203                Topic::CanisterReserve,
204                Ok,
205                "reserve low: {reserve_size}/{min_size}, creating {missing}"
206            );
207
208            spawn(async move {
209                for i in 0..missing {
210                    match reserve_create_canister().await {
211                        Ok(_) => log!(
212                            Topic::CanisterReserve,
213                            Ok,
214                            "created reserve canister {}/{}",
215                            i + 1,
216                            missing
217                        ),
218                        Err(e) => log!(
219                            Topic::CanisterReserve,
220                            Warn,
221                            "failed reserve creation: {e:?}"
222                        ),
223                    }
224                }
225            });
226
227            return missing;
228        }
229
230        0
231    }
232
233    /// Pops the first entry in the reserve.
234    #[must_use]
235    pub fn pop_first() -> Option<(Principal, CanisterReserveEntry)> {
236        CanisterReserveStorageOps::pop_first()
237    }
238
239    /// Returns true if the reserve pool contains the given canister.
240    #[must_use]
241    pub fn contains(pid: &Principal) -> bool {
242        CanisterReserveStorageOps::contains(pid)
243    }
244
245    /// Full export of reserve contents.
246    #[must_use]
247    pub fn export() -> CanisterReserveView {
248        CanisterReserveStorageOps::export()
249    }
250
251    pub async fn admin(cmd: ReserveAdminCommand) -> Result<ReserveAdminResponse, Error> {
252        match cmd {
253            ReserveAdminCommand::CreateEmpty => {
254                let pid = reserve_create_canister().await?;
255                Ok(ReserveAdminResponse::Created { pid })
256            }
257            ReserveAdminCommand::Recycle { pid } => {
258                reserve_recycle_canister(pid).await?;
259                Ok(ReserveAdminResponse::Recycled)
260            }
261            ReserveAdminCommand::Import { pid } => {
262                reserve_import_canister(pid).await?;
263                Ok(ReserveAdminResponse::Imported)
264            }
265        }
266    }
267
268    fn enabled_subnet_config() -> Option<SubnetConfig> {
269        match ConfigOps::current_subnet() {
270            Ok(cfg) if cfg.reserve.minimum_size > 0 => Some(cfg),
271            _ => None,
272        }
273    }
274}
275
276//
277// CREATE
278//
279
280/// Creates a new empty reserve canister.
281pub async fn reserve_create_canister() -> Result<Principal, Error> {
282    OpsError::require_root()?;
283
284    let cycles = Cycles::new(RESERVE_CANISTER_CYCLES);
285    let pid = create_canister(reserve_controllers(), cycles.clone()).await?;
286
287    CanisterReserveStorageOps::register(pid, cycles, None, None, None);
288
289    Ok(pid)
290}
291
292//
293// IMPORT / RECYCLE
294//
295
296/// Import an arbitrary canister into the reserve (destructive).
297pub async fn reserve_import_canister(pid: Principal) -> Result<(), Error> {
298    OpsError::require_root()?;
299
300    let _ = SubnetCanisterRegistryOps::remove(&pid);
301
302    let cycles = reset_into_reserve(pid).await?;
303    CanisterReserveStorageOps::register(pid, cycles, None, None, None);
304
305    Ok(())
306}
307
308/// Recycle a managed topology canister into the reserve.
309pub async fn reserve_recycle_canister(pid: Principal) -> Result<(), Error> {
310    OpsError::require_root()?;
311
312    let entry =
313        SubnetCanisterRegistryOps::get(pid).ok_or(ReserveOpsError::ReserveEntryMissing { pid })?;
314
315    let role = Some(entry.role.clone());
316    let hash = entry.module_hash.clone();
317
318    let _ = SubnetCanisterRegistryOps::remove(&pid);
319
320    let cycles = reset_into_reserve(pid).await?;
321    CanisterReserveStorageOps::register(pid, cycles, role, None, hash);
322
323    Ok(())
324}
325
326//
327// EXPORT
328//
329
330/// Removes a canister from the reserve and returns its stored metadata.
331///
332/// NOTE:
333/// - This does NOT attach the canister to topology.
334/// - This does NOT install code.
335/// - The caller is responsible for any reactivation workflow.
336pub async fn reserve_export_canister(pid: Principal) -> Result<(CanisterRole, Vec<u8>), Error> {
337    OpsError::require_root()?;
338
339    let entry = CanisterReserveStorageOps::take(&pid)
340        .ok_or(ReserveOpsError::ReserveEntryMissing { pid })?;
341
342    let role = entry.role.ok_or(ReserveOpsError::MissingType { pid })?;
343    let hash = entry
344        .module_hash
345        .ok_or(ReserveOpsError::MissingModuleHash { pid })?;
346
347    Ok((role, hash))
348}
349
350//
351// ORCHESTRATION HOOK
352//
353
354/// Recycles a canister via the orchestrator. Triggers topology and directory cascades.
355pub async fn recycle_via_orchestrator(pid: Principal) -> Result<(), Error> {
356    use crate::ops::orchestration::orchestrator::{CanisterLifecycleOrchestrator, LifecycleEvent};
357
358    CanisterLifecycleOrchestrator::apply(LifecycleEvent::RecycleToReserve { pid })
359        .await
360        .map(|_| ())
361}
362
363//
364// TESTS
365//
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370    use crate::{
371        config::Config, config::schema::ConfigModel, ids::SubnetRole, ops::storage::env::EnvOps,
372    };
373
374    #[test]
375    fn start_skips_when_minimum_size_zero() {
376        ReserveOps::stop();
377        Config::reset_for_tests();
378        let cfg = ConfigModel::test_default();
379        Config::init_from_toml(&toml::to_string(&cfg).unwrap()).unwrap();
380        EnvOps::set_subnet_role(SubnetRole::PRIME);
381
382        assert!(ReserveOps::enabled_subnet_config().is_none());
383    }
384
385    #[test]
386    fn start_runs_when_minimum_size_nonzero() {
387        ReserveOps::stop();
388        let mut cfg = ConfigModel::test_default();
389        let subnet = cfg.subnets.entry(SubnetRole::PRIME).or_default();
390        subnet.reserve.minimum_size = 1;
391
392        Config::reset_for_tests();
393        Config::init_from_toml(&toml::to_string(&cfg).unwrap()).unwrap();
394        EnvOps::set_subnet_role(SubnetRole::PRIME);
395
396        assert!(ReserveOps::enabled_subnet_config().is_some());
397    }
398}