Skip to main content

canic_host/fleet_ensure/workflow/readiness/
mod.rs

1//! Early Fleet checks using existing signer, network and retained-state owners.
2//!
3//! No compilation, plan generation, journal mutation or paid effect occurs here.
4
5#[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
30/// Inputs available before any release build exists. Funding is a caller estimate.
31pub 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
45/// Intended generator files checked before builds, without granting import authority.
46pub struct ReadinessGenerationInputs<'a> {
47    pub source: &'a Path,
48    pub seed: &'a Path,
49}
50
51///
52/// ReadinessConversionRequest
53///
54/// Exact public quote providers selected for advisory conversion only.
55///
56
57#[derive(Clone, Copy)]
58pub struct ReadinessConversionRequest {
59    pub icp_ledger: Principal,
60    pub cmc: Principal,
61}
62
63/// Failure to establish even the read-only pre-build facts.
64#[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
94/// Observe current funding and local blockers without creating operator state.
95pub 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}