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};
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///
23/// ConfiguredPoolExpectation
24///
25#[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
37// Enumerate the configured ordinary roles that root must publish before bootstrap resumes.
38pub 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
46// Enumerate the configured fleet roles in the single subnet that owns `root`.
47pub 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
55// Enumerate roles expected to exist after root bootstrap for status checks.
56pub 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
64// Enumerate the local install targets: root plus the ordinary roles owned by its subnet.
65pub 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
74// Estimate local root cycles needed to create bootstrap-owned canisters.
75pub 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
83// Read the required operator fleet name from an install config.
84pub 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
90// Enumerate configured top-level deployment controllers from an install config.
91pub 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
99// Enumerate configured pool identities for the single subnet that owns `root`.
100pub 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// Select config paths whose required [fleet].name matches the requested fleet.
109#[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
120// Enumerate configured role kinds across all subnets for operator-facing tables.
121pub 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
129// Enumerate enabled config capabilities across all configured roles.
130pub 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
138// Enumerate roles declared in subnet auto_create sets.
139pub 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
147// Enumerate configured top-up policy summaries across all configured roles.
148pub 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
156// Enumerate resolved metrics profiles across all configured roles.
157pub 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
165// Enumerate verbose configured details across all configured roles.
166pub 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
174// Enumerate configured role kinds from raw config source.
175pub(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
200// Enumerate enabled config capabilities from raw config source.
201pub(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
240// Enumerate auto-created roles from raw config source.
241pub(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
259// Enumerate configured top-up policy summaries from raw config source.
260pub(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
284// Enumerate resolved metrics profiles from raw config source.
285pub(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
310// Estimate local root create funding from the root subnet bootstrap obligations.
311pub(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
349// Enumerate verbose configured details from raw config source.
350pub(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
480// Read the required operator fleet name from raw config source.
481pub(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
492// Enumerate configured top-level deployment controllers from raw config source.
493pub(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
507// Enumerate configured pool identities for the single subnet that owns `root`.
508pub(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
573// Enumerate the configured ordinary roles for the single subnet that owns `root`.
574pub(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
580// Enumerate all configured roles for the single subnet that owns `root`, except
581// the implicit `wasm_store` bootstrap canister.
582pub(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
588// Enumerate roles expected to be present once root bootstrap has completed.
589pub(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
648// Enumerate roles for the single configured subnet that owns `root`.
649fn 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
686// Sort display/build roles deterministically, keeping `root` first when present.
687fn 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}