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};
18use std::{
19    collections::{BTreeMap, BTreeSet},
20    io,
21    path::{Path, PathBuf},
22    time::{SystemTime, SystemTimeError, UNIX_EPOCH},
23};
24
25use candid::Principal;
26use canic_core::{
27    cdk::types::Cycles,
28    ids::{
29        BuildNetwork, ComponentSpecId, CyclesFundingBudget, FleetSubnetCanisterPoolConfig,
30        FleetSubnetRootLimits, SubnetId,
31    },
32};
33use ic_query::subnet_catalog::{
34    DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT, SubnetCatalog, SubnetCatalogCacheRequest, SubnetInfo,
35    SubnetKind, SubnetSpecialization, load_or_refresh_subnet_catalog,
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_managed_canisters: u32,
225    maximum_registry_bytes: u64,
226    maximum_wasm_store_bytes: u64,
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_or_refresh_subnet_catalog(
249            &SubnetCatalogCacheRequest::new(icp_root, "ic"),
250            DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT,
251            now_unix_secs,
252        )?;
253        return resolve_document(&document, build_network, Some(&cached.catalog));
254    }
255
256    resolve_document(&document, build_network, None)
257}
258
259fn load_document(path: &Path) -> Result<FleetInstallInputDocument, FleetInstallInputError> {
260    let bytes = match read_optional_regular_bytes(path) {
261        Ok(Some(bytes)) => bytes,
262        Ok(None) => {
263            return Err(FleetInstallInputError::Missing {
264                path: path.to_path_buf(),
265            });
266        }
267        Err(RegularFileReadError::NotRegular) => {
268            return Err(FleetInstallInputError::NotRegular {
269                path: path.to_path_buf(),
270            });
271        }
272        Err(RegularFileReadError::Io(source)) => {
273            return Err(FleetInstallInputError::Io {
274                path: path.to_path_buf(),
275                source,
276            });
277        }
278        #[cfg(not(unix))]
279        Err(RegularFileReadError::UnsupportedPlatform) => {
280            return Err(FleetInstallInputError::Io {
281                path: path.to_path_buf(),
282                source: io::Error::new(
283                    io::ErrorKind::Unsupported,
284                    "regular no-follow Fleet input reads are unsupported on this platform",
285                ),
286            });
287        }
288    };
289    if bytes.len() > MAX_FLEET_INSTALL_INPUT_BYTES {
290        return Err(FleetInstallInputError::TooLarge {
291            maximum_bytes: MAX_FLEET_INSTALL_INPUT_BYTES,
292            actual_bytes: bytes.len(),
293        });
294    }
295    let document = toml::from_slice(&bytes).map_err(|source| FleetInstallInputError::Decode {
296        path: path.to_path_buf(),
297        source,
298    })?;
299    validate_schema_version(&document)?;
300    Ok(document)
301}
302
303const fn validate_schema_version(
304    document: &FleetInstallInputDocument,
305) -> Result<(), FleetInstallInputError> {
306    if document.schema_version == FLEET_INSTALL_INPUT_SCHEMA_VERSION {
307        Ok(())
308    } else {
309        Err(FleetInstallInputError::UnsupportedSchemaVersion {
310            actual: document.schema_version,
311        })
312    }
313}
314
315fn resolve_document(
316    document: &FleetInstallInputDocument,
317    build_network: BuildNetwork,
318    catalog: Option<&SubnetCatalog>,
319) -> Result<ResolvedFleetInstallInput, FleetInstallInputError> {
320    validate_schema_version(document)?;
321    let coordinator_subnet =
322        resolve_coordinator_subnet(&document.coordinator.subnet, build_network, catalog)?;
323    let coordinator_funding = resolve_funding(
324        "Fleet Coordinator",
325        coordinator_subnet,
326        &document.coordinator.creation_funding,
327        build_network,
328        catalog,
329    )?;
330    let coordinator = PlannedFleetCoordinator {
331        coordinator_subnet,
332        creation_funding: coordinator_funding,
333    };
334
335    let mut fleet_subnet_roots = Vec::with_capacity(document.fleet_subnet_roots.len());
336    let mut imported_canisters = BTreeSet::new();
337    for root in &document.fleet_subnet_roots {
338        let placement_subnet = parse_subnet(
339            "fleet_subnet_roots.placement_subnet",
340            &root.placement_subnet,
341        )?;
342        let root_creation_funding = resolve_funding(
343            &format!("Fleet Subnet Root {placement_subnet}"),
344            placement_subnet,
345            &root.root_creation_funding,
346            build_network,
347            catalog,
348        )?;
349        let wasm_store_creation_funding = resolve_funding(
350            &format!("Wasm Store for Fleet Subnet Root {placement_subnet}"),
351            placement_subnet,
352            &root.wasm_store_creation_funding,
353            build_network,
354            catalog,
355        )?;
356        let component_admissions = root
357            .component_admissions
358            .iter()
359            .map(
360                |(component_spec, maximum_root_instances)| RootComponentAdmissionInput {
361                    component_spec: component_spec.clone(),
362                    maximum_root_instances: *maximum_root_instances,
363                },
364            )
365            .collect();
366        let canister_pool_imports = root
367            .canister_pool
368            .imports
369            .iter()
370            .map(|value| parse_canister("fleet_subnet_roots.canister_pool.imports", value))
371            .collect::<Result<Vec<_>, _>>()?;
372        validate_canister_pool(root, &canister_pool_imports)?;
373        validate_imported_canister_placements(
374            placement_subnet,
375            &canister_pool_imports,
376            build_network,
377            catalog,
378        )?;
379        if let Some(duplicate) = canister_pool_imports
380            .iter()
381            .find(|canister_id| !imported_canisters.insert(**canister_id))
382        {
383            return Err(FleetInstallInputError::InvalidCanisterPool {
384                reason: format!(
385                    "imported Canister {duplicate} is assigned to more than one Fleet Subnet Root"
386                ),
387            });
388        }
389        fleet_subnet_roots.push(PlannedFleetSubnetRootInput {
390            placement_subnet,
391            component_admissions,
392            limits: FleetSubnetRootLimits {
393                maximum_component_instances: root.limits.maximum_component_instances,
394                maximum_managed_canisters: root.limits.maximum_managed_canisters,
395                maximum_registry_bytes: root.limits.maximum_registry_bytes,
396                maximum_wasm_store_bytes: root.limits.maximum_wasm_store_bytes,
397                canister_pool: FleetSubnetCanisterPoolConfig {
398                    minimum_size: root.canister_pool.minimum_size,
399                    maximum_size: root.canister_pool.maximum_size,
400                    canister_cycles: root.canister_pool.canister_cycles.clone(),
401                },
402                cycles_funding: CyclesFundingBudget {
403                    window_secs: root.limits.cycles_funding.window_secs,
404                    maximum_cycles: root.limits.cycles_funding.maximum_cycles.clone(),
405                },
406            },
407            canister_pool_imports,
408            root_creation_funding,
409            wasm_store_creation_funding,
410        });
411    }
412
413    Ok(ResolvedFleetInstallInput {
414        coordinator,
415        fleet_subnet_roots,
416    })
417}
418
419fn resolve_coordinator_subnet(
420    selector: &CoordinatorSubnetSelector,
421    build_network: BuildNetwork,
422    catalog: Option<&SubnetCatalog>,
423) -> Result<SubnetId, FleetInstallInputError> {
424    match selector {
425        CoordinatorSubnetSelector::Explicit { subnet } => {
426            let subnet = parse_subnet("coordinator.subnet", subnet)?;
427            if build_network == BuildNetwork::Ic {
428                let info = trusted_subnet(catalog, subnet)?;
429                validate_eligible_subnet(info)?;
430            }
431            Ok(subnet)
432        }
433        CoordinatorSubnetSelector::Recommended => {
434            require_public_catalog(build_network, catalog, "recommended")?;
435            select_unique_subnet(
436                catalog.expect("public catalog required"),
437                "recommended",
438                |info| {
439                    info.subnet_kind == SubnetKind::Application
440                        && info.subnet_specialization == SubnetSpecialization::Fiduciary
441                },
442            )
443        }
444        CoordinatorSubnetSelector::Profile { profile } => {
445            validate_profile(profile)?;
446            require_public_catalog(build_network, catalog, &format!("profile {profile:?}"))?;
447            select_unique_subnet(
448                catalog.expect("public catalog required"),
449                &format!("profile {profile:?}"),
450                |info| info.subnet_kind == SubnetKind::Application && info.subnet_label == *profile,
451            )
452        }
453    }
454}
455
456fn resolve_funding(
457    owner: &str,
458    subnet: SubnetId,
459    funding: &CreationFundingDocument,
460    build_network: BuildNetwork,
461    catalog: Option<&SubnetCatalog>,
462) -> Result<PlannedCanisterCreationFunding, FleetInstallInputError> {
463    let planned = planned_funding(owner, funding)?;
464    if build_network != BuildNetwork::Ic {
465        return match planned {
466            PlannedCanisterCreationFunding::Cycles { .. } => Ok(planned),
467            PlannedCanisterCreationFunding::Icp { .. } => {
468                Err(FleetInstallInputError::NonPublicFunding {
469                    owner: owner.to_string(),
470                })
471            }
472        };
473    }
474
475    let info = trusted_subnet(catalog, subnet)?;
476    validate_eligible_subnet(info)?;
477    let matches = matches!(
478        (&planned, info.subnet_kind),
479        (
480            PlannedCanisterCreationFunding::Cycles { .. },
481            SubnetKind::Application
482        ) | (
483            PlannedCanisterCreationFunding::Icp { .. },
484            SubnetKind::System
485        )
486    );
487    if matches {
488        return Ok(planned);
489    }
490    Err(FleetInstallInputError::FundingMismatch {
491        owner: owner.to_string(),
492        subnet,
493        kind: info.subnet_kind.as_str().to_string(),
494        expected: match info.subnet_kind {
495            SubnetKind::Application => "cycles",
496            SubnetKind::System => "icp",
497            SubnetKind::CloudEngine | SubnetKind::Unknown => {
498                unreachable!("ineligible Subnets reject before funding validation")
499            }
500        },
501    })
502}
503
504fn planned_funding(
505    owner: &str,
506    funding: &CreationFundingDocument,
507) -> Result<PlannedCanisterCreationFunding, FleetInstallInputError> {
508    match funding {
509        CreationFundingDocument::Cycles { cycles } if cycles.to_u128() > 0 => {
510            Ok(PlannedCanisterCreationFunding::Cycles {
511                cycles: cycles.to_u128(),
512            })
513        }
514        CreationFundingDocument::Icp { e8s } if *e8s > 0 => {
515            Ok(PlannedCanisterCreationFunding::Icp { e8s: *e8s })
516        }
517        CreationFundingDocument::Cycles { .. } | CreationFundingDocument::Icp { .. } => {
518            Err(FleetInstallInputError::NonPositiveCreationFunding {
519                owner: owner.to_string(),
520            })
521        }
522    }
523}
524
525fn parse_subnet(field: &str, value: &str) -> Result<SubnetId, FleetInstallInputError> {
526    let principal =
527        Principal::from_text(value).map_err(|error| FleetInstallInputError::InvalidSubnet {
528            field: field.to_string(),
529            value: value.to_string(),
530            reason: error.to_string(),
531        })?;
532    if principal == Principal::anonymous() || principal == Principal::management_canister() {
533        return Err(FleetInstallInputError::InvalidSubnet {
534            field: field.to_string(),
535            value: value.to_string(),
536            reason: "anonymous principal is not a physical Subnet".to_string(),
537        });
538    }
539    Ok(SubnetId::from_principal(principal))
540}
541
542fn parse_canister(field: &str, value: &str) -> Result<Principal, FleetInstallInputError> {
543    let principal =
544        Principal::from_text(value).map_err(|error| FleetInstallInputError::InvalidCanister {
545            field: field.to_string(),
546            value: value.to_string(),
547            reason: error.to_string(),
548        })?;
549    if principal == Principal::anonymous() || principal == Principal::management_canister() {
550        return Err(FleetInstallInputError::InvalidCanister {
551            field: field.to_string(),
552            value: value.to_string(),
553            reason: "reserved principal is not a Canister".to_string(),
554        });
555    }
556    Ok(principal)
557}
558
559fn validate_canister_pool(
560    root: &FleetSubnetRootInputDocument,
561    imports: &[Principal],
562) -> Result<(), FleetInstallInputError> {
563    let pool = &root.canister_pool;
564    if pool.minimum_size == 0 {
565        return Err(FleetInstallInputError::InvalidCanisterPool {
566            reason: "minimum_size must be greater than zero for every Fleet Subnet Root"
567                .to_string(),
568        });
569    }
570    if pool.maximum_size < pool.minimum_size {
571        return Err(FleetInstallInputError::InvalidCanisterPool {
572            reason: format!(
573                "maximum_size {} is smaller than minimum_size {}",
574                pool.maximum_size, pool.minimum_size
575            ),
576        });
577    }
578    if pool.maximum_size > root.limits.maximum_managed_canisters {
579        return Err(FleetInstallInputError::InvalidCanisterPool {
580            reason: format!(
581                "maximum_size {} exceeds maximum_managed_canisters {}",
582                pool.maximum_size, root.limits.maximum_managed_canisters
583            ),
584        });
585    }
586    if pool.canister_cycles.to_u128() == 0 {
587        return Err(FleetInstallInputError::InvalidCanisterPool {
588            reason: "canister_cycles must be greater than zero".to_string(),
589        });
590    }
591    if imports.len() > pool.maximum_size as usize {
592        return Err(FleetInstallInputError::InvalidCanisterPool {
593            reason: format!(
594                "{} imported Canisters exceed maximum_size {}",
595                imports.len(),
596                pool.maximum_size
597            ),
598        });
599    }
600    let unique = imports.iter().collect::<std::collections::BTreeSet<_>>();
601    if unique.len() != imports.len() {
602        return Err(FleetInstallInputError::InvalidCanisterPool {
603            reason: "imported Canister principals must be unique within one root".to_string(),
604        });
605    }
606    Ok(())
607}
608
609fn validate_imported_canister_placements(
610    expected_subnet: SubnetId,
611    imports: &[Principal],
612    build_network: BuildNetwork,
613    catalog: Option<&SubnetCatalog>,
614) -> Result<(), FleetInstallInputError> {
615    if build_network != BuildNetwork::Ic || imports.is_empty() {
616        return Ok(());
617    }
618    let catalog = catalog.ok_or_else(|| FleetInstallInputError::TrustedMetadataRequired {
619        selector: format!("Canister pool imports for Subnet {expected_subnet}"),
620    })?;
621    for canister in imports {
622        let resolved = catalog
623            .resolve_canister(&canister.to_text())
624            .map_err(|error| FleetInstallInputError::ImportedCanisterRoute {
625                canister: *canister,
626                reason: error.to_string(),
627            })?;
628        let actual = parse_subnet(
629            "trusted Canister routing catalog",
630            &resolved.subnet.subnet_principal,
631        )?;
632        if actual != expected_subnet {
633            return Err(FleetInstallInputError::ImportedCanisterSubnetMismatch {
634                canister: *canister,
635                expected: expected_subnet,
636                actual,
637            });
638        }
639    }
640    Ok(())
641}
642
643fn validate_profile(profile: &str) -> Result<(), FleetInstallInputError> {
644    if !profile.is_empty()
645        && profile.len() <= MAX_SUBNET_PROFILE_BYTES
646        && profile.bytes().all(|byte| {
647            byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
648        })
649    {
650        Ok(())
651    } else {
652        Err(FleetInstallInputError::InvalidSubnetProfile {
653            profile: profile.to_string(),
654        })
655    }
656}
657
658fn require_public_catalog(
659    build_network: BuildNetwork,
660    catalog: Option<&SubnetCatalog>,
661    selector: &str,
662) -> Result<(), FleetInstallInputError> {
663    if build_network == BuildNetwork::Ic && catalog.is_some() {
664        Ok(())
665    } else {
666        Err(FleetInstallInputError::TrustedMetadataRequired {
667            selector: selector.to_string(),
668        })
669    }
670}
671
672fn trusted_subnet(
673    catalog: Option<&SubnetCatalog>,
674    subnet: SubnetId,
675) -> Result<&SubnetInfo, FleetInstallInputError> {
676    let catalog = catalog.ok_or_else(|| FleetInstallInputError::TrustedMetadataRequired {
677        selector: format!("explicit Subnet {subnet}"),
678    })?;
679    catalog
680        .subnets
681        .iter()
682        .find(|info| info.subnet_principal == subnet.to_string())
683        .ok_or_else(|| FleetInstallInputError::SubnetNotFound {
684            selector: format!("explicit Subnet {subnet}"),
685        })
686}
687
688fn validate_eligible_subnet(info: &SubnetInfo) -> Result<(), FleetInstallInputError> {
689    if matches!(
690        info.subnet_kind,
691        SubnetKind::Application | SubnetKind::System
692    ) {
693        return Ok(());
694    }
695    let subnet = parse_subnet("trusted subnet catalog", &info.subnet_principal)?;
696    Err(FleetInstallInputError::IneligibleSubnet {
697        subnet,
698        kind: info.subnet_kind.as_str().to_string(),
699    })
700}
701
702fn select_unique_subnet(
703    catalog: &SubnetCatalog,
704    selector: &str,
705    matches: impl Fn(&SubnetInfo) -> bool,
706) -> Result<SubnetId, FleetInstallInputError> {
707    let candidates = catalog
708        .subnets
709        .iter()
710        .filter(|info| matches(info))
711        .collect::<Vec<_>>();
712    match candidates.as_slice() {
713        [info] => parse_subnet("trusted subnet catalog", &info.subnet_principal),
714        [] => Err(FleetInstallInputError::SubnetNotFound {
715            selector: selector.to_string(),
716        }),
717        _ => Err(FleetInstallInputError::AmbiguousSubnetSelector {
718            selector: selector.to_string(),
719            matches: candidates.len(),
720        }),
721    }
722}