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 EnsurePaths, EnsurePlatform, EnsureStateError, action_sha256, lock_operation, read_journal,
16 read_plan, read_state, resolve_desired_artifacts, write_journal, write_plan, write_state,
17 },
18 policy::{
19 EnsurePolicyError, compile_plan, expected_plan_sha256, operation_id, validate_path_identity,
20 },
21};
22use std::{collections::BTreeMap, path::Path};
23use thiserror::Error as ThisError;
24
25#[derive(Debug, ThisError)]
28pub enum EnsureWorkflowError<E>
29where
30 E: std::error::Error + 'static,
31{
32 #[error("another reviewed Fleet plan is already in progress: {operation_id}")]
33 ActiveOperation { operation_id: String },
34
35 #[error("reviewed Fleet plan digest changed before its first effect")]
36 DriftedBeforeApply,
37
38 #[error("reviewed Fleet plan {expected} does not match retained plan {actual}")]
39 PlanDigestMismatch { actual: String, expected: String },
40
41 #[error("reviewed Fleet plan is missing; run `canic fleet ensure <fleet>` first")]
42 PlanMissing,
43
44 #[error("retained Fleet plan failed its content digest")]
45 PlanIntegrity,
46
47 #[error("retained Fleet journal does not match the reviewed Fleet operation")]
48 JournalIntegrity,
49
50 #[error(
51 "Fleet ensure made no progress for {observations} consecutive observations; operation remains resumable"
52 )]
53 Stalled { observations: u32 },
54
55 #[error(
56 "selected Cycles Ledger account has {actual} cycles, below reviewed maximum debit {required}"
57 )]
58 InsufficientOperatorCycles { actual: u128, required: u128 },
59
60 #[error("terminal Fleet cycle conservation failed: {0}")]
61 Conservation(String),
62
63 #[error("terminal Fleet inventory failed exact current-authority validation: {0}")]
64 TerminalInventory(String),
65
66 #[error(
67 "reviewed effects completed, but live Fleet state still differs from desired state; review a new plan"
68 )]
69 ConvergenceDrift,
70
71 #[error(transparent)]
72 Policy(#[from] EnsurePolicyError),
73
74 #[error(transparent)]
75 Inventory(#[from] crate::fleet_ensure::CurrentFleetInventoryError),
76
77 #[error("Fleet platform operation failed: {0}")]
78 Platform(#[source] E),
79
80 #[error(transparent)]
81 State(#[from] EnsureStateError),
82}
83
84pub fn plan<P>(
86 root: &Path,
87 desired: &crate::fleet_ensure::model::DesiredFleet,
88 desired_sha256: &str,
89 requested_fleet: &str,
90 created_at_time: u64,
91 platform: &mut P,
92) -> Result<FleetEnsureReport, EnsureWorkflowError<P::Error>>
93where
94 P: EnsurePlatform,
95{
96 validate_path_identity(desired, requested_fleet)?;
97 let paths = EnsurePaths::under(root, &desired.environment, requested_fleet);
98 let _lock = lock_operation(&paths)?;
99 if let Some(journal) = read_journal(&paths)?
100 && journal.completion == FleetEnsureCompletion::InProgress
101 {
102 let retained = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
103 verify_journal(&journal, &retained, requested_fleet)?;
104 if retained.desired_sha256 != desired_sha256 {
105 return Err(EnsureWorkflowError::ActiveOperation {
106 operation_id: journal.operation_id,
107 });
108 }
109 return Ok(FleetEnsureReport {
110 actual_conservation: None,
111 effects_applied: applied_count(&journal),
112 plan: retained,
113 terminal: false,
114 });
115 }
116 let state = read_state(&paths, requested_fleet)?;
117 let operation_id = operation_id(desired_sha256, &desired.environment, requested_fleet);
118 let mut observation = platform
119 .observe(&operation_id, &state)
120 .map_err(EnsureWorkflowError::Platform)?;
121 if state.active_registry.is_some() {
122 let prior = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
123 let inventory = platform
124 .terminal_inventory(&prior.operation_id, &state)
125 .map_err(EnsureWorkflowError::Platform)?;
126 attach_terminal_cycles(&mut observation, inventory.controlled_cycles_by_principal)?;
127 }
128 let protocol_actions = platform
129 .protocol_actions(&operation_id, &state)
130 .map_err(EnsureWorkflowError::Platform)?;
131 let artifacts = resolve_desired_artifacts(root, desired)?;
132 let plan = compile_plan(
133 desired,
134 &artifacts,
135 &protocol_actions,
136 desired_sha256,
137 requested_fleet,
138 &observation,
139 created_at_time,
140 )?;
141 write_plan(&paths, &plan)?;
142 Ok(FleetEnsureReport {
143 actual_conservation: None,
144 effects_applied: 0,
145 plan,
146 terminal: false,
147 })
148}
149
150#[expect(
152 clippy::too_many_lines,
153 reason = "the sole effect driver keeps intent, reconciliation and terminal publication visibly ordered"
154)]
155pub fn apply<P>(
156 root: &Path,
157 desired: &crate::fleet_ensure::model::DesiredFleet,
158 desired_sha256: &str,
159 requested_fleet: &str,
160 reviewed_plan_sha256: &str,
161 platform: &mut P,
162) -> Result<FleetEnsureReport, EnsureWorkflowError<P::Error>>
163where
164 P: EnsurePlatform,
165{
166 validate_path_identity(desired, requested_fleet)?;
167 let paths = EnsurePaths::under(root, &desired.environment, requested_fleet);
168 let _lock = lock_operation(&paths)?;
169 let retained_plan = verified_plan(read_plan(&paths)?.ok_or(EnsureWorkflowError::PlanMissing)?)?;
170 if retained_plan.plan_sha256 != reviewed_plan_sha256 {
171 return Err(EnsureWorkflowError::PlanDigestMismatch {
172 actual: retained_plan.plan_sha256,
173 expected: reviewed_plan_sha256.to_string(),
174 });
175 }
176 if retained_plan.desired_sha256 != desired_sha256 {
177 return Err(EnsureWorkflowError::DriftedBeforeApply);
178 }
179 let mut state = read_state(&paths, requested_fleet)?;
180 let mut journal = match read_journal(&paths)? {
181 Some(journal) if journal.completion == FleetEnsureCompletion::InProgress => {
182 verify_journal(&journal, &retained_plan, requested_fleet)?;
183 journal
184 }
185 _ => {
186 let observation = verify_fresh_plan(
187 root,
188 desired,
189 desired_sha256,
190 requested_fleet,
191 &retained_plan,
192 platform,
193 &state,
194 )?;
195 let journal = FleetEnsureJournalRecord {
196 completion: FleetEnsureCompletion::InProgress,
197 effects: Vec::new(),
198 fleet: requested_fleet.to_string(),
199 initial_controlled_cycles: retained_plan.conservation.observed_controlled_cycles,
200 initial_operator_cycles: observation.operator_cycles,
201 operation_id: retained_plan.operation_id.clone(),
202 plan_sha256: retained_plan.plan_sha256.clone(),
203 schema_version: FLEET_ENSURE_SCHEMA_VERSION,
204 stalled_observations: 0,
205 };
206 write_journal(&paths, &journal)?;
207 journal
208 }
209 };
210
211 let actions = ordered_actions(&retained_plan);
212 for (index, action) in actions.iter().enumerate() {
213 let action_hash = action_sha256(action);
214 if journal.effects.len() <= index {
215 let pre_cycles = platform
216 .action_cycles(action, &state)
217 .map_err(EnsureWorkflowError::Platform)?;
218 let destination_pre_cycles = platform
219 .action_destination_cycles(action, &state)
220 .map_err(EnsureWorkflowError::Platform)?;
221 journal.effects.push(EffectRecord {
222 action_sha256: action_hash.clone(),
223 created_principal: None,
224 destination_post_cycles: destination_pre_cycles,
225 destination_pre_cycles,
226 post_cycles: None,
227 pre_cycles,
228 progress_identity: None,
229 receipt: None,
230 state: EffectState::Intent,
231 });
232 write_journal(&paths, &journal)?;
233 }
234 loop {
235 let record = journal
236 .effects
237 .get(index)
238 .ok_or(EnsureWorkflowError::JournalIntegrity)?;
239 if record.action_sha256 != action_hash {
240 return Err(EnsureWorkflowError::DriftedBeforeApply);
241 }
242 if matches!(record.state, EffectState::Applied) {
243 break;
244 }
245
246 let observed = platform
247 .observe_effect(&journal.operation_id, action, record, &state)
248 .map_err(EnsureWorkflowError::Platform)?;
249 let source_cycles = platform
250 .action_cycles(action, &state)
251 .map_err(EnsureWorkflowError::Platform)?;
252 let destination_cycles = platform
253 .action_destination_cycles(action, &state)
254 .map_err(EnsureWorkflowError::Platform)?;
255 let record = journal
256 .effects
257 .get_mut(index)
258 .ok_or(EnsureWorkflowError::JournalIntegrity)?;
259 record.post_cycles = source_cycles;
260 record.destination_post_cycles = destination_cycles;
261 if observed.applied {
262 record.progress_identity = Some(observed.progress_identity);
263 record.state = EffectState::Applied;
264 journal.stalled_observations = 0;
265 write_journal(&paths, &journal)?;
266 break;
267 }
268
269 if matches!(record.state, EffectState::Intent) {
270 let outcome = match platform.apply(&journal.operation_id, action, record, &state) {
271 Ok(outcome) => outcome,
272 Err(source) => {
273 journal.stalled_observations =
274 journal.stalled_observations.saturating_add(1);
275 write_journal(&paths, &journal)?;
276 if journal.stalled_observations >= desired.maximum_stalled_observations {
277 return Err(EnsureWorkflowError::Stalled {
278 observations: journal.stalled_observations,
279 });
280 }
281 return Err(EnsureWorkflowError::Platform(source));
282 }
283 };
284 if let Some(created) = &outcome.created_principal {
285 state
286 .pending_principals
287 .insert(action.name().to_string(), created.clone());
288 write_state(&paths, &state)?;
289 }
290 let record = journal
291 .effects
292 .get_mut(index)
293 .ok_or(EnsureWorkflowError::JournalIntegrity)?;
294 record.created_principal = outcome.created_principal;
295 record.receipt = outcome.receipt;
296 record.post_cycles = outcome.post_cycles;
297 record.progress_identity = Some(observed.progress_identity);
298 record.state = EffectState::Issued;
299 journal.stalled_observations = 0;
300 write_journal(&paths, &journal)?;
301 continue;
302 }
303
304 if record.progress_identity.as_deref() == Some(&observed.progress_identity) {
305 journal.stalled_observations = journal.stalled_observations.saturating_add(1);
306 } else {
307 record.progress_identity = Some(observed.progress_identity);
308 journal.stalled_observations = 0;
309 }
310 write_journal(&paths, &journal)?;
311 if journal.stalled_observations >= desired.maximum_stalled_observations {
312 return Err(EnsureWorkflowError::Stalled {
313 observations: journal.stalled_observations,
314 });
315 }
316 }
317 }
318
319 let mut terminal_state = state.clone();
320 publish_terminal_state(desired, &retained_plan, &mut terminal_state);
321 project_current_fleet_inventory(&terminal_state)?;
322 let terminal_observation = platform
323 .observe(&retained_plan.operation_id, &terminal_state)
324 .map_err(EnsureWorkflowError::Platform)?;
325 let artifacts = resolve_desired_artifacts(root, desired)?;
326 let protocol_actions = platform
327 .protocol_actions(&retained_plan.operation_id, &terminal_state)
328 .map_err(EnsureWorkflowError::Platform)?;
329 let converged = compile_plan(
330 desired,
331 &artifacts,
332 &protocol_actions,
333 desired_sha256,
334 requested_fleet,
335 &terminal_observation,
336 retained_plan.planned_at_time,
337 )?;
338 if converged
339 .canisters
340 .iter()
341 .any(|canister| !canister.actions.is_empty())
342 || !converged.protocol_actions.is_empty()
343 {
344 journal.completion = FleetEnsureCompletion::ReplanRequired;
345 journal.stalled_observations = 0;
346 write_journal(&paths, &journal)?;
347 return Err(EnsureWorkflowError::ConvergenceDrift);
348 }
349 let terminal_inventory = platform
350 .terminal_inventory(&retained_plan.operation_id, &terminal_state)
351 .map_err(EnsureWorkflowError::Platform)?;
352 let terminal_cycles = terminal_inventory.controlled_cycles_by_principal.clone();
353 merge_terminal_inventory(&mut terminal_state, terminal_inventory)?;
354 project_current_fleet_inventory(&terminal_state)?;
355 let mut final_observation = platform
356 .observe(&retained_plan.operation_id, &terminal_state)
357 .map_err(EnsureWorkflowError::Platform)?;
358 attach_terminal_cycles(&mut final_observation, terminal_cycles)?;
359 let actual_conservation =
360 verify_terminal_conservation(&retained_plan, &journal, &final_observation)?;
361 write_state(&paths, &terminal_state)?;
362 journal.completion = FleetEnsureCompletion::Converged;
363 journal.stalled_observations = 0;
364 write_journal(&paths, &journal)?;
365 Ok(FleetEnsureReport {
366 actual_conservation: Some(actual_conservation),
367 effects_applied: applied_count(&journal),
368 plan: retained_plan,
369 terminal: true,
370 })
371}
372
373fn verify_fresh_plan<P>(
374 root: &Path,
375 desired: &crate::fleet_ensure::model::DesiredFleet,
376 desired_sha256: &str,
377 requested_fleet: &str,
378 retained_plan: &FleetEnsurePlan,
379 platform: &mut P,
380 state: &FleetEnsureStateRecord,
381) -> Result<FleetObservation, EnsureWorkflowError<P::Error>>
382where
383 P: EnsurePlatform,
384{
385 let observation = platform
386 .observe(&retained_plan.operation_id, state)
387 .map_err(EnsureWorkflowError::Platform)?;
388 if observation.operator_cycles < retained_plan.conservation.maximum_operator_debit_cycles {
389 return Err(EnsureWorkflowError::InsufficientOperatorCycles {
390 actual: observation.operator_cycles,
391 required: retained_plan.conservation.maximum_operator_debit_cycles,
392 });
393 }
394 let artifacts = resolve_desired_artifacts(root, desired)?;
395 let protocol_actions = platform
396 .protocol_actions(&retained_plan.operation_id, state)
397 .map_err(EnsureWorkflowError::Platform)?;
398 let current = compile_plan(
399 desired,
400 &artifacts,
401 &protocol_actions,
402 desired_sha256,
403 requested_fleet,
404 &observation,
405 retained_plan.planned_at_time,
406 )?;
407 if current.plan_sha256 != retained_plan.plan_sha256
408 && !compatible_after_bounded_observation(retained_plan, ¤t, desired, &observation)
409 {
410 return Err(EnsureWorkflowError::DriftedBeforeApply);
411 }
412 Ok(observation)
413}
414
415fn compatible_after_bounded_observation(
416 retained: &FleetEnsurePlan,
417 current: &FleetEnsurePlan,
418 desired: &crate::fleet_ensure::model::DesiredFleet,
419 observation: &FleetObservation,
420) -> bool {
421 let Ok(maximum_observation_burn) = desired.maximum_observation_burn_cycles.parse::<u128>()
422 else {
423 return false;
424 };
425 if retained.canisters.len() != current.canisters.len() {
426 return false;
427 }
428 for (retained_canister, current_canister) in retained.canisters.iter().zip(¤t.canisters) {
429 let Some(decrease) = retained_canister
430 .observed_cycles
431 .checked_sub(current_canister.observed_cycles)
432 else {
433 return false;
434 };
435 if decrease > maximum_observation_burn {
436 return false;
437 }
438 }
439 if !retained_funding_remains_sufficient(retained, desired, observation) {
440 return false;
441 }
442 normalized_plan(retained) == normalized_plan(current)
443}
444
445fn retained_funding_remains_sufficient(
446 retained: &FleetEnsurePlan,
447 desired: &crate::fleet_ensure::model::DesiredFleet,
448 observation: &FleetObservation,
449) -> bool {
450 retained
451 .canisters
452 .iter()
453 .flat_map(|canister| &canister.actions)
454 .filter_map(|action| match action {
455 EnsureAction::Fund { amount, name, .. } => Some((amount, name)),
456 _ => None,
457 })
458 .all(|(amount, name)| {
459 let Some(live) = observation.canisters.get(name).and_then(Option::as_ref) else {
460 return false;
461 };
462 let Some(configured) = desired
463 .canisters
464 .iter()
465 .find(|configured| configured.name == *name)
466 else {
467 return false;
468 };
469 let Ok(minimum) = configured.minimum_cycles.parse::<u128>() else {
470 return false;
471 };
472 live.cycles
473 .checked_add(*amount)
474 .is_some_and(|funded| funded >= minimum)
475 })
476}
477
478fn normalized_plan(plan: &FleetEnsurePlan) -> FleetEnsurePlan {
479 let mut normalized = plan.clone();
480 normalized.plan_sha256.clear();
481 normalized.conservation = CycleConservation {
482 expected_post_operation_cycles: 0,
483 maximum_execution_burn_cycles: 0,
484 maximum_new_funding_cycles: 0,
485 maximum_operator_debit_cycles: 0,
486 maximum_unavoidable_fee_cycles: 0,
487 observed_controlled_cycles: 0,
488 retained_in_reused_canisters_cycles: 0,
489 scheduled_transfer_cycles: 0,
490 };
491 for canister in &mut normalized.canisters {
492 canister.observed_cycles = 0;
493 for action in &mut canister.actions {
494 if let EnsureAction::Fund { amount, .. } = action {
495 *amount = 0;
496 }
497 }
498 }
499 normalized
500}
501
502fn attach_terminal_cycles<E>(
503 observation: &mut FleetObservation,
504 additional: BTreeMap<String, u128>,
505) -> Result<(), EnsureWorkflowError<E>>
506where
507 E: std::error::Error + 'static,
508{
509 let mut retained = BTreeMap::new();
510 for (principal, cycles) in additional {
511 let Some(configured) = observation
512 .canisters
513 .values_mut()
514 .filter_map(Option::as_mut)
515 .find(|canister| canister.principal == principal)
516 else {
517 retained.insert(principal, cycles);
518 continue;
519 };
520 if configured.root_owned_lifecycle
521 != Some(crate::fleet_ensure::model::RootOwnedCanisterLifecycle::Workload)
522 {
523 return Err(EnsureWorkflowError::TerminalInventory(
524 "terminal cycle observation duplicates a configured canister outside its exact Root-owned workload lifecycle"
525 .to_string(),
526 ));
527 }
528 configured.cycles = configured.cycles.min(cycles);
532 }
533 observation.additional_controlled_cycles = retained;
534 Ok(())
535}
536
537fn verify_terminal_conservation<E>(
538 plan: &FleetEnsurePlan,
539 journal: &FleetEnsureJournalRecord,
540 terminal: &FleetObservation,
541) -> Result<ActualCycleConservation, EnsureWorkflowError<E>>
542where
543 E: std::error::Error + 'static,
544{
545 let final_controlled_cycles = controlled_cycles(terminal)?;
546 let operator_debit_cycles = journal
547 .initial_operator_cycles
548 .checked_sub(terminal.operator_cycles)
549 .ok_or_else(|| {
550 EnsureWorkflowError::Conservation(
551 "operator balance increased during apply; review a new plan".to_string(),
552 )
553 })?;
554 if operator_debit_cycles > plan.conservation.maximum_operator_debit_cycles {
555 return Err(EnsureWorkflowError::Conservation(format!(
556 "operator debit {operator_debit_cycles} exceeded reviewed maximum {}",
557 plan.conservation.maximum_operator_debit_cycles
558 )));
559 }
560 let received_new_funding_cycles = operator_debit_cycles
561 .checked_sub(plan.conservation.maximum_unavoidable_fee_cycles)
562 .ok_or_else(|| {
563 EnsureWorkflowError::Conservation(
564 "operator debit was below the exact reviewed fee total".to_string(),
565 )
566 })?;
567 if received_new_funding_cycles > plan.conservation.maximum_new_funding_cycles {
568 return Err(EnsureWorkflowError::Conservation(format!(
569 "received funding {received_new_funding_cycles} exceeded reviewed maximum {}",
570 plan.conservation.maximum_new_funding_cycles
571 )));
572 }
573 let available = journal
574 .initial_controlled_cycles
575 .checked_add(received_new_funding_cycles)
576 .ok_or_else(|| {
577 EnsureWorkflowError::Conservation(
578 "terminal controlled-cycle arithmetic overflowed".to_string(),
579 )
580 })?;
581 let measured_execution_burn_cycles = available
582 .checked_sub(final_controlled_cycles)
583 .ok_or_else(|| {
584 EnsureWorkflowError::Conservation(
585 "terminal estate contains more cycles than starting estate plus received funding"
586 .to_string(),
587 )
588 })?;
589 if measured_execution_burn_cycles > plan.conservation.maximum_execution_burn_cycles {
590 return Err(EnsureWorkflowError::Conservation(format!(
591 "measured execution burn {measured_execution_burn_cycles} exceeded reviewed maximum {}",
592 plan.conservation.maximum_execution_burn_cycles
593 )));
594 }
595 Ok(ActualCycleConservation {
596 exact_unavoidable_fee_cycles: plan.conservation.maximum_unavoidable_fee_cycles,
597 final_controlled_cycles,
598 measured_execution_burn_cycles,
599 observed_starting_cycles: journal.initial_controlled_cycles,
600 operator_debit_cycles,
601 received_new_funding_cycles,
602 })
603}
604
605fn controlled_cycles<E>(observation: &FleetObservation) -> Result<u128, EnsureWorkflowError<E>>
606where
607 E: std::error::Error + 'static,
608{
609 observation
610 .canisters
611 .values()
612 .filter_map(Option::as_ref)
613 .try_fold(0_u128, |total, live| total.checked_add(live.cycles))
614 .and_then(|total| {
615 observation
616 .additional_controlled_cycles
617 .values()
618 .try_fold(total, |subtotal, cycles| subtotal.checked_add(*cycles))
619 })
620 .ok_or_else(|| {
621 EnsureWorkflowError::Conservation("controlled-cycle total overflowed".to_string())
622 })
623}
624
625fn verified_plan<E>(plan: FleetEnsurePlan) -> Result<FleetEnsurePlan, EnsureWorkflowError<E>>
626where
627 E: std::error::Error + 'static,
628{
629 if expected_plan_sha256(&plan) != plan.plan_sha256 {
630 return Err(EnsureWorkflowError::PlanIntegrity);
631 }
632 Ok(plan)
633}
634
635fn verify_journal<E>(
636 journal: &FleetEnsureJournalRecord,
637 plan: &FleetEnsurePlan,
638 requested_fleet: &str,
639) -> Result<(), EnsureWorkflowError<E>>
640where
641 E: std::error::Error + 'static,
642{
643 if journal.fleet != requested_fleet
644 || journal.operation_id != plan.operation_id
645 || journal.plan_sha256 != plan.plan_sha256
646 {
647 return Err(EnsureWorkflowError::JournalIntegrity);
648 }
649 Ok(())
650}
651
652fn publish_terminal_state(
653 desired: &crate::fleet_ensure::model::DesiredFleet,
654 plan: &FleetEnsurePlan,
655 state: &mut FleetEnsureStateRecord,
656) {
657 for canister in &plan.canisters {
658 match canister.disposition {
659 CanisterDisposition::Create | CanisterDisposition::Replace => {
660 if let Some(principal) = state.pending_principals.remove(&canister.name) {
661 state.principals.insert(canister.name.clone(), principal);
662 }
663 }
664 CanisterDisposition::Reinstall | CanisterDisposition::Reuse => {
665 if let Some(principal) = state
666 .pending_principals
667 .remove(&canister.name)
668 .or_else(|| canister.principal.clone())
669 {
670 state.principals.insert(canister.name.clone(), principal);
671 }
672 }
673 CanisterDisposition::Delete => {
674 state.principals.remove(&canister.name);
675 state.pending_principals.remove(&canister.name);
676 }
677 }
678 }
679 state.topology = desired
680 .canisters
681 .iter()
682 .filter(|canister| {
683 canister.presence == crate::fleet_ensure::model::DesiredPresence::Present
684 })
685 .map(|canister| {
686 (
687 canister.name.clone(),
688 crate::fleet_ensure::model::FleetEnsureTopologyRecord {
689 kind: canister.kind,
690 module_hash: None,
691 parent: canister.parent.clone(),
692 protocol_binding: canister.protocol_binding.clone(),
693 role: canister
694 .protocol_binding
695 .as_ref()
696 .map(|binding| binding.role.to_string()),
697 },
698 )
699 })
700 .collect();
701}
702
703fn merge_terminal_inventory<E>(
704 state: &mut FleetEnsureStateRecord,
705 inventory: crate::fleet_ensure::ops::TerminalFleetInventory,
706) -> Result<(), EnsureWorkflowError<E>>
707where
708 E: std::error::Error + 'static,
709{
710 if inventory.entries.is_empty() {
711 state.active_registry = inventory.active_registry;
712 return Ok(());
713 }
714 let entries = inventory.entries;
715 let mut names_by_principal = state
716 .principals
717 .iter()
718 .map(|(name, principal)| (principal.clone(), name.clone()))
719 .collect::<BTreeMap<_, _>>();
720 if names_by_principal.len() != state.principals.len() {
721 return Err(EnsureWorkflowError::TerminalInventory(
722 "one Principal is retained under more than one desired role".to_string(),
723 ));
724 }
725 for entry in &entries {
726 names_by_principal
727 .entry(entry.pid.clone())
728 .or_insert_with(|| format!("observed:{}", entry.pid));
729 }
730 for entry in entries {
731 let name = names_by_principal.get(&entry.pid).cloned().ok_or_else(|| {
732 EnsureWorkflowError::TerminalInventory(
733 "verified entry lost its Principal binding".to_string(),
734 )
735 })?;
736 let parent = entry
737 .parent_pid
738 .as_ref()
739 .map(|principal| {
740 names_by_principal.get(principal).cloned().ok_or_else(|| {
741 EnsureWorkflowError::TerminalInventory(format!(
742 "Canister {} names unknown parent {principal}",
743 entry.pid
744 ))
745 })
746 })
747 .transpose()?;
748 let existing = state.topology.get(&name);
749 if existing
750 .and_then(|topology| topology.parent.as_ref())
751 .zip(parent.as_ref())
752 .is_some_and(|(expected, actual)| expected != actual)
753 {
754 return Err(EnsureWorkflowError::TerminalInventory(format!(
755 "Canister {} conflicts with retained parent authority",
756 entry.pid
757 )));
758 }
759 let kind = existing.map_or_else(
760 || {
761 if entry.module_hash.is_some() {
762 crate::fleet_ensure::model::DesiredCanisterKind::Component
763 } else {
764 crate::fleet_ensure::model::DesiredCanisterKind::Auxiliary
765 }
766 },
767 |topology| {
768 if topology.kind == crate::fleet_ensure::model::DesiredCanisterKind::Pool
769 && entry.module_hash.is_some()
770 && entry.protocol_binding.is_some()
771 {
772 crate::fleet_ensure::model::DesiredCanisterKind::Component
773 } else {
774 topology.kind
775 }
776 },
777 );
778 state.principals.insert(name.clone(), entry.pid);
779 state.topology.insert(
780 name,
781 crate::fleet_ensure::model::FleetEnsureTopologyRecord {
782 kind,
783 module_hash: entry.module_hash,
784 parent,
785 protocol_binding: entry.protocol_binding,
786 role: entry.role,
787 },
788 );
789 }
790 state.active_registry = inventory.active_registry;
791 Ok(())
792}
793
794fn applied_count(journal: &FleetEnsureJournalRecord) -> u32 {
795 u32::try_from(
796 journal
797 .effects
798 .iter()
799 .filter(|effect| matches!(effect.state, EffectState::Applied))
800 .count(),
801 )
802 .unwrap_or(u32::MAX)
803}
804
805fn ordered_actions(plan: &FleetEnsurePlan) -> Vec<&EnsureAction> {
806 let mut actions = plan
807 .canisters
808 .iter()
809 .flat_map(|canister| canister.actions.iter())
810 .chain(plan.protocol_actions.iter())
811 .collect::<Vec<_>>();
812 actions.sort_by_key(|action| match action {
813 EnsureAction::Create { .. } => 0,
814 EnsureAction::Fund { .. } => 1,
815 EnsureAction::Install {
816 canic_init: Some(crate::fleet_ensure::model::DesiredCanisterInit::Store { .. }),
817 ..
818 } => 4,
819 EnsureAction::Install { .. } => 2,
820 EnsureAction::FleetProtocol { action, .. }
821 if matches!(
822 action.as_ref(),
823 crate::fleet_ensure::model::CurrentFleetProtocolAction::AdoptStore { .. }
824 ) =>
825 {
826 3
827 }
828 EnsureAction::SetControllers { .. } | EnsureAction::Start { .. } => 5,
829 EnsureAction::FleetProtocol { .. } | EnsureAction::Protocol { .. } => 6,
830 EnsureAction::Transfer { .. } => 7,
831 EnsureAction::Stop { .. } => 8,
832 EnsureAction::Delete { .. } => 9,
833 });
834 actions
835}