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 authority_seal;
8mod bounded_observations;
9mod canic_init;
10pub(super) mod continuation;
11mod current_inventory;
12pub(super) mod current_protocol;
13pub(super) mod effect_preparation;
14pub(super) mod funding;
15mod install_history;
16mod plan_content;
17mod platform;
18mod protocol;
19pub(super) mod recovery;
20pub(super) mod reinstall;
21pub(super) mod startup_funding;
22
23use crate::{
24    durable_io::{
25        RegularFileLockError, RegularFileReadError, lock_regular_file_with_parents,
26        read_optional_regular_bytes, write_bytes,
27    },
28    fleet_ensure::model::{
29        DesiredCanisterKind, DesiredFleet, DesiredFleetArtifacts, EffectRecord, EnsureAction,
30        FLEET_ENSURE_SCHEMA_VERSION, FleetEnsureJournalRecord, FleetEnsurePlan,
31        FleetEnsureStateRecord, FleetObservation, ProtocolArtifactDigests,
32        RetainedRootStartAuthorityRecord, RootManagementObservation, RootOwnedCanisterLifecycle,
33    },
34};
35use canic_core::{
36    cdk::{types::Cycles, utils::hash::sha256_hex},
37    dto::pool::CanisterPoolAssetStatus,
38};
39use serde::{Serialize, de::DeserializeOwned};
40use std::{
41    collections::{BTreeMap, BTreeSet},
42    fs::File,
43    io,
44    path::{Path, PathBuf},
45};
46use thiserror::Error as ThisError;
47
48#[cfg(feature = "local-fleet")]
49pub(crate) use canic_init::{CanicInitRequest, compile_arguments, compile_root_authorities};
50#[cfg(test)]
51pub(crate) use platform::install_effect_applied;
52pub(crate) use platform::{
53    EstateFundingObservation, NativeFundingObservation, estate_funding_applied,
54    native_funding_applied,
55};
56pub use platform::{IcpEnsurePlatform, IcpEnsurePlatformError};
57
58/// Decode the reviewed bounds for Create execution and its first live observation.
59pub(crate) fn maximum_creation_observation_burn(desired: &DesiredFleet) -> Option<u128> {
60    let observation = desired
61        .maximum_observation_burn_cycles
62        .parse::<Cycles>()
63        .ok()?
64        .to_u128();
65    let update = desired
66        .maximum_update_burn_cycles
67        .parse::<Cycles>()
68        .ok()?
69        .to_u128();
70    crate::fleet_ensure::model::creation_observation_burn(observation, update)
71}
72
73pub(crate) const fn root_owned_lifecycle(
74    kind: DesiredCanisterKind,
75    status: &CanisterPoolAssetStatus,
76) -> Option<RootOwnedCanisterLifecycle> {
77    match kind {
78        DesiredCanisterKind::Store if matches!(status, CanisterPoolAssetStatus::Store) => {
79            Some(RootOwnedCanisterLifecycle::Store)
80        }
81        DesiredCanisterKind::Pool => match status {
82            CanisterPoolAssetStatus::Ready => Some(RootOwnedCanisterLifecycle::Idle),
83            CanisterPoolAssetStatus::PendingReset | CanisterPoolAssetStatus::Failed { .. } => {
84                Some(RootOwnedCanisterLifecycle::Reconciling)
85            }
86            CanisterPoolAssetStatus::Claimed { .. } => Some(RootOwnedCanisterLifecycle::Claimed),
87            CanisterPoolAssetStatus::Workload { .. } => Some(RootOwnedCanisterLifecycle::Workload),
88            _ => None,
89        },
90        _ => None,
91    }
92}
93
94/// Successful evidence returned by one exact effect or exact replay.
95
96#[derive(Clone, Debug, Eq, PartialEq)]
97pub struct EffectOutcome {
98    pub created_principal: Option<String>,
99    pub post_cycles: Option<u128>,
100    pub receipt: Option<String>,
101}
102
103/// One exact live observation of whether an issued effect reached its terminal state.
104#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct EffectObservation {
106    /// Informational progress only; never used for completion or retry authority.
107    pub provisioning_progress: Option<crate::fleet_ensure::dto::FleetProvisioningProgress>,
108    /// Latest protected failure, excluded from work-progress identity.
109    pub provisioning_failure:
110        Option<canic_core::dto::component_provisioning::FleetComponentProvisioningRootFailure>,
111    pub applied: bool,
112    /// Exact Root-owned Cycles Ledger pause returned by current Component provisioning.
113    pub estate_funding_required:
114        Option<canic_core::dto::component_provisioning::RootEstateFundingRequired>,
115    /// Exact live source balance observed while reconciling this effect.
116    ///
117    /// This is populated only when the terminal predicate itself owns a
118    /// stronger observation than the ordinary inventory surface.
119    pub post_cycles: Option<u128>,
120    pub progress_identity: String,
121    pub retry: EffectRetry,
122}
123
124/// Typed observation-owned authority to replay one exact retained issued command.
125#[derive(Clone, Copy, Debug, Eq, PartialEq)]
126pub enum EffectRetry {
127    None,
128    /// The protected Ready reserve is incomplete; continue the bounded Root-owned refill.
129    ContinuePoolMaintenance,
130    /// A created Root-owned canister is intentionally not observable by the
131    /// operator until the exact Root installation later in this same plan.
132    /// Continue the reviewed prerequisites, then revisit this issued effect.
133    DeferUntilControllerObservation,
134    /// The exact created Principal and Ledger receipt are retained, but the
135    /// first live balance is outside the reviewed target and observation-burn
136    /// bound. Close the immutable operation before any later action.
137    ReplanRequiredAfterCreateBalanceDrift,
138    ReplayExactIssuedCommand,
139}
140
141/// Complete verified terminal projection published by one current protocol owner.
142#[derive(Clone, Debug, Default, Eq, PartialEq)]
143pub struct TerminalFleetInventory {
144    pub active_registry: Option<canic_core::dto::fleet_registry::FleetRegistry>,
145    pub controlled_cycles_by_principal: BTreeMap<String, u128>,
146    pub entries: Vec<crate::registry::RegistryEntry>,
147}
148
149/// Exact current infrastructure status and sealed physical reset closure.
150#[derive(Clone, Debug)]
151pub struct FleetReinstallObservation {
152    pub authorities:
153        BTreeMap<String, crate::fleet_ensure::model::RootManagementCanisterObservation>,
154    pub assets: Vec<crate::fleet_ensure::model::FleetReinstallAssetRecord>,
155    pub observation: FleetObservation,
156}
157
158/// Installed inactive-activation receipts and the complete physical source estate.
159#[derive(Clone, Debug)]
160pub struct FleetActivationResetObservation {
161    pub inventory: FleetReinstallObservation,
162    pub roots: Vec<crate::fleet_ensure::model::RootActivationResetRecord>,
163}
164
165/// The authority evidence required at a physical-pool verification boundary.
166#[derive(Clone, Copy, Debug, Eq, PartialEq)]
167pub enum ReinstallAssetCheck {
168    /// Preserve the captured module and controllers before resetting Root records.
169    BeforeReset,
170    /// Preserve controllers after pool roles and current modules are reassigned.
171    Terminal,
172}
173
174/// Platform boundary used by the workflow and deterministic test adapters.
175pub trait EnsurePlatform {
176    type Error: std::error::Error + Send + Sync + 'static;
177
178    /// Report informational progress without changing operation authority or effects.
179    fn report_progress(&mut self, _progress: crate::fleet_ensure::dto::FleetEnsureProgress) {}
180
181    /// Observe whether this exact operation owns the durable authority seal.
182    fn authority_sealed(
183        &mut self,
184        _operation_id: &str,
185        _action: &EnsureAction,
186    ) -> Result<bool, Self::Error> {
187        Ok(false)
188    }
189
190    /// Reinspect the reviewed pool closure immediately before its Root loses old records.
191    fn reinstall_assets_match(
192        &mut self,
193        _intent: &crate::fleet_ensure::model::FleetReinstallRecord,
194        _root: &str,
195        _check: ReinstallAssetCheck,
196    ) -> Result<bool, Self::Error> {
197        Ok(false)
198    }
199
200    /// Observe exact management authority for all infrastructure reset targets.
201    fn reinstall_authorities(
202        &mut self,
203        _state: &FleetEnsureStateRecord,
204    ) -> Result<
205        Option<BTreeMap<String, crate::fleet_ensure::model::RootManagementCanisterObservation>>,
206        Self::Error,
207    > {
208        Ok(None)
209    }
210
211    /// Capture the complete controlled pool after the existing authority seal.
212    fn reinstall_inventory(
213        &mut self,
214        _source_operation_id: &str,
215        _state: &FleetEnsureStateRecord,
216    ) -> Result<Option<FleetReinstallObservation>, Self::Error> {
217        Ok(None)
218    }
219
220    /// Observe source activation receipts and every controlled physical asset before supersession.
221    fn activation_reset_inventory(
222        &mut self,
223        _source: &crate::fleet_ensure::model::FleetActivationSourceRecord,
224        _state: &FleetEnsureStateRecord,
225    ) -> Result<Option<FleetActivationResetObservation>, Self::Error> {
226        Ok(None)
227    }
228
229    /// Observe the exact retained physical closure through newly installed current Roots.
230    fn activation_reset_inventory_after_reset(
231        &mut self,
232        _intent: &crate::fleet_ensure::model::FleetReinstallRecord,
233        _state: &FleetEnsureStateRecord,
234    ) -> Result<Option<FleetReinstallObservation>, Self::Error> {
235        Ok(None)
236    }
237
238    /// Require exact retained Root activation authority before a dependent Store install.
239    /// A reviewed Root install in the same closure supplies its own new authority.
240    fn require_retained_root_activation(
241        &mut self,
242        _operation_id: &str,
243        _root: &str,
244        _state: &FleetEnsureStateRecord,
245    ) -> Result<(), Self::Error> {
246        Ok(())
247    }
248
249    /// Bind every observation and effect to the desired input retained by the
250    /// reviewed operation. Production adapters must replace any newer caller
251    /// input before resuming an in-progress journal.
252    fn bind_reviewed_desired(&mut self, desired: &DesiredFleet) -> Result<(), Self::Error>;
253
254    /// Observe configured Roots through management authority only. Production
255    /// returns this evidence before any protected Root-owned child query.
256    fn observe_root_management(
257        &mut self,
258        _state: &FleetEnsureStateRecord,
259        _reviewed_targets: &std::collections::BTreeSet<String>,
260    ) -> Result<Option<RootManagementObservation>, Self::Error> {
261        Ok(None)
262    }
263
264    /// Read the operator's Cycles Ledger account and current fee without runtime queries.
265    fn observe_operator_funding(
266        &mut self,
267    ) -> Result<Option<crate::fleet_ensure::view::OperatorFundingObservation>, Self::Error> {
268        Ok(None)
269    }
270
271    /// Inspect one configured Root and the operator Ledger without runtime queries.
272    fn observe_native_funding(
273        &mut self,
274        _root: &str,
275        _state: &FleetEnsureStateRecord,
276    ) -> Result<Option<crate::fleet_ensure::model::NativeFundingObservation>, Self::Error> {
277        Ok(None)
278    }
279
280    fn observe(
281        &mut self,
282        operation_id: &str,
283        state: &FleetEnsureStateRecord,
284    ) -> Result<FleetObservation, Self::Error>;
285
286    /// Compile current Canic control-plane work from protected roles, topology,
287    /// and live Registry evidence. Generic applications have no such work.
288    fn protocol_actions(
289        &mut self,
290        _operation_id: &str,
291        _state: &FleetEnsureStateRecord,
292    ) -> Result<Vec<EnsureAction>, Self::Error> {
293        Ok(Vec::new())
294    }
295
296    /// Expand the complete fresh protocol from reviewed init inputs, without remote effects.
297    fn fresh_protocol_actions(
298        &mut self,
299        _operation_id: &str,
300        _state: &FleetEnsureStateRecord,
301    ) -> Result<Vec<EnsureAction>, Self::Error> {
302        Ok(Vec::new())
303    }
304
305    /// Return one complete verified live inventory after the typed protocol has
306    /// no remaining transition. The workflow persists this derived projection
307    /// only after every reviewed effect is terminal.
308    fn terminal_inventory(
309        &mut self,
310        _operation_id: &str,
311        _state: &FleetEnsureStateRecord,
312    ) -> Result<TerminalFleetInventory, Self::Error> {
313        Ok(TerminalFleetInventory::default())
314    }
315
316    /// Observe completion and its exact live balance. Native funding must return
317    /// its protected balance in `post_cycles`, including before the first intent;
318    /// a record without a receipt cannot establish paid completion.
319    fn observe_effect(
320        &mut self,
321        operation_id: &str,
322        action: &EnsureAction,
323        record: &EffectRecord,
324        state: &FleetEnsureStateRecord,
325    ) -> Result<EffectObservation, Self::Error>;
326
327    fn action_cycles(
328        &mut self,
329        action: &EnsureAction,
330        state: &FleetEnsureStateRecord,
331    ) -> Result<Option<u128>, Self::Error>;
332
333    fn action_destination_cycles(
334        &mut self,
335        action: &EnsureAction,
336        state: &FleetEnsureStateRecord,
337    ) -> Result<Option<u128>, Self::Error>;
338
339    /// Return the exact management canister version before an install effect.
340    /// Other effect classes have no version boundary.
341    fn action_canister_version(
342        &mut self,
343        _action: &EnsureAction,
344        _state: &FleetEnsureStateRecord,
345    ) -> Result<Option<u64>, Self::Error> {
346        Ok(None)
347    }
348
349    /// Pace the next passive observation of one typed asynchronous effect.
350    ///
351    /// The workflow calls this only after retaining a valid nonterminal
352    /// observation. Production adapters wait without issuing an IC effect;
353    /// deterministic adapters may advance their simulated clock instead.
354    fn pace_effect_observation(
355        &mut self,
356        action: &EnsureAction,
357        consecutive_unchanged_observations: u32,
358    );
359
360    /// Pace re-observation of one retained Root-owned canister whose protected
361    /// lifecycle has not yet exposed its terminal live balance.
362    ///
363    /// This is observation-only: implementations must not submit a command,
364    /// allocate funding, or mutate the remote estate.
365    fn pace_root_owned_observation(
366        &mut self,
367        _target: &str,
368        _consecutive_retained_observations: u32,
369    ) {
370    }
371
372    fn apply(
373        &mut self,
374        operation_id: &str,
375        action: &EnsureAction,
376        record: &EffectRecord,
377        state: &FleetEnsureStateRecord,
378    ) -> Result<EffectOutcome, Self::Error>;
379}
380
381/// Current Fleet ensure state paths. Historical install directories are never read.
382
383#[derive(Clone, Debug, Eq, PartialEq)]
384pub struct EnsurePaths {
385    pub workspace: PathBuf,
386    pub content: PathBuf,
387    pub journal: PathBuf,
388    pub lock: PathBuf,
389    pub plan: PathBuf,
390    pub root_start_authority: PathBuf,
391    pub state: PathBuf,
392}
393
394impl EnsurePaths {
395    #[must_use]
396    pub fn under(root: &Path, environment: &str, fleet: &str) -> Self {
397        let directory = root
398            .join(".canic")
399            .join("fleet-ensure")
400            .join(environment)
401            .join(fleet);
402        Self {
403            workspace: root.to_path_buf(),
404            content: root
405                .join(".canic")
406                .join("fleet-ensure")
407                .join("objects")
408                .join("sha256"),
409            journal: directory.join("journal.json"),
410            lock: directory.join("operation.lock"),
411            plan: directory.join("plan.json"),
412            root_start_authority: directory.join("root-start-authority.json"),
413            state: directory.join("state.json"),
414        }
415    }
416}
417
418/// Durable current-state I/O failure.
419
420#[derive(Debug, ThisError)]
421pub enum EnsureStateError {
422    #[error("startup funding configuration differs from the reviewed deployment configuration")]
423    StartupConfigurationMismatch,
424
425    #[error("startup funding configuration is unavailable: {0}")]
426    StartupConfiguration(#[source] Box<crate::release_set::AppConfigError>),
427
428    #[error("startup funding requirement is invalid: {0}")]
429    StartupFunding(#[source] Box<crate::fleet_ensure::policy::EnsurePolicyError>),
430    #[error(
431        "retained activation source does not prove an Applied host prefix followed by Issued provisioning"
432    )]
433    InvalidActivationSource,
434
435    #[error(
436        "activation reset review or local adoption documents changed; preserve the retained evidence"
437    )]
438    ActivationResetAdoptionConflict,
439
440    #[error("Fleet ensure continuation authority is invalid: {reason}")]
441    ContinuationAuthority { reason: String },
442    #[error("Fleet ensure document is invalid at {}: {source}", path.display())]
443    Decode {
444        path: PathBuf,
445        #[source]
446        source: serde_json::Error,
447    },
448
449    #[error("Fleet ensure document is unsafe at {}", path.display())]
450    Unsafe { path: PathBuf },
451
452    #[error("Fleet ensure retained Root-start authority is invalid at {}", path.display())]
453    InvalidRootStartAuthority { path: PathBuf },
454
455    #[error("Fleet ensure document has unsupported schema {actual} at {}", path.display())]
456    WrongSchema { path: PathBuf, actual: u16 },
457
458    #[error("configured Fleet artifact is not a regular file: {}", path.display())]
459    ArtifactUnavailable { path: PathBuf },
460
461    #[error("failed to read configured Fleet artifact {}: {source}", path.display())]
462    ArtifactRead {
463        path: PathBuf,
464        #[source]
465        source: io::Error,
466    },
467
468    #[error("failed to access Fleet ensure state {}: {source}", path.display())]
469    Io {
470        path: PathBuf,
471        #[source]
472        source: io::Error,
473    },
474
475    #[error("Fleet ensure Store chunk authority is invalid: {reason}")]
476    StoreChunkAuthority { reason: String },
477
478    #[error("Fleet ensure Store chunk content is unavailable at {}", path.display())]
479    StoreChunkUnavailable { path: PathBuf },
480
481    #[error("Fleet ensure Store chunk content differs from its retained hash at {}", path.display())]
482    StoreChunkMismatch { path: PathBuf },
483
484    #[error("failed to lock Fleet ensure state {}", path.display())]
485    Lock { path: PathBuf },
486
487    #[error("Fleet ensure state at {} belongs to Fleet {actual}, expected {expected}", path.display())]
488    FleetMismatch {
489        actual: String,
490        expected: String,
491        path: PathBuf,
492    },
493}
494
495pub fn lock_operation(paths: &EnsurePaths) -> Result<File, EnsureStateError> {
496    let lock = lock_regular_file_with_parents(&paths.lock).map_err(|error| match error {
497        RegularFileLockError::Io(source) => EnsureStateError::Io {
498            path: paths.lock.clone(),
499            source,
500        },
501        RegularFileLockError::NotRegular => EnsureStateError::Lock {
502            path: paths.lock.clone(),
503        },
504        #[cfg(windows)]
505        RegularFileLockError::UnsupportedPlatform => EnsureStateError::Lock {
506            path: paths.lock.clone(),
507        },
508    })?;
509    reinstall::adoption::recover(paths)?;
510    Ok(lock)
511}
512
513pub fn read_journal(
514    paths: &EnsurePaths,
515) -> Result<Option<FleetEnsureJournalRecord>, EnsureStateError> {
516    let mut value: Option<FleetEnsureJournalRecord> = read_current(&paths.journal)?;
517    if let Some(journal) = &mut value {
518        continuation::hydrate_phases(paths, journal)?;
519    }
520    validate_schema(value, &paths.journal, |record| record.schema_version)
521}
522
523pub fn read_plan(paths: &EnsurePaths) -> Result<Option<FleetEnsurePlan>, EnsureStateError> {
524    let Some(bytes) = read_document_bytes(&paths.plan)? else {
525        return Ok(None);
526    };
527    let mut projection: serde_json::Value =
528        serde_json::from_slice(&bytes).map_err(|source| EnsureStateError::Decode {
529            path: paths.plan.clone(),
530            source,
531        })?;
532    plan_content::hydrate(paths, &mut projection)?;
533    let value: FleetEnsurePlan =
534        serde_json::from_value(projection).map_err(|source| EnsureStateError::Decode {
535            path: paths.plan.clone(),
536            source,
537        })?;
538    validate_schema(Some(value), &paths.plan, |record| record.schema_version)
539}
540
541/// Load and verify the exact generator-owned retained Root-start authority, when present.
542pub(crate) fn read_root_start_authority(
543    paths: &EnsurePaths,
544) -> Result<Option<RetainedRootStartAuthorityRecord>, EnsureStateError> {
545    let value: Option<RetainedRootStartAuthorityRecord> =
546        read_current(&paths.root_start_authority)?;
547    let value = validate_schema(value, &paths.root_start_authority, |record| {
548        record.schema_version
549    })?;
550    value
551        .map(|record| {
552            valid_root_start_authority(&record)
553                .then_some(record)
554                .ok_or_else(|| EnsureStateError::InvalidRootStartAuthority {
555                    path: paths.root_start_authority.clone(),
556                })
557        })
558        .transpose()
559}
560
561pub(crate) fn compact_inline_plan(
562    paths: &EnsurePaths,
563    plan: &FleetEnsurePlan,
564) -> Result<bool, EnsureStateError> {
565    let Some(bytes) = read_document_bytes(&paths.plan)? else {
566        return Ok(false);
567    };
568    let projection: serde_json::Value =
569        serde_json::from_slice(&bytes).map_err(|source| EnsureStateError::Decode {
570            path: paths.plan.clone(),
571            source,
572        })?;
573    if !plan_content::contains_inline_bytes(&projection)? {
574        return Ok(false);
575    }
576    write_plan(paths, plan)?;
577    Ok(true)
578}
579
580/// Remove untyped inventory names superseded by a current configured binding.
581/// Unique dynamic assets and conflicting configured owners remain for verification.
582pub(crate) fn retain_configured_principal_bindings(state: &mut FleetEnsureStateRecord) {
583    let configured = state
584        .topology
585        .keys()
586        .filter_map(|name| state.principals.get(name).cloned())
587        .collect::<BTreeSet<_>>();
588    state.principals.retain(|name, principal| {
589        state.topology.contains_key(name) || !configured.contains(principal)
590    });
591}
592
593pub fn read_state(
594    paths: &EnsurePaths,
595    fleet: &str,
596) -> Result<FleetEnsureStateRecord, EnsureStateError> {
597    let value: Option<FleetEnsureStateRecord> = read_current(&paths.state)?;
598    let value = validate_schema(value, &paths.state, |record| record.schema_version)?;
599    if let Some(record) = &value
600        && record.fleet != fleet
601    {
602        return Err(EnsureStateError::FleetMismatch {
603            actual: record.fleet.clone(),
604            expected: fleet.to_string(),
605            path: paths.state.clone(),
606        });
607    }
608    Ok(value.unwrap_or_else(|| FleetEnsureStateRecord {
609        active_registry: None,
610        completed_reinstall_action_sha256: BTreeMap::default(),
611        completed_reinstall_operation_id: None,
612        completed_reinstalls: BTreeMap::default(),
613        fleet: fleet.to_string(),
614        pending_principals: BTreeMap::default(),
615        principals: BTreeMap::default(),
616        retained_cycles_by_principal: BTreeMap::default(),
617        schema_version: FLEET_ENSURE_SCHEMA_VERSION,
618        topology: BTreeMap::default(),
619    }))
620}
621
622/// Resolve current desired artifact identities outside pure policy code.
623pub fn resolve_desired_artifacts(
624    root: &Path,
625    desired: &DesiredFleet,
626) -> Result<DesiredFleetArtifacts, EnsureStateError> {
627    let mut startup_funding_by_root = startup_funding::resolve(root, desired)?;
628    let continuation =
629        continuation::resolve_authority(root, desired, &mut startup_funding_by_root)?;
630    let mut artifacts = DesiredFleetArtifacts {
631        continuation,
632        startup_funding_by_root,
633        ..DesiredFleetArtifacts::default()
634    };
635    for canister in &desired.canisters {
636        if let Some(wasm) = &canister.wasm {
637            artifacts
638                .wasm_sha256_by_canister
639                .insert(canister.name.clone(), artifact_sha256(root, wasm)?);
640        }
641        if let Some(init_arg) = &canister.init_arg {
642            artifacts
643                .init_arg_sha256_by_canister
644                .insert(canister.name.clone(), artifact_sha256(root, init_arg)?);
645        }
646        if let Some(init_candid) = &canister.init_candid {
647            artifacts
648                .init_candid_sha256_by_canister
649                .insert(canister.name.clone(), artifact_sha256(root, init_candid)?);
650        }
651        if let Some(drain) = &canister.drain {
652            artifacts
653                .drain_candid_sha256_by_canister
654                .insert(canister.name.clone(), artifact_sha256(root, &drain.candid)?);
655        }
656    }
657    for step in &desired.protocol_steps {
658        artifacts.protocol_by_step.insert(
659            step.name.clone(),
660            ProtocolArtifactDigests {
661                candid_sha256: artifact_sha256(root, &step.candid)?,
662                command_args_sha256: artifact_sha256(root, &step.command_args)?,
663                expected_status_sha256: artifact_sha256(root, &step.expected_status)?,
664                status_args_sha256: artifact_sha256(root, &step.status_args)?,
665            },
666        );
667    }
668    Ok(artifacts)
669}
670
671fn artifact_sha256(root: &Path, configured: &str) -> Result<String, EnsureStateError> {
672    let configured = Path::new(configured);
673    let path = if configured.is_absolute() {
674        configured.to_path_buf()
675    } else {
676        root.join(configured)
677    };
678    if !path.is_file() {
679        return Err(EnsureStateError::ArtifactUnavailable { path });
680    }
681    let bytes = std::fs::read(&path).map_err(|source| EnsureStateError::ArtifactRead {
682        path: path.clone(),
683        source,
684    })?;
685    Ok(sha256_hex(&bytes))
686}
687
688/// Consume reviewed publication authority in memory before workflow persists and issues it.
689/// A failed persistence prevents the effect; uncertain persistence conservatively consumes it.
690pub(crate) const fn reserve_fixture_publication_attempt(
691    record: &mut EffectRecord,
692    maximum_attempts: u32,
693) -> bool {
694    if record.publication_attempts >= maximum_attempts {
695        return false;
696    }
697    record.publication_attempts += 1;
698    true
699}
700
701pub fn write_journal(
702    paths: &EnsurePaths,
703    journal: &FleetEnsureJournalRecord,
704) -> Result<(), EnsureStateError> {
705    write_current(&paths.journal, journal)
706}
707
708pub fn write_plan(paths: &EnsurePaths, plan: &FleetEnsurePlan) -> Result<(), EnsureStateError> {
709    plan_content::retain(paths, plan)?;
710    let mut projection =
711        super::json::to_value(plan).map_err(|source| EnsureStateError::Decode {
712            path: paths.plan.clone(),
713            source,
714        })?;
715    plan_content::remove_inline_bytes(&mut projection)?;
716    let bytes =
717        serde_json::to_vec_pretty(&projection).map_err(|source| EnsureStateError::Decode {
718            path: paths.plan.clone(),
719            source,
720        })?;
721    write_bytes(&paths.plan, &bytes).map_err(|source| EnsureStateError::Io {
722        path: paths.plan.clone(),
723        source,
724    })
725}
726
727/// Atomically retain one complete generator-owned Root-start authority.
728pub(crate) fn write_root_start_authority(
729    paths: &EnsurePaths,
730    mut authority: RetainedRootStartAuthorityRecord,
731) -> Result<RetainedRootStartAuthorityRecord, EnsureStateError> {
732    authority.seal();
733    write_current(&paths.root_start_authority, &authority)?;
734    read_root_start_authority(paths)?.ok_or_else(|| EnsureStateError::InvalidRootStartAuthority {
735        path: paths.root_start_authority.clone(),
736    })
737}
738
739fn is_sha256(value: &str) -> bool {
740    value.len() == 64
741        && value
742            .bytes()
743            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
744}
745
746fn valid_root_start_authority(authority: &RetainedRootStartAuthorityRecord) -> bool {
747    if authority.roots.is_empty()
748        || authority.roots.len() > crate::fleet_ensure::model::MAX_FLEET_ENSURE_CANISTERS
749        || !authority.has_valid_digest()
750    {
751        return false;
752    }
753    let mut names = BTreeSet::new();
754    let mut principals = BTreeSet::new();
755    authority.roots.iter().all(|root| {
756        let mut controllers = root.controllers.clone();
757        controllers.sort();
758        controllers.dedup();
759        !root.name.is_empty()
760            && !root.principal.is_empty()
761            && !root.subnet.is_empty()
762            && controllers == root.controllers
763            && !controllers.is_empty()
764            && names.insert(root.name.as_str())
765            && principals.insert(root.principal.as_str())
766            && is_sha256(&root.module_sha256)
767    })
768}
769
770pub fn write_state(
771    paths: &EnsurePaths,
772    state: &FleetEnsureStateRecord,
773) -> Result<(), EnsureStateError> {
774    write_current(&paths.state, state)
775}
776
777#[must_use]
778/// Hash one exact effect action.
779///
780/// # Panics
781///
782/// Panics only if the maintained action enum stops being JSON serializable.
783pub fn action_sha256(action: &EnsureAction) -> String {
784    sha256_hex(&super::json::to_vec(action).expect("ensure action is JSON serializable"))
785}
786
787fn read_current<T>(path: &Path) -> Result<Option<T>, EnsureStateError>
788where
789    T: DeserializeOwned,
790{
791    let Some(bytes) = read_document_bytes(path)? else {
792        return Ok(None);
793    };
794    serde_json::from_slice(&bytes)
795        .map(Some)
796        .map_err(|source| EnsureStateError::Decode {
797            path: path.to_path_buf(),
798            source,
799        })
800}
801
802fn read_document_bytes(path: &Path) -> Result<Option<Vec<u8>>, EnsureStateError> {
803    match read_optional_regular_bytes(path) {
804        Ok(bytes) => Ok(bytes),
805        Err(RegularFileReadError::NotRegular) => Err(EnsureStateError::Unsafe {
806            path: path.to_path_buf(),
807        }),
808        Err(RegularFileReadError::Io(source)) => Err(EnsureStateError::Io {
809            path: path.to_path_buf(),
810            source,
811        }),
812        #[cfg(not(unix))]
813        Err(RegularFileReadError::UnsupportedPlatform) => Err(EnsureStateError::Unsafe {
814            path: path.to_path_buf(),
815        }),
816    }
817}
818
819fn write_current(path: &Path, value: &impl Serialize) -> Result<(), EnsureStateError> {
820    let bytes = serde_json::to_vec_pretty(value).map_err(|source| EnsureStateError::Decode {
821        path: path.to_path_buf(),
822        source,
823    })?;
824    write_bytes(path, &bytes).map_err(|source| EnsureStateError::Io {
825        path: path.to_path_buf(),
826        source,
827    })
828}
829
830fn validate_schema<T>(
831    value: Option<T>,
832    path: &Path,
833    schema: impl Fn(&T) -> u16,
834) -> Result<Option<T>, EnsureStateError> {
835    if let Some(record) = value.as_ref()
836        && schema(record) != FLEET_ENSURE_SCHEMA_VERSION
837    {
838        return Err(EnsureStateError::WrongSchema {
839            path: path.to_path_buf(),
840            actual: schema(record),
841        });
842    }
843    Ok(value)
844}