1pub 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#[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#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
74pub enum ReserveAdminCommand {
75 CreateEmpty,
76 Recycle { pid: Principal },
77 Import { pid: Principal },
78}
79
80#[derive(CandidType, Clone, Debug, Deserialize, Eq, PartialEq)]
85pub enum ReserveAdminResponse {
86 Created { pid: Principal },
87 Recycled,
88 Imported,
89}
90
91thread_local! {
96 static TIMER: RefCell<Option<TimerId>> = const { RefCell::new(None) };
97}
98
99const RESERVE_CANISTER_CYCLES: u128 = 5 * TC;
101
102fn 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
120async 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
136pub struct ReserveOps;
141
142impl ReserveOps {
143 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 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 #[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 #[must_use]
235 pub fn pop_first() -> Option<(Principal, CanisterReserveEntry)> {
236 CanisterReserveStorageOps::pop_first()
237 }
238
239 #[must_use]
241 pub fn contains(pid: &Principal) -> bool {
242 CanisterReserveStorageOps::contains(pid)
243 }
244
245 #[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
276pub 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
292pub 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
308pub 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
326pub 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
350pub 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#[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}