Skip to main content

canic_host/component_operation/ops/transport/
mod.rs

1//! Individual protected Root calls for the host Component workflow.
2//!
3//! Selection reuses terminal Fleet inventory and its exact protocol sidecars.
4
5use crate::{
6    canister_protocol::{call_with_arg, query_with_arg},
7    component_operation::{
8        ComponentOperationError,
9        model::{ComponentAuthorityRecord, ComponentPlanRecord},
10        ops::ComponentTransport,
11        view::{ComponentObservation, ComponentProgressObservation},
12    },
13    fleet_ensure::{
14        ops::{EnsurePaths, read_state},
15        resolve_current_fleet,
16    },
17    icp::IcpCli,
18    protocol_binding::{ResolvedProtocolBinding, resolve_registry_protocol_binding},
19};
20use candid::{CandidType, Principal};
21use canic_control_plane::dto::{
22    fleet_coordinator::{CoordinatorRegistryRequest, CoordinatorRegistryResponse},
23    root::RootOperationStatusResponse,
24};
25use canic_core::{
26    diagnostics::codes::STATE_UNAVAILABLE,
27    dto::{
28        component_registry::RootComponentAllocationRequest,
29        fleet_registry::FleetSubnetRootStatus,
30        fleet_subnet_root::FleetSubnetRootAuthority,
31        pool::{CanisterPoolResponse, CanisterPoolStatusRequest},
32        role::{OperationReceipt, OperationStatusRequest},
33    },
34    ids::{ComponentSpecId, FleetSubnetRootBinding},
35    protocol,
36};
37use serde::Deserialize;
38use sha2::{Digest, Sha256};
39use std::path::{Path, PathBuf};
40
41#[derive(CandidType, Deserialize)]
42enum RootRead {
43    FleetAuthority,
44    Operation(OperationStatusRequest),
45    Pool(CanisterPoolStatusRequest),
46}
47
48#[derive(CandidType, Deserialize)]
49enum RootResponse {
50    FleetAuthority(Box<FleetSubnetRootAuthority>),
51    Operation(Box<RootOperationStatusResponse>),
52    Pool(Box<CanisterPoolResponse>),
53}
54
55#[derive(CandidType, Deserialize)]
56enum RootCommand {
57    ProvisionComponent(RootComponentAllocationRequest),
58}
59
60#[derive(CandidType, Deserialize)]
61enum RootCommandResponse {
62    OperationAccepted(OperationReceipt),
63}
64
65/// ICP transport rooted in the same selected workspace and environment as its review.
66pub struct IcpComponentTransport {
67    root: PathBuf,
68    icp: IcpCli,
69}
70
71impl IcpComponentTransport {
72    /// Retain the operator-selected ICP context; this constructor performs no calls.
73    #[must_use]
74    pub fn new(root: &Path, icp: IcpCli) -> Self {
75        Self {
76            root: root.to_path_buf(),
77            icp: icp.with_cwd(root),
78        }
79    }
80
81    /// Resolve immutable authority from the current terminal Fleet and selected identity.
82    pub fn authority(
83        &self,
84        environment: &str,
85        fleet: &str,
86        root_name: &str,
87        spec: &ComponentSpecId,
88    ) -> Result<ComponentAuthorityRecord, ComponentOperationError> {
89        if self.icp.environment() != Some(environment) {
90            return Err(ComponentOperationError::Authority {
91                field: "ICP environment",
92            });
93        }
94        let current = self.current_fleet(environment, fleet)?;
95        let registry = current.initial_active_registry(fleet)?;
96        let desired = current
97            .plan
98            .reviewed_desired
99            .as_deref()
100            .ok_or(ComponentOperationError::Integrity)?
101            .desired();
102        let configured = desired
103            .canisters
104            .iter()
105            .find(|entry| entry.name == root_name)
106            .ok_or(ComponentOperationError::Missing)?;
107        let state = read_state(&EnsurePaths::under(&self.root, environment, fleet), fleet)?;
108        let principal = state
109            .principals
110            .get(root_name)
111            .ok_or(ComponentOperationError::Missing)?;
112        let root = registry
113            .fleet_subnet_roots
114            .iter()
115            .find(|entry| entry.fleet_subnet_root.to_text() == *principal)
116            .ok_or(ComponentOperationError::Missing)?;
117        if root.status != FleetSubnetRootStatus::Active {
118            return Err(ComponentOperationError::Authority {
119                field: "active Root",
120            });
121        }
122        let selected = registry
123            .component_specs
124            .iter()
125            .find(|entry| entry.component_spec == *spec)
126            .ok_or(ComponentOperationError::Spec)?;
127        if !root.component_admissions.iter().any(|entry| {
128            entry.component_spec == *spec
129                && entry.spec_hash == selected.spec_hash
130                && entry.maximum_root_instances > 0
131        }) {
132            return Err(ComponentOperationError::Spec);
133        }
134        let entry = current
135            .registry
136            .entries
137            .iter()
138            .find(|entry| entry.pid == *principal)
139            .ok_or(ComponentOperationError::Missing)?;
140        let protocol = resolve_registry_protocol_binding(&self.root, environment, entry)?;
141        let mut controllers = configured.controllers.clone();
142        for name in &configured.controller_canisters {
143            controllers.push(
144                state
145                    .principals
146                    .get(name)
147                    .ok_or(ComponentOperationError::Missing)?
148                    .clone(),
149            );
150        }
151        controllers.sort();
152        controllers.dedup();
153        let operator = Principal::from_text(self.icp.identity_principal_text()?)
154            .map_err(|_| ComponentOperationError::Authority { field: "operator" })?;
155        if !controllers.contains(&operator.to_text()) {
156            return Err(ComponentOperationError::Authority {
157                field: "operator controller",
158            });
159        }
160        Ok(ComponentAuthorityRecord {
161            environment: environment.to_string(),
162            fleet: fleet.to_string(),
163            root_name: root_name.to_string(),
164            source_plan_sha256: current.plan.plan_sha256.clone(),
165            binding: FleetSubnetRootBinding {
166                authority: registry.authority.clone(),
167                placement_subnet: root.placement_subnet,
168                fleet_subnet_root: root.fleet_subnet_root,
169                component_admissions: root.component_admissions.clone(),
170                component_topology_digest: root.component_topology_digest,
171                limits: root.limits.clone(),
172                funding: root.funding.clone(),
173            },
174            release_set: root.active_release_set,
175            root_module_sha256: entry
176                .module_hash
177                .clone()
178                .ok_or(ComponentOperationError::Integrity)?,
179            root_candid_sha256: protocol.binding().candid_sha256,
180            root_controllers: controllers,
181            registry_sha256: Sha256::digest(serde_json::to_vec(registry)?).into(),
182            operator,
183            component_spec: spec.clone(),
184            spec_hash: selected.spec_hash,
185            role: selected.component_role.clone(),
186        })
187    }
188
189    fn current_fleet(
190        &self,
191        environment: &str,
192        fleet: &str,
193    ) -> Result<crate::fleet_ensure::CurrentFleetResolution, ComponentOperationError> {
194        let current = resolve_current_fleet(&self.root, environment, fleet)?;
195        if crate::fleet_ensure::policy::expected_plan_sha256(&current.plan)
196            != current.plan.plan_sha256
197        {
198            return Err(ComponentOperationError::Integrity);
199        }
200        let registry = current.initial_active_registry(fleet)?;
201        let network =
202            crate::network::resolve_canonical_network_id_from_root(&self.root, environment)?;
203        if registry.authority.binding.fleet.fleet.canonical_network_id != network {
204            return Err(ComponentOperationError::Authority {
205                field: "selected network",
206            });
207        }
208        Ok(current)
209    }
210
211    fn binding(
212        &self,
213        plan: &ComponentPlanRecord,
214        principal: Principal,
215    ) -> Result<ResolvedProtocolBinding, ComponentOperationError> {
216        let current = resolve_current_fleet(
217            &self.root,
218            &plan.authority.environment,
219            &plan.authority.fleet,
220        )?;
221        let entry = current
222            .registry
223            .entries
224            .iter()
225            .find(|entry| entry.pid == principal.to_text())
226            .ok_or(ComponentOperationError::Missing)?;
227        Ok(resolve_registry_protocol_binding(
228            &self.root,
229            &plan.authority.environment,
230            entry,
231        )?)
232    }
233
234    fn query(
235        &self,
236        plan: &ComponentPlanRecord,
237        request: &RootRead,
238    ) -> Result<RootResponse, ComponentOperationError> {
239        let principal = plan.authority.binding.fleet_subnet_root;
240        Ok(query_with_arg(
241            &self.icp,
242            &self.binding(plan, principal)?,
243            principal,
244            match request {
245                RootRead::Operation(_) => protocol::CANIC_ROOT_OPERATION_STATUS,
246                _ => protocol::CANIC_ROOT_STATUS,
247            },
248            request,
249        )?)
250    }
251}
252
253impl ComponentTransport for IcpComponentTransport {
254    fn observe(
255        &mut self,
256        plan: &ComponentPlanRecord,
257    ) -> Result<ComponentObservation, ComponentOperationError> {
258        let expected = &plan.authority;
259        let authority = self.authority(
260            &expected.environment,
261            &expected.fleet,
262            &expected.root_name,
263            &expected.component_spec,
264        )?;
265        crate::component_operation::policy::validate_authority(expected, &authority)?;
266        let RootResponse::FleetAuthority(root) = self.query(plan, &RootRead::FleetAuthority)?
267        else {
268            return Err(ComponentOperationError::Progress);
269        };
270        let matching_binding = root.binding == authority.binding;
271        let matching_release = root.initial_release_set == authority.release_set;
272        let matching_module = canic_core::cdk::utils::hash::hex_bytes(root.expected_module_hash)
273            == authority.root_module_sha256;
274        if !(matching_binding && matching_release && matching_module) {
275            return Err(ComponentOperationError::Authority {
276                field: "live Root binding",
277            });
278        }
279        let coordinator = authority.binding.authority.binding.coordinator;
280        let CoordinatorRegistryResponse::Registry(registry) = query_with_arg(
281            &self.icp,
282            &self.binding(plan, coordinator)?,
283            coordinator,
284            protocol::CANIC_COORDINATOR_REGISTRY,
285            &CoordinatorRegistryRequest::Registry,
286        )?;
287        let registry_sha256: [u8; 32] = Sha256::digest(serde_json::to_vec(&registry)?).into();
288        if registry_sha256 != authority.registry_sha256 {
289            return Err(ComponentOperationError::Authority {
290                field: "live Registry",
291            });
292        }
293        let report = self
294            .icp
295            .canister_status_report(&authority.binding.fleet_subnet_root.to_text())?;
296        let mut controllers = report
297            .settings
298            .ok_or(ComponentOperationError::Integrity)?
299            .controllers;
300        controllers.sort();
301        let module = report
302            .module_hash
303            .map(|hash| hash.trim_start_matches("0x").to_ascii_lowercase());
304        if report.id != authority.binding.fleet_subnet_root.to_text()
305            || controllers != authority.root_controllers
306            || module.as_ref() != Some(&authority.root_module_sha256)
307        {
308            return Err(ComponentOperationError::Authority {
309                field: "Root management",
310            });
311        }
312        let RootResponse::Pool(pool) = self.query(
313            plan,
314            &RootRead::Pool(CanisterPoolStatusRequest {
315                start_after: None,
316                limit: 1,
317            }),
318        )?
319        else {
320            return Err(ComponentOperationError::Progress);
321        };
322        Ok(ComponentObservation {
323            authority,
324            ready_assets: pool.ready,
325        })
326    }
327
328    fn progress(
329        &mut self,
330        plan: &ComponentPlanRecord,
331    ) -> Result<ComponentProgressObservation, ComponentOperationError> {
332        let response = self.query(
333            plan,
334            &RootRead::Operation(OperationStatusRequest {
335                operation_id: plan.operation_id,
336            }),
337        );
338        let response = match response {
339            Err(ComponentOperationError::Protocol(error))
340                if error.is_rejected_with(STATE_UNAVAILABLE) =>
341            {
342                return Ok(ComponentProgressObservation { progress: None });
343            }
344            other => other?,
345        };
346        let RootResponse::Operation(status) = response else {
347            return Err(ComponentOperationError::Progress);
348        };
349        let RootOperationStatusResponse::ProvisionComponent(status) = *status else {
350            return Err(ComponentOperationError::Progress);
351        };
352        crate::component_operation::ops::project_progress(plan, status)
353    }
354
355    fn submit(&mut self, plan: &ComponentPlanRecord) -> Result<(), ComponentOperationError> {
356        let principal = plan.authority.binding.fleet_subnet_root;
357        let RootCommandResponse::OperationAccepted(receipt) = call_with_arg(
358            &self.icp,
359            &self.binding(plan, principal)?,
360            principal,
361            protocol::CANIC_ROOT_COMMAND,
362            &RootCommand::ProvisionComponent(RootComponentAllocationRequest {
363                operation_id: plan.operation_id,
364                component_spec: plan.authority.component_spec.clone(),
365            }),
366        )?;
367        if receipt.operation_id != plan.operation_id {
368            return Err(ComponentOperationError::Progress);
369        }
370        Ok(())
371    }
372}