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_contracts::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    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}