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 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#[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_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
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_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}