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,
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::{BuildNetwork, ComponentSpecId, CyclesFundingBudget, FleetSubnetRootLimits, SubnetId},
29};
30use ic_query::subnet_catalog::{
31 DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT, SubnetCatalog, SubnetCatalogCacheRequest, SubnetInfo,
32 SubnetKind, SubnetSpecialization, load_or_refresh_subnet_catalog,
33};
34use serde::Deserialize;
35use thiserror::Error as ThisError;
36
37const FLEET_INSTALL_INPUT_SCHEMA_VERSION: u32 = 1;
38const MAX_FLEET_INSTALL_INPUT_BYTES: usize = 1_024 * 1_024;
39const MAX_SUBNET_PROFILE_BYTES: usize = 64;
40
41#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct ResolvedFleetInstallInput {
49 pub coordinator: PlannedFleetCoordinator,
50 pub fleet_subnet_roots: Vec<PlannedFleetSubnetRootInput>,
51}
52
53#[derive(Debug, ThisError)]
60pub enum FleetInstallInputError {
61 #[error("Fleet installation input is missing: {path}")]
62 Missing { path: PathBuf },
63
64 #[error("Fleet installation input is not a regular no-follow file: {path}")]
65 NotRegular { path: PathBuf },
66
67 #[error("Fleet installation input exceeds the {maximum_bytes}-byte bound: {actual_bytes}")]
68 TooLarge {
69 maximum_bytes: usize,
70 actual_bytes: usize,
71 },
72
73 #[error("Fleet installation input has unsupported schema version {actual}; expected 1")]
74 UnsupportedSchemaVersion { actual: u32 },
75
76 #[error("could not decode Fleet installation input {path}: {source}")]
77 Decode {
78 path: PathBuf,
79 #[source]
80 source: toml::de::Error,
81 },
82
83 #[error("invalid {field} Subnet principal {value:?}: {reason}")]
84 InvalidSubnet {
85 field: String,
86 value: String,
87 reason: String,
88 },
89
90 #[error("Subnet profile {profile:?} is invalid")]
91 InvalidSubnetProfile { profile: String },
92
93 #[error("{selector} requires trusted public-IC Subnet metadata")]
94 TrustedMetadataRequired { selector: String },
95
96 #[error("trusted Subnet selector {selector} matched no eligible Subnet")]
97 SubnetNotFound { selector: String },
98
99 #[error("trusted Subnet selector {selector} is ambiguous across {matches} eligible Subnets")]
100 AmbiguousSubnetSelector { selector: String, matches: usize },
101
102 #[error("Subnet {subnet} is not eligible for Fleet infrastructure: kind is {kind}")]
103 IneligibleSubnet { subnet: SubnetId, kind: String },
104
105 #[error(
106 "{owner} funding is incompatible with trusted Subnet {subnet} kind {kind}; expected {expected}"
107 )]
108 FundingMismatch {
109 owner: String,
110 subnet: SubnetId,
111 kind: String,
112 expected: &'static str,
113 },
114
115 #[error("non-public network funding must use positive cycles for {owner}")]
116 NonPublicFunding { owner: String },
117
118 #[error("creation funding amount must be positive for {owner}")]
119 NonPositiveCreationFunding { owner: String },
120
121 #[error("failed to read Fleet installation input {path}: {source}")]
122 Io {
123 path: PathBuf,
124 #[source]
125 source: io::Error,
126 },
127
128 #[error(transparent)]
129 IcpConfig(#[from] IcpConfigError),
130
131 #[error("system clock is before the Unix epoch: {0}")]
132 Clock(#[from] SystemTimeError),
133
134 #[error("trusted Subnet catalog resolution failed: {0}")]
135 SubnetCatalog(#[from] ic_query::subnet_catalog::SubnetCatalogHostError),
136}
137
138#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
139#[serde(deny_unknown_fields)]
140struct FleetInstallInputDocument {
141 schema_version: u32,
142 coordinator: CoordinatorInputDocument,
143 fleet_subnet_roots: Vec<FleetSubnetRootInputDocument>,
144}
145
146#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
147#[serde(deny_unknown_fields)]
148struct CoordinatorInputDocument {
149 subnet: CoordinatorSubnetSelector,
150 creation_funding: CreationFundingDocument,
151}
152
153#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
154#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "kind")]
155enum CoordinatorSubnetSelector {
156 Recommended,
157 Profile { profile: String },
158 Explicit { subnet: String },
159}
160
161#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
162#[serde(deny_unknown_fields, rename_all = "snake_case", tag = "kind")]
163enum CreationFundingDocument {
164 Cycles {
165 #[serde(deserialize_with = "Cycles::from_config")]
166 cycles: Cycles,
167 },
168 Icp {
169 e8s: u64,
170 },
171}
172
173#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
174#[serde(deny_unknown_fields)]
175struct FleetSubnetRootInputDocument {
176 placement_subnet: String,
177 component_admissions: BTreeMap<ComponentSpecId, u32>,
178 limits: FleetSubnetRootLimitsDocument,
179 creation_funding: CreationFundingDocument,
180}
181
182#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
183#[serde(deny_unknown_fields)]
184struct FleetSubnetRootLimitsDocument {
185 maximum_component_instances: u32,
186 maximum_managed_canisters: u32,
187 maximum_registry_bytes: u64,
188 maximum_wasm_store_bytes: u64,
189 cycles_funding: CyclesFundingBudgetDocument,
190}
191
192#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
193#[serde(deny_unknown_fields)]
194struct CyclesFundingBudgetDocument {
195 window_secs: u64,
196 #[serde(deserialize_with = "Cycles::from_config")]
197 maximum_cycles: Cycles,
198}
199
200pub fn load_and_resolve_fleet_install_input(
202 icp_root: &Path,
203 environment: &str,
204 path: &Path,
205) -> Result<ResolvedFleetInstallInput, FleetInstallInputError> {
206 let document = load_document(path)?;
207 let build_network = resolve_icp_build_network_from_root(icp_root, environment)?;
208 if build_network == BuildNetwork::Ic {
209 let now_unix_secs = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
210 let cached = load_or_refresh_subnet_catalog(
211 &SubnetCatalogCacheRequest::new(icp_root, "ic"),
212 DEFAULT_SUBNET_CATALOG_SOURCE_ENDPOINT,
213 now_unix_secs,
214 )?;
215 return resolve_document(&document, build_network, Some(&cached.catalog));
216 }
217
218 resolve_document(&document, build_network, None)
219}
220
221fn load_document(path: &Path) -> Result<FleetInstallInputDocument, FleetInstallInputError> {
222 let bytes = match read_optional_regular_bytes(path) {
223 Ok(Some(bytes)) => bytes,
224 Ok(None) => {
225 return Err(FleetInstallInputError::Missing {
226 path: path.to_path_buf(),
227 });
228 }
229 Err(RegularFileReadError::NotRegular) => {
230 return Err(FleetInstallInputError::NotRegular {
231 path: path.to_path_buf(),
232 });
233 }
234 Err(RegularFileReadError::Io(source)) => {
235 return Err(FleetInstallInputError::Io {
236 path: path.to_path_buf(),
237 source,
238 });
239 }
240 #[cfg(not(unix))]
241 Err(RegularFileReadError::UnsupportedPlatform) => {
242 return Err(FleetInstallInputError::Io {
243 path: path.to_path_buf(),
244 source: io::Error::new(
245 io::ErrorKind::Unsupported,
246 "regular no-follow Fleet input reads are unsupported on this platform",
247 ),
248 });
249 }
250 };
251 if bytes.len() > MAX_FLEET_INSTALL_INPUT_BYTES {
252 return Err(FleetInstallInputError::TooLarge {
253 maximum_bytes: MAX_FLEET_INSTALL_INPUT_BYTES,
254 actual_bytes: bytes.len(),
255 });
256 }
257 let document = toml::from_slice(&bytes).map_err(|source| FleetInstallInputError::Decode {
258 path: path.to_path_buf(),
259 source,
260 })?;
261 validate_schema_version(&document)?;
262 Ok(document)
263}
264
265const fn validate_schema_version(
266 document: &FleetInstallInputDocument,
267) -> Result<(), FleetInstallInputError> {
268 if document.schema_version == FLEET_INSTALL_INPUT_SCHEMA_VERSION {
269 Ok(())
270 } else {
271 Err(FleetInstallInputError::UnsupportedSchemaVersion {
272 actual: document.schema_version,
273 })
274 }
275}
276
277fn resolve_document(
278 document: &FleetInstallInputDocument,
279 build_network: BuildNetwork,
280 catalog: Option<&SubnetCatalog>,
281) -> Result<ResolvedFleetInstallInput, FleetInstallInputError> {
282 validate_schema_version(document)?;
283 let coordinator_subnet =
284 resolve_coordinator_subnet(&document.coordinator.subnet, build_network, catalog)?;
285 let coordinator_funding = resolve_funding(
286 "Fleet Coordinator",
287 coordinator_subnet,
288 &document.coordinator.creation_funding,
289 build_network,
290 catalog,
291 )?;
292 let coordinator = PlannedFleetCoordinator {
293 coordinator_subnet,
294 creation_funding: coordinator_funding,
295 };
296
297 let mut fleet_subnet_roots = Vec::with_capacity(document.fleet_subnet_roots.len());
298 for root in &document.fleet_subnet_roots {
299 let placement_subnet = parse_subnet(
300 "fleet_subnet_roots.placement_subnet",
301 &root.placement_subnet,
302 )?;
303 let owner = format!("Fleet Subnet Root {placement_subnet}");
304 let creation_funding = resolve_funding(
305 &owner,
306 placement_subnet,
307 &root.creation_funding,
308 build_network,
309 catalog,
310 )?;
311 let component_admissions = root
312 .component_admissions
313 .iter()
314 .map(
315 |(component_spec, maximum_root_instances)| RootComponentAdmissionInput {
316 component_spec: component_spec.clone(),
317 maximum_root_instances: *maximum_root_instances,
318 },
319 )
320 .collect();
321 fleet_subnet_roots.push(PlannedFleetSubnetRootInput {
322 placement_subnet,
323 component_admissions,
324 limits: FleetSubnetRootLimits {
325 maximum_component_instances: root.limits.maximum_component_instances,
326 maximum_managed_canisters: root.limits.maximum_managed_canisters,
327 maximum_registry_bytes: root.limits.maximum_registry_bytes,
328 maximum_wasm_store_bytes: root.limits.maximum_wasm_store_bytes,
329 cycles_funding: CyclesFundingBudget {
330 window_secs: root.limits.cycles_funding.window_secs,
331 maximum_cycles: root.limits.cycles_funding.maximum_cycles.clone(),
332 },
333 },
334 creation_funding,
335 });
336 }
337
338 Ok(ResolvedFleetInstallInput {
339 coordinator,
340 fleet_subnet_roots,
341 })
342}
343
344fn resolve_coordinator_subnet(
345 selector: &CoordinatorSubnetSelector,
346 build_network: BuildNetwork,
347 catalog: Option<&SubnetCatalog>,
348) -> Result<SubnetId, FleetInstallInputError> {
349 match selector {
350 CoordinatorSubnetSelector::Explicit { subnet } => {
351 let subnet = parse_subnet("coordinator.subnet", subnet)?;
352 if build_network == BuildNetwork::Ic {
353 let info = trusted_subnet(catalog, subnet)?;
354 validate_eligible_subnet(info)?;
355 }
356 Ok(subnet)
357 }
358 CoordinatorSubnetSelector::Recommended => {
359 require_public_catalog(build_network, catalog, "recommended")?;
360 select_unique_subnet(
361 catalog.expect("public catalog required"),
362 "recommended",
363 |info| {
364 info.subnet_kind == SubnetKind::Application
365 && info.subnet_specialization == SubnetSpecialization::Fiduciary
366 },
367 )
368 }
369 CoordinatorSubnetSelector::Profile { profile } => {
370 validate_profile(profile)?;
371 require_public_catalog(build_network, catalog, &format!("profile {profile:?}"))?;
372 select_unique_subnet(
373 catalog.expect("public catalog required"),
374 &format!("profile {profile:?}"),
375 |info| info.subnet_kind == SubnetKind::Application && info.subnet_label == *profile,
376 )
377 }
378 }
379}
380
381fn resolve_funding(
382 owner: &str,
383 subnet: SubnetId,
384 funding: &CreationFundingDocument,
385 build_network: BuildNetwork,
386 catalog: Option<&SubnetCatalog>,
387) -> Result<PlannedCanisterCreationFunding, FleetInstallInputError> {
388 let planned = planned_funding(owner, funding)?;
389 if build_network != BuildNetwork::Ic {
390 return match planned {
391 PlannedCanisterCreationFunding::Cycles { .. } => Ok(planned),
392 PlannedCanisterCreationFunding::Icp { .. } => {
393 Err(FleetInstallInputError::NonPublicFunding {
394 owner: owner.to_string(),
395 })
396 }
397 };
398 }
399
400 let info = trusted_subnet(catalog, subnet)?;
401 validate_eligible_subnet(info)?;
402 let matches = matches!(
403 (&planned, info.subnet_kind),
404 (
405 PlannedCanisterCreationFunding::Cycles { .. },
406 SubnetKind::Application
407 ) | (
408 PlannedCanisterCreationFunding::Icp { .. },
409 SubnetKind::System
410 )
411 );
412 if matches {
413 return Ok(planned);
414 }
415 Err(FleetInstallInputError::FundingMismatch {
416 owner: owner.to_string(),
417 subnet,
418 kind: info.subnet_kind.as_str().to_string(),
419 expected: match info.subnet_kind {
420 SubnetKind::Application => "cycles",
421 SubnetKind::System => "icp",
422 SubnetKind::CloudEngine | SubnetKind::Unknown => {
423 unreachable!("ineligible Subnets reject before funding validation")
424 }
425 },
426 })
427}
428
429fn planned_funding(
430 owner: &str,
431 funding: &CreationFundingDocument,
432) -> Result<PlannedCanisterCreationFunding, FleetInstallInputError> {
433 match funding {
434 CreationFundingDocument::Cycles { cycles } if cycles.to_u128() > 0 => {
435 Ok(PlannedCanisterCreationFunding::Cycles {
436 cycles: cycles.to_u128(),
437 })
438 }
439 CreationFundingDocument::Icp { e8s } if *e8s > 0 => {
440 Ok(PlannedCanisterCreationFunding::Icp { e8s: *e8s })
441 }
442 CreationFundingDocument::Cycles { .. } | CreationFundingDocument::Icp { .. } => {
443 Err(FleetInstallInputError::NonPositiveCreationFunding {
444 owner: owner.to_string(),
445 })
446 }
447 }
448}
449
450fn parse_subnet(field: &str, value: &str) -> Result<SubnetId, FleetInstallInputError> {
451 let principal =
452 Principal::from_text(value).map_err(|error| FleetInstallInputError::InvalidSubnet {
453 field: field.to_string(),
454 value: value.to_string(),
455 reason: error.to_string(),
456 })?;
457 if principal == Principal::anonymous() {
458 return Err(FleetInstallInputError::InvalidSubnet {
459 field: field.to_string(),
460 value: value.to_string(),
461 reason: "anonymous principal is not a physical Subnet".to_string(),
462 });
463 }
464 Ok(SubnetId::from_principal(principal))
465}
466
467fn validate_profile(profile: &str) -> Result<(), FleetInstallInputError> {
468 if !profile.is_empty()
469 && profile.len() <= MAX_SUBNET_PROFILE_BYTES
470 && profile.bytes().all(|byte| {
471 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_' | b'.')
472 })
473 {
474 Ok(())
475 } else {
476 Err(FleetInstallInputError::InvalidSubnetProfile {
477 profile: profile.to_string(),
478 })
479 }
480}
481
482fn require_public_catalog(
483 build_network: BuildNetwork,
484 catalog: Option<&SubnetCatalog>,
485 selector: &str,
486) -> Result<(), FleetInstallInputError> {
487 if build_network == BuildNetwork::Ic && catalog.is_some() {
488 Ok(())
489 } else {
490 Err(FleetInstallInputError::TrustedMetadataRequired {
491 selector: selector.to_string(),
492 })
493 }
494}
495
496fn trusted_subnet(
497 catalog: Option<&SubnetCatalog>,
498 subnet: SubnetId,
499) -> Result<&SubnetInfo, FleetInstallInputError> {
500 let catalog = catalog.ok_or_else(|| FleetInstallInputError::TrustedMetadataRequired {
501 selector: format!("explicit Subnet {subnet}"),
502 })?;
503 catalog
504 .subnets
505 .iter()
506 .find(|info| info.subnet_principal == subnet.to_string())
507 .ok_or_else(|| FleetInstallInputError::SubnetNotFound {
508 selector: format!("explicit Subnet {subnet}"),
509 })
510}
511
512fn validate_eligible_subnet(info: &SubnetInfo) -> Result<(), FleetInstallInputError> {
513 if matches!(
514 info.subnet_kind,
515 SubnetKind::Application | SubnetKind::System
516 ) {
517 return Ok(());
518 }
519 let subnet = parse_subnet("trusted subnet catalog", &info.subnet_principal)?;
520 Err(FleetInstallInputError::IneligibleSubnet {
521 subnet,
522 kind: info.subnet_kind.as_str().to_string(),
523 })
524}
525
526fn select_unique_subnet(
527 catalog: &SubnetCatalog,
528 selector: &str,
529 matches: impl Fn(&SubnetInfo) -> bool,
530) -> Result<SubnetId, FleetInstallInputError> {
531 let candidates = catalog
532 .subnets
533 .iter()
534 .filter(|info| matches(info))
535 .collect::<Vec<_>>();
536 match candidates.as_slice() {
537 [info] => parse_subnet("trusted subnet catalog", &info.subnet_principal),
538 [] => Err(FleetInstallInputError::SubnetNotFound {
539 selector: selector.to_string(),
540 }),
541 _ => Err(FleetInstallInputError::AmbiguousSubnetSelector {
542 selector: selector.to_string(),
543 matches: candidates.len(),
544 }),
545 }
546}