canic_host/fleet_ensure/workflow/readiness/
mod.rs1#[cfg(test)]
6pub(in crate::fleet_ensure) mod tests;
7
8use crate::{
9 fleet_ensure::{
10 model::FleetEnsureCompletion,
11 ops::{
12 EnsurePaths, EnsureStateError,
13 operator_mint::transport::{OperatorMintTransport, OperatorMintTransportError},
14 read_journal, read_plan,
15 },
16 policy::{EnsurePolicyError, validate_path_labels},
17 view::readiness::{
18 FleetReadiness, ReadinessBlocker, ReadinessConversionQuote, ReadinessUnresolved,
19 RetainedReadinessOperation,
20 },
21 },
22 icp::IcpCli,
23 network::{NetworkIdentityError, resolve_canonical_network_id_from_root},
24};
25use candid::Principal;
26use canic_core::ids::CanonicalNetworkId;
27use std::path::Path;
28use thiserror::Error;
29
30pub struct FleetReadinessRequest<'a> {
32 pub workspace: &'a Path,
33 pub environment: &'a str,
34 pub fleet: &'a str,
35 pub icp_executable: &'a str,
36 pub signing_identity: Option<&'a str>,
37 pub operator: Principal,
38 pub cycles_ledger: Principal,
39 pub estimated_required_cycles: Option<u128>,
40 pub desired: Option<&'a crate::fleet_ensure::dto::LoadedDesiredFleet>,
41 pub generation_inputs: Option<ReadinessGenerationInputs<'a>>,
42 pub conversion: Option<ReadinessConversionRequest>,
43}
44
45pub struct ReadinessGenerationInputs<'a> {
47 pub source: &'a Path,
48 pub seed: &'a Path,
49}
50
51#[derive(Clone, Copy)]
58pub struct ReadinessConversionRequest {
59 pub icp_ledger: Principal,
60 pub cmc: Principal,
61}
62
63#[derive(Debug, Error)]
65pub enum FleetReadinessError {
66 #[error(transparent)]
67 GenerationInputs(#[from] Box<crate::fleet_ensure::generate::FleetGenerateError>),
68 #[error(transparent)]
69 RetainedContract(#[from] crate::fleet_ensure::ops::retained_contract::RetainedContractError),
70 #[error(transparent)]
71 Policy(#[from] EnsurePolicyError),
72 #[error(transparent)]
73 State(#[from] EnsureStateError),
74 #[error(transparent)]
75 Network(#[from] NetworkIdentityError),
76 #[error(transparent)]
77 Transport(Box<OperatorMintTransportError>),
78 #[error("selected signer or network does not match readiness authority")]
79 AuthorityMismatch,
80 #[error("readiness observation clock is unavailable")]
81 Clock,
82 #[error(
83 "retained Fleet evidence is inconsistent; preserve it and use the existing recovery flow"
84 )]
85 RetainedEvidence,
86}
87
88impl From<OperatorMintTransportError> for FleetReadinessError {
89 fn from(error: OperatorMintTransportError) -> Self {
90 Self::Transport(Box::new(error))
91 }
92}
93
94pub fn inspect(request: &FleetReadinessRequest<'_>) -> Result<FleetReadiness, FleetReadinessError> {
96 validate_path_labels(request.environment, request.fleet)?;
97 crate::fleet_ensure::ops::retained_contract::check(
98 request.workspace,
99 request.environment,
100 request.fleet,
101 )?;
102 if let Some(inputs) = &request.generation_inputs {
103 crate::fleet_ensure::generate::preflight::validate_generation_inputs(
104 &crate::fleet_ensure::generate::preflight::FleetGenerationInputsRequest {
105 root: request.workspace,
106 environment: request.environment,
107 fleet: request.fleet,
108 source: inputs.source,
109 seed: inputs.seed,
110 },
111 request.operator,
112 request.cycles_ledger,
113 )
114 .map_err(Box::new)?;
115 }
116 let started = now_ms()?;
117 let paths = EnsurePaths::under(request.workspace, request.environment, request.fleet);
118 let retained_operation = retained(&paths, request)?;
119 let expected_network =
120 resolve_canonical_network_id_from_root(request.workspace, request.environment)?;
121 if let Some(selected) = request.desired {
122 let desired = &selected.desired;
123 crate::fleet_ensure::policy::validate_path_identity(desired, request.fleet)?;
124 let identity_matches = desired.environment == request.environment
125 && desired.operator == request.operator.to_text()
126 && desired.cycles_ledger == request.cycles_ledger.to_text();
127 let network_matches = desired
128 .bootstrap
129 .as_ref()
130 .is_none_or(|bootstrap| bootstrap.canonical_network_id == expected_network);
131 if !identity_matches || !network_matches {
132 return Err(FleetReadinessError::AuthorityMismatch);
133 }
134 crate::fleet_ensure::policy::validate_funding_policy(desired)?;
135 }
136 let icp = IcpCli::new(request.icp_executable, Some(request.environment.into()))
137 .with_identity(request.signing_identity)
138 .with_cwd(request.workspace);
139 let transport = OperatorMintTransport::from_icp(&icp)?;
140 verify_authority(
141 request.operator,
142 transport.operator()?,
143 &expected_network,
144 &transport.canonical_network_id()?,
145 )?;
146 let available = transport.operator_balance(request.cycles_ledger)?;
147 let mut report = report(
148 request,
149 expected_network.to_string(),
150 available,
151 retained_operation,
152 );
153 report.observed_at_unix_ms = started;
154 if let Some(inputs) = &request.generation_inputs {
155 let forecast = reset_forecast(request, inputs, &icp)?;
156 record_reset_forecast(&paths, &mut report, forecast)?;
157 }
158 if let Some(selected) = request.desired {
159 report.funding =
160 crate::fleet_ensure::ops::readiness::roots(request.workspace, &selected.desired, &icp)?;
161 report.funding.desired_sha256 = Some(selected.sha256.clone());
162 if report
163 .funding
164 .roots
165 .iter()
166 .any(|root| root.floor_shortfall_cycles.is_some_and(|cycles| cycles > 0))
167 {
168 report.blockers.push(ReadinessBlocker::RootNativeShortfall);
169 }
170 if report
171 .funding
172 .roots
173 .iter()
174 .any(|root| root.unfunded_role.is_some())
175 {
176 report.blockers.push(ReadinessBlocker::StartupFundingPolicy);
177 }
178 }
179 if let Some(conversion) = request.conversion {
180 report.funding.unresolved.retain(|reason| *reason != crate::fleet_ensure::view::readiness::ReadinessUnresolved::ConversionNotRequested);
181 let quote =
182 transport.quote_blocking(conversion.icp_ledger, conversion.cmc, request.cycles_ledger);
183 apply_quote(&mut report, conversion, quote.ok(), now_ms()?);
184 }
185 report.completed_at_unix_ms = now_ms()?;
186 Ok(report)
187}
188
189fn record_reset_forecast(
190 paths: &EnsurePaths,
191 report: &mut FleetReadiness,
192 forecast: Option<crate::fleet_ensure::view::readiness::InfrastructureFundingReadiness>,
193) -> Result<(), EnsureStateError> {
194 let unavailable = if forecast.is_none() {
195 Some(
196 crate::fleet_ensure::ops::operation_selection::infrastructure_funding_unavailable(
197 paths,
198 &report.environment,
199 &report.fleet,
200 )?,
201 )
202 } else {
203 None
204 };
205 let shortfall = forecast
206 .as_ref()
207 .and_then(|value| value.maximum_funding_cycles)
208 .is_some_and(|funding| funding > report.available_cycles);
209 if shortfall
210 && !report
211 .blockers
212 .contains(&ReadinessBlocker::EstimatedFundingShortfall)
213 {
214 report
215 .blockers
216 .push(ReadinessBlocker::EstimatedFundingShortfall);
217 }
218 report.funding.clean_reinstall_infrastructure = forecast;
219 report.funding.clean_reinstall_infrastructure_unavailable = unavailable;
220 Ok(())
221}
222
223fn reset_forecast(
224 request: &FleetReadinessRequest<'_>,
225 inputs: &ReadinessGenerationInputs<'_>,
226 icp: &IcpCli,
227) -> Result<
228 Option<crate::fleet_ensure::view::readiness::InfrastructureFundingReadiness>,
229 FleetReadinessError,
230> {
231 let targets = crate::fleet_ensure::generate::preflight::bootstrap_funding_targets(
232 &crate::fleet_ensure::generate::preflight::FleetGenerationInputsRequest {
233 root: request.workspace,
234 environment: request.environment,
235 fleet: request.fleet,
236 source: inputs.source,
237 seed: inputs.seed,
238 },
239 request.operator,
240 request.cycles_ledger,
241 )
242 .map_err(Box::new)?;
243 Ok(crate::fleet_ensure::ops::readiness::infrastructure(
244 targets, icp,
245 )?)
246}
247
248fn retained(
249 paths: &EnsurePaths,
250 request: &FleetReadinessRequest<'_>,
251) -> Result<Option<RetainedReadinessOperation>, FleetReadinessError> {
252 if let Some(completed) = crate::fleet_ensure::ops::operation_selection::completed(
253 paths,
254 request.environment,
255 request.fleet,
256 )? {
257 return Ok(Some(completed));
258 }
259 let Some(journal) = read_journal(paths)? else {
260 return Ok(None);
261 };
262 if journal.fleet != request.fleet {
263 return Err(FleetReadinessError::RetainedEvidence);
264 }
265 if journal.completion != FleetEnsureCompletion::Converged {
266 let plan = read_plan(paths)?.ok_or(FleetReadinessError::RetainedEvidence)?;
267 if plan.plan_sha256 != journal.plan_sha256
268 || plan.operation_id != journal.operation_id
269 || plan.environment != request.environment
270 || plan.fleet != request.fleet
271 {
272 return Err(FleetReadinessError::RetainedEvidence);
273 }
274 }
275 Ok(Some(RetainedReadinessOperation {
276 operation_id: journal.operation_id,
277 plan_sha256: journal.plan_sha256,
278 completion: journal.completion,
279 }))
280}
281
282fn report(
283 request: &FleetReadinessRequest<'_>,
284 network_identity: String,
285 available_cycles: u128,
286 retained_operation: Option<RetainedReadinessOperation>,
287) -> FleetReadiness {
288 let estimated_shortfall_cycles = request
289 .estimated_required_cycles
290 .map(|amount| amount.saturating_sub(available_cycles));
291 let mut blockers = Vec::new();
292 if retained_operation
293 .as_ref()
294 .is_some_and(|operation| operation.completion != FleetEnsureCompletion::Converged)
295 {
296 blockers.push(ReadinessBlocker::RetainedOperation);
297 }
298 if estimated_shortfall_cycles.is_some_and(|shortfall| shortfall > 0) {
299 blockers.push(ReadinessBlocker::EstimatedFundingShortfall);
300 }
301 FleetReadiness {
302 environment: request.environment.into(),
303 fleet: request.fleet.into(),
304 operator: request.operator.to_text(),
305 cycles_ledger: request.cycles_ledger.to_text(),
306 network_identity,
307 generation_inputs_checked: request.generation_inputs.is_some(),
308 available_cycles,
309 estimated_required_cycles: request.estimated_required_cycles,
310 estimated_shortfall_cycles,
311 retained_operation,
312 blockers,
313 observed_at_unix_ms: 0,
314 completed_at_unix_ms: 0,
315 funding: crate::fleet_ensure::ops::readiness::unknown(),
316 }
317}
318
319fn verify_authority(
320 expected_operator: Principal,
321 signer: Principal,
322 expected_network: &CanonicalNetworkId,
323 observed_network: &CanonicalNetworkId,
324) -> Result<(), FleetReadinessError> {
325 let valid_signer = expected_operator != Principal::anonymous() && signer == expected_operator;
326 if valid_signer && expected_network == observed_network {
327 Ok(())
328 } else {
329 Err(FleetReadinessError::AuthorityMismatch)
330 }
331}
332
333fn now_ms() -> Result<u64, FleetReadinessError> {
334 std::time::SystemTime::now()
335 .duration_since(std::time::UNIX_EPOCH)
336 .ok()
337 .and_then(|value| u64::try_from(value.as_millis()).ok())
338 .ok_or(FleetReadinessError::Clock)
339}
340
341fn apply_quote(
342 report: &mut FleetReadiness,
343 selected: ReadinessConversionRequest,
344 quote: Option<crate::fleet_ensure::view::operator_mint::OperatorMintRateQuote>,
345 observed_at_ms: u64,
346) {
347 let Some(rate) = quote.filter(|rate| {
348 rate.rate_timestamp_seconds <= observed_at_ms / 1000 && rate.xdr_permyriad_per_icp > 0
349 }) else {
350 report
351 .funding
352 .unresolved
353 .push(ReadinessUnresolved::ConversionObservationFailed);
354 return;
355 };
356 let amount = report.estimated_shortfall_cycles.and_then(|shortfall| {
357 if shortfall == 0 {
358 return Some(0);
359 }
360 crate::fleet_ensure::policy::operator_mint::quote::amount_e8s(
361 shortfall,
362 rate.estimated_deposit_fee_cycles,
363 rate.xdr_permyriad_per_icp,
364 rate.transfer_fee_e8s,
365 )
366 });
367 if amount.is_none() {
368 report
369 .funding
370 .unresolved
371 .push(ReadinessUnresolved::ConversionAmountUnavailable);
372 }
373 let debit = amount.and_then(|amount| {
374 if amount == 0 {
375 Some(0)
376 } else {
377 amount.checked_add(rate.transfer_fee_e8s)
378 }
379 });
380 report.funding.conversion = Some(ReadinessConversionQuote {
381 cmc: selected.cmc.to_text(),
382 icp_ledger: selected.icp_ledger.to_text(),
383 rate,
384 estimated_mint_e8s: amount,
385 estimated_total_icp_debit_e8s: debit,
386 });
387}