Skip to main content

canic_host/component_topology/
mod.rs

1//! Module: component_topology
2//!
3//! Responsibility: finalize Fleet root topology bindings from config and explicit host input.
4//! Does not own: Subnet selection, Canister creation, release sets, Registry commit, or runtime.
5//! Boundary: accepts resolved authority/placement facts and emits validated immutable bindings.
6
7#[cfg(test)]
8mod tests;
9
10use candid::Principal;
11use canic_core::{
12    bootstrap::compiled::{ComponentTopology, ConfigModel},
13    ids::{
14        ComponentSpecAdmission, ComponentSpecId, ComponentTopologyDigest, FleetRegistryAuthority,
15        FleetSubnetRootBinding, FleetSubnetRootLimits, SubnetId,
16    },
17};
18use std::collections::BTreeSet;
19use thiserror::Error as ThisError;
20
21///
22/// RootComponentAdmissionInput
23///
24/// Operator-planned positive instance capacity for one Spec on one resolved root.
25///
26
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub struct RootComponentAdmissionInput {
29    pub component_spec: ComponentSpecId,
30    pub maximum_root_instances: u32,
31}
32
33///
34/// FleetSubnetRootTopologyInput
35///
36/// Exact resolved physical root placement, admissions, and immutable aggregate limits.
37///
38
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct FleetSubnetRootTopologyInput {
41    pub placement_subnet: SubnetId,
42    pub fleet_subnet_root: Principal,
43    pub component_admissions: Vec<RootComponentAdmissionInput>,
44    pub limits: FleetSubnetRootLimits,
45}
46
47///
48/// PlannedFleetSubnetRootTopologyInput
49///
50/// Exact root placement, admissions, and limits resolved before Canister creation.
51///
52
53#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct PlannedFleetSubnetRootTopologyInput {
55    pub placement_subnet: SubnetId,
56    pub component_admissions: Vec<RootComponentAdmissionInput>,
57    pub limits: FleetSubnetRootLimits,
58}
59
60///
61/// PlannedFleetSubnetRootTopology
62///
63/// Canonical pre-creation root topology with no fabricated Canister principal.
64///
65
66#[derive(Clone, Debug, Eq, PartialEq)]
67pub struct PlannedFleetSubnetRootTopology {
68    pub placement_subnet: SubnetId,
69    pub component_admissions: Vec<ComponentSpecAdmission>,
70    pub component_topology_digest: ComponentTopologyDigest,
71    pub limits: FleetSubnetRootLimits,
72}
73
74///
75/// PlannedFleetTopology
76///
77/// Canonical Component Topology and every validated pre-creation root plan.
78///
79
80#[derive(Clone, Debug, Eq, PartialEq)]
81pub struct PlannedFleetTopology {
82    pub component_topology: ComponentTopology,
83    pub fleet_subnet_roots: Vec<PlannedFleetSubnetRootTopology>,
84}
85
86///
87/// FleetTopologyPlan
88///
89/// Canonical Fleet topology plus every validated root-local binding.
90///
91
92#[derive(Clone, Debug, Eq, PartialEq)]
93pub struct FleetTopologyPlan {
94    pub component_topology: ComponentTopology,
95    pub fleet_subnet_roots: Vec<FleetSubnetRootBinding>,
96}
97
98///
99/// FleetTopologyPlanError
100///
101/// Typed rejection while finalizing immutable root topology input.
102///
103
104#[derive(Debug, Eq, PartialEq, ThisError)]
105pub enum FleetTopologyPlanError {
106    #[error(
107        "Fleet authority App '{authority_app}' does not match configured App '{configured_app}'"
108    )]
109    AppMismatch {
110        configured_app: String,
111        authority_app: String,
112    },
113
114    #[error("root input repeats Component Spec admission '{component_spec}'")]
115    DuplicateAdmission { component_spec: ComponentSpecId },
116
117    #[error("root input repeats placement Subnet '{placement_subnet}'")]
118    DuplicatePlacementSubnet { placement_subnet: SubnetId },
119
120    #[error("root placement Subnet must not be anonymous")]
121    AnonymousPlacementSubnet,
122
123    #[error(transparent)]
124    Topology(#[from] canic_core::bootstrap::compiled::ComponentTopologyError),
125
126    #[error("root input references unknown Component Spec '{component_spec}'")]
127    UnknownComponentSpec { component_spec: ComponentSpecId },
128}
129
130/// Finalize canonical pre-creation root plans without inventing Canister principals.
131pub fn plan_initial_fleet_topology(
132    config: &ConfigModel,
133    root_inputs: Vec<PlannedFleetSubnetRootTopologyInput>,
134) -> Result<PlannedFleetTopology, FleetTopologyPlanError> {
135    let component_topology = config.compile_component_topology()?;
136    let mut fleet_subnet_roots = root_inputs
137        .into_iter()
138        .map(|input| finalize_planned_root(&component_topology, input))
139        .collect::<Result<Vec<_>, _>>()?;
140    fleet_subnet_roots.sort_by_key(|root| root.placement_subnet);
141
142    let mut placement_subnets = BTreeSet::new();
143    for root in &fleet_subnet_roots {
144        if root.placement_subnet.as_principal() == &Principal::anonymous() {
145            return Err(FleetTopologyPlanError::AnonymousPlacementSubnet);
146        }
147        if !placement_subnets.insert(root.placement_subnet) {
148            return Err(FleetTopologyPlanError::DuplicatePlacementSubnet {
149                placement_subnet: root.placement_subnet,
150            });
151        }
152        component_topology.validate_planned_root(
153            &root.component_admissions,
154            root.component_topology_digest,
155            &root.limits,
156        )?;
157    }
158    let admissions = fleet_subnet_roots
159        .iter()
160        .map(|root| root.component_admissions.as_slice())
161        .collect::<Vec<_>>();
162    component_topology.validate_fleet_admissions(&admissions)?;
163
164    Ok(PlannedFleetTopology {
165        component_topology,
166        fleet_subnet_roots,
167    })
168}
169
170/// Finalize canonical root bindings without accepting caller-supplied hashes or digests.
171pub fn plan_fleet_topology(
172    config: &ConfigModel,
173    authority: FleetRegistryAuthority,
174    root_inputs: Vec<FleetSubnetRootTopologyInput>,
175) -> Result<FleetTopologyPlan, FleetTopologyPlanError> {
176    if config.app_id() != &authority.binding.fleet.app {
177        return Err(FleetTopologyPlanError::AppMismatch {
178            configured_app: config.app_id().to_string(),
179            authority_app: authority.binding.fleet.app.to_string(),
180        });
181    }
182
183    let component_topology = config.compile_component_topology()?;
184    let mut fleet_subnet_roots = Vec::with_capacity(root_inputs.len());
185
186    for input in root_inputs {
187        fleet_subnet_roots.push(finalize_root_binding(
188            &component_topology,
189            &authority,
190            input,
191        )?);
192    }
193    fleet_subnet_roots.sort_by(|left, right| {
194        left.placement_subnet
195            .cmp(&right.placement_subnet)
196            .then_with(|| left.fleet_subnet_root.cmp(&right.fleet_subnet_root))
197    });
198
199    component_topology.validate_fleet_root_bindings(&fleet_subnet_roots)?;
200    Ok(FleetTopologyPlan {
201        component_topology,
202        fleet_subnet_roots,
203    })
204}
205
206fn finalize_root_binding(
207    component_topology: &ComponentTopology,
208    authority: &FleetRegistryAuthority,
209    mut input: FleetSubnetRootTopologyInput,
210) -> Result<FleetSubnetRootBinding, FleetTopologyPlanError> {
211    input
212        .component_admissions
213        .sort_by(|left, right| left.component_spec.cmp(&right.component_spec));
214
215    let mut seen = BTreeSet::new();
216    let mut component_admissions = Vec::with_capacity(input.component_admissions.len());
217    for admission in input.component_admissions {
218        if !seen.insert(admission.component_spec.clone()) {
219            return Err(FleetTopologyPlanError::DuplicateAdmission {
220                component_spec: admission.component_spec,
221            });
222        }
223        let component_spec = component_topology
224            .get(&admission.component_spec)
225            .ok_or_else(|| FleetTopologyPlanError::UnknownComponentSpec {
226                component_spec: admission.component_spec.clone(),
227            })?;
228        component_admissions.push(ComponentSpecAdmission {
229            component_spec: admission.component_spec,
230            spec_hash: component_spec.spec_hash,
231            maximum_root_instances: admission.maximum_root_instances,
232        });
233    }
234
235    let projection = component_topology.project_for_admissions(&component_admissions)?;
236    Ok(FleetSubnetRootBinding {
237        authority: authority.clone(),
238        placement_subnet: input.placement_subnet,
239        fleet_subnet_root: input.fleet_subnet_root,
240        component_admissions,
241        component_topology_digest: projection.digest()?,
242        limits: input.limits,
243    })
244}
245
246fn finalize_planned_root(
247    component_topology: &ComponentTopology,
248    mut input: PlannedFleetSubnetRootTopologyInput,
249) -> Result<PlannedFleetSubnetRootTopology, FleetTopologyPlanError> {
250    input
251        .component_admissions
252        .sort_by(|left, right| left.component_spec.cmp(&right.component_spec));
253
254    let mut seen = BTreeSet::new();
255    let mut component_admissions = Vec::with_capacity(input.component_admissions.len());
256    for admission in input.component_admissions {
257        if !seen.insert(admission.component_spec.clone()) {
258            return Err(FleetTopologyPlanError::DuplicateAdmission {
259                component_spec: admission.component_spec,
260            });
261        }
262        let component_spec = component_topology
263            .get(&admission.component_spec)
264            .ok_or_else(|| FleetTopologyPlanError::UnknownComponentSpec {
265                component_spec: admission.component_spec.clone(),
266            })?;
267        component_admissions.push(ComponentSpecAdmission {
268            component_spec: admission.component_spec,
269            spec_hash: component_spec.spec_hash,
270            maximum_root_instances: admission.maximum_root_instances,
271        });
272    }
273
274    let projection = component_topology.project_for_admissions(&component_admissions)?;
275    Ok(PlannedFleetSubnetRootTopology {
276        placement_subnet: input.placement_subnet,
277        component_admissions,
278        component_topology_digest: projection.digest()?,
279        limits: input.limits,
280    })
281}