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::get().controllers.clone();
109 let root = canister_self();
110
111 if !controllers.contains(&root) {
112 controllers.push(root);
113 }
114
115 controllers
116}
117
118async 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
134pub struct ReserveOps;
139
140impl ReserveOps {
141 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 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 #[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 #[must_use]
233 pub fn pop_first() -> Option<(Principal, CanisterReserveEntry)> {
234 CanisterReserveStorageOps::pop_first()
235 }
236
237 #[must_use]
239 pub fn contains(pid: &Principal) -> bool {
240 CanisterReserveStorageOps::contains(pid)
241 }
242
243 #[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
274pub 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
290pub 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
306pub 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
324pub 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
348pub 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#[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}