Skip to main content

canic_host/fleet_ensure/ops/
mod.rs

1//! Module: fleet_ensure::ops
2//!
3//! Responsibility: own current-generation durable files and approved single IC effects.
4//! Does not own: plan decisions or multi-step orchestration.
5//! Boundary: workflow persists an intent here before invoking one platform effect.
6
7mod canic_init;
8mod current_inventory;
9pub(super) mod current_protocol;
10mod plan_content;
11mod platform;
12mod protocol;
13
14use crate::{
15    durable_io::{
16        RegularFileLockError, RegularFileReadError, lock_regular_file_with_parents,
17        read_optional_regular_bytes, write_bytes,
18    },
19    fleet_ensure::model::{
20        DesiredCanisterKind, DesiredFleet, DesiredFleetArtifacts, EffectRecord, EnsureAction,
21        FLEET_ENSURE_SCHEMA_VERSION, FleetEnsureJournalRecord, FleetEnsurePlan,
22        FleetEnsureStateRecord, FleetObservation, ProtocolArtifactDigests,
23        RootOwnedCanisterLifecycle,
24    },
25};
26use canic_core::{cdk::utils::hash::sha256_hex, dto::pool::CanisterPoolAssetStatus};
27use serde::{Serialize, de::DeserializeOwned};
28use std::{
29    collections::BTreeMap,
30    fs::File,
31    io,
32    path::{Path, PathBuf},
33};
34use thiserror::Error as ThisError;
35
36pub use platform::{IcpEnsurePlatform, IcpEnsurePlatformError};
37#[cfg(test)]
38pub(crate) use platform::{install_effect_applied, native_funding_applied};
39
40pub(crate) const fn root_owned_lifecycle(
41    kind: DesiredCanisterKind,
42    status: &CanisterPoolAssetStatus,
43) -> Option<RootOwnedCanisterLifecycle> {
44    match kind {
45        DesiredCanisterKind::Store if matches!(status, CanisterPoolAssetStatus::Store) => {
46            Some(RootOwnedCanisterLifecycle::Store)
47        }
48        DesiredCanisterKind::Pool => match status {
49            CanisterPoolAssetStatus::Ready
50            | CanisterPoolAssetStatus::PendingReset
51            | CanisterPoolAssetStatus::Failed { .. } => Some(RootOwnedCanisterLifecycle::Idle),
52            CanisterPoolAssetStatus::Claimed { .. } => Some(RootOwnedCanisterLifecycle::Claimed),
53            CanisterPoolAssetStatus::Workload { .. } => Some(RootOwnedCanisterLifecycle::Workload),
54            _ => None,
55        },
56        _ => None,
57    }
58}
59
60/// Successful evidence returned by one exact effect or exact replay.
61
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct EffectOutcome {
64    pub created_principal: Option<String>,
65    pub post_cycles: Option<u128>,
66    pub receipt: Option<String>,
67}
68
69/// One exact live observation of whether an issued effect reached its terminal state.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct EffectObservation {
72    pub applied: bool,
73    pub progress_identity: String,
74    pub retry: EffectRetry,
75}
76
77/// Typed observation-owned authority to replay one exact retained issued command.
78#[derive(Clone, Copy, Debug, Eq, PartialEq)]
79pub enum EffectRetry {
80    None,
81    ReplayExactIssuedCommand,
82}
83
84/// Complete verified terminal projection published by one current protocol owner.
85#[derive(Clone, Debug, Default, Eq, PartialEq)]
86pub struct TerminalFleetInventory {
87    pub active_registry: Option<canic_core::dto::fleet_registry::FleetRegistry>,
88    pub controlled_cycles_by_principal: BTreeMap<String, u128>,
89    pub entries: Vec<crate::registry::RegistryEntry>,
90}
91
92/// Platform boundary used by the workflow and deterministic test adapters.
93pub trait EnsurePlatform {
94    type Error: std::error::Error + Send + Sync + 'static;
95
96    /// Bind every observation and effect to the desired input retained by the
97    /// reviewed operation. Production adapters must replace any newer caller
98    /// input before resuming an in-progress journal.
99    fn bind_reviewed_desired(&mut self, desired: &DesiredFleet) -> Result<(), Self::Error>;
100
101    fn observe(
102        &mut self,
103        operation_id: &str,
104        state: &FleetEnsureStateRecord,
105    ) -> Result<FleetObservation, Self::Error>;
106
107    /// Compile current Canic control-plane work from protected roles, topology,
108    /// and live Registry evidence. Generic applications have no such work.
109    fn protocol_actions(
110        &mut self,
111        _operation_id: &str,
112        _state: &FleetEnsureStateRecord,
113    ) -> Result<Vec<EnsureAction>, Self::Error> {
114        Ok(Vec::new())
115    }
116
117    /// Return one complete verified live inventory after the typed protocol has
118    /// no remaining transition. The workflow persists this derived projection
119    /// only after every reviewed effect is terminal.
120    fn terminal_inventory(
121        &mut self,
122        _operation_id: &str,
123        _state: &FleetEnsureStateRecord,
124    ) -> Result<TerminalFleetInventory, Self::Error> {
125        Ok(TerminalFleetInventory::default())
126    }
127
128    fn observe_effect(
129        &mut self,
130        operation_id: &str,
131        action: &EnsureAction,
132        record: &EffectRecord,
133        state: &FleetEnsureStateRecord,
134    ) -> Result<EffectObservation, Self::Error>;
135
136    fn action_cycles(
137        &mut self,
138        action: &EnsureAction,
139        state: &FleetEnsureStateRecord,
140    ) -> Result<Option<u128>, Self::Error>;
141
142    fn action_destination_cycles(
143        &mut self,
144        action: &EnsureAction,
145        state: &FleetEnsureStateRecord,
146    ) -> Result<Option<u128>, Self::Error>;
147
148    /// Return the exact management canister version before an install effect.
149    /// Other effect classes have no version boundary.
150    fn action_canister_version(
151        &mut self,
152        _action: &EnsureAction,
153        _state: &FleetEnsureStateRecord,
154    ) -> Result<Option<u64>, Self::Error> {
155        Ok(None)
156    }
157
158    fn apply(
159        &mut self,
160        operation_id: &str,
161        action: &EnsureAction,
162        record: &EffectRecord,
163        state: &FleetEnsureStateRecord,
164    ) -> Result<EffectOutcome, Self::Error>;
165}
166
167/// Current Fleet ensure state paths. Historical install directories are never read.
168
169#[derive(Clone, Debug, Eq, PartialEq)]
170pub struct EnsurePaths {
171    pub content: PathBuf,
172    pub journal: PathBuf,
173    pub lock: PathBuf,
174    pub plan: PathBuf,
175    pub state: PathBuf,
176}
177
178impl EnsurePaths {
179    #[must_use]
180    pub fn under(root: &Path, environment: &str, fleet: &str) -> Self {
181        let directory = root
182            .join(".canic")
183            .join("fleet-ensure")
184            .join(environment)
185            .join(fleet);
186        Self {
187            content: root
188                .join(".canic")
189                .join("fleet-ensure")
190                .join("objects")
191                .join("sha256"),
192            journal: directory.join("journal.json"),
193            lock: directory.join("operation.lock"),
194            plan: directory.join("plan.json"),
195            state: directory.join("state.json"),
196        }
197    }
198}
199
200/// Durable current-state I/O failure.
201
202#[derive(Debug, ThisError)]
203pub enum EnsureStateError {
204    #[error("Fleet ensure document is invalid at {}: {source}", path.display())]
205    Decode {
206        path: PathBuf,
207        #[source]
208        source: serde_json::Error,
209    },
210
211    #[error("Fleet ensure document is unsafe at {}", path.display())]
212    Unsafe { path: PathBuf },
213
214    #[error("Fleet ensure document has unsupported schema {actual} at {}", path.display())]
215    WrongSchema { path: PathBuf, actual: u16 },
216
217    #[error("configured Fleet artifact is not a regular file: {}", path.display())]
218    ArtifactUnavailable { path: PathBuf },
219
220    #[error("failed to read configured Fleet artifact {}: {source}", path.display())]
221    ArtifactRead {
222        path: PathBuf,
223        #[source]
224        source: io::Error,
225    },
226
227    #[error("failed to access Fleet ensure state {}: {source}", path.display())]
228    Io {
229        path: PathBuf,
230        #[source]
231        source: io::Error,
232    },
233
234    #[error("Fleet ensure Store chunk authority is invalid: {reason}")]
235    StoreChunkAuthority { reason: String },
236
237    #[error("Fleet ensure Store chunk content is unavailable at {}", path.display())]
238    StoreChunkUnavailable { path: PathBuf },
239
240    #[error("Fleet ensure Store chunk content differs from its retained hash at {}", path.display())]
241    StoreChunkMismatch { path: PathBuf },
242
243    #[error("failed to lock Fleet ensure state {}", path.display())]
244    Lock { path: PathBuf },
245
246    #[error("Fleet ensure state at {} belongs to Fleet {actual}, expected {expected}", path.display())]
247    FleetMismatch {
248        actual: String,
249        expected: String,
250        path: PathBuf,
251    },
252}
253
254pub fn lock_operation(paths: &EnsurePaths) -> Result<File, EnsureStateError> {
255    lock_regular_file_with_parents(&paths.lock).map_err(|error| match error {
256        RegularFileLockError::Io(source) => EnsureStateError::Io {
257            path: paths.lock.clone(),
258            source,
259        },
260        RegularFileLockError::NotRegular => EnsureStateError::Lock {
261            path: paths.lock.clone(),
262        },
263        #[cfg(windows)]
264        RegularFileLockError::UnsupportedPlatform => EnsureStateError::Lock {
265            path: paths.lock.clone(),
266        },
267    })
268}
269
270pub fn read_journal(
271    paths: &EnsurePaths,
272) -> Result<Option<FleetEnsureJournalRecord>, EnsureStateError> {
273    let value: Option<FleetEnsureJournalRecord> = read_current(&paths.journal)?;
274    validate_schema(value, &paths.journal, |record| record.schema_version)
275}
276
277pub fn read_plan(paths: &EnsurePaths) -> Result<Option<FleetEnsurePlan>, EnsureStateError> {
278    let Some(bytes) = read_document_bytes(&paths.plan)? else {
279        return Ok(None);
280    };
281    let mut projection: serde_json::Value =
282        serde_json::from_slice(&bytes).map_err(|source| EnsureStateError::Decode {
283            path: paths.plan.clone(),
284            source,
285        })?;
286    plan_content::hydrate(paths, &mut projection)?;
287    let value: FleetEnsurePlan =
288        serde_json::from_value(projection).map_err(|source| EnsureStateError::Decode {
289            path: paths.plan.clone(),
290            source,
291        })?;
292    validate_schema(Some(value), &paths.plan, |record| record.schema_version)
293}
294
295pub(crate) fn compact_inline_plan(
296    paths: &EnsurePaths,
297    plan: &FleetEnsurePlan,
298) -> Result<bool, EnsureStateError> {
299    let Some(bytes) = read_document_bytes(&paths.plan)? else {
300        return Ok(false);
301    };
302    let projection: serde_json::Value =
303        serde_json::from_slice(&bytes).map_err(|source| EnsureStateError::Decode {
304            path: paths.plan.clone(),
305            source,
306        })?;
307    if !plan_content::contains_inline_bytes(&projection)? {
308        return Ok(false);
309    }
310    write_plan(paths, plan)?;
311    Ok(true)
312}
313
314pub fn read_state(
315    paths: &EnsurePaths,
316    fleet: &str,
317) -> Result<FleetEnsureStateRecord, EnsureStateError> {
318    let value: Option<FleetEnsureStateRecord> = read_current(&paths.state)?;
319    let value = validate_schema(value, &paths.state, |record| record.schema_version)?;
320    if let Some(record) = &value
321        && record.fleet != fleet
322    {
323        return Err(EnsureStateError::FleetMismatch {
324            actual: record.fleet.clone(),
325            expected: fleet.to_string(),
326            path: paths.state.clone(),
327        });
328    }
329    Ok(value.unwrap_or_else(|| FleetEnsureStateRecord {
330        active_registry: None,
331        completed_reinstalls: BTreeMap::default(),
332        fleet: fleet.to_string(),
333        pending_principals: BTreeMap::default(),
334        principals: BTreeMap::default(),
335        retained_cycles_by_principal: BTreeMap::default(),
336        schema_version: FLEET_ENSURE_SCHEMA_VERSION,
337        topology: BTreeMap::default(),
338    }))
339}
340
341/// Resolve current desired artifact identities outside pure policy code.
342pub fn resolve_desired_artifacts(
343    root: &Path,
344    desired: &DesiredFleet,
345) -> Result<DesiredFleetArtifacts, EnsureStateError> {
346    let mut artifacts = DesiredFleetArtifacts::default();
347    for canister in &desired.canisters {
348        if let Some(wasm) = &canister.wasm {
349            artifacts
350                .wasm_sha256_by_canister
351                .insert(canister.name.clone(), artifact_sha256(root, wasm)?);
352        }
353        if let Some(init_arg) = &canister.init_arg {
354            artifacts
355                .init_arg_sha256_by_canister
356                .insert(canister.name.clone(), artifact_sha256(root, init_arg)?);
357        }
358        if let Some(init_candid) = &canister.init_candid {
359            artifacts
360                .init_candid_sha256_by_canister
361                .insert(canister.name.clone(), artifact_sha256(root, init_candid)?);
362        }
363        if let Some(drain) = &canister.drain {
364            artifacts
365                .drain_candid_sha256_by_canister
366                .insert(canister.name.clone(), artifact_sha256(root, &drain.candid)?);
367        }
368    }
369    for step in &desired.protocol_steps {
370        artifacts.protocol_by_step.insert(
371            step.name.clone(),
372            ProtocolArtifactDigests {
373                candid_sha256: artifact_sha256(root, &step.candid)?,
374                command_args_sha256: artifact_sha256(root, &step.command_args)?,
375                expected_status_sha256: artifact_sha256(root, &step.expected_status)?,
376                status_args_sha256: artifact_sha256(root, &step.status_args)?,
377            },
378        );
379    }
380    Ok(artifacts)
381}
382
383fn artifact_sha256(root: &Path, configured: &str) -> Result<String, EnsureStateError> {
384    let configured = Path::new(configured);
385    let path = if configured.is_absolute() {
386        configured.to_path_buf()
387    } else {
388        root.join(configured)
389    };
390    if !path.is_file() {
391        return Err(EnsureStateError::ArtifactUnavailable { path });
392    }
393    let bytes = std::fs::read(&path).map_err(|source| EnsureStateError::ArtifactRead {
394        path: path.clone(),
395        source,
396    })?;
397    Ok(sha256_hex(&bytes))
398}
399
400pub fn write_journal(
401    paths: &EnsurePaths,
402    journal: &FleetEnsureJournalRecord,
403) -> Result<(), EnsureStateError> {
404    write_current(&paths.journal, journal)
405}
406
407pub fn write_plan(paths: &EnsurePaths, plan: &FleetEnsurePlan) -> Result<(), EnsureStateError> {
408    plan_content::retain(paths, plan)?;
409    let mut projection =
410        super::json::to_value(plan).map_err(|source| EnsureStateError::Decode {
411            path: paths.plan.clone(),
412            source,
413        })?;
414    plan_content::remove_inline_bytes(&mut projection)?;
415    let bytes =
416        serde_json::to_vec_pretty(&projection).map_err(|source| EnsureStateError::Decode {
417            path: paths.plan.clone(),
418            source,
419        })?;
420    write_bytes(&paths.plan, &bytes).map_err(|source| EnsureStateError::Io {
421        path: paths.plan.clone(),
422        source,
423    })
424}
425
426pub fn write_state(
427    paths: &EnsurePaths,
428    state: &FleetEnsureStateRecord,
429) -> Result<(), EnsureStateError> {
430    write_current(&paths.state, state)
431}
432
433#[must_use]
434/// Hash one exact effect action.
435///
436/// # Panics
437///
438/// Panics only if the maintained action enum stops being JSON serializable.
439pub fn action_sha256(action: &EnsureAction) -> String {
440    sha256_hex(&super::json::to_vec(action).expect("ensure action is JSON serializable"))
441}
442
443fn read_current<T>(path: &Path) -> Result<Option<T>, EnsureStateError>
444where
445    T: DeserializeOwned,
446{
447    let Some(bytes) = read_document_bytes(path)? else {
448        return Ok(None);
449    };
450    serde_json::from_slice(&bytes)
451        .map(Some)
452        .map_err(|source| EnsureStateError::Decode {
453            path: path.to_path_buf(),
454            source,
455        })
456}
457
458fn read_document_bytes(path: &Path) -> Result<Option<Vec<u8>>, EnsureStateError> {
459    match read_optional_regular_bytes(path) {
460        Ok(bytes) => Ok(bytes),
461        Err(RegularFileReadError::NotRegular) => Err(EnsureStateError::Unsafe {
462            path: path.to_path_buf(),
463        }),
464        Err(RegularFileReadError::Io(source)) => Err(EnsureStateError::Io {
465            path: path.to_path_buf(),
466            source,
467        }),
468        #[cfg(not(unix))]
469        Err(RegularFileReadError::UnsupportedPlatform) => Err(EnsureStateError::Unsafe {
470            path: path.to_path_buf(),
471        }),
472    }
473}
474
475fn write_current(path: &Path, value: &impl Serialize) -> Result<(), EnsureStateError> {
476    let bytes = serde_json::to_vec_pretty(value).map_err(|source| EnsureStateError::Decode {
477        path: path.to_path_buf(),
478        source,
479    })?;
480    write_bytes(path, &bytes).map_err(|source| EnsureStateError::Io {
481        path: path.to_path_buf(),
482        source,
483    })
484}
485
486fn validate_schema<T>(
487    value: Option<T>,
488    path: &Path,
489    schema: impl Fn(&T) -> u16,
490) -> Result<Option<T>, EnsureStateError> {
491    if let Some(record) = value.as_ref()
492        && schema(record) != FLEET_ENSURE_SCHEMA_VERSION
493    {
494        return Err(EnsureStateError::WrongSchema {
495            path: path.to_path_buf(),
496            actual: schema(record),
497        });
498    }
499    Ok(value)
500}