Skip to main content

canic_host/release_set/
config.rs

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///
24/// ConfiguredPoolExpectation
25///
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct ConfiguredPoolExpectation {
28    pub pool: String,
29    pub canister_role: String,
30}
31
32///
33/// ConfiguredRoleLifecycle
34///
35#[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///
48/// DeclaredFleetRole
49///
50#[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///
59/// AttachedFleetRole
60///
61#[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///
72/// RenamedFleetRole
73///
74#[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
91// Enumerate the configured ordinary roles that root must publish before bootstrap resumes.
92pub 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
100// Enumerate deployable roles in the single subnet that owns `root`.
101pub 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
109// Enumerate roles expected to exist after root bootstrap for status checks.
110pub 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
118// Enumerate the local install targets: root plus the ordinary roles owned by its subnet.
119pub 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
128// Estimate local root cycles needed to create bootstrap-owned canisters.
129pub 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
137// Read the required operator fleet name from an install config.
138pub 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
144// Enumerate configured top-level deployment controllers from an install config.
145pub 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
153// Enumerate configured pool identities for the single subnet that owns `root`.
154pub 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
162// Enumerate declared role lifecycle state for one fleet config.
163pub 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
171// Declare a package-backed role without attaching it to topology.
172pub 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
185// Attach a declared package-backed role directly to subnet topology.
186pub 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
200// Rename a declared role and its role-bearing topology references.
201pub 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// Select config paths whose required [fleet].name matches the requested fleet.
219#[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
230// Enumerate configured role kinds across all subnets for operator-facing tables.
231pub 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
239// Enumerate enabled config capabilities across all configured roles.
240pub 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
248// Enumerate roles derived for root auto-create.
249pub 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
257// Enumerate configured top-up policy summaries across all configured roles.
258pub 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
266// Enumerate resolved metrics profiles across all configured roles.
267pub 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
275// Enumerate verbose configured details across all configured roles.
276pub 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
284// Enumerate configured role kinds from raw config source.
285pub(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
310// Enumerate declared role lifecycle state from raw config source.
311pub(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
808// Enumerate enabled config capabilities from raw config source.
809pub(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
851// Enumerate derived auto-created service roles from raw config source.
852pub(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
870// Enumerate configured top-up policy summaries from raw config source.
871pub(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
895// Enumerate resolved metrics profiles from raw config source.
896pub(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
921// Estimate local root create funding from the root subnet bootstrap obligations.
922pub(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
960// Enumerate verbose configured details from raw config source.
961pub(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
1094// Read the required operator fleet name from raw config source.
1095pub(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
1108// Enumerate configured top-level deployment controllers from raw config source.
1109pub(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
1123// Enumerate configured pool identities for the single subnet that owns `root`.
1124pub(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
1189// Enumerate the configured ordinary roles for the single subnet that owns `root`.
1190pub(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
1196// Enumerate deployable roles for the single subnet that owns `root`, except the
1197// implicit `wasm_store` bootstrap canister.
1198pub(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
1204// Enumerate roles expected to be present once root bootstrap has completed.
1205pub(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
1264// Enumerate roles for the single configured subnet that owns `root`.
1265fn 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
1302// Sort display/build roles deterministically, keeping `root` first when present.
1303fn 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}