1#[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#[derive(Clone, Debug, Eq, PartialEq)]
51pub struct ResolvedFleetInstallInput {
52 pub coordinator: PlannedFleetCoordinator,
53 pub fleet_subnet_roots: Vec<PlannedFleetSubnetRootInput>,
54}
55
56#[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
238pub 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}