1use crate::fleet_ensure::{
8 inventory::project_current_fleet_inventory,
9 model::{
10 ActualCycleConservation, CanisterDisposition, CycleConservation, EffectRecord, EffectState,
11 EnsureAction, FLEET_ENSURE_SCHEMA_VERSION, FleetEnsureCompletion, FleetEnsureJournalRecord,
12 FleetEnsurePlan, FleetEnsureReport, FleetEnsureStateRecord, FleetObservation,
13 },
14 ops::{
15 EffectRetry, EnsurePaths, EnsurePlatform, EnsureStateError, action_sha256,
16 compact_inline_plan, lock_operation, read_journal, read_plan, read_state,
17 resolve_desired_artifacts, write_journal, write_plan, write_state,
18 },
19 policy::{
20 EnsurePolicyError, compile_plan, expected_plan_sha256, operation_id,
21 validate_path_identity, validate_path_labels,
22 },
23};
24use canic_core::cdk::types::Cycles;
25use std::{
26 collections::{BTreeMap, BTreeSet},
27 path::Path,
28};
29use thiserror::Error as ThisError;
30
31#[derive(Debug, ThisError)]
34pub enum EnsureWorkflowError<E>
35where
36 E: std::error::Error + 'static,
37{
38 #[error("reviewed Fleet plan digest changed before its first effect")]
39 DriftedBeforeApply,
40
41 #[error(
42 "in-progress Fleet plan requires desired input {expected}, but the retained plan predates desired-input retention and the supplied input is {actual}; supply the exact reviewed desired document"
43 )]
44 RetainedDesiredUnavailable { actual: String, expected: String },
45
46 #[error("reviewed Fleet plan {expected} does not match retained plan {actual}")]
47 PlanDigestMismatch { actual: String, expected: String },
48
49 #[error("reviewed Fleet plan is missing; run `canic fleet ensure <fleet>` first")]
50 PlanMissing,
51
52 #[error("retained Fleet plan failed its content digest")]
53 PlanIntegrity,
54
55 #[error("retained Fleet journal does not match the reviewed Fleet operation")]
56 JournalIntegrity,
57
58 #[error(
59 "Fleet ensure made no progress for {observations} consecutive observations; operation remains resumable"
60 )]
61 Stalled { observations: u32 },
62
63 #[error(
64 "selected Cycles Ledger account has {actual} cycles, below reviewed maximum debit {required}"
65 )]
66 InsufficientOperatorCycles { actual: u128, required: u128 },
67
68 #[error("terminal Fleet cycle conservation failed: {0}")]
69 Conservation(String),
70
71 #[error("terminal Fleet inventory failed exact current-authority validation: {0}")]
72 TerminalInventory(String),
73
74 #[error(
75 "reviewed effects completed, but live Fleet state still differs from desired state; review a new plan"
76 )]
77 ConvergenceDrift,
78
79 #[error(transparent)]
80 Policy(#[from] EnsurePolicyError),
81
82 #[error(transparent)]
83 Inventory(#[from] crate::fleet_ensure::CurrentFleetInventoryError),
84
85 #[error("Fleet platform operation failed: {0}")]
86 Platform(#[source] E),
87
88 #[error(transparent)]
89 State(#[from] EnsureStateError),
90}
91
92pub fn retained_in_progress_plan<E>(
94 root: &Path,
95 environment: &str,
96 requested_fleet: &str,
97) -> Result<Option<FleetEnsurePlan>, EnsureWorkflowError<E>>
98where
99 E: std::error::Error + 'static,
100{
101 validate_path_labels(environment, requested_fleet)?;
102 let paths = EnsurePaths::under(root, environment, requested_fleet);
103 let _lock = lock_operation(&paths)?;
104 let Some(journal) = read_journal(&paths)? else {
105 return Ok(None);
106 };
107 if journal.completion != FleetEnsureCompletion::InProgress {
108 return Ok(None);
109 }
110 let retained = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
111 if retained.environment != environment || retained.fleet != requested_fleet {
112 return Err(EnsureWorkflowError::PlanIntegrity);
113 }
114 verify_journal(&journal, &retained, requested_fleet)?;
115 Ok(Some(retained))
116}
117
118pub fn plan<P>(
120 root: &Path,
121 desired: &crate::fleet_ensure::model::DesiredFleet,
122 desired_sha256: &str,
123 requested_fleet: &str,
124 created_at_time: u64,
125 platform: &mut P,
126) -> Result<FleetEnsureReport, EnsureWorkflowError<P::Error>>
127where
128 P: EnsurePlatform,
129{
130 validate_path_identity(desired, requested_fleet)?;
131 let paths = EnsurePaths::under(root, &desired.environment, requested_fleet);
132 let _lock = lock_operation(&paths)?;
133 if let Some(journal) = read_journal(&paths)?
134 && journal.completion == FleetEnsureCompletion::InProgress
135 {
136 let retained = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
137 verify_journal(&journal, &retained, requested_fleet)?;
138 return Ok(FleetEnsureReport {
139 actual_conservation: None,
140 effects_applied: applied_count(&journal),
141 plan: retained,
142 terminal: false,
143 });
144 }
145 let mut state = read_state(&paths, requested_fleet)?;
146 let prior_plan = read_plan(&paths)?.map(verified_plan).transpose()?;
147 if let Some(prior) = prior_plan.as_ref().filter(|prior| {
148 prior.fleet == requested_fleet
149 && prior.environment == desired.environment
150 && prior.desired_sha256 == desired_sha256
151 }) {
152 retain_plan_cycles(&mut state, prior);
153 if let Some(journal) = read_journal(&paths)? {
154 retain_completed_reinstalls(&mut state, prior, &journal);
155 }
156 }
157 let operation_id = operation_id(desired_sha256, &desired.environment, requested_fleet);
158 let mut observation = platform
159 .observe(&operation_id, &state)
160 .map_err(EnsureWorkflowError::Platform)?;
161 retain_observed_cycles(&mut state, &observation);
162 write_state(&paths, &state)?;
163 if state.active_registry.is_some() {
164 let prior = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
165 attach_terminal_inventory_cycles(&prior, &state, platform, &mut observation)?;
166 }
167 let protocol_actions = platform
168 .protocol_actions(&operation_id, &state)
169 .map_err(EnsureWorkflowError::Platform)?;
170 let artifacts = resolve_desired_artifacts(root, desired)?;
171 let plan = compile_plan(
172 desired,
173 &artifacts,
174 &protocol_actions,
175 desired_sha256,
176 requested_fleet,
177 &observation,
178 created_at_time,
179 )?;
180 write_plan(&paths, &plan)?;
181 Ok(FleetEnsureReport {
182 actual_conservation: None,
183 effects_applied: 0,
184 plan,
185 terminal: false,
186 })
187}
188
189fn retain_completed_reinstalls(
190 state: &mut FleetEnsureStateRecord,
191 plan: &FleetEnsurePlan,
192 journal: &FleetEnsureJournalRecord,
193) {
194 for (action, effect) in ordered_actions(plan).into_iter().zip(&journal.effects) {
195 let EnsureAction::Install {
196 mode: crate::fleet_ensure::model::InstallMode::Reinstall,
197 name,
198 ..
199 } = action
200 else {
201 continue;
202 };
203 if effect.state == EffectState::Applied
204 && let Some(pre_canister_version) = effect.pre_canister_version
205 {
206 state
207 .completed_reinstalls
208 .insert(name.clone(), pre_canister_version);
209 }
210 }
211}
212
213fn retain_plan_cycles(state: &mut FleetEnsureStateRecord, plan: &FleetEnsurePlan) {
214 for canister in &plan.canisters {
215 if let Some(principal) = &canister.principal {
216 state
217 .retained_cycles_by_principal
218 .entry(principal.clone())
219 .or_insert(canister.observed_cycles);
220 }
221 }
222}
223
224fn retain_observed_cycles(state: &mut FleetEnsureStateRecord, observation: &FleetObservation) {
225 for live in observation.canisters.values().filter_map(Option::as_ref) {
226 state
227 .retained_cycles_by_principal
228 .insert(live.principal.clone(), live.cycles);
229 }
230 state
231 .retained_cycles_by_principal
232 .extend(observation.additional_controlled_cycles.clone());
233}
234
235#[expect(
237 clippy::too_many_lines,
238 reason = "the sole effect driver keeps intent, reconciliation and terminal publication visibly ordered"
239)]
240pub fn apply<P>(
241 root: &Path,
242 desired: &crate::fleet_ensure::model::DesiredFleet,
243 desired_sha256: &str,
244 requested_fleet: &str,
245 reviewed_plan_sha256: &str,
246 platform: &mut P,
247) -> Result<FleetEnsureReport, EnsureWorkflowError<P::Error>>
248where
249 P: EnsurePlatform,
250{
251 validate_path_identity(desired, requested_fleet)?;
252 let paths = EnsurePaths::under(root, &desired.environment, requested_fleet);
253 let _lock = lock_operation(&paths)?;
254 let retained_plan = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
255 if retained_plan.plan_sha256 != reviewed_plan_sha256 {
256 return Err(EnsureWorkflowError::PlanDigestMismatch {
257 actual: retained_plan.plan_sha256,
258 expected: reviewed_plan_sha256.to_string(),
259 });
260 }
261 let mut state = read_state(&paths, requested_fleet)?;
262 let retained_journal = read_journal(&paths)?;
263 let in_progress = retained_journal
264 .as_ref()
265 .is_some_and(|journal| journal.completion == FleetEnsureCompletion::InProgress);
266 if let Some(journal) = retained_journal.as_ref().filter(|_| in_progress) {
267 verify_journal(journal, &retained_plan, requested_fleet)?;
268 compact_inline_plan(&paths, &retained_plan)?;
269 }
270 let mut issued_observation_resume = false;
271 let operation_desired = if in_progress {
272 if let Some(reviewed) = retained_plan.reviewed_desired.as_deref() {
273 let reviewed = reviewed.desired();
274 validate_path_identity(reviewed, requested_fleet)?;
275 if reviewed.environment != retained_plan.environment {
276 return Err(EnsureWorkflowError::PlanIntegrity);
277 }
278 reviewed
279 } else if retained_plan.desired_sha256 == desired_sha256 {
280 desired
281 } else if retained_journal
282 .as_ref()
283 .is_some_and(|journal| safe_issued_observation_resume(&retained_plan, journal, desired))
284 {
285 issued_observation_resume = true;
286 desired
287 } else {
288 return Err(EnsureWorkflowError::RetainedDesiredUnavailable {
289 actual: desired_sha256.to_string(),
290 expected: retained_plan.desired_sha256.clone(),
291 });
292 }
293 } else {
294 if retained_plan.desired_sha256 != desired_sha256 {
295 return Err(EnsureWorkflowError::DriftedBeforeApply);
296 }
297 desired
298 };
299 platform
300 .bind_reviewed_desired(operation_desired)
301 .map_err(EnsureWorkflowError::Platform)?;
302 let operation_desired_sha256 = retained_plan.desired_sha256.as_str();
303 let mut journal = match retained_journal {
304 Some(journal) if journal.completion == FleetEnsureCompletion::InProgress => {
305 verify_journal(&journal, &retained_plan, requested_fleet)?;
306 journal
307 }
308 _ => {
309 let (observation, initial_controlled_cycles) = verify_fresh_plan(
310 root,
311 operation_desired,
312 operation_desired_sha256,
313 requested_fleet,
314 &retained_plan,
315 platform,
316 &state,
317 )?;
318 let journal = FleetEnsureJournalRecord {
319 completion: FleetEnsureCompletion::InProgress,
320 effects: Vec::new(),
321 fleet: requested_fleet.to_string(),
322 initial_controlled_cycles,
323 initial_operator_cycles: observation.operator_cycles,
324 operation_id: retained_plan.operation_id.clone(),
325 plan_sha256: retained_plan.plan_sha256.clone(),
326 schema_version: FLEET_ENSURE_SCHEMA_VERSION,
327 stalled_observations: 0,
328 };
329 write_journal(&paths, &journal)?;
330 journal
331 }
332 };
333
334 let actions = ordered_actions(&retained_plan);
335 let mut replayed_issued_commands = BTreeSet::new();
336 for (index, action) in actions.iter().enumerate() {
337 let action_hash = action_sha256(action);
338 if journal.effects.len() <= index {
339 let pre_cycles = platform
340 .action_cycles(action, &state)
341 .map_err(EnsureWorkflowError::Platform)?;
342 let destination_pre_cycles = platform
343 .action_destination_cycles(action, &state)
344 .map_err(EnsureWorkflowError::Platform)?;
345 let pre_canister_version = platform
346 .action_canister_version(action, &state)
347 .map_err(EnsureWorkflowError::Platform)?;
348 journal.effects.push(EffectRecord {
349 action_sha256: action_hash.clone(),
350 created_principal: None,
351 destination_post_cycles: destination_pre_cycles,
352 destination_pre_cycles,
353 post_cycles: None,
354 pre_cycles,
355 pre_canister_version,
356 progress_identity: None,
357 receipt: None,
358 state: EffectState::Intent,
359 });
360 write_journal(&paths, &journal)?;
361 }
362 loop {
363 let record = journal
364 .effects
365 .get(index)
366 .ok_or(EnsureWorkflowError::JournalIntegrity)?;
367 if record.action_sha256 != action_hash {
368 return Err(EnsureWorkflowError::DriftedBeforeApply);
369 }
370 if matches!(record.state, EffectState::Applied) {
371 break;
372 }
373
374 let observed = platform
375 .observe_effect(&journal.operation_id, action, record, &state)
376 .map_err(EnsureWorkflowError::Platform)?;
377 let source_cycles = platform
378 .action_cycles(action, &state)
379 .map_err(EnsureWorkflowError::Platform)?;
380 let destination_cycles = platform
381 .action_destination_cycles(action, &state)
382 .map_err(EnsureWorkflowError::Platform)?;
383 let record = journal
384 .effects
385 .get_mut(index)
386 .ok_or(EnsureWorkflowError::JournalIntegrity)?;
387 record.post_cycles = source_cycles;
388 record.destination_post_cycles = destination_cycles;
389 if observed.applied {
390 record.progress_identity = Some(observed.progress_identity);
391 record.state = EffectState::Applied;
392 journal.stalled_observations = 0;
393 write_journal(&paths, &journal)?;
394 break;
395 }
396
397 if matches!(record.state, EffectState::Intent) {
398 let outcome = match platform.apply(&journal.operation_id, action, record, &state) {
399 Ok(outcome) => outcome,
400 Err(source) => {
401 journal.stalled_observations =
402 journal.stalled_observations.saturating_add(1);
403 write_journal(&paths, &journal)?;
404 if journal.stalled_observations
405 >= operation_desired.maximum_stalled_observations
406 {
407 return Err(EnsureWorkflowError::Stalled {
408 observations: journal.stalled_observations,
409 });
410 }
411 return Err(EnsureWorkflowError::Platform(source));
412 }
413 };
414 if let Some(created) = &outcome.created_principal {
415 state
416 .pending_principals
417 .insert(action.name().to_string(), created.clone());
418 write_state(&paths, &state)?;
419 }
420 let record = journal
421 .effects
422 .get_mut(index)
423 .ok_or(EnsureWorkflowError::JournalIntegrity)?;
424 record.created_principal = outcome.created_principal;
425 record.receipt = outcome.receipt;
426 record.post_cycles = outcome.post_cycles;
427 record.progress_identity = Some(observed.progress_identity);
428 record.state = EffectState::Issued;
429 journal.stalled_observations = 0;
430 write_journal(&paths, &journal)?;
431 continue;
432 }
433
434 if observed.retry == EffectRetry::ReplayExactIssuedCommand
435 && !replayed_issued_commands.contains(&index)
436 {
437 if !matches!(
438 action,
439 EnsureAction::FleetProtocol { action, .. }
440 if matches!(
441 action.as_ref(),
442 crate::fleet_ensure::model::CurrentFleetProtocolAction::ProvisionComponents { .. }
443 )
444 ) {
445 return Err(EnsureWorkflowError::JournalIntegrity);
446 }
447 let retained_receipt = record.receipt.clone();
448 let outcome = platform
449 .apply(&journal.operation_id, action, record, &state)
450 .map_err(EnsureWorkflowError::Platform)?;
451 if outcome.created_principal.is_some()
452 || outcome.receipt != retained_receipt
453 || outcome.post_cycles.is_some()
454 {
455 return Err(EnsureWorkflowError::JournalIntegrity);
456 }
457 let record = journal
458 .effects
459 .get_mut(index)
460 .ok_or(EnsureWorkflowError::JournalIntegrity)?;
461 record.progress_identity = Some(observed.progress_identity);
462 journal.stalled_observations = 0;
463 write_journal(&paths, &journal)?;
464 replayed_issued_commands.insert(index);
465 continue;
466 }
467
468 if record.progress_identity.as_deref() == Some(&observed.progress_identity) {
469 journal.stalled_observations = journal.stalled_observations.saturating_add(1);
470 } else {
471 record.progress_identity = Some(observed.progress_identity);
472 journal.stalled_observations = 0;
473 }
474 write_journal(&paths, &journal)?;
475 if journal.stalled_observations >= operation_desired.maximum_stalled_observations {
476 return Err(EnsureWorkflowError::Stalled {
477 observations: journal.stalled_observations,
478 });
479 }
480 }
481 }
482
483 let mut terminal_state = state.clone();
484 publish_terminal_state(operation_desired, &retained_plan, &mut terminal_state);
485 project_current_fleet_inventory(&terminal_state)?;
486 let terminal_observation = platform
487 .observe(&retained_plan.operation_id, &terminal_state)
488 .map_err(EnsureWorkflowError::Platform)?;
489 if !issued_observation_resume {
490 let artifacts = resolve_desired_artifacts(root, operation_desired)?;
491 let protocol_actions = platform
492 .protocol_actions(&retained_plan.operation_id, &terminal_state)
493 .map_err(EnsureWorkflowError::Platform)?;
494 let converged = compile_plan(
495 operation_desired,
496 &artifacts,
497 &protocol_actions,
498 operation_desired_sha256,
499 requested_fleet,
500 &terminal_observation,
501 retained_plan.planned_at_time,
502 )?;
503 if converged
504 .canisters
505 .iter()
506 .any(|canister| !canister.actions.is_empty())
507 || !converged.protocol_actions.is_empty()
508 {
509 journal.completion = FleetEnsureCompletion::ReplanRequired;
510 journal.stalled_observations = 0;
511 write_journal(&paths, &journal)?;
512 return Err(EnsureWorkflowError::ConvergenceDrift);
513 }
514 }
515 let terminal_inventory = platform
516 .terminal_inventory(&retained_plan.operation_id, &terminal_state)
517 .map_err(EnsureWorkflowError::Platform)?;
518 let terminal_cycles = terminal_inventory.controlled_cycles_by_principal.clone();
519 merge_terminal_inventory(&mut terminal_state, terminal_inventory)?;
520 project_current_fleet_inventory(&terminal_state)?;
521 let mut final_observation = platform
522 .observe(&retained_plan.operation_id, &terminal_state)
523 .map_err(EnsureWorkflowError::Platform)?;
524 attach_terminal_cycles(&mut final_observation, terminal_cycles)?;
525 let actual_conservation =
526 verify_terminal_conservation(&retained_plan, &journal, &final_observation)?;
527 write_state(&paths, &terminal_state)?;
528 journal.completion = FleetEnsureCompletion::Converged;
529 journal.stalled_observations = 0;
530 write_journal(&paths, &journal)?;
531 Ok(FleetEnsureReport {
532 actual_conservation: Some(actual_conservation),
533 effects_applied: applied_count(&journal),
534 plan: retained_plan,
535 terminal: true,
536 })
537}
538
539fn verify_fresh_plan<P>(
540 root: &Path,
541 desired: &crate::fleet_ensure::model::DesiredFleet,
542 desired_sha256: &str,
543 requested_fleet: &str,
544 retained_plan: &FleetEnsurePlan,
545 platform: &mut P,
546 state: &FleetEnsureStateRecord,
547) -> Result<(FleetObservation, u128), EnsureWorkflowError<P::Error>>
548where
549 P: EnsurePlatform,
550{
551 let mut observation = platform
552 .observe(&retained_plan.operation_id, state)
553 .map_err(EnsureWorkflowError::Platform)?;
554 if state.active_registry.is_some() {
555 attach_terminal_inventory_cycles(retained_plan, state, platform, &mut observation)?;
556 }
557 if observation.operator_cycles < retained_plan.conservation.maximum_operator_debit_cycles {
558 return Err(EnsureWorkflowError::InsufficientOperatorCycles {
559 actual: observation.operator_cycles,
560 required: retained_plan.conservation.maximum_operator_debit_cycles,
561 });
562 }
563 let artifacts = resolve_desired_artifacts(root, desired)?;
564 let protocol_actions = platform
565 .protocol_actions(&retained_plan.operation_id, state)
566 .map_err(EnsureWorkflowError::Platform)?;
567 let current = compile_plan(
568 desired,
569 &artifacts,
570 &protocol_actions,
571 desired_sha256,
572 requested_fleet,
573 &observation,
574 retained_plan.planned_at_time,
575 )?;
576 if current.plan_sha256 != retained_plan.plan_sha256
577 && !compatible_after_bounded_observation(retained_plan, ¤t, desired, &observation)
578 {
579 return Err(EnsureWorkflowError::DriftedBeforeApply);
580 }
581 Ok((observation, current.conservation.observed_controlled_cycles))
582}
583
584fn attach_terminal_inventory_cycles<P>(
585 plan: &FleetEnsurePlan,
586 state: &FleetEnsureStateRecord,
587 platform: &mut P,
588 observation: &mut FleetObservation,
589) -> Result<(), EnsureWorkflowError<P::Error>>
590where
591 P: EnsurePlatform,
592{
593 let inventory = platform
594 .terminal_inventory(&plan.operation_id, state)
595 .map_err(EnsureWorkflowError::Platform)?;
596 attach_terminal_cycles(observation, inventory.controlled_cycles_by_principal)
597}
598
599fn compatible_after_bounded_observation(
600 retained: &FleetEnsurePlan,
601 current: &FleetEnsurePlan,
602 desired: &crate::fleet_ensure::model::DesiredFleet,
603 observation: &FleetObservation,
604) -> bool {
605 let Ok(maximum_observation_movement) = desired
606 .maximum_observation_burn_cycles
607 .parse::<Cycles>()
608 .map(|cycles| cycles.to_u128())
609 else {
610 return false;
611 };
612 if retained.canisters.len() != current.canisters.len() {
613 return false;
614 }
615 for (retained_canister, current_canister) in retained.canisters.iter().zip(¤t.canisters) {
616 let movement = retained_canister
617 .observed_cycles
618 .abs_diff(current_canister.observed_cycles);
619 if movement > maximum_observation_movement {
620 return false;
621 }
622 }
623 if !retained_funding_remains_sufficient(retained, desired, observation) {
624 return false;
625 }
626 normalized_plan(retained) == normalized_plan(current)
627}
628
629fn retained_funding_remains_sufficient(
630 retained: &FleetEnsurePlan,
631 desired: &crate::fleet_ensure::model::DesiredFleet,
632 observation: &FleetObservation,
633) -> bool {
634 retained
635 .canisters
636 .iter()
637 .flat_map(|canister| &canister.actions)
638 .filter_map(|action| match action {
639 EnsureAction::Fund { amount, name, .. } => Some((amount, name)),
640 _ => None,
641 })
642 .all(|(amount, name)| {
643 let Some(live) = observation.canisters.get(name).and_then(Option::as_ref) else {
644 return false;
645 };
646 let Some(configured) = desired
647 .canisters
648 .iter()
649 .find(|configured| configured.name == *name)
650 else {
651 return false;
652 };
653 let Ok(minimum) = configured
654 .minimum_cycles
655 .parse::<Cycles>()
656 .map(|cycles| cycles.to_u128())
657 else {
658 return false;
659 };
660 live.cycles
661 .checked_add(*amount)
662 .is_some_and(|funded| funded >= minimum)
663 })
664}
665
666fn normalized_plan(plan: &FleetEnsurePlan) -> FleetEnsurePlan {
667 let mut normalized = plan.clone();
668 normalized.plan_sha256.clear();
669 normalized.reviewed_desired = None;
670 normalized.conservation = CycleConservation {
671 expected_post_operation_cycles: 0,
672 maximum_execution_burn_cycles: 0,
673 maximum_new_funding_cycles: 0,
674 maximum_operator_debit_cycles: 0,
675 maximum_unavoidable_fee_cycles: 0,
676 observed_controlled_cycles: 0,
677 retained_in_reused_canisters_cycles: 0,
678 scheduled_transfer_cycles: 0,
679 };
680 for canister in &mut normalized.canisters {
681 canister.observed_cycles = 0;
682 for action in &mut canister.actions {
683 if let EnsureAction::Fund {
684 amount,
685 funding_deficit_cycles,
686 ..
687 } = action
688 {
689 *amount = 0;
690 *funding_deficit_cycles = 0;
691 }
692 }
693 }
694 normalized
695}
696
697fn safe_issued_observation_resume(
698 plan: &FleetEnsurePlan,
699 journal: &FleetEnsureJournalRecord,
700 supplied: &crate::fleet_ensure::model::DesiredFleet,
701) -> bool {
702 if plan.conservation.maximum_new_funding_cycles != 0
703 || plan.conservation.maximum_operator_debit_cycles != 0
704 || plan.conservation.maximum_unavoidable_fee_cycles != 0
705 || plan.canisters.iter().any(|canister| {
706 canister.disposition != CanisterDisposition::Reuse
707 || !canister.actions.is_empty()
708 || canister.principal.is_none()
709 })
710 {
711 return false;
712 }
713 let supplied_principals = supplied
714 .canisters
715 .iter()
716 .filter(|canister| {
717 canister.presence == crate::fleet_ensure::model::DesiredPresence::Present
718 && !canister.replace
719 })
720 .filter_map(|canister| {
721 canister
722 .principal
723 .as_ref()
724 .map(|principal| (canister.name.as_str(), principal.as_str()))
725 })
726 .collect::<BTreeMap<_, _>>();
727 if supplied_principals.len() != plan.canisters.len()
728 || plan.canisters.iter().any(|canister| {
729 canister.principal.as_deref().is_none_or(|principal| {
730 supplied_principals.get(canister.name.as_str()).copied() != Some(principal)
731 })
732 })
733 {
734 return false;
735 }
736 let actions = ordered_actions(plan);
737 if actions.len() != journal.effects.len() || actions.is_empty() {
738 return false;
739 }
740 if actions
741 .iter()
742 .zip(&journal.effects)
743 .any(|(action, effect)| action_sha256(action) != effect.action_sha256)
744 {
745 return false;
746 }
747 let Some((last_action, last_effect)) = actions.last().zip(journal.effects.last()) else {
748 return false;
749 };
750 journal.effects[..journal.effects.len() - 1]
751 .iter()
752 .all(|effect| effect.state == EffectState::Applied)
753 && matches!(
754 last_effect.state,
755 EffectState::Issued | EffectState::Applied
756 )
757 && matches!(
758 last_action,
759 EnsureAction::FleetProtocol { action, .. }
760 if matches!(
761 action.as_ref(),
762 crate::fleet_ensure::model::CurrentFleetProtocolAction::ProvisionComponents { .. }
763 )
764 )
765}
766
767fn attach_terminal_cycles<E>(
768 observation: &mut FleetObservation,
769 additional: BTreeMap<String, u128>,
770) -> Result<(), EnsureWorkflowError<E>>
771where
772 E: std::error::Error + 'static,
773{
774 let mut retained = BTreeMap::new();
775 for (principal, cycles) in additional {
776 let Some(configured) = observation
777 .canisters
778 .values_mut()
779 .filter_map(Option::as_mut)
780 .find(|canister| canister.principal == principal)
781 else {
782 retained.insert(principal, cycles);
783 continue;
784 };
785 if configured.root_owned_lifecycle
786 != Some(crate::fleet_ensure::model::RootOwnedCanisterLifecycle::Workload)
787 {
788 return Err(EnsureWorkflowError::TerminalInventory(
789 "terminal cycle observation duplicates a configured canister outside its exact Root-owned workload lifecycle"
790 .to_string(),
791 ));
792 }
793 configured.cycles = configured.cycles.min(cycles);
797 }
798 observation.additional_controlled_cycles = retained;
799 Ok(())
800}
801
802fn verify_terminal_conservation<E>(
803 plan: &FleetEnsurePlan,
804 journal: &FleetEnsureJournalRecord,
805 terminal: &FleetObservation,
806) -> Result<ActualCycleConservation, EnsureWorkflowError<E>>
807where
808 E: std::error::Error + 'static,
809{
810 let final_controlled_cycles = controlled_cycles(terminal)?;
811 let operator_debit_cycles = journal
812 .initial_operator_cycles
813 .checked_sub(terminal.operator_cycles)
814 .ok_or_else(|| {
815 EnsureWorkflowError::Conservation(
816 "operator balance increased during apply; review a new plan".to_string(),
817 )
818 })?;
819 if operator_debit_cycles > plan.conservation.maximum_operator_debit_cycles {
820 return Err(EnsureWorkflowError::Conservation(format!(
821 "operator debit {operator_debit_cycles} exceeded reviewed maximum {}",
822 plan.conservation.maximum_operator_debit_cycles
823 )));
824 }
825 let received_new_funding_cycles = operator_debit_cycles
826 .checked_sub(plan.conservation.maximum_unavoidable_fee_cycles)
827 .ok_or_else(|| {
828 EnsureWorkflowError::Conservation(
829 "operator debit was below the exact reviewed fee total".to_string(),
830 )
831 })?;
832 if received_new_funding_cycles > plan.conservation.maximum_new_funding_cycles {
833 return Err(EnsureWorkflowError::Conservation(format!(
834 "received funding {received_new_funding_cycles} exceeded reviewed maximum {}",
835 plan.conservation.maximum_new_funding_cycles
836 )));
837 }
838 let available = journal
839 .initial_controlled_cycles
840 .checked_add(received_new_funding_cycles)
841 .ok_or_else(|| {
842 EnsureWorkflowError::Conservation(
843 "terminal controlled-cycle arithmetic overflowed".to_string(),
844 )
845 })?;
846 let measured_execution_burn_cycles = available
847 .checked_sub(final_controlled_cycles)
848 .ok_or_else(|| {
849 EnsureWorkflowError::Conservation(
850 "terminal estate contains more cycles than starting estate plus received funding"
851 .to_string(),
852 )
853 })?;
854 if measured_execution_burn_cycles > plan.conservation.maximum_execution_burn_cycles {
855 return Err(EnsureWorkflowError::Conservation(format!(
856 "measured execution burn {measured_execution_burn_cycles} exceeded reviewed maximum {}",
857 plan.conservation.maximum_execution_burn_cycles
858 )));
859 }
860 Ok(ActualCycleConservation {
861 exact_unavoidable_fee_cycles: plan.conservation.maximum_unavoidable_fee_cycles,
862 final_controlled_cycles,
863 measured_execution_burn_cycles,
864 observed_starting_cycles: journal.initial_controlled_cycles,
865 operator_debit_cycles,
866 received_new_funding_cycles,
867 })
868}
869
870fn controlled_cycles<E>(observation: &FleetObservation) -> Result<u128, EnsureWorkflowError<E>>
871where
872 E: std::error::Error + 'static,
873{
874 observation
875 .canisters
876 .values()
877 .filter_map(Option::as_ref)
878 .try_fold(0_u128, |total, live| total.checked_add(live.cycles))
879 .and_then(|total| {
880 observation
881 .additional_controlled_cycles
882 .values()
883 .try_fold(total, |subtotal, cycles| subtotal.checked_add(*cycles))
884 })
885 .ok_or_else(|| {
886 EnsureWorkflowError::Conservation("controlled-cycle total overflowed".to_string())
887 })
888}
889
890fn verified_plan<E>(plan: FleetEnsurePlan) -> Result<FleetEnsurePlan, EnsureWorkflowError<E>>
891where
892 E: std::error::Error + 'static,
893{
894 if expected_plan_sha256(&plan) != plan.plan_sha256 {
895 return Err(EnsureWorkflowError::PlanIntegrity);
896 }
897 Ok(plan)
898}
899
900fn verify_journal<E>(
901 journal: &FleetEnsureJournalRecord,
902 plan: &FleetEnsurePlan,
903 requested_fleet: &str,
904) -> Result<(), EnsureWorkflowError<E>>
905where
906 E: std::error::Error + 'static,
907{
908 if journal.fleet != requested_fleet
909 || journal.operation_id != plan.operation_id
910 || journal.plan_sha256 != plan.plan_sha256
911 {
912 return Err(EnsureWorkflowError::JournalIntegrity);
913 }
914 Ok(())
915}
916
917fn publish_terminal_state(
918 desired: &crate::fleet_ensure::model::DesiredFleet,
919 plan: &FleetEnsurePlan,
920 state: &mut FleetEnsureStateRecord,
921) {
922 for canister in &plan.canisters {
923 match canister.disposition {
924 CanisterDisposition::Create | CanisterDisposition::Replace => {
925 if let Some(principal) = state.pending_principals.remove(&canister.name) {
926 state.principals.insert(canister.name.clone(), principal);
927 }
928 }
929 CanisterDisposition::Reinstall | CanisterDisposition::Reuse => {
930 if let Some(principal) = state
931 .pending_principals
932 .remove(&canister.name)
933 .or_else(|| canister.principal.clone())
934 {
935 state.principals.insert(canister.name.clone(), principal);
936 }
937 }
938 CanisterDisposition::Delete => {
939 state.principals.remove(&canister.name);
940 state.pending_principals.remove(&canister.name);
941 }
942 }
943 }
944 state.topology = desired
945 .canisters
946 .iter()
947 .filter(|canister| {
948 canister.presence == crate::fleet_ensure::model::DesiredPresence::Present
949 })
950 .map(|canister| {
951 (
952 canister.name.clone(),
953 crate::fleet_ensure::model::FleetEnsureTopologyRecord {
954 kind: canister.kind,
955 module_hash: None,
956 parent: canister.parent.clone(),
957 protocol_binding: canister.protocol_binding.clone(),
958 role: canister
959 .protocol_binding
960 .as_ref()
961 .map(|binding| binding.role.to_string()),
962 },
963 )
964 })
965 .collect();
966}
967
968fn merge_terminal_inventory<E>(
969 state: &mut FleetEnsureStateRecord,
970 inventory: crate::fleet_ensure::ops::TerminalFleetInventory,
971) -> Result<(), EnsureWorkflowError<E>>
972where
973 E: std::error::Error + 'static,
974{
975 if inventory.entries.is_empty() {
976 state.active_registry = inventory.active_registry;
977 return Ok(());
978 }
979 let entries = inventory.entries;
980 let mut names_by_principal = state
981 .principals
982 .iter()
983 .map(|(name, principal)| (principal.clone(), name.clone()))
984 .collect::<BTreeMap<_, _>>();
985 if names_by_principal.len() != state.principals.len() {
986 return Err(EnsureWorkflowError::TerminalInventory(
987 "one Principal is retained under more than one desired role".to_string(),
988 ));
989 }
990 for entry in &entries {
991 names_by_principal
992 .entry(entry.pid.clone())
993 .or_insert_with(|| format!("observed:{}", entry.pid));
994 }
995 for entry in entries {
996 let name = names_by_principal.get(&entry.pid).cloned().ok_or_else(|| {
997 EnsureWorkflowError::TerminalInventory(
998 "verified entry lost its Principal binding".to_string(),
999 )
1000 })?;
1001 let parent = entry
1002 .parent_pid
1003 .as_ref()
1004 .map(|principal| {
1005 names_by_principal.get(principal).cloned().ok_or_else(|| {
1006 EnsureWorkflowError::TerminalInventory(format!(
1007 "Canister {} names unknown parent {principal}",
1008 entry.pid
1009 ))
1010 })
1011 })
1012 .transpose()?;
1013 let existing = state.topology.get(&name);
1014 if existing
1015 .and_then(|topology| topology.parent.as_ref())
1016 .zip(parent.as_ref())
1017 .is_some_and(|(expected, actual)| expected != actual)
1018 {
1019 return Err(EnsureWorkflowError::TerminalInventory(format!(
1020 "Canister {} conflicts with retained parent authority",
1021 entry.pid
1022 )));
1023 }
1024 let kind = existing.map_or_else(
1025 || {
1026 if entry.module_hash.is_some() {
1027 crate::fleet_ensure::model::DesiredCanisterKind::Component
1028 } else {
1029 crate::fleet_ensure::model::DesiredCanisterKind::Auxiliary
1030 }
1031 },
1032 |topology| {
1033 if topology.kind == crate::fleet_ensure::model::DesiredCanisterKind::Pool
1034 && entry.module_hash.is_some()
1035 && entry.protocol_binding.is_some()
1036 {
1037 crate::fleet_ensure::model::DesiredCanisterKind::Component
1038 } else {
1039 topology.kind
1040 }
1041 },
1042 );
1043 state.principals.insert(name.clone(), entry.pid);
1044 state.topology.insert(
1045 name,
1046 crate::fleet_ensure::model::FleetEnsureTopologyRecord {
1047 kind,
1048 module_hash: entry.module_hash,
1049 parent,
1050 protocol_binding: entry.protocol_binding,
1051 role: entry.role,
1052 },
1053 );
1054 }
1055 state.active_registry = inventory.active_registry;
1056 Ok(())
1057}
1058
1059fn applied_count(journal: &FleetEnsureJournalRecord) -> u32 {
1060 u32::try_from(
1061 journal
1062 .effects
1063 .iter()
1064 .filter(|effect| matches!(effect.state, EffectState::Applied))
1065 .count(),
1066 )
1067 .unwrap_or(u32::MAX)
1068}
1069
1070pub(super) fn ordered_actions(plan: &FleetEnsurePlan) -> Vec<&EnsureAction> {
1071 let mut actions = plan
1072 .canisters
1073 .iter()
1074 .flat_map(|canister| canister.actions.iter())
1075 .chain(plan.protocol_actions.iter())
1076 .collect::<Vec<_>>();
1077 actions.sort_by_key(|action| action_order(action));
1078 actions
1079}
1080
1081pub(super) fn action_order(action: &EnsureAction) -> u8 {
1082 match action {
1083 EnsureAction::Create { .. } => 0,
1084 EnsureAction::Fund { .. } => 1,
1085 EnsureAction::Install {
1086 canic_init: Some(crate::fleet_ensure::model::DesiredCanisterInit::Coordinator),
1087 ..
1088 } => 2,
1089 EnsureAction::FleetProtocol { action, .. }
1090 if matches!(
1091 action.as_ref(),
1092 crate::fleet_ensure::model::CurrentFleetProtocolAction::AdoptStore { .. }
1093 ) =>
1094 {
1095 3
1096 }
1097 EnsureAction::Install {
1098 canic_init: Some(crate::fleet_ensure::model::DesiredCanisterInit::Store { .. }),
1099 ..
1100 } => 4,
1101 EnsureAction::Install { .. } => 5,
1102 EnsureAction::SetControllers { .. } | EnsureAction::Start { .. } => 6,
1103 EnsureAction::FleetProtocol { .. } | EnsureAction::Protocol { .. } => 7,
1104 EnsureAction::Transfer { .. } => 8,
1105 EnsureAction::Stop { .. } => 9,
1106 EnsureAction::Delete { .. } => 10,
1107 }
1108}