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
7pub mod attempt_recovery;
8mod bounded_observations;
9mod canic_init;
10pub mod capacity_import;
11
12pub(in crate::fleet_ensure) mod certified_custody;
13pub(super) mod clean_reinstall;
14pub(super) mod continuation;
15mod current_inventory;
16pub(super) mod current_protocol;
17pub(super) mod effect_preparation;
18pub(super) mod funding;
19pub(super) mod funding_observation;
20pub mod independent_effects;
21pub mod infrastructure_bootstrap;
22mod install_history;
23pub(super) mod operation_selection;
24pub mod operator_mint;
25mod plan_content;
26mod platform;
27pub(super) mod progress;
28mod protocol;
29pub(super) mod readiness;
30pub(super) mod recovery;
31pub(super) mod reinstall;
32pub mod release;
33pub mod retained_contract;
34pub(super) mod startup_funding;
35pub(super) mod terminal;
36
37use crate::{
38    MAX_DOCUMENT_READ_BYTES,
39    fleet_ensure::model::{
40        DesiredCanisterKind, DesiredFleet, DesiredFleetArtifacts, EffectRecord, EnsureAction,
41        FLEET_ENSURE_SCHEMA_VERSION, FleetEnsureJournalRecord, FleetEnsurePlan,
42        FleetEnsureStateRecord, FleetObservation, ProtocolArtifactDigests,
43        RetainedRootStartAuthorityRecord, RootManagementObservation, RootOwnedCanisterLifecycle,
44    },
45};
46use canic_contracts::{cycles::Cycles, dto::pool::CanisterPoolAssetStatus};
47use canic_core::cdk::utils::hash::sha256_hex;
48use ic_host_artifacts::artifact::ArtifactError;
49use ic_host_fs::{
50    durable::{RegularFileLockError, lock_regular_file_with_parents, write_bytes},
51    read::read_optional_file_no_follow,
52};
53use serde::{Serialize, de::DeserializeOwned};
54use std::{
55    collections::{BTreeMap, BTreeSet},
56    fs::File,
57    io,
58    path::{Path, PathBuf},
59};
60use thiserror::Error as ThisError;
61
62#[cfg(feature = "local-fleet")]
63pub(crate) use canic_init::compile_root_authorities;
64#[cfg(test)]
65pub(in crate::fleet_ensure) use canic_init::tests::qualify_release_input_reuse;
66#[cfg(feature = "local-fleet")]
67pub use canic_init::{CanicInitError, CanicInitRequest, compile_arguments};
68#[cfg(test)]
69pub(crate) use platform::install_effect_applied;
70pub(crate) use platform::{
71    EstateFundingObservation, NativeFundingObservation, estate_funding_applied,
72    native_funding_applied,
73};
74pub use platform::{IcpEnsurePlatform, IcpEnsurePlatformError};
75
76/// Decode the reviewed bounds for Create execution and its first live observation.
77pub(crate) fn maximum_creation_observation_burn(desired: &DesiredFleet) -> Option<u128> {
78    let observation = desired
79        .maximum_observation_burn_cycles
80        .parse::<Cycles>()
81        .ok()?
82        .to_u128();
83    let update = desired
84        .maximum_update_burn_cycles
85        .parse::<Cycles>()
86        .ok()?
87        .to_u128();
88    crate::fleet_ensure::model::creation_observation_burn(observation, update)
89}
90
91pub(crate) const fn root_owned_lifecycle(
92    kind: DesiredCanisterKind,
93    status: &CanisterPoolAssetStatus,
94) -> Option<RootOwnedCanisterLifecycle> {
95    match kind {
96        DesiredCanisterKind::Store if matches!(status, CanisterPoolAssetStatus::Store) => {
97            Some(RootOwnedCanisterLifecycle::Store)
98        }
99        DesiredCanisterKind::Pool => match status {
100            CanisterPoolAssetStatus::Ready => Some(RootOwnedCanisterLifecycle::Idle),
101            CanisterPoolAssetStatus::PendingReset | CanisterPoolAssetStatus::Failed { .. } => {
102                Some(RootOwnedCanisterLifecycle::Reconciling)
103            }
104            CanisterPoolAssetStatus::Claimed { .. } => Some(RootOwnedCanisterLifecycle::Claimed),
105            CanisterPoolAssetStatus::Workload { .. } => Some(RootOwnedCanisterLifecycle::Workload),
106            _ => None,
107        },
108        _ => None,
109    }
110}
111
112/// Successful evidence returned by one exact effect or exact replay.
113
114#[derive(Clone, Debug, Eq, PartialEq)]
115pub struct EffectOutcome {
116    pub created_principal: Option<String>,
117    pub post_cycles: Option<u128>,
118    pub receipt: Option<String>,
119}
120
121/// One exact live observation of whether an issued effect reached its terminal state.
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct EffectObservation {
124    /// Informational progress only; never used for completion or retry authority.
125    pub provisioning_progress: Option<crate::fleet_ensure::dto::FleetProvisioningProgress>,
126    /// Latest protected failure, excluded from work-progress identity.
127    pub provisioning_failure:
128        Option<canic_contracts::dto::component_provisioning::FleetComponentProvisioningRootFailure>,
129    pub applied: bool,
130    /// Exact Root-owned Cycles Ledger pause returned by current Component provisioning.
131    pub estate_funding_required:
132        Option<canic_contracts::dto::component_provisioning::RootEstateFundingRequired>,
133    /// Exact live source balance observed while reconciling this effect.
134    ///
135    /// This is populated only when the terminal predicate itself owns a
136    /// stronger observation than the ordinary inventory surface.
137    pub post_cycles: Option<u128>,
138    pub progress_identity: String,
139    pub retry: EffectRetry,
140}
141
142/// Typed observation-owned authority to replay one exact retained issued command.
143#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub enum EffectRetry {
145    None,
146    /// The protected Ready reserve is incomplete; continue the bounded Root-owned refill.
147    ContinuePoolMaintenance,
148    /// A created Root-owned canister is intentionally not observable by the
149    /// operator until the exact Root installation later in this same plan.
150    /// Continue the reviewed prerequisites, then revisit this issued effect.
151    DeferUntilControllerObservation,
152    /// The exact created Principal and Ledger receipt are retained, but the
153    /// first live balance is outside the reviewed target and observation-burn
154    /// bound. Close the immutable operation before any later action.
155    ReplanRequiredAfterCreateBalanceDrift,
156    ReplayExactIssuedCommand,
157}
158
159/// Complete verified terminal projection published by one current protocol owner.
160#[derive(Clone, Debug, Default, Eq, PartialEq)]
161pub struct TerminalFleetInventory {
162    pub active_registry: Option<canic_contracts::dto::fleet_registry::FleetRegistry>,
163    pub controlled_cycles_by_principal: BTreeMap<String, u128>,
164    pub entries: Vec<crate::registry::RegistryEntry>,
165}
166
167/// Exact current infrastructure status and sealed physical reset closure.
168#[derive(Clone, Debug)]
169pub struct FleetReinstallObservation {
170    pub authorities:
171        BTreeMap<String, crate::fleet_ensure::model::RootManagementCanisterObservation>,
172    pub assets: Vec<crate::fleet_ensure::model::FleetReinstallAssetRecord>,
173    pub observation: FleetObservation,
174}
175
176/// Installed inactive-activation receipts and the complete physical source estate.
177#[derive(Clone, Debug)]
178pub struct FleetActivationResetObservation {
179    pub inventory: FleetReinstallObservation,
180    pub roots: Vec<crate::fleet_ensure::model::RootActivationResetRecord>,
181}
182
183/// The authority evidence required at a physical-pool verification boundary.
184#[derive(Clone, Copy, Debug, Eq, PartialEq)]
185pub enum ReinstallAssetCheck {
186    /// Preserve the captured module and controllers before resetting Root records.
187    BeforeReset,
188    /// Preserve controllers after pool roles and current modules are reassigned.
189    Terminal,
190}
191
192/// Platform boundary used by the workflow and deterministic test adapters.
193pub trait EnsurePlatform {
194    type Error: std::error::Error + Send + Sync + 'static;
195
196    /// Inspect explicitly supplied infrastructure without querying uninitialized Root protocols.
197    fn infrastructure_bootstrap_observation(
198        &mut self,
199        _record: &crate::fleet_ensure::model::infrastructure_bootstrap::InfrastructureBootstrapRecord,
200        _state: &FleetEnsureStateRecord,
201    ) -> Result<
202        Option<
203            crate::fleet_ensure::view::infrastructure_bootstrap::InfrastructureBootstrapObservation,
204        >,
205        Self::Error,
206    > {
207        Ok(None)
208    }
209
210    /// Reuse observations only within one read-only planning transaction. Adapters
211    /// must expire evidence on exit, retries, changed inputs and before any effect.
212    fn with_planning_observations<T, E>(
213        &mut self,
214        observe: impl FnOnce(&mut Self) -> Result<T, E>,
215    ) -> Result<T, E>
216    where
217        Self: Sized,
218    {
219        observe(self)
220    }
221
222    /// Share fresh pre-intent reads for one action; never retain them across submission.
223    fn with_preparation_observations<T>(
224        &mut self,
225        _action: &EnsureAction,
226        observe: impl FnOnce(&mut Self) -> Result<T, Self::Error>,
227    ) -> Result<T, Self::Error>
228    where
229        Self: Sized,
230    {
231        observe(self)
232    }
233
234    /// Share Store catalog reads within one independent-effect reconciliation pass.
235    /// Expire them on exit and before submission; balances remain fresh per effect.
236    fn with_independent_observations<T, E>(
237        &mut self,
238        observe: impl FnOnce(&mut Self) -> Result<T, E>,
239    ) -> Result<T, E>
240    where
241        Self: Sized,
242    {
243        observe(self)
244    }
245
246    /// Measure an existing activity without changing its result or owning its effects.
247    fn with_activity<T, E>(
248        &mut self,
249        _stage: crate::fleet_ensure::dto::FleetObservationStage,
250        activity: impl FnOnce(&mut Self) -> Result<T, E>,
251    ) -> Result<T, E>
252    where
253        Self: Sized,
254    {
255        activity(self)
256    }
257
258    /// Report informational progress without changing operation authority or effects.
259    fn report_progress(&mut self, _progress: crate::fleet_ensure::dto::FleetEnsureProgress) {}
260
261    /// Reinspect the reviewed pool closure immediately before its Root loses old records.
262    fn reinstall_assets_match(
263        &mut self,
264        _intent: &crate::fleet_ensure::model::FleetReinstallRecord,
265        _root: &str,
266        _check: ReinstallAssetCheck,
267    ) -> Result<bool, Self::Error> {
268        Ok(false)
269    }
270
271    /// Observe exact management authority for all infrastructure reset targets.
272    fn reinstall_authorities(
273        &mut self,
274        _state: &FleetEnsureStateRecord,
275    ) -> Result<
276        Option<BTreeMap<String, crate::fleet_ensure::model::RootManagementCanisterObservation>>,
277        Self::Error,
278    > {
279        Ok(None)
280    }
281
282    /// Observe source activation receipts and every controlled physical asset before supersession.
283    fn activation_reset_inventory(
284        &mut self,
285        _source: &crate::fleet_ensure::model::FleetActivationSourceRecord,
286        _state: &FleetEnsureStateRecord,
287    ) -> Result<Option<FleetActivationResetObservation>, Self::Error> {
288        Ok(None)
289    }
290
291    /// Observe the exact retained physical closure through newly installed current Roots.
292    fn activation_reset_inventory_after_reset(
293        &mut self,
294        _intent: &crate::fleet_ensure::model::FleetReinstallRecord,
295        _state: &FleetEnsureStateRecord,
296    ) -> Result<Option<FleetReinstallObservation>, Self::Error> {
297        Ok(None)
298    }
299
300    /// Require exact retained Root activation authority before a dependent Store install.
301    /// A reviewed Root install in the same closure supplies its own new authority.
302    fn require_retained_root_activation(
303        &mut self,
304        _operation_id: &str,
305        _root: &str,
306        _state: &FleetEnsureStateRecord,
307    ) -> Result<(), Self::Error> {
308        Ok(())
309    }
310
311    /// Bind every observation and effect to the desired input retained by the
312    /// reviewed operation. Production adapters must replace any newer caller
313    /// input before resuming an in-progress journal.
314    fn bind_reviewed_desired(&mut self, desired: &DesiredFleet) -> Result<(), Self::Error>;
315
316    /// Bind original supplied custody for infrastructure initialization.
317    fn bind_infrastructure_bootstrap(
318        &mut self,
319        _source: &crate::fleet_ensure::model::infrastructure_bootstrap::InfrastructureBootstrapRecord,
320    ) -> Result<(), Self::Error> {
321        Ok(())
322    }
323
324    /// Bind fresh funding observations to the separately approved initialization targets.
325    fn bind_bootstrap_registration_recovery(
326        &mut self,
327        _review: &crate::fleet_ensure::model::infrastructure_bootstrap::registration_recovery::BootstrapRegistrationReviewRecord,
328    ) -> Result<bool, Self::Error> {
329        Ok(false)
330    }
331
332    /// Verify exact current Coordinator genesis before dependent infrastructure effects.
333    fn verify_bootstrap_coordinator(
334        &mut self,
335        _state: &FleetEnsureStateRecord,
336    ) -> Result<bool, Self::Error> {
337        Ok(false)
338    }
339
340    /// Verify completed infrastructure registration before publication permits pool import.
341    fn verify_bootstrap_registry(
342        &mut self,
343        _expected: &canic_contracts::dto::fleet_registry::FleetRegistry,
344    ) -> Result<bool, Self::Error> {
345        Ok(false)
346    }
347
348    /// Observe configured Roots through management authority only. Production
349    /// returns this evidence before any protected Root-owned child query.
350    fn observe_root_management(
351        &mut self,
352        _state: &FleetEnsureStateRecord,
353        _reviewed_targets: &std::collections::BTreeSet<String>,
354    ) -> Result<Option<RootManagementObservation>, Self::Error> {
355        Ok(None)
356    }
357
358    /// Read the operator's Cycles Ledger account and current fee without runtime queries.
359    fn observe_operator_funding(
360        &mut self,
361    ) -> Result<Option<crate::fleet_ensure::view::OperatorFundingObservation>, Self::Error> {
362        Ok(None)
363    }
364
365    /// Inspect one configured Root and the operator Ledger without runtime queries.
366    fn observe_native_funding(
367        &mut self,
368        _root: &str,
369        _state: &FleetEnsureStateRecord,
370    ) -> Result<Option<crate::fleet_ensure::model::NativeFundingObservation>, Self::Error> {
371        Ok(None)
372    }
373
374    fn observe(
375        &mut self,
376        operation_id: &str,
377        state: &FleetEnsureStateRecord,
378    ) -> Result<FleetObservation, Self::Error>;
379
380    /// Compile current Canic control-plane work from protected roles, topology,
381    /// and live Registry evidence. Generic applications have no such work.
382    fn protocol_actions(
383        &mut self,
384        _operation_id: &str,
385        _state: &FleetEnsureStateRecord,
386    ) -> Result<Vec<EnsureAction>, Self::Error> {
387        Ok(Vec::new())
388    }
389
390    /// Expand the complete fresh protocol from reviewed init inputs, without remote effects.
391    fn fresh_protocol_actions(
392        &mut self,
393        _operation_id: &str,
394        _state: &FleetEnsureStateRecord,
395    ) -> Result<Vec<EnsureAction>, Self::Error> {
396        Ok(Vec::new())
397    }
398
399    /// Expand only Store and Registry setup from reviewed initialization authority.
400    fn infrastructure_protocol_actions(
401        &mut self,
402        _operation_id: &str,
403        _state: &FleetEnsureStateRecord,
404    ) -> Result<Vec<EnsureAction>, Self::Error> {
405        Ok(Vec::new())
406    }
407
408    /// Return one complete verified live inventory after the typed protocol has
409    /// no remaining transition. The workflow persists this derived projection
410    /// only after every reviewed effect is terminal.
411    fn terminal_inventory(
412        &mut self,
413        _operation_id: &str,
414        _state: &FleetEnsureStateRecord,
415    ) -> Result<TerminalFleetInventory, Self::Error> {
416        Ok(TerminalFleetInventory::default())
417    }
418
419    /// Observe completion and its exact live balance. Native funding must return
420    /// its protected balance in `post_cycles`, including before the first intent;
421    /// a record without a receipt cannot establish paid completion.
422    fn observe_effect(
423        &mut self,
424        operation_id: &str,
425        action: &EnsureAction,
426        record: &EffectRecord,
427        state: &FleetEnsureStateRecord,
428    ) -> Result<EffectObservation, Self::Error>;
429
430    fn action_cycles(
431        &mut self,
432        action: &EnsureAction,
433        state: &FleetEnsureStateRecord,
434    ) -> Result<Option<u128>, Self::Error>;
435
436    fn action_destination_cycles(
437        &mut self,
438        action: &EnsureAction,
439        state: &FleetEnsureStateRecord,
440    ) -> Result<Option<u128>, Self::Error>;
441
442    /// Return the exact management canister version before an install effect.
443    /// Other effect classes have no version boundary.
444    fn action_canister_version(
445        &mut self,
446        _action: &EnsureAction,
447        _state: &FleetEnsureStateRecord,
448    ) -> Result<Option<u64>, Self::Error> {
449        Ok(None)
450    }
451
452    /// Pace the next passive observation of one typed asynchronous effect.
453    ///
454    /// The workflow calls this only after retaining a valid nonterminal
455    /// observation. Production adapters wait without issuing an IC effect;
456    /// deterministic adapters may advance their simulated clock instead.
457    fn pace_effect_observation(
458        &mut self,
459        action: &EnsureAction,
460        consecutive_unchanged_observations: u32,
461    );
462
463    /// Pace re-observation of one retained Root-owned canister whose protected
464    /// lifecycle has not yet exposed its terminal live balance.
465    ///
466    /// This is observation-only: implementations must not submit a command,
467    /// allocate funding, or mutate the remote estate.
468    fn pace_root_owned_observation(
469        &mut self,
470        _target: &str,
471        _consecutive_retained_observations: u32,
472    ) {
473    }
474
475    fn apply(
476        &mut self,
477        operation_id: &str,
478        action: &EnsureAction,
479        record: &EffectRecord,
480        state: &FleetEnsureStateRecord,
481    ) -> Result<EffectOutcome, Self::Error>;
482
483    /// Submit admitted independent effects with durable intents and drain every result.
484    /// Results retain input order; a failed call must not discard sibling receipts.
485    /// An outer error is permitted only before submitting any of the calls.
486    fn apply_independent_effects(
487        &mut self,
488        operation_id: &str,
489        uploads: &[independent_effects::IndependentEffect<'_>],
490        state: &FleetEnsureStateRecord,
491    ) -> Result<Vec<Result<EffectOutcome, Self::Error>>, Self::Error> {
492        Ok(uploads
493            .iter()
494            .map(|upload| self.apply(operation_id, upload.action, upload.record, state))
495            .collect())
496    }
497}
498
499/// Current Fleet ensure state paths. Historical install directories are never read.
500
501#[derive(Clone, Debug, Eq, PartialEq)]
502pub struct EnsurePaths {
503    pub workspace: PathBuf,
504    pub content: PathBuf,
505    pub journal: PathBuf,
506    pub lock: PathBuf,
507    pub plan: PathBuf,
508    pub root_start_authority: PathBuf,
509    pub state: PathBuf,
510}
511
512impl EnsurePaths {
513    #[must_use]
514    pub fn under(root: &Path, environment: &str, fleet: &str) -> Self {
515        let directory = root
516            .join(".canic")
517            .join("fleet-ensure")
518            .join(environment)
519            .join(fleet);
520        Self {
521            workspace: root.to_path_buf(),
522            content: root
523                .join(".canic")
524                .join("fleet-ensure")
525                .join("objects")
526                .join("sha256"),
527            journal: directory.join("journal.json"),
528            lock: directory.join("operation.lock"),
529            plan: directory.join("plan.json"),
530            root_start_authority: directory.join("root-start-authority.json"),
531            state: directory.join("state.json"),
532        }
533    }
534}
535
536/// Durable current-state I/O failure.
537
538#[derive(Debug, ThisError)]
539pub enum EnsureStateError {
540    #[error("reset requires reconciliation of the uncertain paid effect at {} ({effect}); preserve its request and receipt evidence before replacing this installation", path.display())]
541    ResetUncertainEffect { path: PathBuf, effect: String },
542    #[error("unfinished Fleet release at {} retains this operation; resume it before Ensure or import", path.display())]
543    ReleaseInProgress { path: PathBuf },
544    #[error("reset review digest or cancellation evidence differs; preserve retained authority")]
545    ResetReviewConflict,
546    #[error("reset publication path {} differs from retained {}; keep the selected policy and inventory paths", selected.display(), retained.display())]
547    ResetInputConflict {
548        retained: PathBuf,
549        selected: PathBuf,
550    },
551
552    #[error(
553        "reset review cancellation {plan_sha256} is incomplete; resume fleet ensure --cancel-reinstall with that exact digest"
554    )]
555    ResetReviewCancellationPending { plan_sha256: String },
556
557    #[error("reset review has execution or side-operation evidence at {}; preserve it and resume its existing owner", path.display())]
558    ResetReviewEffectEvidence { path: PathBuf },
559
560    #[error("approved Fleet capacity import at {} must resume under its original authority before other Fleet operations", path.display())]
561    CapacityImportInProgress { path: PathBuf },
562
563    #[error("invalid Fleet capacity import journal at {}: {source}", path.display())]
564    CapacityImportJournal {
565        path: PathBuf,
566        #[source]
567        source: Box<capacity_import::journal::CapacityImportJournalError>,
568    },
569    #[error(
570        "retained terminal evidence is incomplete or inconsistent; preserve all source documents and paid-effect receipts"
571    )]
572    InvalidTerminalSource,
573
574    #[error("startup funding configuration differs from the reviewed deployment configuration")]
575    StartupConfigurationMismatch,
576
577    #[error("startup funding configuration is unavailable: {0}")]
578    StartupConfiguration(#[source] Box<crate::release_set::AppConfigError>),
579
580    #[error("startup funding requirement is invalid: {0}")]
581    StartupFunding(#[source] Box<crate::fleet_ensure::policy::EnsurePolicyError>),
582    #[error(
583        "retained activation source does not prove an Applied host prefix followed by Issued provisioning"
584    )]
585    InvalidActivationSource,
586
587    #[error(
588        "activation reset review or local adoption documents changed; preserve the retained evidence"
589    )]
590    ActivationResetAdoptionConflict,
591
592    #[error("Fleet ensure continuation authority is invalid: {reason}")]
593    ContinuationAuthority { reason: String },
594    #[error(
595        "Fleet ensure document is invalid at {}: {source}; preserve the Fleet directory, artifacts and paid-effect receipts; use current-build fleet ensure --reinstall with explicit physical inventory for replacement; genuinely uncertain paid effects require reconciliation, but predecessor application completion is not required; do not insert missing fields or delete journals; see docs/features/operations/fleet-ensure.md#unreadable-retained-plan",
596        path.display()
597    )]
598    Decode {
599        path: PathBuf,
600        #[source]
601        source: serde_json::Error,
602    },
603
604    #[error("Fleet ensure document is unsafe at {}", path.display())]
605    Unsafe { path: PathBuf },
606
607    #[error("Fleet ensure retained Root-start authority is invalid at {}", path.display())]
608    InvalidRootStartAuthority { path: PathBuf },
609
610    #[error("Fleet ensure document has unsupported schema {actual} at {}", path.display())]
611    WrongSchema { path: PathBuf, actual: u16 },
612
613    #[error("configured Fleet artifact is not a regular file: {}", path.display())]
614    ArtifactUnavailable { path: PathBuf },
615
616    #[error("failed to read configured Fleet artifact {}: {source}", path.display())]
617    ArtifactRead {
618        path: PathBuf,
619        #[source]
620        source: io::Error,
621    },
622
623    #[error("failed to access Fleet ensure state {}: {source}", path.display())]
624    Io {
625        path: PathBuf,
626        #[source]
627        source: io::Error,
628    },
629
630    #[error("Fleet ensure Store chunk authority is invalid: {reason}")]
631    StoreChunkAuthority { reason: String },
632
633    #[error("Fleet ensure Store chunk content is unavailable at {}", path.display())]
634    StoreChunkUnavailable { path: PathBuf },
635
636    #[error("Fleet ensure Store chunk content differs from its retained hash at {}", path.display())]
637    StoreChunkMismatch { path: PathBuf },
638
639    #[error("failed to lock Fleet ensure state {}", path.display())]
640    Lock { path: PathBuf },
641
642    #[error("Fleet ensure state at {} belongs to Fleet {actual}, expected {expected}", path.display())]
643    FleetMismatch {
644        actual: String,
645        expected: String,
646        path: PathBuf,
647    },
648}
649
650pub fn lock_operation(paths: &EnsurePaths) -> Result<File, EnsureStateError> {
651    let lock = lock_fleet_file(paths)?;
652    release::reservation::require_no_active_release(paths)?;
653    capacity_import::journal::require_no_approved_import(paths)?;
654    reinstall::adoption::recover(paths)?;
655    Ok(lock)
656}
657
658/// The capacity owner resumes its retained journal while holding the ordinary Fleet lock.
659fn lock_capacity_import_operation(paths: &EnsurePaths) -> Result<File, EnsureStateError> {
660    let lock = lock_fleet_file(paths)?;
661    release::reservation::require_no_active_release(paths)?;
662    Ok(lock)
663}
664
665fn lock_fleet_file(paths: &EnsurePaths) -> Result<File, EnsureStateError> {
666    let lock = lock_fleet_file_without_recovery(paths)?;
667    clean_reinstall::cancellation::require_no_pending(paths)?;
668    operation_selection::retirement::recover(paths)?;
669    Ok(lock)
670}
671
672/// Acquire only the lock inode so exact-digest cancellation can inspect its own intent first.
673pub(in crate::fleet_ensure::ops) fn lock_fleet_file_without_recovery(
674    paths: &EnsurePaths,
675) -> Result<File, EnsureStateError> {
676    let lock = lock_regular_file_with_parents(&paths.lock).map_err(|error| match error {
677        RegularFileLockError::Io(source) => EnsureStateError::Io {
678            path: paths.lock.clone(),
679            source,
680        },
681        RegularFileLockError::Publication(source) => EnsureStateError::Io {
682            path: paths.lock.clone(),
683            source: crate::publication::ops::io_error(source),
684        },
685        RegularFileLockError::NotRegular => EnsureStateError::Lock {
686            path: paths.lock.clone(),
687        },
688        #[cfg(windows)]
689        RegularFileLockError::UnsupportedPlatform => EnsureStateError::Lock {
690            path: paths.lock.clone(),
691        },
692    })?;
693    Ok(lock)
694}
695
696pub fn read_journal(
697    paths: &EnsurePaths,
698) -> Result<Option<FleetEnsureJournalRecord>, EnsureStateError> {
699    let Some(bytes) = read_document_bytes(&paths.journal)? else {
700        return Ok(None);
701    };
702    let journal = decode_journal(paths, &bytes)?;
703    validate_schema(Some(journal), &paths.journal, |record| {
704        record.schema_version
705    })
706}
707
708/// Resolve content references before any current or archived journal is interpreted.
709pub(super) fn decode_journal(
710    paths: &EnsurePaths,
711    bytes: &[u8],
712) -> Result<FleetEnsureJournalRecord, EnsureStateError> {
713    let decode = |source| EnsureStateError::Decode {
714        path: paths.journal.clone(),
715        source,
716    };
717    let mut projection: serde_json::Value = serde_json::from_slice(bytes).map_err(decode)?;
718    let mut journal: FleetEnsureJournalRecord = if let Some(review) = projection
719        .pointer_mut("/bootstrap_registration_recovery/review")
720        .filter(|review| !review.is_null())
721    {
722        plan_content::hydrate(paths, review)?;
723        serde_json::from_value(projection).map_err(decode)?
724    } else {
725        serde_json::from_slice(bytes).map_err(decode)?
726    };
727    continuation::hydrate_phases(paths, &mut journal.successor_phases)?;
728    Ok(journal)
729}
730
731pub fn read_plan(paths: &EnsurePaths) -> Result<Option<FleetEnsurePlan>, EnsureStateError> {
732    let Some(bytes) = read_document_bytes(&paths.plan)? else {
733        return Ok(None);
734    };
735    let mut projection: serde_json::Value =
736        serde_json::from_slice(&bytes).map_err(|source| EnsureStateError::Decode {
737            path: paths.plan.clone(),
738            source,
739        })?;
740    plan_content::hydrate(paths, &mut projection)?;
741    let value: FleetEnsurePlan =
742        serde_json::from_value(projection).map_err(|source| EnsureStateError::Decode {
743            path: paths.plan.clone(),
744            source,
745        })?;
746    validate_schema(Some(value), &paths.plan, |record| record.schema_version)
747}
748
749/// Load and verify the exact generator-owned retained Root-start authority, when present.
750pub(crate) fn read_root_start_authority(
751    paths: &EnsurePaths,
752) -> Result<Option<RetainedRootStartAuthorityRecord>, EnsureStateError> {
753    let value: Option<RetainedRootStartAuthorityRecord> =
754        read_current(&paths.root_start_authority)?;
755    let value = validate_schema(value, &paths.root_start_authority, |record| {
756        record.schema_version
757    })?;
758    value
759        .map(|record| {
760            valid_root_start_authority(&record)
761                .then_some(record)
762                .ok_or_else(|| EnsureStateError::InvalidRootStartAuthority {
763                    path: paths.root_start_authority.clone(),
764                })
765        })
766        .transpose()
767}
768
769/// Remove untyped inventory names superseded by a current configured binding.
770/// Unique dynamic assets and conflicting configured owners remain for verification.
771pub(crate) fn retain_configured_principal_bindings(state: &mut FleetEnsureStateRecord) {
772    let configured = state
773        .topology
774        .keys()
775        .filter_map(|name| state.principals.get(name).cloned())
776        .collect::<BTreeSet<_>>();
777    state.principals.retain(|name, principal| {
778        state.topology.contains_key(name) || !configured.contains(principal)
779    });
780}
781
782pub fn read_state(
783    paths: &EnsurePaths,
784    fleet: &str,
785) -> Result<FleetEnsureStateRecord, EnsureStateError> {
786    let value: Option<FleetEnsureStateRecord> = read_current(&paths.state)?;
787    let value = validate_schema(value, &paths.state, |record| record.schema_version)?;
788    if let Some(record) = &value
789        && record.fleet != fleet
790    {
791        return Err(EnsureStateError::FleetMismatch {
792            actual: record.fleet.clone(),
793            expected: fleet.to_string(),
794            path: paths.state.clone(),
795        });
796    }
797    Ok(value.unwrap_or_else(|| FleetEnsureStateRecord {
798        active_registry: None,
799        completed_reinstall_action_sha256: BTreeMap::default(),
800        completed_reinstall_operation_id: None,
801        completed_reinstalls: BTreeMap::default(),
802        fleet: fleet.to_string(),
803        pending_principals: BTreeMap::default(),
804        principals: BTreeMap::default(),
805        retained_cycles_by_principal: BTreeMap::default(),
806        schema_version: FLEET_ENSURE_SCHEMA_VERSION,
807        topology: BTreeMap::default(),
808    }))
809}
810
811/// Resolve current desired artifact identities outside pure policy code.
812pub fn resolve_desired_artifacts(
813    root: &Path,
814    desired: &DesiredFleet,
815) -> Result<DesiredFleetArtifacts, EnsureStateError> {
816    let mut startup_funding_by_root = startup_funding::resolve(root, desired)?;
817    let continuation =
818        continuation::resolve_authority(root, desired, &mut startup_funding_by_root)?;
819    let mut artifacts = DesiredFleetArtifacts {
820        continuation,
821        startup_funding_by_root,
822        ..DesiredFleetArtifacts::default()
823    };
824    for canister in &desired.canisters {
825        if let Some(wasm) = &canister.wasm {
826            artifacts
827                .wasm_sha256_by_canister
828                .insert(canister.name.clone(), artifact_sha256(root, wasm)?);
829        }
830        if let Some(init_arg) = &canister.init_arg {
831            artifacts
832                .init_arg_sha256_by_canister
833                .insert(canister.name.clone(), artifact_sha256(root, init_arg)?);
834        }
835        if let Some(init_candid) = &canister.init_candid {
836            artifacts
837                .init_candid_sha256_by_canister
838                .insert(canister.name.clone(), artifact_sha256(root, init_candid)?);
839        }
840        if let Some(drain) = &canister.drain {
841            artifacts
842                .drain_candid_sha256_by_canister
843                .insert(canister.name.clone(), artifact_sha256(root, &drain.candid)?);
844        }
845    }
846    for step in &desired.protocol_steps {
847        artifacts.protocol_by_step.insert(
848            step.name.clone(),
849            ProtocolArtifactDigests {
850                candid_sha256: artifact_sha256(root, &step.candid)?,
851                command_args_sha256: artifact_sha256(root, &step.command_args)?,
852                expected_status_sha256: artifact_sha256(root, &step.expected_status)?,
853                status_args_sha256: artifact_sha256(root, &step.status_args)?,
854            },
855        );
856    }
857    Ok(artifacts)
858}
859
860fn artifact_sha256(root: &Path, configured: &str) -> Result<String, EnsureStateError> {
861    let configured = Path::new(configured);
862    let path = if configured.is_absolute() {
863        configured.to_path_buf()
864    } else {
865        root.join(configured)
866    };
867    if !path.is_file() {
868        return Err(EnsureStateError::ArtifactUnavailable { path });
869    }
870    let bytes = std::fs::read(&path).map_err(|source| EnsureStateError::ArtifactRead {
871        path: path.clone(),
872        source,
873    })?;
874    Ok(sha256_hex(&bytes))
875}
876
877/// Consume reviewed publication authority in memory before workflow persists and issues it.
878/// A failed persistence prevents the effect; uncertain persistence conservatively consumes it.
879pub(crate) const fn reserve_fixture_publication_attempt(
880    record: &mut EffectRecord,
881    maximum_attempts: u32,
882) -> bool {
883    if record.publication_attempts >= maximum_attempts {
884        return false;
885    }
886    record.publication_attempts += 1;
887    true
888}
889
890pub fn write_journal(
891    paths: &EnsurePaths,
892    journal: &FleetEnsureJournalRecord,
893) -> Result<(), EnsureStateError> {
894    if journal
895        .bootstrap_registration_recovery
896        .as_ref()
897        .is_some_and(|record| record.review.is_some())
898    {
899        let projection = infrastructure_bootstrap::registration_recovery::journal_projection(
900            journal,
901        )
902        .map_err(|source| EnsureStateError::Decode {
903            path: paths.journal.clone(),
904            source,
905        })?;
906        return write_current(&paths.journal, &projection);
907    }
908    write_current(&paths.journal, journal)
909}
910
911pub fn write_plan(paths: &EnsurePaths, plan: &FleetEnsurePlan) -> Result<(), EnsureStateError> {
912    plan_content::retain(paths, plan)?;
913    let mut projection =
914        super::json::to_value(plan).map_err(|source| EnsureStateError::Decode {
915            path: paths.plan.clone(),
916            source,
917        })?;
918    plan_content::remove_inline_bytes(&mut projection)?;
919    let bytes =
920        serde_json::to_vec_pretty(&projection).map_err(|source| EnsureStateError::Decode {
921            path: paths.plan.clone(),
922            source,
923        })?;
924    write_bytes(&paths.plan, &bytes)
925        .map_err(crate::publication::ops::io_error)
926        .map_err(|source| EnsureStateError::Io {
927            path: paths.plan.clone(),
928            source,
929        })
930}
931
932/// Atomically retain one complete generator-owned Root-start authority.
933pub(crate) fn write_root_start_authority(
934    paths: &EnsurePaths,
935    mut authority: RetainedRootStartAuthorityRecord,
936) -> Result<RetainedRootStartAuthorityRecord, EnsureStateError> {
937    authority.seal();
938    write_current(&paths.root_start_authority, &authority)?;
939    read_root_start_authority(paths)?.ok_or_else(|| EnsureStateError::InvalidRootStartAuthority {
940        path: paths.root_start_authority.clone(),
941    })
942}
943
944fn is_sha256(value: &str) -> bool {
945    value.len() == 64
946        && value
947            .bytes()
948            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
949}
950
951fn valid_root_start_authority(authority: &RetainedRootStartAuthorityRecord) -> bool {
952    if authority.roots.is_empty()
953        || authority.roots.len() > crate::fleet_ensure::model::MAX_FLEET_ENSURE_CANISTERS
954        || !authority.has_valid_digest()
955    {
956        return false;
957    }
958    let mut names = BTreeSet::new();
959    let mut principals = BTreeSet::new();
960    authority.roots.iter().all(|root| {
961        let mut controllers = root.controllers.clone();
962        controllers.sort();
963        controllers.dedup();
964        !root.name.is_empty()
965            && !root.principal.is_empty()
966            && !root.subnet.is_empty()
967            && controllers == root.controllers
968            && !controllers.is_empty()
969            && names.insert(root.name.as_str())
970            && principals.insert(root.principal.as_str())
971            && is_sha256(&root.module_sha256)
972    })
973}
974
975pub fn write_state(
976    paths: &EnsurePaths,
977    state: &FleetEnsureStateRecord,
978) -> Result<(), EnsureStateError> {
979    write_current(&paths.state, state)
980}
981
982#[must_use]
983/// Hash one exact effect action.
984///
985/// # Panics
986///
987/// Panics only if the maintained action enum stops being JSON serializable.
988pub fn action_sha256(action: &EnsureAction) -> String {
989    sha256_hex(&super::json::to_vec(action).expect("ensure action is JSON serializable"))
990}
991
992fn read_current<T>(path: &Path) -> Result<Option<T>, EnsureStateError>
993where
994    T: DeserializeOwned,
995{
996    let Some(bytes) = read_document_bytes(path)? else {
997        return Ok(None);
998    };
999    serde_json::from_slice(&bytes)
1000        .map(Some)
1001        .map_err(|source| EnsureStateError::Decode {
1002            path: path.to_path_buf(),
1003            source,
1004        })
1005}
1006
1007fn read_document_bytes(path: &Path) -> Result<Option<Vec<u8>>, EnsureStateError> {
1008    match read_optional_file_no_follow(path, MAX_DOCUMENT_READ_BYTES) {
1009        Ok(bytes) => Ok(bytes),
1010        Err(ArtifactError::NotRegularFile) => Err(EnsureStateError::Unsafe {
1011            path: path.to_path_buf(),
1012        }),
1013        Err(source) => Err(EnsureStateError::Io {
1014            path: path.to_path_buf(),
1015            source: source.into(),
1016        }),
1017    }
1018}
1019
1020fn write_current(path: &Path, value: &impl Serialize) -> Result<(), EnsureStateError> {
1021    let bytes = serde_json::to_vec_pretty(value).map_err(|source| EnsureStateError::Decode {
1022        path: path.to_path_buf(),
1023        source,
1024    })?;
1025    write_bytes(path, &bytes)
1026        .map_err(crate::publication::ops::io_error)
1027        .map_err(|source| EnsureStateError::Io {
1028            path: path.to_path_buf(),
1029            source,
1030        })
1031}
1032
1033fn validate_schema<T>(
1034    value: Option<T>,
1035    path: &Path,
1036    schema: impl Fn(&T) -> u16,
1037) -> Result<Option<T>, EnsureStateError> {
1038    if let Some(record) = value.as_ref()
1039        && schema(record) != FLEET_ENSURE_SCHEMA_VERSION
1040    {
1041        return Err(EnsureStateError::WrongSchema {
1042            path: path.to_path_buf(),
1043            actual: schema(record),
1044        });
1045    }
1046    Ok(value)
1047}