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: public-IC 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,
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///
42/// ResolvedFleetInstallInput
43///
44/// Exact pre-effect input accepted by the immutable Fleet install planner.
45///
46
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct ResolvedFleetInstallInput {
49    pub coordinator: PlannedFleetCoordinator,
50    pub fleet_subnet_roots: Vec<PlannedFleetSubnetRootInput>,
51}
52
53///
54/// FleetInstallInputError
55///
56/// Typed rejection while loading or resolving one operator input document.
57///
58
59#[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
200/// Load and resolve one strict operator input document for the selected network.
201pub 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}