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::get().controllers.clone();
109    let root = canister_self();
110
111    if !controllers.contains(&root) {
112        controllers.push(root);
113    }
114
115    controllers
116}
117
118/// Reset a canister into a clean reserve state.
119async fn reset_into_reserve(pid: Principal) -> Result<Cycles, Error> {
120    uninstall_code(pid).await?;
121
122    update_settings(&UpdateSettingsArgs {
123        canister_id: pid,
124        settings: CanisterSettings {
125            controllers: Some(reserve_controllers()),
126            ..Default::default()
127        },
128    })
129    .await?;
130
131    get_cycles(pid).await
132}
133
134///
135/// ReserveOps
136///
137
138pub struct ReserveOps;
139
140impl ReserveOps {
141    /// Starts periodic reserve maintenance. Safe to call multiple times.
142    pub fn start() {
143        TIMER.with_borrow_mut(|slot| {
144            if slot.is_some() {
145                return;
146            }
147
148            let Some(_cfg) = Self::enabled_subnet_config() else {
149                return;
150            };
151
152            let id = TimerOps::set(OPS_RESERVE_INIT_DELAY, "reserve:init", async {
153                let _ = Self::check();
154
155                let interval_id = TimerOps::set_interval(
156                    OPS_RESERVE_CHECK_INTERVAL,
157                    "reserve:interval",
158                    || async {
159                        let _ = Self::check();
160                    },
161                );
162
163                TIMER.with_borrow_mut(|slot| *slot = Some(interval_id));
164            });
165
166            *slot = Some(id);
167        });
168    }
169
170    /// Stops reserve maintenance callbacks.
171    pub fn stop() {
172        TIMER.with_borrow_mut(|slot| {
173            if let Some(id) = slot.take() {
174                TimerOps::clear(id);
175            }
176        });
177    }
178
179    /// Ensures the reserve contains the minimum required entries.
180    #[must_use]
181    pub fn check() -> u64 {
182        let subnet_cfg = match ConfigOps::current_subnet() {
183            Ok(cfg) => cfg,
184            Err(e) => {
185                log!(
186                    Topic::CanisterReserve,
187                    Warn,
188                    "cannot read subnet config: {e:?}"
189                );
190                return 0;
191            }
192        };
193
194        let min_size: u64 = subnet_cfg.reserve.minimum_size.into();
195        let reserve_size = CanisterReserveStorageOps::len();
196
197        if reserve_size < min_size {
198            let missing = (min_size - reserve_size).min(10);
199
200            log!(
201                Topic::CanisterReserve,
202                Ok,
203                "reserve low: {reserve_size}/{min_size}, creating {missing}"
204            );
205
206            spawn(async move {
207                for i in 0..missing {
208                    match reserve_create_canister().await {
209                        Ok(_) => log!(
210                            Topic::CanisterReserve,
211                            Ok,
212                            "created reserve canister {}/{}",
213                            i + 1,
214                            missing
215                        ),
216                        Err(e) => log!(
217                            Topic::CanisterReserve,
218                            Warn,
219                            "failed reserve creation: {e:?}"
220                        ),
221                    }
222                }
223            });
224
225            return missing;
226        }
227
228        0
229    }
230
231    /// Pops the first entry in the reserve.
232    #[must_use]
233    pub fn pop_first() -> Option<(Principal, CanisterReserveEntry)> {
234        CanisterReserveStorageOps::pop_first()
235    }
236
237    /// Returns true if the reserve pool contains the given canister.
238    #[must_use]
239    pub fn contains(pid: &Principal) -> bool {
240        CanisterReserveStorageOps::contains(pid)
241    }
242
243    /// Full export of reserve contents.
244    #[must_use]
245    pub fn export() -> CanisterReserveView {
246        CanisterReserveStorageOps::export()
247    }
248
249    pub async fn admin(cmd: ReserveAdminCommand) -> Result<ReserveAdminResponse, Error> {
250        match cmd {
251            ReserveAdminCommand::CreateEmpty => {
252                let pid = reserve_create_canister().await?;
253                Ok(ReserveAdminResponse::Created { pid })
254            }
255            ReserveAdminCommand::Recycle { pid } => {
256                reserve_recycle_canister(pid).await?;
257                Ok(ReserveAdminResponse::Recycled)
258            }
259            ReserveAdminCommand::Import { pid } => {
260                reserve_import_canister(pid).await?;
261                Ok(ReserveAdminResponse::Imported)
262            }
263        }
264    }
265
266    fn enabled_subnet_config() -> Option<SubnetConfig> {
267        match ConfigOps::current_subnet() {
268            Ok(cfg) if cfg.reserve.minimum_size > 0 => Some(cfg),
269            _ => None,
270        }
271    }
272}
273
274//
275// CREATE
276//
277
278/// Creates a new empty reserve canister.
279pub async fn reserve_create_canister() -> Result<Principal, Error> {
280    OpsError::require_root()?;
281
282    let cycles = Cycles::new(RESERVE_CANISTER_CYCLES);
283    let pid = create_canister(reserve_controllers(), cycles.clone()).await?;
284
285    CanisterReserveStorageOps::register(pid, cycles, None, None, None);
286
287    Ok(pid)
288}
289
290//
291// IMPORT / RECYCLE
292//
293
294/// Import an arbitrary canister into the reserve (destructive).
295pub async fn reserve_import_canister(pid: Principal) -> Result<(), Error> {
296    OpsError::require_root()?;
297
298    let _ = SubnetCanisterRegistryOps::remove(&pid);
299
300    let cycles = reset_into_reserve(pid).await?;
301    CanisterReserveStorageOps::register(pid, cycles, None, None, None);
302
303    Ok(())
304}
305
306/// Recycle a managed topology canister into the reserve.
307pub async fn reserve_recycle_canister(pid: Principal) -> Result<(), Error> {
308    OpsError::require_root()?;
309
310    let entry =
311        SubnetCanisterRegistryOps::get(pid).ok_or(ReserveOpsError::ReserveEntryMissing { pid })?;
312
313    let role = Some(entry.role.clone());
314    let hash = entry.module_hash.clone();
315
316    let _ = SubnetCanisterRegistryOps::remove(&pid);
317
318    let cycles = reset_into_reserve(pid).await?;
319    CanisterReserveStorageOps::register(pid, cycles, role, None, hash);
320
321    Ok(())
322}
323
324//
325// EXPORT
326//
327
328/// Removes a canister from the reserve and returns its stored metadata.
329///
330/// NOTE:
331/// - This does NOT attach the canister to topology.
332/// - This does NOT install code.
333/// - The caller is responsible for any reactivation workflow.
334pub async fn reserve_export_canister(pid: Principal) -> Result<(CanisterRole, Vec<u8>), Error> {
335    OpsError::require_root()?;
336
337    let entry = CanisterReserveStorageOps::take(&pid)
338        .ok_or(ReserveOpsError::ReserveEntryMissing { pid })?;
339
340    let role = entry.role.ok_or(ReserveOpsError::MissingType { pid })?;
341    let hash = entry
342        .module_hash
343        .ok_or(ReserveOpsError::MissingModuleHash { pid })?;
344
345    Ok((role, hash))
346}
347
348//
349// ORCHESTRATION HOOK
350//
351
352/// Recycles a canister via the orchestrator. Triggers topology and directory cascades.
353pub async fn recycle_via_orchestrator(pid: Principal) -> Result<(), Error> {
354    use crate::ops::orchestration::orchestrator::{CanisterLifecycleOrchestrator, LifecycleEvent};
355
356    CanisterLifecycleOrchestrator::apply(LifecycleEvent::RecycleToReserve { pid })
357        .await
358        .map(|_| ())
359}
360
361//
362// TESTS
363//
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::{
369        config::Config, config::schema::ConfigModel, ids::SubnetRole, ops::storage::env::EnvOps,
370    };
371
372    #[test]
373    fn start_skips_when_minimum_size_zero() {
374        ReserveOps::stop();
375        Config::reset_for_tests();
376        let cfg = ConfigModel::test_default();
377        Config::init_from_toml(&toml::to_string(&cfg).unwrap()).unwrap();
378        EnvOps::set_subnet_role(SubnetRole::PRIME);
379
380        assert!(ReserveOps::enabled_subnet_config().is_none());
381    }
382
383    #[test]
384    fn start_runs_when_minimum_size_nonzero() {
385        ReserveOps::stop();
386        let mut cfg = ConfigModel::test_default();
387        let subnet = cfg.subnets.entry(SubnetRole::PRIME).or_default();
388        subnet.reserve.minimum_size = 1;
389
390        Config::reset_for_tests();
391        Config::init_from_toml(&toml::to_string(&cfg).unwrap()).unwrap();
392        EnvOps::set_subnet_role(SubnetRole::PRIME);
393
394        assert!(ReserveOps::enabled_subnet_config().is_some());
395    }
396}