1use crate::format::cycles_tc;
2use canic_core::{
3 bootstrap::{compiled::MetricsProfile, parse_config_model},
4 ids::CanisterRole,
5};
6use std::{
7 collections::{BTreeMap, BTreeSet},
8 fs,
9 path::{Path, PathBuf},
10};
11
12#[derive(Clone, Copy)]
13enum RootSubnetRoleScope {
14 Release,
15 Fleet,
16}
17
18const DEFAULT_INITIAL_CYCLES: u128 = 5_000_000_000_000;
19pub const LOCAL_ROOT_MIN_READY_CYCLES: u128 = 100_000_000_000_000;
20const DEFAULT_RANDOMNESS_RESEED_INTERVAL_SECS: u64 = 3600;
21
22#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct ConfiguredPoolExpectation {
27 pub pool: String,
28 pub canister_role: String,
29}
30
31impl RootSubnetRoleScope {
32 const fn includes_root(self) -> bool {
33 matches!(self, Self::Fleet)
34 }
35}
36
37pub fn configured_release_roles(
39 config_path: &Path,
40) -> Result<Vec<String>, Box<dyn std::error::Error>> {
41 let config_source = fs::read_to_string(config_path)?;
42 configured_release_roles_from_source(&config_source)
43 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
44}
45
46pub fn configured_fleet_roles(
48 config_path: &Path,
49) -> Result<Vec<String>, Box<dyn std::error::Error>> {
50 let config_source = fs::read_to_string(config_path)?;
51 configured_fleet_roles_from_source(&config_source)
52 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
53}
54
55pub fn configured_bootstrap_roles(
57 config_path: &Path,
58) -> Result<Vec<String>, Box<dyn std::error::Error>> {
59 let config_source = fs::read_to_string(config_path)?;
60 configured_bootstrap_roles_from_source(&config_source)
61 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
62}
63
64pub fn configured_install_targets(
66 config_path: &Path,
67 root_canister: &str,
68) -> Result<Vec<String>, Box<dyn std::error::Error>> {
69 let mut targets = vec![root_canister.to_string()];
70 targets.extend(configured_release_roles(config_path)?);
71 Ok(targets)
72}
73
74pub fn configured_local_root_create_cycles(
76 config_path: &Path,
77) -> Result<u128, Box<dyn std::error::Error>> {
78 let config_source = fs::read_to_string(config_path)?;
79 configured_local_root_create_cycles_from_source(&config_source)
80 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
81}
82
83pub fn configured_fleet_name(config_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
85 let config_source = fs::read_to_string(config_path)?;
86 configured_fleet_name_from_source(&config_source)
87 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
88}
89
90pub fn configured_controllers(
92 config_path: &Path,
93) -> Result<Vec<String>, Box<dyn std::error::Error>> {
94 let config_source = fs::read_to_string(config_path)?;
95 configured_controllers_from_source(&config_source)
96 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
97}
98
99pub fn configured_pool_expectations(
101 config_path: &Path,
102) -> Result<Vec<ConfiguredPoolExpectation>, Box<dyn std::error::Error>> {
103 let config_source = fs::read_to_string(config_path)?;
104 configured_pool_expectations_from_source(&config_source)
105 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
106}
107
108#[must_use]
110pub fn matching_fleet_config_paths(choices: &[PathBuf], fleet: &str) -> Vec<PathBuf> {
111 choices
112 .iter()
113 .filter_map(|path| match configured_fleet_name(path) {
114 Ok(name) if name == fleet => Some(path.clone()),
115 Ok(_) | Err(_) => None,
116 })
117 .collect()
118}
119
120pub fn configured_role_kinds(
122 config_path: &Path,
123) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
124 let config_source = fs::read_to_string(config_path)?;
125 configured_role_kinds_from_source(&config_source)
126 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
127}
128
129pub fn configured_role_capabilities(
131 config_path: &Path,
132) -> Result<BTreeMap<String, Vec<String>>, Box<dyn std::error::Error>> {
133 let config_source = fs::read_to_string(config_path)?;
134 configured_role_capabilities_from_source(&config_source)
135 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
136}
137
138pub fn configured_role_auto_create(
140 config_path: &Path,
141) -> Result<BTreeSet<String>, Box<dyn std::error::Error>> {
142 let config_source = fs::read_to_string(config_path)?;
143 configured_role_auto_create_from_source(&config_source)
144 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
145}
146
147pub fn configured_role_topups(
149 config_path: &Path,
150) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
151 let config_source = fs::read_to_string(config_path)?;
152 configured_role_topups_from_source(&config_source)
153 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
154}
155
156pub fn configured_role_metrics_profiles(
158 config_path: &Path,
159) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
160 let config_source = fs::read_to_string(config_path)?;
161 configured_role_metrics_profiles_from_source(&config_source)
162 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
163}
164
165pub fn configured_role_details(
167 config_path: &Path,
168) -> Result<BTreeMap<String, Vec<String>>, Box<dyn std::error::Error>> {
169 let config_source = fs::read_to_string(config_path)?;
170 configured_role_details_from_source(&config_source)
171 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
172}
173
174pub(super) fn configured_role_kinds_from_source(
176 config_source: &str,
177) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
178 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
179 let mut kinds = BTreeMap::<String, String>::new();
180
181 for subnet in config.subnets.values() {
182 for (role, canister) in &subnet.canisters {
183 let role = role.as_str().to_string();
184 let kind = canister.kind.to_string();
185 match kinds.get(&role) {
186 Some(existing) if existing != &kind => {
187 kinds.insert(role, "mixed".to_string());
188 }
189 Some(_) => {}
190 None => {
191 kinds.insert(role, kind);
192 }
193 }
194 }
195 }
196
197 Ok(kinds)
198}
199
200pub(super) fn configured_role_capabilities_from_source(
202 config_source: &str,
203) -> Result<BTreeMap<String, Vec<String>>, Box<dyn std::error::Error>> {
204 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
205 let mut capabilities = BTreeMap::<String, BTreeSet<String>>::new();
206
207 for subnet in config.subnets.values() {
208 for (role, canister) in &subnet.canisters {
209 let mut role_capabilities = BTreeSet::new();
210 if canister.auth.delegated_token_signer || canister.auth.role_attestation_cache {
211 role_capabilities.insert("auth".to_string());
212 }
213 if canister.sharding.is_some() {
214 role_capabilities.insert("sharding".to_string());
215 }
216 if canister.scaling.is_some() {
217 role_capabilities.insert("scaling".to_string());
218 }
219 if canister.directory.is_some() {
220 role_capabilities.insert("directory".to_string());
221 }
222 if canister.standards.icrc21 {
223 role_capabilities.insert("icrc21".to_string());
224 }
225 if !role_capabilities.is_empty() {
226 capabilities
227 .entry(role.as_str().to_string())
228 .or_default()
229 .extend(role_capabilities);
230 }
231 }
232 }
233
234 Ok(capabilities
235 .into_iter()
236 .map(|(role, capabilities)| (role, capabilities.into_iter().collect()))
237 .collect())
238}
239
240pub(super) fn configured_role_auto_create_from_source(
242 config_source: &str,
243) -> Result<BTreeSet<String>, Box<dyn std::error::Error>> {
244 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
245 let mut auto_create = BTreeSet::<String>::new();
246
247 for subnet in config.subnets.values() {
248 auto_create.extend(
249 subnet
250 .auto_create
251 .iter()
252 .map(|role| role.as_str().to_string()),
253 );
254 }
255
256 Ok(auto_create)
257}
258
259pub(super) fn configured_role_topups_from_source(
261 config_source: &str,
262) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
263 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
264 let mut topups = BTreeMap::<String, String>::new();
265
266 for subnet in config.subnets.values() {
267 for (role, canister) in &subnet.canisters {
268 if let Some(policy) = &canister.topup {
269 topups.insert(
270 role.as_str().to_string(),
271 format!(
272 "{} @ {}",
273 cycles_tc(policy.amount.to_u128()),
274 cycles_tc(policy.threshold.to_u128())
275 ),
276 );
277 }
278 }
279 }
280
281 Ok(topups)
282}
283
284pub(super) fn configured_role_metrics_profiles_from_source(
286 config_source: &str,
287) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
288 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
289 let mut profiles = BTreeMap::<String, String>::new();
290
291 for subnet in config.subnets.values() {
292 for (role, canister) in &subnet.canisters {
293 let role_name = role.as_str().to_string();
294 let profile = metrics_profile_label(canister.resolved_metrics_profile(role));
295 match profiles.get(&role_name) {
296 Some(existing) if existing != profile => {
297 profiles.insert(role_name, "mixed".to_string());
298 }
299 Some(_) => {}
300 None => {
301 profiles.insert(role_name, profile.to_string());
302 }
303 }
304 }
305 }
306
307 Ok(profiles)
308}
309
310pub(super) fn configured_local_root_create_cycles_from_source(
312 config_source: &str,
313) -> Result<u128, Box<dyn std::error::Error>> {
314 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
315 let mut root_subnet = None;
316
317 for (subnet_role, subnet) in &config.subnets {
318 if !subnet.canisters.keys().any(CanisterRole::is_root) {
319 continue;
320 }
321 if root_subnet.is_some() {
322 return Err(format!(
323 "multiple subnets define a root canister; expected exactly one root subnet (found at least '{subnet_role}')"
324 )
325 .into());
326 }
327 root_subnet = Some(subnet);
328 }
329
330 let subnet = root_subnet.ok_or_else(|| {
331 "no subnet defines a root canister; expected exactly one root subnet".to_string()
332 })?;
333
334 let mut cycles = subnet
335 .get_canister(&CanisterRole::WASM_STORE)
336 .map_or(DEFAULT_INITIAL_CYCLES, |cfg| cfg.initial_cycles.to_u128());
337 for role in &subnet.auto_create {
338 if let Some(cfg) = subnet.get_canister(role) {
339 cycles = cycles.saturating_add(cfg.initial_cycles.to_u128());
340 }
341 }
342 cycles = cycles.saturating_add(
343 u128::from(subnet.pool.minimum_size).saturating_mul(DEFAULT_INITIAL_CYCLES),
344 );
345
346 Ok(cycles.saturating_add(LOCAL_ROOT_MIN_READY_CYCLES))
347}
348
349pub(super) fn configured_role_details_from_source(
351 config_source: &str,
352) -> Result<BTreeMap<String, Vec<String>>, Box<dyn std::error::Error>> {
353 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
354 let mut details = BTreeMap::<String, BTreeSet<String>>::new();
355
356 for role in &config.app_index {
357 details
358 .entry(role.as_str().to_string())
359 .or_default()
360 .insert("app_index".to_string());
361 }
362
363 for subnet in config.subnets.values() {
364 for role in &subnet.auto_create {
365 details
366 .entry(role.as_str().to_string())
367 .or_default()
368 .insert("auto_create".to_string());
369 }
370 for role in &subnet.subnet_index {
371 details
372 .entry(role.as_str().to_string())
373 .or_default()
374 .insert("subnet_index".to_string());
375 }
376
377 for (role, canister) in &subnet.canisters {
378 let role_details = details.entry(role.as_str().to_string()).or_default();
379 let profile = canister.resolved_metrics_profile(role);
380 let profile_source = if canister.metrics.profile.is_some() {
381 "configured"
382 } else {
383 "inferred"
384 };
385 role_details.insert(format!(
386 "metrics profile={} tiers={} ({profile_source})",
387 metrics_profile_label(profile),
388 metrics_profile_tiers_label(profile)
389 ));
390 if canister.initial_cycles.to_u128() != DEFAULT_INITIAL_CYCLES {
391 role_details.insert(format!("initial_cycles={}", canister.initial_cycles));
392 }
393 if !canister.randomness.enabled {
394 role_details.insert("randomness=off".to_string());
395 } else if randomness_source_label(canister.randomness.source) != "ic"
396 || canister.randomness.reseed_interval_secs
397 != DEFAULT_RANDOMNESS_RESEED_INTERVAL_SECS
398 {
399 role_details.insert(format!(
400 "randomness={} reseed={}s",
401 randomness_source_label(canister.randomness.source),
402 canister.randomness.reseed_interval_secs
403 ));
404 }
405 if canister.auth.delegated_token_signer {
406 role_details.insert("auth delegated-token-signer".to_string());
407 }
408 if canister.auth.role_attestation_cache {
409 role_details.insert("auth role-attestation-cache".to_string());
410 }
411 if canister.standards.icrc21 {
412 role_details.insert("standard icrc21".to_string());
413 }
414 if let Some(scaling) = &canister.scaling {
415 for (pool_name, pool) in &scaling.pools {
416 role_details.insert(format!(
417 "scaling {pool_name}->{} initial={} min={} max={}",
418 pool.canister_role.as_str(),
419 pool.policy.initial_workers,
420 pool.policy.min_workers,
421 pool.policy.max_workers
422 ));
423 }
424 }
425 if let Some(sharding) = &canister.sharding {
426 for (pool_name, pool) in &sharding.pools {
427 role_details.insert(format!(
428 "sharding {pool_name}->{} cap={} initial={} max={}",
429 pool.canister_role.as_str(),
430 pool.policy.capacity,
431 pool.policy.initial_shards,
432 pool.policy.max_shards
433 ));
434 }
435 }
436 if let Some(directory) = &canister.directory {
437 for (pool_name, pool) in &directory.pools {
438 role_details.insert(format!(
439 "directory {pool_name}->{} key={}",
440 pool.canister_role.as_str(),
441 pool.key_name
442 ));
443 }
444 }
445 }
446 }
447
448 Ok(details
449 .into_iter()
450 .filter(|(_, details)| !details.is_empty())
451 .map(|(role, details)| (role, details.into_iter().collect()))
452 .collect())
453}
454
455fn randomness_source_label(source: impl std::fmt::Debug) -> String {
456 format!("{source:?}").to_ascii_lowercase()
457}
458
459const fn metrics_profile_label(profile: MetricsProfile) -> &'static str {
460 match profile {
461 MetricsProfile::Leaf => "leaf",
462 MetricsProfile::Hub => "hub",
463 MetricsProfile::Storage => "storage",
464 MetricsProfile::Root => "root",
465 MetricsProfile::Full => "full",
466 }
467}
468
469const fn metrics_profile_tiers_label(profile: MetricsProfile) -> &'static str {
470 match profile {
471 MetricsProfile::Leaf => "core,runtime,security",
472 MetricsProfile::Hub => "core,placement,runtime,security",
473 MetricsProfile::Storage => "core,runtime,storage",
474 MetricsProfile::Root | MetricsProfile::Full => {
475 "core,placement,platform,runtime,security,storage"
476 }
477 }
478}
479
480pub(super) fn configured_fleet_name_from_source(
482 config_source: &str,
483) -> Result<String, Box<dyn std::error::Error>> {
484 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
485 let name = config
486 .fleet
487 .and_then(|fleet| fleet.name)
488 .ok_or_else(|| "missing required [fleet].name in canic.toml".to_string())?;
489 Ok(name)
490}
491
492pub(super) fn configured_controllers_from_source(
494 config_source: &str,
495) -> Result<Vec<String>, Box<dyn std::error::Error>> {
496 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
497 let mut controllers = config
498 .controllers
499 .iter()
500 .map(canic_core::cdk::types::Principal::to_text)
501 .collect::<Vec<_>>();
502 controllers.sort();
503 controllers.dedup();
504 Ok(controllers)
505}
506
507pub(super) fn configured_pool_expectations_from_source(
509 config_source: &str,
510) -> Result<Vec<ConfiguredPoolExpectation>, Box<dyn std::error::Error>> {
511 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
512 let mut root_subnet = None;
513
514 for (subnet_role, subnet) in &config.subnets {
515 if !subnet.canisters.keys().any(CanisterRole::is_root) {
516 continue;
517 }
518
519 if root_subnet.is_some() {
520 return Err(format!(
521 "multiple subnets define a root canister; expected exactly one root subnet (found at least '{subnet_role}')"
522 )
523 .into());
524 }
525
526 root_subnet = Some(subnet);
527 }
528
529 let subnet = root_subnet.ok_or_else(|| {
530 "no subnet defines a root canister; expected exactly one root subnet".to_string()
531 })?;
532 let mut pools = BTreeMap::<String, ConfiguredPoolExpectation>::new();
533
534 for canister in subnet.canisters.values() {
535 if let Some(scaling) = &canister.scaling {
536 for (pool_name, pool) in &scaling.pools {
537 pools.insert(
538 format!("scaling:{pool_name}:{}", pool.canister_role.as_str()),
539 ConfiguredPoolExpectation {
540 pool: pool_name.clone(),
541 canister_role: pool.canister_role.as_str().to_string(),
542 },
543 );
544 }
545 }
546 if let Some(sharding) = &canister.sharding {
547 for (pool_name, pool) in &sharding.pools {
548 pools.insert(
549 format!("sharding:{pool_name}:{}", pool.canister_role.as_str()),
550 ConfiguredPoolExpectation {
551 pool: pool_name.clone(),
552 canister_role: pool.canister_role.as_str().to_string(),
553 },
554 );
555 }
556 }
557 if let Some(directory) = &canister.directory {
558 for (pool_name, pool) in &directory.pools {
559 pools.insert(
560 format!("directory:{pool_name}:{}", pool.canister_role.as_str()),
561 ConfiguredPoolExpectation {
562 pool: pool_name.clone(),
563 canister_role: pool.canister_role.as_str().to_string(),
564 },
565 );
566 }
567 }
568 }
569
570 Ok(pools.into_values().collect())
571}
572
573pub(super) fn configured_release_roles_from_source(
575 config_source: &str,
576) -> Result<Vec<String>, Box<dyn std::error::Error>> {
577 configured_root_subnet_roles_from_source(config_source, RootSubnetRoleScope::Release)
578}
579
580pub(super) fn configured_fleet_roles_from_source(
583 config_source: &str,
584) -> Result<Vec<String>, Box<dyn std::error::Error>> {
585 configured_root_subnet_roles_from_source(config_source, RootSubnetRoleScope::Fleet)
586}
587
588pub(super) fn configured_bootstrap_roles_from_source(
590 config_source: &str,
591) -> Result<Vec<String>, Box<dyn std::error::Error>> {
592 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
593 let mut root_subnet = None;
594
595 for (subnet_role, subnet) in &config.subnets {
596 if !subnet.canisters.keys().any(CanisterRole::is_root) {
597 continue;
598 }
599
600 if root_subnet.is_some() {
601 return Err(format!(
602 "multiple subnets define a root canister; expected exactly one root subnet (found at least '{subnet_role}')"
603 )
604 .into());
605 }
606
607 root_subnet = Some(subnet);
608 }
609
610 let subnet = root_subnet.ok_or_else(|| {
611 "no subnet defines a root canister; expected exactly one root subnet".to_string()
612 })?;
613
614 let mut roles = BTreeSet::<String>::new();
615 roles.insert(CanisterRole::ROOT.as_str().to_string());
616 roles.extend(
617 subnet
618 .auto_create
619 .iter()
620 .map(|role| role.as_str().to_string()),
621 );
622
623 for role in &subnet.auto_create {
624 let Some(canister) = subnet.get_canister(role) else {
625 continue;
626 };
627
628 if let Some(sharding) = &canister.sharding {
629 for pool in sharding.pools.values() {
630 if pool.policy.initial_shards > 0 {
631 roles.insert(pool.canister_role.as_str().to_string());
632 }
633 }
634 }
635
636 if let Some(scaling) = &canister.scaling {
637 for pool in scaling.pools.values() {
638 if pool.policy.initial_workers > 0 {
639 roles.insert(pool.canister_role.as_str().to_string());
640 }
641 }
642 }
643 }
644
645 Ok(sort_root_subnet_roles(roles.into_iter().collect()))
646}
647
648fn configured_root_subnet_roles_from_source(
650 config_source: &str,
651 scope: RootSubnetRoleScope,
652) -> Result<Vec<String>, Box<dyn std::error::Error>> {
653 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
654 let mut root_subnet_roles = None;
655
656 for (subnet_role, subnet) in &config.subnets {
657 if !subnet.canisters.keys().any(CanisterRole::is_root) {
658 continue;
659 }
660
661 if root_subnet_roles.is_some() {
662 return Err(format!(
663 "multiple subnets define a root canister; expected exactly one root subnet (found at least '{subnet_role}')"
664 )
665 .into());
666 }
667
668 root_subnet_roles = Some(
669 subnet
670 .canisters
671 .keys()
672 .filter(|role| !role.is_wasm_store())
673 .filter(|role| scope.includes_root() || !role.is_root())
674 .map(|role| role.as_str().to_string())
675 .collect::<Vec<_>>(),
676 );
677 }
678
679 let root_subnet_roles = root_subnet_roles.ok_or_else(|| {
680 "no subnet defines a root canister; expected exactly one root subnet".to_string()
681 })?;
682
683 Ok(sort_root_subnet_roles(root_subnet_roles))
684}
685
686fn sort_root_subnet_roles(mut roles: Vec<String>) -> Vec<String> {
688 roles.sort_by(|left, right| {
689 match (
690 left == CanisterRole::ROOT.as_str(),
691 right == CanisterRole::ROOT.as_str(),
692 ) {
693 (true, false) => std::cmp::Ordering::Less,
694 (false, true) => std::cmp::Ordering::Greater,
695 _ => left.cmp(right),
696 }
697 });
698 roles
699}