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_contracts::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 let reset = if let Some(inputs) = &request.generation_inputs {
98 crate::fleet_ensure::generate::preflight::validate_selection(
99 &crate::fleet_ensure::generate::preflight::FleetGenerationInputsRequest {
100 root: request.workspace,
101 environment: request.environment,
102 fleet: request.fleet,
103 source: inputs.source,
104 seed: inputs.seed,
105 },
106 request.operator,
107 request.cycles_ledger,
108 )
109 .map_err(Box::new)?
110 } else {
111 false
112 };
113 if !reset {
114 crate::fleet_ensure::ops::retained_contract::check(
115 request.workspace,
116 request.environment,
117 request.fleet,
118 )?;
119 }
120 let started = now_ms()?;
121 let paths = EnsurePaths::under(request.workspace, request.environment, request.fleet);
122 let retained_operation = if reset {
123 None
124 } else {
125 retained(&paths, request)?
126 };
127 let expected_network =
128 resolve_canonical_network_id_from_root(request.workspace, request.environment)?;
129 if let Some(selected) = request.desired {
130 let desired = &selected.desired;
131 crate::fleet_ensure::policy::validate_path_identity(desired, request.fleet)?;
132 let identity_matches = desired.environment == request.environment
133 && desired.operator == request.operator.to_text()
134 && desired.cycles_ledger == request.cycles_ledger.to_text();
135 let network_matches = desired
136 .bootstrap
137 .as_ref()
138 .is_none_or(|bootstrap| bootstrap.canonical_network_id == expected_network);
139 if !identity_matches || !network_matches {
140 return Err(FleetReadinessError::AuthorityMismatch);
141 }
142 crate::fleet_ensure::policy::validate_funding_policy(desired)?;
143 }
144 let icp = IcpCli::new(request.icp_executable, Some(request.environment.into()))
145 .with_identity(request.signing_identity)
146 .with_cwd(request.workspace);
147 let transport = OperatorMintTransport::from_icp(&icp)?;
148 verify_authority(
149 request.operator,
150 transport.operator()?,
151 &expected_network,
152 &transport.canonical_network_id()?,
153 )?;
154 let available = transport.operator_balance(request.cycles_ledger)?;
155 let mut report = report(
156 request,
157 expected_network.to_string(),
158 available,
159 retained_operation,
160 );
161 report.observed_at_unix_ms = started;
162 if let Some(inputs) = &request.generation_inputs {
163 let forecast = reset_forecast(request, inputs, &icp)?;
164 record_reset_forecast(&paths, &mut report, forecast)?;
165 }
166 if let Some(selected) = request.desired {
167 report.funding =
168 crate::fleet_ensure::ops::readiness::roots(request.workspace, &selected.desired, &icp)?;
169 report.funding.desired_sha256 = Some(selected.sha256.clone());
170 if report
171 .funding
172 .roots
173 .iter()
174 .any(|root| root.floor_shortfall_cycles.is_some_and(|cycles| cycles > 0))
175 {
176 report.blockers.push(ReadinessBlocker::RootNativeShortfall);
177 }
178 if report
179 .funding
180 .roots
181 .iter()
182 .any(|root| root.unfunded_role.is_some())
183 {
184 report.blockers.push(ReadinessBlocker::StartupFundingPolicy);
185 }
186 }
187 if let Some(conversion) = request.conversion {
188 report.funding.unresolved.retain(|reason| *reason != crate::fleet_ensure::view::readiness::ReadinessUnresolved::ConversionNotRequested);
189 let quote =
190 transport.quote_blocking(conversion.icp_ledger, conversion.cmc, request.cycles_ledger);
191 apply_quote(&mut report, conversion, quote.ok(), now_ms()?);
192 }
193 report.completed_at_unix_ms = now_ms()?;
194 Ok(report)
195}
196
197fn record_reset_forecast(
198 paths: &EnsurePaths,
199 report: &mut FleetReadiness,
200 forecast: Option<crate::fleet_ensure::view::readiness::InfrastructureFundingReadiness>,
201) -> Result<(), EnsureStateError> {
202 let unavailable = if forecast.is_none() {
203 Some(
204 crate::fleet_ensure::ops::operation_selection::infrastructure_funding_unavailable(
205 paths,
206 &report.environment,
207 &report.fleet,
208 )?,
209 )
210 } else {
211 None
212 };
213 let shortfall = forecast
214 .as_ref()
215 .and_then(|value| value.maximum_funding_cycles)
216 .is_some_and(|funding| funding > report.available_cycles);
217 if shortfall
218 && !report
219 .blockers
220 .contains(&ReadinessBlocker::EstimatedFundingShortfall)
221 {
222 report
223 .blockers
224 .push(ReadinessBlocker::EstimatedFundingShortfall);
225 }
226 report.funding.clean_reinstall_infrastructure = forecast;
227 report.funding.clean_reinstall_infrastructure_unavailable = unavailable;
228 Ok(())
229}
230
231fn reset_forecast(
232 request: &FleetReadinessRequest<'_>,
233 inputs: &ReadinessGenerationInputs<'_>,
234 icp: &IcpCli,
235) -> Result<
236 Option<crate::fleet_ensure::view::readiness::InfrastructureFundingReadiness>,
237 FleetReadinessError,
238> {
239 let targets = crate::fleet_ensure::generate::preflight::bootstrap_funding_targets(
240 &crate::fleet_ensure::generate::preflight::FleetGenerationInputsRequest {
241 root: request.workspace,
242 environment: request.environment,
243 fleet: request.fleet,
244 source: inputs.source,
245 seed: inputs.seed,
246 },
247 request.operator,
248 request.cycles_ledger,
249 )
250 .map_err(Box::new)?;
251 Ok(crate::fleet_ensure::ops::readiness::infrastructure(
252 targets, icp,
253 )?)
254}
255
256fn retained(
257 paths: &EnsurePaths,
258 request: &FleetReadinessRequest<'_>,
259) -> Result<Option<RetainedReadinessOperation>, FleetReadinessError> {
260 if let Some(completed) = crate::fleet_ensure::ops::operation_selection::completed(
261 paths,
262 request.environment,
263 request.fleet,
264 )? {
265 return Ok(Some(completed));
266 }
267 let Some(journal) = read_journal(paths)? else {
268 return Ok(None);
269 };
270 if journal.fleet != request.fleet {
271 return Err(FleetReadinessError::RetainedEvidence);
272 }
273 if journal.completion != FleetEnsureCompletion::Converged {
274 let plan = read_plan(paths)?.ok_or(FleetReadinessError::RetainedEvidence)?;
275 if plan.plan_sha256 != journal.plan_sha256
276 || plan.operation_id != journal.operation_id
277 || plan.environment != request.environment
278 || plan.fleet != request.fleet
279 {
280 return Err(FleetReadinessError::RetainedEvidence);
281 }
282 }
283 Ok(Some(RetainedReadinessOperation {
284 operation_id: journal.operation_id,
285 plan_sha256: journal.plan_sha256,
286 completion: journal.completion,
287 }))
288}
289
290fn report(
291 request: &FleetReadinessRequest<'_>,
292 network_identity: String,
293 available_cycles: u128,
294 retained_operation: Option<RetainedReadinessOperation>,
295) -> FleetReadiness {
296 let estimated_shortfall_cycles = request
297 .estimated_required_cycles
298 .map(|amount| amount.saturating_sub(available_cycles));
299 let mut blockers = Vec::new();
300 if retained_operation
301 .as_ref()
302 .is_some_and(|operation| operation.completion != FleetEnsureCompletion::Converged)
303 {
304 blockers.push(ReadinessBlocker::RetainedOperation);
305 }
306 if estimated_shortfall_cycles.is_some_and(|shortfall| shortfall > 0) {
307 blockers.push(ReadinessBlocker::EstimatedFundingShortfall);
308 }
309 FleetReadiness {
310 environment: request.environment.into(),
311 fleet: request.fleet.into(),
312 operator: request.operator.to_text(),
313 cycles_ledger: request.cycles_ledger.to_text(),
314 network_identity,
315 generation_inputs_checked: request.generation_inputs.is_some(),
316 available_cycles,
317 estimated_required_cycles: request.estimated_required_cycles,
318 estimated_shortfall_cycles,
319 retained_operation,
320 blockers,
321 observed_at_unix_ms: 0,
322 completed_at_unix_ms: 0,
323 funding: crate::fleet_ensure::ops::readiness::unknown(),
324 }
325}
326
327fn verify_authority(
328 expected_operator: Principal,
329 signer: Principal,
330 expected_network: &CanonicalNetworkId,
331 observed_network: &CanonicalNetworkId,
332) -> Result<(), FleetReadinessError> {
333 let valid_signer = expected_operator != Principal::anonymous() && signer == expected_operator;
334 if valid_signer && expected_network == observed_network {
335 Ok(())
336 } else {
337 Err(FleetReadinessError::AuthorityMismatch)
338 }
339}
340
341fn now_ms() -> Result<u64, FleetReadinessError> {
342 std::time::SystemTime::now()
343 .duration_since(std::time::UNIX_EPOCH)
344 .ok()
345 .and_then(|value| u64::try_from(value.as_millis()).ok())
346 .ok_or(FleetReadinessError::Clock)
347}
348
349fn apply_quote(
350 report: &mut FleetReadiness,
351 selected: ReadinessConversionRequest,
352 quote: Option<crate::fleet_ensure::view::operator_mint::OperatorMintRateQuote>,
353 observed_at_ms: u64,
354) {
355 let Some(rate) = quote.filter(|rate| {
356 rate.rate_timestamp_seconds <= observed_at_ms / 1000 && rate.xdr_permyriad_per_icp > 0
357 }) else {
358 report
359 .funding
360 .unresolved
361 .push(ReadinessUnresolved::ConversionObservationFailed);
362 return;
363 };
364 let amount = report.estimated_shortfall_cycles.and_then(|shortfall| {
365 if shortfall == 0 {
366 return Some(0);
367 }
368 crate::fleet_ensure::policy::operator_mint::quote::amount_e8s(
369 shortfall,
370 rate.estimated_deposit_fee_cycles,
371 rate.xdr_permyriad_per_icp,
372 rate.transfer_fee_e8s,
373 )
374 });
375 if amount.is_none() {
376 report
377 .funding
378 .unresolved
379 .push(ReadinessUnresolved::ConversionAmountUnavailable);
380 }
381 let debit = amount.and_then(|amount| {
382 if amount == 0 {
383 Some(0)
384 } else {
385 amount.checked_add(rate.transfer_fee_e8s)
386 }
387 });
388 report.funding.conversion = Some(ReadinessConversionQuote {
389 cmc: selected.cmc.to_text(),
390 icp_ledger: selected.icp_ledger.to_text(),
391 rate,
392 estimated_mint_e8s: amount,
393 estimated_total_icp_debit_e8s: debit,
394 });
395}