1mod 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
58pub(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#[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#[derive(Clone, Debug, Eq, PartialEq)]
105pub struct EffectObservation {
106 pub provisioning_progress: Option<crate::fleet_ensure::dto::FleetProvisioningProgress>,
108 pub provisioning_failure:
110 Option<canic_core::dto::component_provisioning::FleetComponentProvisioningRootFailure>,
111 pub applied: bool,
112 pub estate_funding_required:
114 Option<canic_core::dto::component_provisioning::RootEstateFundingRequired>,
115 pub post_cycles: Option<u128>,
120 pub progress_identity: String,
121 pub retry: EffectRetry,
122}
123
124#[derive(Clone, Copy, Debug, Eq, PartialEq)]
126pub enum EffectRetry {
127 None,
128 ContinuePoolMaintenance,
130 DeferUntilControllerObservation,
134 ReplanRequiredAfterCreateBalanceDrift,
138 ReplayExactIssuedCommand,
139}
140
141#[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#[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#[derive(Clone, Debug)]
160pub struct FleetActivationResetObservation {
161 pub inventory: FleetReinstallObservation,
162 pub roots: Vec<crate::fleet_ensure::model::RootActivationResetRecord>,
163}
164
165#[derive(Clone, Copy, Debug, Eq, PartialEq)]
167pub enum ReinstallAssetCheck {
168 BeforeReset,
170 Terminal,
172}
173
174pub trait EnsurePlatform {
176 type Error: std::error::Error + Send + Sync + 'static;
177
178 fn report_progress(&mut self, _progress: crate::fleet_ensure::dto::FleetEnsureProgress) {}
180
181 fn authority_sealed(
183 &mut self,
184 _operation_id: &str,
185 _action: &EnsureAction,
186 ) -> Result<bool, Self::Error> {
187 Ok(false)
188 }
189
190 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 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 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 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 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 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 fn bind_reviewed_desired(&mut self, desired: &DesiredFleet) -> Result<(), Self::Error>;
253
254 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 fn observe_operator_funding(
266 &mut self,
267 ) -> Result<Option<crate::fleet_ensure::view::OperatorFundingObservation>, Self::Error> {
268 Ok(None)
269 }
270
271 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 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 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 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 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 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 fn pace_effect_observation(
355 &mut self,
356 action: &EnsureAction,
357 consecutive_unchanged_observations: u32,
358 );
359
360 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#[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#[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
541pub(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
580pub(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
622pub 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
688pub(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
727pub(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]
778pub 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}