Skip to main content

canic_host/fleet_install_input/
mod.rs

1//! Module: fleet_install_input
2//!
3//! Responsibility: load and resolve strict operator placement, admission, limit, and funding input.
4//! Does not own: immutable plan publication, Canister creation, installation journals, or runtime.
5//! Boundary: IC mainnet selectors and funding are admitted only through trusted Subnet metadata.
6
7#[cfg(test)]
8mod tests;
9
10use crate::{
11    component_topology::RootComponentAdmissionInput,
12    durable_io::{RegularFileReadError, read_optional_regular_bytes},
13    fleet_install_plan::{
14        PlannedCanisterCreationFunding, PlannedFleetCoordinator, PlannedFleetSubnetRootInput,
15    },
16    icp_config::{IcpConfigError, resolve_icp_build_network_from_root},
17    subnet_catalog::load_mainnet_subnet_catalog,
18};
19use std::{
20    collections::{BTreeMap, BTreeSet},
21    io,
22    path::{Path, PathBuf},
23    time::{SystemTime, SystemTimeError, UNIX_EPOCH},
24};
25
26use candid::Principal;
27use canic_core::{
28    cdk::types::Cycles,
29    ids::{
30        BuildNetwork, ComponentSpecId, CyclesFundingBudget, FleetSubnetCanisterPoolConfig,
31        FleetSubnetRootLimits, SubnetId,
32    },
33};
34use ic_query::subnet_catalog::{
35    SubnetInfo, SubnetKind, SubnetSpecialization, ValidatedSubnetCatalog,
36};
37use serde::Deserialize;
38use thiserror::Error as ThisError;
39
40const FLEET_INSTALL_INPUT_SCHEMA_VERSION: u32 = 1;
41const MAX_FLEET_INSTALL_INPUT_BYTES: usize = 1_024 * 1_024;
42const MAX_SUBNET_PROFILE_BYTES: usize = 64;
43
44///
45/// ResolvedFleetInstallInput
46///
47/// Exact pre-effect input accepted by the immutable Fleet install planner.
48///
49
50#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct ResolvedFleetInstallInput {
52    pub coordinator: PlannedFleetCoordinator,
53    pub fleet_subnet_roots: Vec<PlannedFleetSubnetRootInput>,
54}
55
56///
57/// FleetInstallInputError
58///
59/// Typed rejection while loading or resolving one operator input document.
60///
61
62#[derive(Debug, ThisError)]
63pub enum FleetInstallInputError {
64    #[error("Fleet installation input is missing: {path}")]
65    Missing { path: PathBuf },
66
67    #[error("Fleet installation input is not a regular no-follow file: {path}")]
68    NotRegular { path: PathBuf },
69
70    #[error("Fleet installation input exceeds the {maximum_bytes}-byte bound: {actual_bytes}")]
71    TooLarge {
72        maximum_bytes: usize,
73        actual_bytes: usize,
74    },
75
76    #[error("Fleet installation input has unsupported schema version {actual}; expected 1")]
77    UnsupportedSchemaVersion { actual: u32 },
78
79    #[error("could not decode Fleet installation input {path}: {source}")]
80    Decode {
81        path: PathBuf,
82        #[source]
83        source: toml::de::Error,
84    },
85
86    #[error("invalid {field} Subnet principal {value:?}: {reason}")]
87    InvalidSubnet {
88        field: String,
89        value: String,
90        reason: String,
91    },
92
93    #[error("invalid {field} Canister principal {value:?}: {reason}")]
94    InvalidCanister {
95        field: String,
96        value: String,
97        reason: String,
98    },
99
100    #[error("invalid Fleet Subnet Root Canister pool: {reason}")]
101    InvalidCanisterPool { reason: String },
102
103    #[error("imported Canister pool asset {canister} has no trusted IC routing evidence: {reason}")]
104    ImportedCanisterRoute { canister: Principal, reason: String },
105
106    #[error(
107        "imported Canister pool asset {canister} is routed to Subnet {actual}; expected Fleet Subnet Root placement {expected}"
108    )]
109    ImportedCanisterSubnetMismatch {
110        canister: Principal,
111        expected: SubnetId,
112        actual: SubnetId,
113    },
114
115    #[error("Subnet profile {profile:?} is invalid")]
116    InvalidSubnetProfile { profile: String },
117
118    #[error("{selector} requires trusted Subnet metadata for IC mainnet")]
119    TrustedMetadataRequired { selector: String },
120
121    #[error("trusted Subnet selector {selector} matched no eligible Subnet")]
122    SubnetNotFound { selector: String },
123
124    #[error("trusted Subnet selector {selector} is ambiguous across {matches} eligible Subnets")]
125    AmbiguousSubnetSelector { selector: String, matches: usize },
126
127    #[error("Subnet {subnet} is not eligible for Fleet infrastructure: kind is {kind}")]
128    IneligibleSubnet { subnet: SubnetId, kind: String },
129
130    #[error(
131        "{owner} funding is incompatible with trusted Subnet {subnet} kind {kind}; expected {expected}"
132    )]
133    FundingMismatch {
134        owner: String,
135        subnet: SubnetId,
136        kind: String,
137        expected: &'static str,
138    },
139
140    #[error("non-public network funding must use positive cycles for {owner}")]
141    NonPublicFunding { owner: String },
142
143    #[error("creation funding amount must be positive for {owner}")]
144    NonPositiveCreationFunding { owner: String },
145
146    #[error("failed to read Fleet installation input {path}: {source}")]
147    Io {
148        path: PathBuf,
149        #[source]
150        source: io::Error,
151    },
152
153    #[error(transparent)]
154    IcpConfig(#[from] IcpConfigError),
155
156    #[error("system clock is before the Unix epoch: {0}")]
157    Clock(#[from] SystemTimeError),
158
159    #[error("trusted Subnet catalog resolution failed: {0}")]
160    SubnetCatalog(#[from] ic_query::subnet_catalog::SubnetCatalogHostError),
161}
162
163#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
164#[serde(deny_unknown_fields)]
165struct FleetInstallInputDocument {
166    schema_version: u32,
167    coordinator: CoordinatorInputDocument,
168    fleet_subnet_roots: Vec<FleetSubnetRootInputDocument>,
169}
170
171#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
172#[serde(deny_unknown_fields)]
173struct CoordinatorInputDocument {
174    subnet: CoordinatorSubnetSelector,
175    creation_funding: CreationFundingDocument,
176}
177
178#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
179#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "kind")]
180enum CoordinatorSubnetSelector {
181    Recommended,
182    Profile { profile: String },
183    Explicit { subnet: String },
184}
185
186#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
187#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "kind")]
188enum CreationFundingDocument {
189    Cycles {
190        #[serde(deserialize_with = "Cycles::from_config")]
191        cycles: Cycles,
192    },
193    Icp {
194        e8s: u64,
195    },
196}
197
198#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
199#[serde(deny_unknown_fields)]
200struct FleetSubnetRootInputDocument {
201    placement_subnet: String,
202    component_admissions: BTreeMap<ComponentSpecId, u32>,
203    limits: FleetSubnetRootLimitsDocument,
204    canister_pool: CanisterPoolInputDocument,
205    root_creation_funding: CreationFundingDocument,
206    wasm_store_creation_funding: CreationFundingDocument,
207}
208
209#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
210#[serde(deny_unknown_fields)]
211struct CanisterPoolInputDocument {
212    minimum_size: u32,
213    maximum_size: u32,
214    #[serde(deserialize_with = "Cycles::from_config")]
215    canister_cycles: Cycles,
216    #[serde(default)]
217    imports: Vec<String>,
218}
219
220#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
221#[serde(deny_unknown_fields)]
222struct FleetSubnetRootLimitsDocument {
223    maximum_component_instances: u32,
224    maximum_registry_bytes: u64,
225    maximum_wasm_store_bytes: u64,
226    maximum_group_placements: u32,
227    cycles_funding: CyclesFundingBudgetDocument,
228}
229
230#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
231#[serde(deny_unknown_fields)]
232struct CyclesFundingBudgetDocument {
233    window_secs: u64,
234    #[serde(deserialize_with = "Cycles::from_config")]
235    maximum_cycles: Cycles,
236}
237
238/// Load and resolve one strict operator input document for the selected network.
239pub fn load_and_resolve_fleet_install_input(
240    icp_root: &Path,
241    environment: &str,
242    path: &Path,
243) -> Result<ResolvedFleetInstallInput, FleetInstallInputError> {
244    let document = load_document(path)?;
245    let build_network = resolve_icp_build_network_from_root(icp_root, environment)?;
246    if build_network == BuildNetwork::Ic {
247        let now_unix_secs = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
248        let cached = load_mainnet_subnet_catalog(icp_root, now_unix_secs)?;
249        return resolve_document(&document, build_network, Some(&cached.catalog));
250    }
251
252    resolve_document(&document, build_network, None)
253}
254
255fn load_document(path: &Path) -> Result<FleetInstallInputDocument, FleetInstallInputError> {
256    let bytes = match read_optional_regular_bytes(path) {
257        Ok(Some(bytes)) => bytes,
258        Ok(None) => {
259            return Err(FleetInstallInputError::Missing {
260                path: path.to_path_buf(),
261            });
262        }
263        Err(RegularFileReadError::NotRegular) => {
264            return Err(FleetInstallInputError::NotRegular {
265                path: path.to_path_buf(),
266            });
267        }
268        Err(RegularFileReadError::Io(source)) => {
269            return Err(FleetInstallInputError::Io {
270                path: path.to_path_buf(),
271                source,
272            });
273        }
274        #[cfg(not(unix))]
275        Err(RegularFileReadError::UnsupportedPlatform) => {
276            return Err(FleetInstallInputError::Io {
277                path: path.to_path_buf(),
278                source: io::Error::new(
279                    io::ErrorKind::Unsupported,
280                    "regular no-follow Fleet input reads are unsupported on this platform",
281                ),
282            });
283        }
284    };
285    if bytes.len() > MAX_FLEET_INSTALL_INPUT_BYTES {
286        return Err(FleetInstallInputError::TooLarge {
287            maximum_bytes: MAX_FLEET_INSTALL_INPUT_BYTES,
288            actual_bytes: bytes.len(),
289        });
290    }
291    let document = toml::from_slice(&bytes).map_err(|source| FleetInstallInputError::Decode {
292        path: path.to_path_buf(),
293        source,
294    })?;
295    validate_schema_version(&document)?;
296    Ok(document)
297}
298
299const fn validate_schema_version(
300    document: &FleetInstallInputDocument,
301) -> Result<(), FleetInstallInputError> {
302    if document.schema_version == FLEET_INSTALL_INPUT_SCHEMA_VERSION {
303        Ok(())
304    } else {
305        Err(FleetInstallInputError::UnsupportedSchemaVersion {
306            actual: document.schema_version,
307        })
308    }
309}
310
311fn resolve_document(
312    document: &FleetInstallInputDocument,
313    build_network: BuildNetwork,
314    catalog: Option<&ValidatedSubnetCatalog>,
315) -> Result<ResolvedFleetInstallInput, FleetInstallInputError> {
316    validate_schema_version(document)?;
317    let coordinator_subnet =
318        resolve_coordinator_subnet(&document.coordinator.subnet, build_network, catalog)?;
319    let coordinator_funding = resolve_funding(
320        "Fleet Coordinator",
321        coordinator_subnet,
322        &document.coordinator.creation_funding,
323        build_network,
324        catalog,
325    )?;
326    let coordinator = PlannedFleetCoordinator {
327        coordinator_subnet,
328        creation_funding: coordinator_funding,
329    };
330
331    let mut fleet_subnet_roots = Vec::with_capacity(document.fleet_subnet_roots.len());
332    let mut imported_canisters = BTreeSet::new();
333    for root in &document.fleet_subnet_roots {
334        let placement_subnet = parse_subnet(
335            "fleet_subnet_roots.placement_subnet",
336            &root.placement_subnet,
337        )?;
338        let root_creation_funding = resolve_funding(
339            &format!("Fleet Subnet Root {placement_subnet}"),
340            placement_subnet,
341            &root.root_creation_funding,
342            build_network,
343            catalog,
344        )?;
345        let wasm_store_creation_funding = resolve_funding(
346            &format!("Wasm Store for Fleet Subnet Root {placement_subnet}"),
347            placement_subnet,
348            &root.wasm_store_creation_funding,
349            build_network,
350            catalog,
351        )?;
352        let component_admissions = root
353            .component_admissions
354            .iter()
355            .map(
356                |(component_spec, maximum_root_instances)| RootComponentAdmissionInput {
357                    component_spec: component_spec.clone(),
358                    maximum_root_instances: *maximum_root_instances,
359                },
360            )
361            .collect();
362        let canister_pool_imports = root
363            .canister_pool
364            .imports
365            .iter()
366            .map(|value| parse_canister("fleet_subnet_roots.canister_pool.imports", value))
367            .collect::<Result<Vec<_>, _>>()?;
368        validate_canister_pool(root, &canister_pool_imports)?;
369        validate_imported_canister_placements(
370            placement_subnet,
371            &canister_pool_imports,
372            build_network,
373            catalog,
374        )?;
375        if let Some(duplicate) = canister_pool_imports
376            .iter()
377            .find(|canister_id| !imported_canisters.insert(**canister_id))
378        {
379            return Err(FleetInstallInputError::InvalidCanisterPool {
380                reason: format!(
381                    "imported Canister {duplicate} is assigned to more than one Fleet Subnet Root"
382                ),
383            });
384        }
385        fleet_subnet_roots.push(PlannedFleetSubnetRootInput {
386            placement_subnet,
387            component_admissions,
388            limits: FleetSubnetRootLimits {
389                maximum_component_instances: root.limits.maximum_component_instances,
390                maximum_registry_bytes: root.limits.maximum_registry_bytes,
391                maximum_wasm_store_bytes: root.limits.maximum_wasm_store_bytes,
392                canister_pool: FleetSubnetCanisterPoolConfig {
393                    minimum_size: root.canister_pool.minimum_size,
394                    maximum_size: root.canister_pool.maximum_size,
395                    canister_cycles: root.canister_pool.canister_cycles.clone(),
396                },
397                cycles_funding: CyclesFundingBudget {
398                    window_secs: root.limits.cycles_funding.window_secs,
399                    maximum_cycles: root.limits.cycles_funding.maximum_cycles.clone(),
400                },
401                maximum_group_placements: root.limits.maximum_group_placements,
402            },
403            canister_pool_imports,
404            root_creation_funding,
405            wasm_store_creation_funding,
406        });
407    }
408
409    Ok(ResolvedFleetInstallInput {
410        coordinator,
411        fleet_subnet_roots,
412    })
413}
414
415fn resolve_coordinator_subnet(
416    selector: &CoordinatorSubnetSelector,
417    build_network: BuildNetwork,
418    catalog: Option<&ValidatedSubnetCatalog>,
419) -> Result<SubnetId, FleetInstallInputError> {
420    match selector {
421        CoordinatorSubnetSelector::Explicit { subnet } => {
422            let subnet = parse_subnet("coordinator.subnet", subnet)?;
423            if build_network == BuildNetwork::Ic {
424                let info = trusted_subnet(catalog, subnet)?;
425                validate_eligible_subnet(info)?;
426            }
427            Ok(subnet)
428        }
429        CoordinatorSubnetSelector::Recommended => {
430            require_public_catalog(build_network, catalog, "recommended")?;
431            select_unique_subnet(
432                catalog.expect("public catalog required"),
433                "recommended",
434                |info| {
435                    info.subnet_kind == SubnetKind::Application
436                        && info.subnet_specialization == SubnetSpecialization::Fiduciary
437                },
438            )
439        }
440        CoordinatorSubnetSelector::Profile { profile } => {
441            validate_profile(profile)?;
442            require_public_catalog(build_network, catalog, &format!("profile {profile:?}"))?;
443            select_unique_subnet(
444                catalog.expect("public catalog required"),
445                &format!("profile {profile:?}"),
446                |info| info.subnet_kind == SubnetKind::Application && info.subnet_label == *profile,
447            )
448        }
449    }
450}
451
452fn resolve_funding(
453    owner: &str,
454    subnet: SubnetId,
455    funding: &CreationFundingDocument,
456    build_network: BuildNetwork,
457    catalog: Option<&ValidatedSubnetCatalog>,
458) -> Result<PlannedCanisterCreationFunding, FleetInstallInputError> {
459    let planned = planned_funding(owner, funding)?;
460    if build_network != BuildNetwork::Ic {
461        return match planned {
462            PlannedCanisterCreationFunding::Cycles { .. } => Ok(planned),
463            PlannedCanisterCreationFunding::Icp { .. } => {
464                Err(FleetInstallInputError::NonPublicFunding {
465                    owner: owner.to_string(),
466                })
467            }
468        };
469    }
470
471    let info = trusted_subnet(catalog, subnet)?;
472    validate_eligible_subnet(info)?;
473    let matches = matches!(
474        (&planned, info.subnet_kind),
475        (
476            PlannedCanisterCreationFunding::Cycles { .. },
477            SubnetKind::Application
478        ) | (
479            PlannedCanisterCreationFunding::Icp { .. },
480            SubnetKind::System
481        )
482    );
483    if matches {
484        return Ok(planned);
485    }
486    Err(FleetInstallInputError::FundingMismatch {
487        owner: owner.to_string(),
488        subnet,
489        kind: info.subnet_kind.as_str().to_string(),
490        expected: match info.subnet_kind {
491            SubnetKind::Application => "cycles",
492            SubnetKind::System => "icp",
493            SubnetKind::CloudEngine | SubnetKind::Unknown => {
494                unreachable!("ineligible Subnets reject before funding validation")
495            }
496        },
497    })
498}
499
500fn planned_funding(
501    owner: &str,
502    funding: &CreationFundingDocument,
503) -> Result<PlannedCanisterCreationFunding, FleetInstallInputError> {
504    match funding {
505        CreationFundingDocument::Cycles { cycles } if cycles.to_u128() > 0 => {
506            Ok(PlannedCanisterCreationFunding::Cycles {
507                cycles: cycles.to_u128(),
508            })
509        }
510        CreationFundingDocument::Icp { e8s } if *e8s > 0 => {
511            Ok(PlannedCanisterCreationFunding::Icp { e8s: *e8s })
512        }
513        CreationFundingDocument::Cycles { .. } | CreationFundingDocument::Icp { .. } => {
514            Err(FleetInstallInputError::NonPositiveCreationFunding {
515                owner: owner.to_string(),
516            })
517        }
518    }
519}
520
521fn parse_subnet(field: &str, value: &str) -> Result<SubnetId, FleetInstallInputError> {
522    let principal =
523        Principal::from_text(value).map_err(|error| FleetInstallInputError::InvalidSubnet {
524            field: field.to_string(),
525            value: value.to_string(),
526            reason: error.to_string(),
527        })?;
528    if principal == Principal::anonymous() || principal == Principal::management_canister() {
529        return Err(FleetInstallInputError::InvalidSubnet {
530            field: field.to_string(),
531            value: value.to_string(),
532            reason: "anonymous principal is not a physical Subnet".to_string(),
533        });
534    }
535    Ok(SubnetId::from_principal(principal))
536}
537
538fn parse_canister(field: &str, value: &str) -> Result<Principal, FleetInstallInputError> {
539    let principal =
540        Principal::from_text(value).map_err(|error| FleetInstallInputError::InvalidCanister {
541            field: field.to_string(),
542            value: value.to_string(),
543            reason: error.to_string(),
544        })?;
545    if principal == Principal::anonymous() || principal == Principal::management_canister() {
546        return Err(FleetInstallInputError::InvalidCanister {
547            field: field.to_string(),
548            value: value.to_string(),
549            reason: "reserved principal is not a Canister".to_string(),
550        });
551    }
552    Ok(principal)
553}
554
555fn validate_canister_pool(
556    root: &FleetSubnetRootInputDocument,
557    imports: &[Principal],
558) -> Result<(), FleetInstallInputError> {
559    let pool = &root.canister_pool;
560    if pool.minimum_size == 0 {
561        return Err(FleetInstallInputError::InvalidCanisterPool {
562            reason: "minimum_size must be greater than zero for every Fleet Subnet Root"
563                .to_string(),
564        });
565    }
566    if pool.maximum_size < pool.minimum_size {
567        return Err(FleetInstallInputError::InvalidCanisterPool {
568            reason: format!(
569                "maximum_size {} is smaller than minimum_size {}",
570                pool.maximum_size, pool.minimum_size
571            ),
572        });
573    }
574    if pool.canister_cycles.to_u128() == 0 {
575        return Err(FleetInstallInputError::InvalidCanisterPool {
576            reason: "canister_cycles must be greater than zero".to_string(),
577        });
578    }
579    if imports.len() > pool.maximum_size as usize {
580        return Err(FleetInstallInputError::InvalidCanisterPool {
581            reason: format!(
582                "{} imported Canisters exceed maximum_size {}",
583                imports.len(),
584                pool.maximum_size
585            ),
586        });
587    }
588    let unique = imports.iter().collect::<std::collections::BTreeSet<_>>();
589    if unique.len() != imports.len() {
590        return Err(FleetInstallInputError::InvalidCanisterPool {
591            reason: "imported Canister principals must be unique within one root".to_string(),
592        });
593    }
594    Ok(())
595}
596
597fn validate_imported_canister_placements(
598    expected_subnet: SubnetId,
599    imports: &[Principal],
600    build_network: BuildNetwork,
601    catalog: Option<&ValidatedSubnetCatalog>,
602) -> Result<(), FleetInstallInputError> {
603    if build_network != BuildNetwork::Ic || imports.is_empty() {
604        return Ok(());
605    }
606    let catalog = catalog.ok_or_else(|| FleetInstallInputError::TrustedMetadataRequired {
607        selector: format!("Canister pool imports for Subnet {expected_subnet}"),
608    })?;
609    for canister in imports {
610        let resolved = catalog
611            .resolve_canister_route(&canister.to_text())
612            .map_err(|error| FleetInstallInputError::ImportedCanisterRoute {
613                canister: *canister,
614                reason: error.to_string(),
615            })?;
616        let actual = SubnetId::from_principal(resolved.subnet);
617        if actual != expected_subnet {
618            return Err(FleetInstallInputError::ImportedCanisterSubnetMismatch {
619                canister: *canister,
620                expected: expected_subnet,
621                actual,
622            });
623        }
624    }
625    Ok(())
626}
627
628fn validate_profile(profile: &str) -> Result<(), FleetInstallInputError> {
629    if !profile.is_empty()
630        && profile.len() <= MAX_SUBNET_PROFILE_BYTES
631        && profile.bytes().all(|byte| {
632            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
633        })
634    {
635        Ok(())
636    } else {
637        Err(FleetInstallInputError::InvalidSubnetProfile {
638            profile: profile.to_string(),
639        })
640    }
641}
642
643fn require_public_catalog(
644    build_network: BuildNetwork,
645    catalog: Option<&ValidatedSubnetCatalog>,
646    selector: &str,
647) -> Result<(), FleetInstallInputError> {
648    if build_network == BuildNetwork::Ic && catalog.is_some() {
649        Ok(())
650    } else {
651        Err(FleetInstallInputError::TrustedMetadataRequired {
652            selector: selector.to_string(),
653        })
654    }
655}
656
657fn trusted_subnet(
658    catalog: Option<&ValidatedSubnetCatalog>,
659    subnet: SubnetId,
660) -> Result<&SubnetInfo, FleetInstallInputError> {
661    let catalog = catalog.ok_or_else(|| FleetInstallInputError::TrustedMetadataRequired {
662        selector: format!("explicit Subnet {subnet}"),
663    })?;
664    catalog
665        .subnets()
666        .iter()
667        .find(|info| info.subnet_principal == subnet.to_string())
668        .ok_or_else(|| FleetInstallInputError::SubnetNotFound {
669            selector: format!("explicit Subnet {subnet}"),
670        })
671}
672
673fn validate_eligible_subnet(info: &SubnetInfo) -> Result<(), FleetInstallInputError> {
674    if matches!(
675        info.subnet_kind,
676        SubnetKind::Application | SubnetKind::System
677    ) {
678        return Ok(());
679    }
680    let subnet = parse_subnet("trusted subnet catalog", &info.subnet_principal)?;
681    Err(FleetInstallInputError::IneligibleSubnet {
682        subnet,
683        kind: info.subnet_kind.as_str().to_string(),
684    })
685}
686
687fn select_unique_subnet(
688    catalog: &ValidatedSubnetCatalog,
689    selector: &str,
690    matches: impl Fn(&SubnetInfo) -> bool,
691) -> Result<SubnetId, FleetInstallInputError> {
692    let candidates = catalog
693        .subnets()
694        .iter()
695        .filter(|info| matches(info))
696        .collect::<Vec<_>>();
697    match candidates.as_slice() {
698        [info] => parse_subnet("trusted subnet catalog", &info.subnet_principal),
699        [] => Err(FleetInstallInputError::SubnetNotFound {
700            selector: selector.to_string(),
701        }),
702        _ => Err(FleetInstallInputError::AmbiguousSubnetSelector {
703            selector: selector.to_string(),
704            matches: candidates.len(),
705        }),
706    }
707}