1pub 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
76pub(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#[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#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct EffectObservation {
124 pub provisioning_progress: Option<crate::fleet_ensure::dto::FleetProvisioningProgress>,
126 pub provisioning_failure:
128 Option<canic_contracts::dto::component_provisioning::FleetComponentProvisioningRootFailure>,
129 pub applied: bool,
130 pub estate_funding_required:
132 Option<canic_contracts::dto::component_provisioning::RootEstateFundingRequired>,
133 pub post_cycles: Option<u128>,
138 pub progress_identity: String,
139 pub retry: EffectRetry,
140}
141
142#[derive(Clone, Copy, Debug, Eq, PartialEq)]
144pub enum EffectRetry {
145 None,
146 ContinuePoolMaintenance,
148 DeferUntilControllerObservation,
152 ReplanRequiredAfterCreateBalanceDrift,
156 ReplayExactIssuedCommand,
157}
158
159#[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#[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#[derive(Clone, Debug)]
178pub struct FleetActivationResetObservation {
179 pub inventory: FleetReinstallObservation,
180 pub roots: Vec<crate::fleet_ensure::model::RootActivationResetRecord>,
181}
182
183#[derive(Clone, Copy, Debug, Eq, PartialEq)]
185pub enum ReinstallAssetCheck {
186 BeforeReset,
188 Terminal,
190}
191
192pub trait EnsurePlatform {
194 type Error: std::error::Error + Send + Sync + 'static;
195
196 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 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 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 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 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 fn report_progress(&mut self, _progress: crate::fleet_ensure::dto::FleetEnsureProgress) {}
260
261 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 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 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 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 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 fn bind_reviewed_desired(&mut self, desired: &DesiredFleet) -> Result<(), Self::Error>;
315
316 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 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 fn verify_bootstrap_coordinator(
334 &mut self,
335 _state: &FleetEnsureStateRecord,
336 ) -> Result<bool, Self::Error> {
337 Ok(false)
338 }
339
340 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 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 fn observe_operator_funding(
360 &mut self,
361 ) -> Result<Option<crate::fleet_ensure::view::OperatorFundingObservation>, Self::Error> {
362 Ok(None)
363 }
364
365 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 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 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 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 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 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 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 fn pace_effect_observation(
458 &mut self,
459 action: &EnsureAction,
460 consecutive_unchanged_observations: u32,
461 );
462
463 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 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#[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#[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
658fn 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
672pub(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
708pub(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
749pub(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
769pub(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
811pub 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
877pub(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
932pub(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]
983pub 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}