Skip to main content

canic_host/fleet_ensure/ops/release/reservation/
mod.rs

1//! Durable release read allowances in the existing Fleet journal and operation lock.
2//!
3//! Reservations are never refunded. A persistence error prevents issuing a token and
4//! poisons this owner until its exact durable state is reopened.
5
6#[cfg(test)]
7pub(in crate::fleet_ensure) mod tests;
8
9use crate::fleet_ensure::{
10    model::{
11        FleetEnsureCompletion, FleetEnsureJournalRecord,
12        release::{FleetReleaseAuthority, FleetReleaseExecutionRecord, FleetReleaseReviewRecord},
13    },
14    ops::{EnsurePaths, EnsureStateError, read_journal, write_journal},
15    policy::release::{FleetReleaseError, reserve_observation_calls},
16    view::release::FleetReleaseObservation,
17};
18use candid::Principal;
19use canic_contracts::ids::SubnetId;
20use canic_core::cdk::utils::hash::hex_bytes;
21use std::{collections::BTreeMap, fs::File};
22use thiserror::Error;
23
24/// Local refusal occurs before issuing any paid observation.
25#[derive(Debug, Error)]
26pub enum ReleaseReservationError {
27    #[error(transparent)]
28    State(#[from] EnsureStateError),
29    #[error(transparent)]
30    Evidence(#[from] FleetReleaseError),
31    #[error("release journal, review or operation authority differs")]
32    Integrity,
33    #[error("release reservation persistence was uncertain; reopen the retained journal")]
34    PersistenceUncertain,
35}
36
37/// Holds the ordinary Fleet lock across reservation persistence and its paid reads.
38pub struct ReleaseObservationJournal {
39    _lock: File,
40    paths: EnsurePaths,
41    journal: FleetEnsureJournalRecord,
42    poisoned: bool,
43}
44
45/// One non-cloneable allowance, keeping its exact journal owner alive through observation.
46pub struct ReleaseObservationReservation<'a> {
47    owner: &'a ReleaseObservationJournal,
48    canister: Principal,
49    subnet: SubnetId,
50}
51
52impl ReleaseObservationReservation<'_> {
53    pub(super) fn authorizes(
54        &self,
55        authority: &FleetReleaseAuthority,
56        canister: Principal,
57        subnet: SubnetId,
58    ) -> bool {
59        self.canister == canister
60            && self.subnet == subnet
61            && self
62                .owner
63                .journal
64                .release
65                .as_ref()
66                .is_some_and(|record| &record.review.authority == authority)
67    }
68}
69
70impl ReleaseObservationJournal {
71    /// Attach admitted release authority to an already retained, matching operation envelope.
72    /// This does not create an operation, authorize destructive effects or collect live evidence.
73    pub fn attach(
74        paths: &EnsurePaths,
75        review: &FleetReleaseReviewRecord,
76        observed: &FleetReleaseObservation,
77    ) -> Result<Self, ReleaseReservationError> {
78        super::verify_review(review, observed)?;
79        let mut owner = Self::read_locked(paths)?;
80        validate_envelope(&owner.journal, review)?;
81        if owner.journal.release.is_none() {
82            if !owner.journal.effects.is_empty() {
83                return Err(ReleaseReservationError::Integrity);
84            }
85            owner.journal.release = Some(FleetReleaseExecutionRecord {
86                plan_sha256: owner.journal.plan_sha256.clone(),
87                review: review.clone(),
88                reserved_paid_calls: review
89                    .sources
90                    .iter()
91                    .map(|source| (source.binding.canister_id.to_text(), 0))
92                    .collect(),
93            });
94            owner.save()?;
95        }
96        owner.validate(review)?;
97        Ok(owner)
98    }
99
100    /// Resume exact retained authority without rebasing balances or spending a live read.
101    pub fn resume(
102        paths: &EnsurePaths,
103        review: &FleetReleaseReviewRecord,
104    ) -> Result<Self, ReleaseReservationError> {
105        let owner = Self::read_locked(paths)?;
106        owner.validate(review)?;
107        Ok(owner)
108    }
109
110    /// Charge all four reads before returning the only token accepted by physical observation.
111    pub fn reserve(
112        &mut self,
113        canister: Principal,
114    ) -> Result<ReleaseObservationReservation<'_>, ReleaseReservationError> {
115        if self.poisoned {
116            return Err(ReleaseReservationError::PersistenceUncertain);
117        }
118        if read_journal(&self.paths)?.as_ref() != Some(&self.journal) {
119            return Err(ReleaseReservationError::Integrity);
120        }
121        let record = self
122            .journal
123            .release
124            .as_mut()
125            .ok_or(ReleaseReservationError::Integrity)?;
126        let source = record
127            .review
128            .sources
129            .iter()
130            .find(|source| source.binding.canister_id == canister)
131            .ok_or(ReleaseReservationError::Integrity)?;
132        let calls = record
133            .reserved_paid_calls
134            .get_mut(&canister.to_text())
135            .ok_or(ReleaseReservationError::Integrity)?;
136        *calls = reserve_observation_calls(
137            source,
138            *calls,
139            super::observation::PHYSICAL_SAMPLE_PAID_CALLS,
140        )?;
141        let subnet = source.binding.subnet;
142        self.save()?;
143        Ok(ReleaseObservationReservation {
144            owner: self,
145            canister,
146            subnet,
147        })
148    }
149
150    fn read_locked(paths: &EnsurePaths) -> Result<Self, ReleaseReservationError> {
151        let lock = crate::fleet_ensure::ops::lock_fleet_file(paths)?;
152        crate::fleet_ensure::ops::capacity_import::journal::require_no_approved_import(paths)?;
153        let journal = read_journal(paths)?.ok_or(ReleaseReservationError::Integrity)?;
154        Ok(Self {
155            _lock: lock,
156            paths: paths.clone(),
157            journal,
158            poisoned: false,
159        })
160    }
161
162    fn validate(&self, review: &FleetReleaseReviewRecord) -> Result<(), ReleaseReservationError> {
163        super::verify_digest(review)?;
164        validate_envelope(&self.journal, review)?;
165        let record = self
166            .journal
167            .release
168            .as_ref()
169            .ok_or(ReleaseReservationError::Integrity)?;
170        let sources = review
171            .sources
172            .iter()
173            .map(|source| (source.binding.canister_id.to_text(), source))
174            .collect::<BTreeMap<_, _>>();
175        if &record.review != review
176            || record.plan_sha256 != self.journal.plan_sha256
177            || sources.len() != review.sources.len()
178            || sources.len() != record.reserved_paid_calls.len()
179        {
180            return Err(ReleaseReservationError::Integrity);
181        }
182        for (id, calls) in &record.reserved_paid_calls {
183            let source = sources.get(id).ok_or(ReleaseReservationError::Integrity)?;
184            reserve_observation_calls(source, *calls, 0)?;
185        }
186        Ok(())
187    }
188
189    fn save(&mut self) -> Result<(), ReleaseReservationError> {
190        if let Err(error) = write_journal(&self.paths, &self.journal) {
191            self.poisoned = true;
192            return Err(error.into());
193        }
194        Ok(())
195    }
196}
197
198fn validate_envelope(
199    journal: &FleetEnsureJournalRecord,
200    review: &FleetReleaseReviewRecord,
201) -> Result<(), ReleaseReservationError> {
202    let operation_matches = journal.operation_id == hex_bytes(review.authority.operation_id)
203        && crate::fleet_ensure::ops::is_sha256(&journal.plan_sha256)
204        && journal.completion == FleetEnsureCompletion::InProgress;
205    let independent_authority = journal.bootstrap_registration_recovery.is_some()
206        || journal.estate_funding_required.is_some();
207    let ordinary_history_empty = journal.funding_reviews.is_empty()
208        && journal.funding_observations.is_empty()
209        && journal.successor_phases.is_empty();
210    if !operation_matches || independent_authority || !ordinary_history_empty {
211        return Err(ReleaseReservationError::Integrity);
212    }
213    Ok(())
214}
215
216/// Ordinary Ensure/import cannot take over an unfinished release operation.
217pub(in crate::fleet_ensure::ops) fn require_no_active_release(
218    paths: &EnsurePaths,
219) -> Result<(), EnsureStateError> {
220    if let Some(journal) = crate::fleet_ensure::ops::operation_selection::read(&paths.journal)?
221        && journal
222            .get("release")
223            .is_some_and(|release| !release.is_null())
224        && journal
225            .get("completion")
226            .and_then(serde_json::Value::as_str)
227            != Some("converged")
228    {
229        return Err(EnsureStateError::ReleaseInProgress {
230            path: paths.journal.clone(),
231        });
232    }
233    Ok(())
234}