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};
11use toml::Value as TomlValue;
12
13#[derive(Clone, Copy)]
14enum RootSubnetRoleScope {
15 Release,
16 Deployable,
17}
18
19const DEFAULT_INITIAL_CYCLES: u128 = 5_000_000_000_000;
20pub const LOCAL_ROOT_MIN_READY_CYCLES: u128 = 100_000_000_000_000;
21const DEFAULT_RANDOMNESS_RESEED_INTERVAL_SECS: u64 = 3600;
22
23#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct ConfiguredPoolExpectation {
28 pub pool: String,
29 pub canister_role: String,
30}
31
32#[derive(Clone, Debug, Eq, PartialEq)]
36pub struct ConfiguredRoleLifecycle {
37 pub fleet: String,
38 pub role: String,
39 pub display: String,
40 pub declaration_kind: String,
41 pub package: String,
42 pub attached: bool,
43 pub state: String,
44 pub topology: Option<String>,
45}
46
47#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct DeclaredFleetRole {
52 pub fleet: String,
53 pub role: String,
54 pub display: String,
55 pub package: String,
56}
57
58#[derive(Clone, Debug, Eq, PartialEq)]
62pub struct AttachedFleetRole {
63 pub fleet: String,
64 pub role: String,
65 pub display: String,
66 pub subnet: String,
67 pub kind: String,
68 pub topology: String,
69}
70
71#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct RenamedFleetRole {
76 pub fleet: String,
77 pub old_role: String,
78 pub new_role: String,
79 pub old_display: String,
80 pub new_display: String,
81 pub package_manifest: Option<PathBuf>,
82 pub package_manifest_note: Option<String>,
83}
84
85impl RootSubnetRoleScope {
86 const fn includes_root(self) -> bool {
87 matches!(self, Self::Deployable)
88 }
89}
90
91pub fn configured_release_roles(
93 config_path: &Path,
94) -> Result<Vec<String>, Box<dyn std::error::Error>> {
95 let config_source = fs::read_to_string(config_path)?;
96 configured_release_roles_from_source(&config_source)
97 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
98}
99
100pub fn configured_deployable_roles(
102 config_path: &Path,
103) -> Result<Vec<String>, Box<dyn std::error::Error>> {
104 let config_source = fs::read_to_string(config_path)?;
105 configured_deployable_roles_from_source(&config_source)
106 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
107}
108
109pub fn configured_bootstrap_roles(
111 config_path: &Path,
112) -> Result<Vec<String>, Box<dyn std::error::Error>> {
113 let config_source = fs::read_to_string(config_path)?;
114 configured_bootstrap_roles_from_source(&config_source)
115 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
116}
117
118pub fn configured_install_targets(
120 config_path: &Path,
121 root_canister: &str,
122) -> Result<Vec<String>, Box<dyn std::error::Error>> {
123 let mut targets = vec![root_canister.to_string()];
124 targets.extend(configured_release_roles(config_path)?);
125 Ok(targets)
126}
127
128pub fn configured_local_root_create_cycles(
130 config_path: &Path,
131) -> Result<u128, Box<dyn std::error::Error>> {
132 let config_source = fs::read_to_string(config_path)?;
133 configured_local_root_create_cycles_from_source(&config_source)
134 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
135}
136
137pub fn configured_fleet_name(config_path: &Path) -> Result<String, Box<dyn std::error::Error>> {
139 let config_source = fs::read_to_string(config_path)?;
140 configured_fleet_name_from_source(&config_source)
141 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
142}
143
144pub fn configured_controllers(
146 config_path: &Path,
147) -> Result<Vec<String>, Box<dyn std::error::Error>> {
148 let config_source = fs::read_to_string(config_path)?;
149 configured_controllers_from_source(&config_source)
150 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
151}
152
153pub fn configured_pool_expectations(
155 config_path: &Path,
156) -> Result<Vec<ConfiguredPoolExpectation>, Box<dyn std::error::Error>> {
157 let config_source = fs::read_to_string(config_path)?;
158 configured_pool_expectations_from_source(&config_source)
159 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
160}
161
162pub fn configured_role_lifecycle(
164 config_path: &Path,
165) -> Result<Vec<ConfiguredRoleLifecycle>, Box<dyn std::error::Error>> {
166 let config_source = fs::read_to_string(config_path)?;
167 configured_role_lifecycle_from_source(&config_source)
168 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
169}
170
171pub fn declare_fleet_role(
173 config_path: &Path,
174 expected_fleet: &str,
175 role: &str,
176 package: &str,
177) -> Result<DeclaredFleetRole, Box<dyn std::error::Error>> {
178 let source = fs::read_to_string(config_path)?;
179 let updated = declare_fleet_role_source(&source, expected_fleet, role, package)
180 .map_err(|err| format!("invalid {}: {err}", config_path.display()))?;
181 fs::write(config_path, updated.source)?;
182 Ok(updated.role)
183}
184
185pub fn attach_fleet_role(
187 config_path: &Path,
188 expected_fleet: &str,
189 role: &str,
190 subnet: &str,
191 kind: &str,
192) -> Result<AttachedFleetRole, Box<dyn std::error::Error>> {
193 let source = fs::read_to_string(config_path)?;
194 let updated = attach_fleet_role_source(&source, expected_fleet, role, subnet, kind)
195 .map_err(|err| format!("invalid {}: {err}", config_path.display()))?;
196 fs::write(config_path, updated.source)?;
197 Ok(updated.role)
198}
199
200pub fn rename_fleet_role(
202 config_path: &Path,
203 expected_fleet: &str,
204 old_role: &str,
205 new_role: &str,
206) -> Result<RenamedFleetRole, Box<dyn std::error::Error>> {
207 let source = fs::read_to_string(config_path)?;
208 let updated =
209 rename_fleet_role_source(&source, config_path, expected_fleet, old_role, new_role)
210 .map_err(|err| format!("invalid {}: {err}", config_path.display()))?;
211 fs::write(config_path, updated.source)?;
212 if let (Some(path), Some(source)) = (&updated.package_manifest, &updated.package_source) {
213 fs::write(path, source)?;
214 }
215 Ok(updated.role)
216}
217
218#[must_use]
220pub fn matching_fleet_config_paths(choices: &[PathBuf], fleet: &str) -> Vec<PathBuf> {
221 choices
222 .iter()
223 .filter_map(|path| match configured_fleet_name(path) {
224 Ok(name) if name == fleet => Some(path.clone()),
225 Ok(_) | Err(_) => None,
226 })
227 .collect()
228}
229
230pub fn configured_role_kinds(
232 config_path: &Path,
233) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
234 let config_source = fs::read_to_string(config_path)?;
235 configured_role_kinds_from_source(&config_source)
236 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
237}
238
239pub fn configured_role_capabilities(
241 config_path: &Path,
242) -> Result<BTreeMap<String, Vec<String>>, Box<dyn std::error::Error>> {
243 let config_source = fs::read_to_string(config_path)?;
244 configured_role_capabilities_from_source(&config_source)
245 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
246}
247
248pub fn configured_role_auto_create(
250 config_path: &Path,
251) -> Result<BTreeSet<String>, Box<dyn std::error::Error>> {
252 let config_source = fs::read_to_string(config_path)?;
253 configured_role_auto_create_from_source(&config_source)
254 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
255}
256
257pub fn configured_role_topups(
259 config_path: &Path,
260) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
261 let config_source = fs::read_to_string(config_path)?;
262 configured_role_topups_from_source(&config_source)
263 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
264}
265
266pub fn configured_role_metrics_profiles(
268 config_path: &Path,
269) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
270 let config_source = fs::read_to_string(config_path)?;
271 configured_role_metrics_profiles_from_source(&config_source)
272 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
273}
274
275pub fn configured_role_details(
277 config_path: &Path,
278) -> Result<BTreeMap<String, Vec<String>>, Box<dyn std::error::Error>> {
279 let config_source = fs::read_to_string(config_path)?;
280 configured_role_details_from_source(&config_source)
281 .map_err(|err| format!("invalid {}: {err}", config_path.display()).into())
282}
283
284pub(super) fn configured_role_kinds_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 kinds = BTreeMap::<String, String>::new();
290
291 for subnet in config.subnets.values() {
292 for (role, canister) in &subnet.canisters {
293 let role = role.as_str().to_string();
294 let kind = canister.kind.to_string();
295 match kinds.get(&role) {
296 Some(existing) if existing != &kind => {
297 kinds.insert(role, "mixed".to_string());
298 }
299 Some(_) => {}
300 None => {
301 kinds.insert(role, kind);
302 }
303 }
304 }
305 }
306
307 Ok(kinds)
308}
309
310pub(super) fn configured_role_lifecycle_from_source(
312 config_source: &str,
313) -> Result<Vec<ConfiguredRoleLifecycle>, Box<dyn std::error::Error>> {
314 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
315 let fleet = config
316 .fleet_name()
317 .ok_or_else(|| "missing required [fleet].name in canic.toml".to_string())?
318 .to_string();
319 let attached_roles = config.attached_roles();
320 let mut topology = BTreeMap::<CanisterRole, Vec<String>>::new();
321
322 for (subnet_role, subnet) in &config.subnets {
323 for (role, canister) in &subnet.canisters {
324 topology
325 .entry(role.clone())
326 .or_default()
327 .push(format!("{subnet_role}/{role}"));
328
329 if let Some(scaling) = &canister.scaling {
330 for (pool, scale_pool) in &scaling.pools {
331 topology
332 .entry(scale_pool.canister_role.clone())
333 .or_default()
334 .push(format!("{subnet_role}/{role}/scaling/{pool}"));
335 }
336 }
337
338 if let Some(sharding) = &canister.sharding {
339 for (pool, shard_pool) in &sharding.pools {
340 topology
341 .entry(shard_pool.canister_role.clone())
342 .or_default()
343 .push(format!("{subnet_role}/{role}/sharding/{pool}"));
344 }
345 }
346
347 if let Some(directory) = &canister.directory {
348 for (pool, directory_pool) in &directory.pools {
349 topology
350 .entry(directory_pool.canister_role.clone())
351 .or_default()
352 .push(format!("{subnet_role}/{role}/directory/{pool}"));
353 }
354 }
355 }
356 }
357
358 Ok(config
359 .roles
360 .iter()
361 .map(|(role, declaration)| {
362 let role_name = role.as_str().to_string();
363 let attached = attached_roles.contains(role);
364 ConfiguredRoleLifecycle {
365 fleet: fleet.clone(),
366 display: format!("{fleet}.{role}"),
367 role: role_name,
368 declaration_kind: if role.is_root() { "root" } else { "canister" }.to_string(),
369 package: declaration.package.clone(),
370 attached,
371 state: if attached { "attached" } else { "declared" }.to_string(),
372 topology: topology.get(role).map(|labels| labels.join(",")),
373 }
374 })
375 .collect())
376}
377
378#[derive(Debug)]
379pub(super) struct DeclaredFleetRoleSource {
380 pub(super) source: String,
381 pub(super) role: DeclaredFleetRole,
382}
383
384#[derive(Debug)]
385pub(super) struct AttachedFleetRoleSource {
386 pub(super) source: String,
387 pub(super) role: AttachedFleetRole,
388}
389
390#[derive(Debug)]
391pub(super) struct RenamedFleetRoleSource {
392 pub(super) source: String,
393 pub(super) package_manifest: Option<PathBuf>,
394 pub(super) package_source: Option<String>,
395 pub(super) role: RenamedFleetRole,
396}
397
398pub(super) fn declare_fleet_role_source(
399 config_source: &str,
400 expected_fleet: &str,
401 role: &str,
402 package: &str,
403) -> Result<DeclaredFleetRoleSource, Box<dyn std::error::Error>> {
404 let role = role.trim();
405 let package = package.trim();
406 if role.is_empty() {
407 return Err("role must not be empty".into());
408 }
409 if package.is_empty() {
410 return Err("package must not be empty".into());
411 }
412 if role == "root" {
413 return Err("root role must be attached to topology; declare ordinary roles only".into());
414 }
415 if !role
416 .bytes()
417 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
418 {
419 return Err("role must contain only ASCII letters, numbers, '_' or '-'".into());
420 }
421
422 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
423 let actual_fleet = config
424 .fleet_name()
425 .ok_or_else(|| "missing required [fleet].name in canic.toml".to_string())?;
426 if actual_fleet != expected_fleet {
427 return Err(format!(
428 "selected config declares fleet {actual_fleet:?}, not {expected_fleet:?}"
429 )
430 .into());
431 }
432
433 let role_id = CanisterRole::owned(role.to_string());
434 if config.declares_role(&role_id) {
435 return Err(format!("role {expected_fleet}.{role} is already declared").into());
436 }
437
438 let mut source = config_source.trim_end().to_string();
439 source.push_str("\n\n[roles.");
440 source.push_str(&toml_string_literal(role));
441 source.push_str("]\nkind = \"canister\"\npackage = ");
442 source.push_str(&toml_string_literal(package));
443 source.push('\n');
444
445 parse_config_model(&source).map_err(|err| err.to_string())?;
446
447 Ok(DeclaredFleetRoleSource {
448 source,
449 role: DeclaredFleetRole {
450 fleet: expected_fleet.to_string(),
451 role: role.to_string(),
452 display: format!("{expected_fleet}.{role}"),
453 package: package.to_string(),
454 },
455 })
456}
457
458pub(super) fn attach_fleet_role_source(
459 config_source: &str,
460 expected_fleet: &str,
461 role: &str,
462 subnet: &str,
463 kind: &str,
464) -> Result<AttachedFleetRoleSource, Box<dyn std::error::Error>> {
465 let role = role.trim();
466 let subnet = subnet.trim();
467 let kind = kind.trim();
468 validate_role_name(role)?;
469 validate_subnet_name(subnet)?;
470 validate_attach_kind(kind)?;
471 if role == "root" {
472 return Err("root role must already be attached through root topology".into());
473 }
474
475 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
476 let actual_fleet = config
477 .fleet_name()
478 .ok_or_else(|| "missing required [fleet].name in canic.toml".to_string())?;
479 if actual_fleet != expected_fleet {
480 return Err(format!(
481 "selected config declares fleet {actual_fleet:?}, not {expected_fleet:?}"
482 )
483 .into());
484 }
485
486 let role_id = CanisterRole::owned(role.to_string());
487 config
488 .roles
489 .get(&role_id)
490 .ok_or_else(|| format!("role {expected_fleet}.{role} is not declared"))?;
491 if config.attached_roles().contains(&role_id) {
492 return Err(format!("role {expected_fleet}.{role} is already attached").into());
493 }
494
495 let mut source = config_source.trim_end().to_string();
496 source.push_str("\n\n[subnets.");
497 source.push_str(&toml_string_literal(subnet));
498 source.push_str(".canisters.");
499 source.push_str(&toml_string_literal(role));
500 source.push_str("]\nkind = ");
501 source.push_str(&toml_string_literal(kind));
502 source.push('\n');
503
504 parse_config_model(&source).map_err(|err| err.to_string())?;
505
506 Ok(AttachedFleetRoleSource {
507 source,
508 role: AttachedFleetRole {
509 fleet: expected_fleet.to_string(),
510 role: role.to_string(),
511 display: format!("{expected_fleet}.{role}"),
512 subnet: subnet.to_string(),
513 kind: kind.to_string(),
514 topology: format!("{subnet}/{role}"),
515 },
516 })
517}
518
519pub(super) fn rename_fleet_role_source(
520 config_source: &str,
521 config_path: &Path,
522 expected_fleet: &str,
523 old_role: &str,
524 new_role: &str,
525) -> Result<RenamedFleetRoleSource, Box<dyn std::error::Error>> {
526 let old_role = old_role.trim();
527 let new_role = new_role.trim();
528 validate_role_name(old_role)?;
529 validate_role_name(new_role)?;
530 if old_role == "root" || new_role == "root" {
531 return Err("root role cannot be renamed through fleet role rename".into());
532 }
533 if old_role == new_role {
534 return Err("old role and new role must differ".into());
535 }
536
537 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
538 let actual_fleet = config
539 .fleet_name()
540 .ok_or_else(|| "missing required [fleet].name in canic.toml".to_string())?;
541 if actual_fleet != expected_fleet {
542 return Err(format!(
543 "selected config declares fleet {actual_fleet:?}, not {expected_fleet:?}"
544 )
545 .into());
546 }
547
548 let old_id = CanisterRole::owned(old_role.to_string());
549 let new_id = CanisterRole::owned(new_role.to_string());
550 let declaration = config
551 .roles
552 .get(&old_id)
553 .ok_or_else(|| format!("role {expected_fleet}.{old_role} is not declared"))?;
554 if config.declares_role(&new_id) {
555 return Err(format!("role {expected_fleet}.{new_role} is already declared").into());
556 }
557
558 let source = rename_config_role_references(config_source, old_role, new_role)?;
559 parse_config_model(&source).map_err(|err| err.to_string())?;
560
561 let (package_manifest, package_source, package_manifest_note) =
562 config_path.parent().map_or_else(
563 || (None, None, Some("config path has no parent".to_string())),
564 |parent| {
565 let manifest = parent.join(&declaration.package).join("Cargo.toml");
566 match update_package_manifest_role(&manifest, expected_fleet, old_role, new_role) {
567 Ok(Some(updated)) => (Some(manifest), Some(updated), None),
568 Ok(None) => (
569 None,
570 None,
571 Some(format!(
572 "{} did not contain matching [package.metadata.canic] fleet/role metadata",
573 manifest.display()
574 )),
575 ),
576 Err(err) => (None, None, Some(err.to_string())),
577 }
578 },
579 );
580
581 Ok(RenamedFleetRoleSource {
582 source,
583 package_manifest: package_manifest.clone(),
584 package_source,
585 role: RenamedFleetRole {
586 fleet: expected_fleet.to_string(),
587 old_role: old_role.to_string(),
588 new_role: new_role.to_string(),
589 old_display: format!("{expected_fleet}.{old_role}"),
590 new_display: format!("{expected_fleet}.{new_role}"),
591 package_manifest,
592 package_manifest_note,
593 },
594 })
595}
596
597fn rename_config_role_references(
598 source: &str,
599 old_role: &str,
600 new_role: &str,
601) -> Result<String, Box<dyn std::error::Error>> {
602 let old_literal = toml_string_literal(old_role);
603 let new_literal = toml_string_literal(new_role);
604 let mut updated = Vec::new();
605
606 for line in source.lines() {
607 let mut line = rename_role_header(line, old_role, new_role)?;
608 let trimmed = line.trim_start();
609 if toml_assignment_key(trimmed) == Some("canister_role")
610 || toml_assignment_key(trimmed) == Some("app_index")
611 {
612 line = line.replace(&old_literal, &new_literal);
613 }
614 updated.push(line);
615 }
616
617 let mut result = updated.join("\n");
618 if source.ends_with('\n') {
619 result.push('\n');
620 }
621 Ok(result)
622}
623
624fn rename_role_header(
625 line: &str,
626 old_role: &str,
627 new_role: &str,
628) -> Result<String, Box<dyn std::error::Error>> {
629 let trimmed = line.trim();
630 if !trimmed.starts_with('[') || !trimmed.ends_with(']') || trimmed.starts_with("[[") {
631 return Ok(line.to_string());
632 }
633
634 let Some(prefix_len) = line.find('[') else {
635 return Ok(line.to_string());
636 };
637 let inner = &trimmed[1..trimmed.len() - 1];
638 let mut path = parse_toml_dotted_path(inner)?;
639 let rename_roles_header = path.len() == 2 && path[0] == "roles" && path[1] == old_role;
640 let rename_canister_header =
641 path.len() >= 4 && path[0] == "subnets" && path[2] == "canisters" && path[3] == old_role;
642
643 if rename_roles_header {
644 path[1] = new_role.to_string();
645 } else if rename_canister_header {
646 path[3] = new_role.to_string();
647 } else {
648 return Ok(line.to_string());
649 }
650
651 Ok(format!(
652 "{}[{}]",
653 &line[..prefix_len],
654 path.iter()
655 .map(|part| toml_string_literal(part))
656 .collect::<Vec<_>>()
657 .join(".")
658 ))
659}
660
661fn parse_toml_dotted_path(path: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
662 let mut parts = Vec::new();
663 let mut current = String::new();
664 let mut chars = path.chars();
665 let mut in_quote = false;
666
667 while let Some(ch) = chars.next() {
668 match ch {
669 '"' if !in_quote => in_quote = true,
670 '"' if in_quote => in_quote = false,
671 '\\' if in_quote => {
672 let Some(escaped) = chars.next() else {
673 return Err("unterminated TOML escape in table header".into());
674 };
675 current.push(escaped);
676 }
677 '.' if !in_quote => {
678 parts.push(current.trim().to_string());
679 current.clear();
680 }
681 ch => current.push(ch),
682 }
683 }
684
685 if in_quote {
686 return Err("unterminated quoted TOML table header".into());
687 }
688 parts.push(current.trim().to_string());
689 Ok(parts)
690}
691
692fn toml_assignment_key(line: &str) -> Option<&str> {
693 let (key, _) = line.split_once('=')?;
694 Some(key.trim())
695}
696
697fn update_package_manifest_role(
698 manifest: &Path,
699 expected_fleet: &str,
700 old_role: &str,
701 new_role: &str,
702) -> Result<Option<String>, Box<dyn std::error::Error>> {
703 if !manifest.is_file() {
704 return Ok(None);
705 }
706
707 let source = fs::read_to_string(manifest)?;
708 let metadata = toml::from_str::<TomlValue>(&source)?;
709 let Some(canic_metadata) = metadata
710 .get("package")
711 .and_then(TomlValue::as_table)
712 .and_then(|package| package.get("metadata"))
713 .and_then(TomlValue::as_table)
714 .and_then(|metadata| metadata.get("canic"))
715 .and_then(TomlValue::as_table)
716 else {
717 return Ok(None);
718 };
719 if canic_metadata.get("fleet").and_then(TomlValue::as_str) != Some(expected_fleet)
720 || canic_metadata.get("role").and_then(TomlValue::as_str) != Some(old_role)
721 {
722 return Ok(None);
723 }
724
725 Ok(Some(rename_package_metadata_role_source(
726 &source, old_role, new_role,
727 )))
728}
729
730fn rename_package_metadata_role_source(source: &str, old_role: &str, new_role: &str) -> String {
731 let mut in_canic_metadata = false;
732 let old_literal = toml_string_literal(old_role);
733 let new_literal = toml_string_literal(new_role);
734 let mut lines = Vec::new();
735
736 for line in source.lines() {
737 let trimmed = line.trim();
738 if trimmed.starts_with('[') && trimmed.ends_with(']') {
739 in_canic_metadata = trimmed == "[package.metadata.canic]";
740 }
741 if in_canic_metadata && toml_assignment_key(line.trim_start()) == Some("role") {
742 lines.push(line.replace(&old_literal, &new_literal));
743 } else {
744 lines.push(line.to_string());
745 }
746 }
747
748 let mut result = lines.join("\n");
749 if source.ends_with('\n') {
750 result.push('\n');
751 }
752 result
753}
754
755fn validate_role_name(role: &str) -> Result<(), Box<dyn std::error::Error>> {
756 if role.is_empty() {
757 return Err("role must not be empty".into());
758 }
759 if !role
760 .bytes()
761 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
762 {
763 return Err("role must contain only ASCII letters, numbers, '_' or '-'".into());
764 }
765 Ok(())
766}
767
768fn validate_subnet_name(subnet: &str) -> Result<(), Box<dyn std::error::Error>> {
769 if subnet.is_empty() {
770 return Err("subnet must not be empty".into());
771 }
772 if !subnet
773 .bytes()
774 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
775 {
776 return Err("subnet must contain only ASCII letters, numbers, '_' or '-'".into());
777 }
778 Ok(())
779}
780
781fn validate_attach_kind(kind: &str) -> Result<(), Box<dyn std::error::Error>> {
782 if matches!(
783 kind,
784 "service" | "singleton" | "shard" | "replica" | "instance"
785 ) {
786 return Ok(());
787 }
788
789 Err("kind must be one of: service, singleton, shard, replica, instance".into())
790}
791
792fn toml_string_literal(value: &str) -> String {
793 let mut escaped = String::from("\"");
794 for ch in value.chars() {
795 match ch {
796 '\\' => escaped.push_str("\\\\"),
797 '"' => escaped.push_str("\\\""),
798 '\n' => escaped.push_str("\\n"),
799 '\r' => escaped.push_str("\\r"),
800 '\t' => escaped.push_str("\\t"),
801 ch => escaped.push(ch),
802 }
803 }
804 escaped.push('"');
805 escaped
806}
807
808pub(super) fn configured_role_capabilities_from_source(
810 config_source: &str,
811) -> Result<BTreeMap<String, Vec<String>>, Box<dyn std::error::Error>> {
812 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
813 let mut capabilities = BTreeMap::<String, BTreeSet<String>>::new();
814
815 for subnet in config.subnets.values() {
816 for (role, canister) in &subnet.canisters {
817 let mut role_capabilities = BTreeSet::new();
818 if canister.auth.delegated_token_issuer
819 || canister.auth.delegated_token_verifier
820 || canister.auth.role_attestation_cache
821 {
822 role_capabilities.insert("auth".to_string());
823 }
824 if canister.sharding.is_some() {
825 role_capabilities.insert("sharding".to_string());
826 }
827 if canister.scaling.is_some() {
828 role_capabilities.insert("scaling".to_string());
829 }
830 if canister.directory.is_some() {
831 role_capabilities.insert("directory".to_string());
832 }
833 if canister.standards.icrc21 {
834 role_capabilities.insert("icrc21".to_string());
835 }
836 if !role_capabilities.is_empty() {
837 capabilities
838 .entry(role.as_str().to_string())
839 .or_default()
840 .extend(role_capabilities);
841 }
842 }
843 }
844
845 Ok(capabilities
846 .into_iter()
847 .map(|(role, capabilities)| (role, capabilities.into_iter().collect()))
848 .collect())
849}
850
851pub(super) fn configured_role_auto_create_from_source(
853 config_source: &str,
854) -> Result<BTreeSet<String>, Box<dyn std::error::Error>> {
855 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
856 let mut auto_create = BTreeSet::<String>::new();
857
858 for subnet in config.subnets.values() {
859 auto_create.extend(
860 subnet
861 .auto_create_roles()
862 .iter()
863 .map(|role| role.as_str().to_string()),
864 );
865 }
866
867 Ok(auto_create)
868}
869
870pub(super) fn configured_role_topups_from_source(
872 config_source: &str,
873) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
874 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
875 let mut topups = BTreeMap::<String, String>::new();
876
877 for subnet in config.subnets.values() {
878 for (role, canister) in &subnet.canisters {
879 if let Some(policy) = &canister.topup {
880 topups.insert(
881 role.as_str().to_string(),
882 format!(
883 "{} @ {}",
884 cycles_tc(policy.amount.to_u128()),
885 cycles_tc(policy.threshold.to_u128())
886 ),
887 );
888 }
889 }
890 }
891
892 Ok(topups)
893}
894
895pub(super) fn configured_role_metrics_profiles_from_source(
897 config_source: &str,
898) -> Result<BTreeMap<String, String>, Box<dyn std::error::Error>> {
899 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
900 let mut profiles = BTreeMap::<String, String>::new();
901
902 for subnet in config.subnets.values() {
903 for (role, canister) in &subnet.canisters {
904 let role_name = role.as_str().to_string();
905 let profile = metrics_profile_label(canister.resolved_metrics_profile(role));
906 match profiles.get(&role_name) {
907 Some(existing) if existing != profile => {
908 profiles.insert(role_name, "mixed".to_string());
909 }
910 Some(_) => {}
911 None => {
912 profiles.insert(role_name, profile.to_string());
913 }
914 }
915 }
916 }
917
918 Ok(profiles)
919}
920
921pub(super) fn configured_local_root_create_cycles_from_source(
923 config_source: &str,
924) -> Result<u128, Box<dyn std::error::Error>> {
925 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
926 let mut root_subnet = None;
927
928 for (subnet_role, subnet) in &config.subnets {
929 if !subnet.canisters.keys().any(CanisterRole::is_root) {
930 continue;
931 }
932 if root_subnet.is_some() {
933 return Err(format!(
934 "multiple subnets define a root canister; expected exactly one root subnet (found at least '{subnet_role}')"
935 )
936 .into());
937 }
938 root_subnet = Some(subnet);
939 }
940
941 let subnet = root_subnet.ok_or_else(|| {
942 "no subnet defines a root canister; expected exactly one root subnet".to_string()
943 })?;
944
945 let mut cycles = subnet
946 .get_canister(&CanisterRole::WASM_STORE)
947 .map_or(DEFAULT_INITIAL_CYCLES, |cfg| cfg.initial_cycles.to_u128());
948 for role in subnet.auto_create_roles() {
949 if let Some(cfg) = subnet.get_canister(&role) {
950 cycles = cycles.saturating_add(cfg.initial_cycles.to_u128());
951 }
952 }
953 cycles = cycles.saturating_add(
954 u128::from(subnet.pool.minimum_size).saturating_mul(DEFAULT_INITIAL_CYCLES),
955 );
956
957 Ok(cycles.saturating_add(LOCAL_ROOT_MIN_READY_CYCLES))
958}
959
960pub(super) fn configured_role_details_from_source(
962 config_source: &str,
963) -> Result<BTreeMap<String, Vec<String>>, Box<dyn std::error::Error>> {
964 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
965 let mut details = BTreeMap::<String, BTreeSet<String>>::new();
966
967 for role in &config.app_index {
968 details
969 .entry(role.as_str().to_string())
970 .or_default()
971 .insert("app_index".to_string());
972 }
973
974 for subnet in config.subnets.values() {
975 for role in subnet.auto_create_roles() {
976 details
977 .entry(role.as_str().to_string())
978 .or_default()
979 .insert("auto_create".to_string());
980 }
981 for role in subnet.subnet_index_roles() {
982 details
983 .entry(role.as_str().to_string())
984 .or_default()
985 .insert("subnet_index".to_string());
986 }
987
988 for (role, canister) in &subnet.canisters {
989 let role_details = details.entry(role.as_str().to_string()).or_default();
990 let profile = canister.resolved_metrics_profile(role);
991 let profile_source = if canister.metrics.profile.is_some() {
992 "configured"
993 } else {
994 "inferred"
995 };
996 role_details.insert(format!(
997 "metrics profile={} tiers={} ({profile_source})",
998 metrics_profile_label(profile),
999 metrics_profile_tiers_label(profile)
1000 ));
1001 if canister.initial_cycles.to_u128() != DEFAULT_INITIAL_CYCLES {
1002 role_details.insert(format!("initial_cycles={}", canister.initial_cycles));
1003 }
1004 if !canister.randomness.enabled {
1005 role_details.insert("randomness=off".to_string());
1006 } else if randomness_source_label(canister.randomness.source) != "ic"
1007 || canister.randomness.reseed_interval_secs
1008 != DEFAULT_RANDOMNESS_RESEED_INTERVAL_SECS
1009 {
1010 role_details.insert(format!(
1011 "randomness={} reseed={}s",
1012 randomness_source_label(canister.randomness.source),
1013 canister.randomness.reseed_interval_secs
1014 ));
1015 }
1016 if canister.auth.delegated_token_issuer {
1017 role_details.insert("auth delegated-token-issuer".to_string());
1018 }
1019 if canister.auth.delegated_token_verifier {
1020 role_details.insert("auth delegated-token-verifier".to_string());
1021 }
1022 if canister.auth.role_attestation_cache {
1023 role_details.insert("auth role-attestation-cache".to_string());
1024 }
1025 if canister.standards.icrc21 {
1026 role_details.insert("standard icrc21".to_string());
1027 }
1028 if let Some(scaling) = &canister.scaling {
1029 for (pool_name, pool) in &scaling.pools {
1030 role_details.insert(format!(
1031 "scaling {pool_name}->{} initial={} min={} max={}",
1032 pool.canister_role.as_str(),
1033 pool.policy.initial_workers,
1034 pool.policy.min_workers,
1035 pool.policy.max_workers
1036 ));
1037 }
1038 }
1039 if let Some(sharding) = &canister.sharding {
1040 for (pool_name, pool) in &sharding.pools {
1041 role_details.insert(format!(
1042 "sharding {pool_name}->{} cap={} initial={} max={}",
1043 pool.canister_role.as_str(),
1044 pool.policy.capacity,
1045 pool.policy.initial_shards,
1046 pool.policy.max_shards
1047 ));
1048 }
1049 }
1050 if let Some(directory) = &canister.directory {
1051 for (pool_name, pool) in &directory.pools {
1052 role_details.insert(format!(
1053 "directory {pool_name}->{} key={}",
1054 pool.canister_role.as_str(),
1055 pool.key_name
1056 ));
1057 }
1058 }
1059 }
1060 }
1061
1062 Ok(details
1063 .into_iter()
1064 .filter(|(_, details)| !details.is_empty())
1065 .map(|(role, details)| (role, details.into_iter().collect()))
1066 .collect())
1067}
1068
1069fn randomness_source_label(source: impl std::fmt::Debug) -> String {
1070 format!("{source:?}").to_ascii_lowercase()
1071}
1072
1073const fn metrics_profile_label(profile: MetricsProfile) -> &'static str {
1074 match profile {
1075 MetricsProfile::Leaf => "leaf",
1076 MetricsProfile::Hub => "hub",
1077 MetricsProfile::Storage => "storage",
1078 MetricsProfile::Root => "root",
1079 MetricsProfile::Full => "full",
1080 }
1081}
1082
1083const fn metrics_profile_tiers_label(profile: MetricsProfile) -> &'static str {
1084 match profile {
1085 MetricsProfile::Leaf => "core,runtime,security",
1086 MetricsProfile::Hub => "core,placement,runtime,security",
1087 MetricsProfile::Storage => "core,runtime,storage",
1088 MetricsProfile::Root | MetricsProfile::Full => {
1089 "core,placement,platform,runtime,security,storage"
1090 }
1091 }
1092}
1093
1094pub(super) fn configured_fleet_name_from_source(
1096 config_source: &str,
1097) -> Result<String, Box<dyn std::error::Error>> {
1098 let config = toml::from_str::<TomlValue>(config_source)?;
1099 let name = config
1100 .get("fleet")
1101 .and_then(TomlValue::as_table)
1102 .and_then(|fleet| fleet.get("name"))
1103 .and_then(TomlValue::as_str)
1104 .ok_or_else(|| "missing required [fleet].name in canic.toml".to_string())?;
1105 Ok(name.to_string())
1106}
1107
1108pub(super) fn configured_controllers_from_source(
1110 config_source: &str,
1111) -> Result<Vec<String>, Box<dyn std::error::Error>> {
1112 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
1113 let mut controllers = config
1114 .controllers
1115 .iter()
1116 .map(canic_core::cdk::types::Principal::to_text)
1117 .collect::<Vec<_>>();
1118 controllers.sort();
1119 controllers.dedup();
1120 Ok(controllers)
1121}
1122
1123pub(super) fn configured_pool_expectations_from_source(
1125 config_source: &str,
1126) -> Result<Vec<ConfiguredPoolExpectation>, Box<dyn std::error::Error>> {
1127 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
1128 let mut root_subnet = None;
1129
1130 for (subnet_role, subnet) in &config.subnets {
1131 if !subnet.canisters.keys().any(CanisterRole::is_root) {
1132 continue;
1133 }
1134
1135 if root_subnet.is_some() {
1136 return Err(format!(
1137 "multiple subnets define a root canister; expected exactly one root subnet (found at least '{subnet_role}')"
1138 )
1139 .into());
1140 }
1141
1142 root_subnet = Some(subnet);
1143 }
1144
1145 let subnet = root_subnet.ok_or_else(|| {
1146 "no subnet defines a root canister; expected exactly one root subnet".to_string()
1147 })?;
1148 let mut pools = BTreeMap::<String, ConfiguredPoolExpectation>::new();
1149
1150 for canister in subnet.canisters.values() {
1151 if let Some(scaling) = &canister.scaling {
1152 for (pool_name, pool) in &scaling.pools {
1153 pools.insert(
1154 format!("scaling:{pool_name}:{}", pool.canister_role.as_str()),
1155 ConfiguredPoolExpectation {
1156 pool: pool_name.clone(),
1157 canister_role: pool.canister_role.as_str().to_string(),
1158 },
1159 );
1160 }
1161 }
1162 if let Some(sharding) = &canister.sharding {
1163 for (pool_name, pool) in &sharding.pools {
1164 pools.insert(
1165 format!("sharding:{pool_name}:{}", pool.canister_role.as_str()),
1166 ConfiguredPoolExpectation {
1167 pool: pool_name.clone(),
1168 canister_role: pool.canister_role.as_str().to_string(),
1169 },
1170 );
1171 }
1172 }
1173 if let Some(directory) = &canister.directory {
1174 for (pool_name, pool) in &directory.pools {
1175 pools.insert(
1176 format!("directory:{pool_name}:{}", pool.canister_role.as_str()),
1177 ConfiguredPoolExpectation {
1178 pool: pool_name.clone(),
1179 canister_role: pool.canister_role.as_str().to_string(),
1180 },
1181 );
1182 }
1183 }
1184 }
1185
1186 Ok(pools.into_values().collect())
1187}
1188
1189pub(super) fn configured_release_roles_from_source(
1191 config_source: &str,
1192) -> Result<Vec<String>, Box<dyn std::error::Error>> {
1193 configured_root_subnet_roles_from_source(config_source, RootSubnetRoleScope::Release)
1194}
1195
1196pub(super) fn configured_deployable_roles_from_source(
1199 config_source: &str,
1200) -> Result<Vec<String>, Box<dyn std::error::Error>> {
1201 configured_root_subnet_roles_from_source(config_source, RootSubnetRoleScope::Deployable)
1202}
1203
1204pub(super) fn configured_bootstrap_roles_from_source(
1206 config_source: &str,
1207) -> Result<Vec<String>, Box<dyn std::error::Error>> {
1208 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
1209 let mut root_subnet = None;
1210
1211 for (subnet_role, subnet) in &config.subnets {
1212 if !subnet.canisters.keys().any(CanisterRole::is_root) {
1213 continue;
1214 }
1215
1216 if root_subnet.is_some() {
1217 return Err(format!(
1218 "multiple subnets define a root canister; expected exactly one root subnet (found at least '{subnet_role}')"
1219 )
1220 .into());
1221 }
1222
1223 root_subnet = Some(subnet);
1224 }
1225
1226 let subnet = root_subnet.ok_or_else(|| {
1227 "no subnet defines a root canister; expected exactly one root subnet".to_string()
1228 })?;
1229
1230 let mut roles = BTreeSet::<String>::new();
1231 roles.insert(CanisterRole::ROOT.as_str().to_string());
1232 roles.extend(
1233 subnet
1234 .auto_create_roles()
1235 .iter()
1236 .map(|role| role.as_str().to_string()),
1237 );
1238
1239 for role in subnet.auto_create_roles() {
1240 let Some(canister) = subnet.get_canister(&role) else {
1241 continue;
1242 };
1243
1244 if let Some(sharding) = &canister.sharding {
1245 for pool in sharding.pools.values() {
1246 if pool.policy.initial_shards > 0 {
1247 roles.insert(pool.canister_role.as_str().to_string());
1248 }
1249 }
1250 }
1251
1252 if let Some(scaling) = &canister.scaling {
1253 for pool in scaling.pools.values() {
1254 if pool.policy.initial_workers > 0 {
1255 roles.insert(pool.canister_role.as_str().to_string());
1256 }
1257 }
1258 }
1259 }
1260
1261 Ok(sort_root_subnet_roles(roles.into_iter().collect()))
1262}
1263
1264fn configured_root_subnet_roles_from_source(
1266 config_source: &str,
1267 scope: RootSubnetRoleScope,
1268) -> Result<Vec<String>, Box<dyn std::error::Error>> {
1269 let config = parse_config_model(config_source).map_err(|err| err.to_string())?;
1270 let mut root_subnet_roles = None;
1271
1272 for (subnet_role, subnet) in &config.subnets {
1273 if !subnet.canisters.keys().any(CanisterRole::is_root) {
1274 continue;
1275 }
1276
1277 if root_subnet_roles.is_some() {
1278 return Err(format!(
1279 "multiple subnets define a root canister; expected exactly one root subnet (found at least '{subnet_role}')"
1280 )
1281 .into());
1282 }
1283
1284 root_subnet_roles = Some(
1285 subnet
1286 .canisters
1287 .keys()
1288 .filter(|role| !role.is_wasm_store())
1289 .filter(|role| scope.includes_root() || !role.is_root())
1290 .map(|role| role.as_str().to_string())
1291 .collect::<Vec<_>>(),
1292 );
1293 }
1294
1295 let root_subnet_roles = root_subnet_roles.ok_or_else(|| {
1296 "no subnet defines a root canister; expected exactly one root subnet".to_string()
1297 })?;
1298
1299 Ok(sort_root_subnet_roles(root_subnet_roles))
1300}
1301
1302fn sort_root_subnet_roles(mut roles: Vec<String>) -> Vec<String> {
1304 roles.sort_by(|left, right| {
1305 match (
1306 left == CanisterRole::ROOT.as_str(),
1307 right == CanisterRole::ROOT.as_str(),
1308 ) {
1309 (true, false) => std::cmp::Ordering::Less,
1310 (false, true) => std::cmp::Ordering::Greater,
1311 _ => left.cmp(right),
1312 }
1313 });
1314 roles
1315}