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