1pub(super) mod recovery;
8pub(super) mod reinstall;
9pub(in crate::fleet_ensure) mod root_reinstall;
10
11use crate::{
12 component_topology::{
13 RootPoolImportCapacityError, RootPoolImportCapacityInput,
14 validate_root_pool_import_capacity,
15 },
16 fleet_ensure::model::{
17 CanisterCyclePolicy, CanisterDisposition, CanisterPlan, CanisterRuntimeStatus,
18 CurrentFleetProtocolAction, CycleConservation, DesiredCanisterKind, DesiredFleet,
19 DesiredFleetArtifacts, DesiredFleetBootstrapRoot, DesiredPresence, EnsureAction,
20 EstateFundingDomainPlan, EstatePoolAssetLifecycle, FLEET_ENSURE_SCHEMA_VERSION,
21 FleetEnsurePlan, FleetEnsurePlanScope, FleetEnsureStateRecord, FleetObservation,
22 InstallMode, LiveCanister, MAX_FLEET_ENSURE_CANISTERS, MAX_FLEET_ENSURE_PROTOCOL_STEPS,
23 PoolFundingAuthority, RetainedRootStartAuthorityRecord, RootManagementBinding,
24 RootManagementObservation, RootOwnedCanisterLifecycle,
25 },
26};
27use candid::Principal;
28use canic_core::{
29 cdk::types::Cycles,
30 control_plane_support::model::fleet_funding_policy::{
31 FleetFundingPolicyValidationError, validate_coordinator_root_funding_policy,
32 validate_fleet_root_funding_capacity, validate_fleet_subnet_root_funding_authority,
33 },
34 ids::{FleetName, FleetSubnetCanisterPoolConfig},
35};
36use sha2::{Digest, Sha256};
37use std::collections::{BTreeMap, BTreeSet};
38use thiserror::Error as ThisError;
39
40#[derive(Debug, Eq, PartialEq, ThisError)]
43pub enum EnsurePolicyError {
44 #[error(
45 "Root {root} reset omits known controlled assets {missing_principals:?}; refresh the estate seed and matching policy imports, regenerate desired state, and review before reinstall"
46 )]
47 IncompleteRootEstate {
48 root: String,
49 missing_principals: Vec<String>,
50 },
51
52 #[error(
53 "Root {root} requires {workloads} Workloads plus {ready_floor} Ready assets, exceeding pool maximum {maximum_size}"
54 )]
55 TerminalPoolCapacity {
56 root: String,
57 workloads: u32,
58 ready_floor: u32,
59 maximum_size: u32,
60 },
61 #[error("invalid protected funding policy: {0}")]
62 FundingPolicy(#[from] FleetFundingPolicyValidationError),
63
64 #[error("cycle arithmetic overflow while compiling {field}")]
65 ArithmeticOverflow { field: &'static str },
66
67 #[error(
68 "Fleet cycle-conservation headroom is insufficient: available {available} cycles after reviewed funding, required maximum burn {required}, shortfall {shortfall}, actions {action_count}; no plan or effect was authorized"
69 )]
70 InsufficientCycleConservation {
71 action_count: usize,
72 available: u128,
73 required: u128,
74 shortfall: u128,
75 },
76
77 #[error("controlled canister {name} has duplicate name or principal authority")]
78 DuplicateAuthority { name: String },
79
80 #[error("controlled canister {name} references unavailable controller canister {controller}")]
81 ControllerCanisterUnavailable { controller: String, name: String },
82
83 #[error("desired Fleet has invalid or anonymous Principal for {field}: {value}")]
84 InvalidPrincipal { field: String, value: String },
85
86 #[error("desired Fleet contains an empty canister or drain-method name")]
87 EmptyName,
88
89 #[error("desired Fleet has unsafe {field} path label: {value}")]
90 UnsafePathLabel { field: &'static str, value: String },
91
92 #[error("controlled canister {name} has invalid {field} cycle value: {value}")]
93 InvalidCanisterCycles {
94 field: &'static str,
95 name: String,
96 value: String,
97 },
98
99 #[error("desired Fleet has invalid {field} cycle value: {value}")]
100 InvalidFleetCycles { field: &'static str, value: String },
101
102 #[error("desired Fleet estate-funding topology is invalid: {reason}")]
103 EstateFundingTopology { reason: String },
104
105 #[error(
106 "Root {root} pool cannot satisfy current desired demand: occupied {occupied_assets}/{maximum_size}, eligible Ready {eligible_ready_assets}, allocated Workloads {allocated_workloads}, pending creations {pending_creations}, required new creations {required_creation_count}, available slots {available_slots}, capacity shortfall {capacity_shortfall}; repair or retire retained pool assets before funding"
107 )]
108 EstatePoolCapacity {
109 allocated_workloads: u32,
110 available_slots: u32,
111 capacity_shortfall: u32,
112 eligible_ready_assets: u32,
113 maximum_size: u32,
114 occupied_assets: u32,
115 pending_creations: u32,
116 required_creation_count: u32,
117 root: String,
118 },
119
120 #[error("live Cycles Ledger fee is {actual}, but reviewed desired fee is {expected}")]
121 LedgerFeeDrift { actual: u128, expected: u128 },
122
123 #[error("desired Fleet has {actual} controlled canisters, above limit {maximum}")]
124 TooManyCanisters { actual: usize, maximum: usize },
125
126 #[error("desired Fleet has {actual} protocol steps, above limit {maximum}")]
127 TooManyProtocolSteps { actual: usize, maximum: usize },
128
129 #[error("desired Fleet maximum_stalled_observations must be greater than zero")]
130 InvalidStallBound,
131
132 #[error("controlled canister {name} initial cycles are below its minimum cycles")]
133 InitialCyclesBelowMinimum { name: String },
134
135 #[error(
136 "fresh pool canister {name} requests {creation_funding_cycles} creation cycles for readiness floor {readiness_floor_cycles}, but its bounded pre-import burn is {admissible_burn_cycles} cycles; required funding {required_creation_funding_cycles}, shortfall {shortfall_cycles}; no effect was authorized"
137 )]
138 FreshPoolCreationFundingInsufficient {
139 admissible_burn_cycles: u128,
140 creation_funding_cycles: u128,
141 name: String,
142 readiness_floor_cycles: u128,
143 required_creation_funding_cycles: u128,
144 shortfall_cycles: u128,
145 },
146
147 #[error(
148 "live observation for controlled canister {name} returned Principal {actual}, expected {expected}"
149 )]
150 ObservationAuthorityMismatch {
151 actual: String,
152 expected: String,
153 name: String,
154 },
155
156 #[error("desired Fleet name {actual} does not match requested Fleet {expected}")]
157 FleetMismatch { actual: String, expected: String },
158
159 #[error("controlled canister {name} is missing exact live observation authority")]
160 MissingObservation { name: String },
161
162 #[error("Fleet treasury {treasury} is not an explicitly controlled present canister")]
163 MissingTreasury { treasury: String },
164
165 #[error("Fleet treasury {treasury} must be reused and cannot be replaced")]
166 TreasuryReplacement { treasury: String },
167
168 #[error(
169 "present controlled canister {name} does not retain the configured operator as controller"
170 )]
171 MissingOperatorController { name: String },
172
173 #[error(
174 "controlled canister {name} retains {cycles} transferable cycles but has no exact treasury-bound drain authority; canister left untouched"
175 )]
176 NoSafeDrain { name: String, cycles: u128 },
177
178 #[error("controlled canister {name} names a drain destination other than Fleet treasury")]
179 WrongDrainDestination { name: String },
180
181 #[error("desired Fleet schema {actual} is unsupported; current schema is {expected}")]
182 WrongSchema { actual: u16, expected: u16 },
183
184 #[error("controlled canister {name} has no resolved current Wasm identity")]
185 MissingWasmIdentity { name: String },
186
187 #[error("controlled canister {name} has no resolved {kind} identity")]
188 MissingArtifactIdentity { kind: &'static str, name: String },
189
190 #[error("protocol step {step} targets unknown or absent controlled canister {canister}")]
191 InvalidProtocolTarget { canister: String, step: String },
192
193 #[error("protocol step {0} has duplicate or empty authority")]
194 InvalidProtocolStep(String),
195
196 #[error("protocol step {0} is missing an exact live terminal observation")]
197 MissingProtocolObservation(String),
198
199 #[error("controlled canister {0} must configure init_arg and init_candid together")]
200 IncompleteInitTemplate(String),
201
202 #[error("Canic infrastructure canister {name} requires one exact typed bootstrap initializer")]
203 MissingCanicInitializer { name: String },
204
205 #[error("Canic infrastructure canister {name} has conflicting typed and generic initializers")]
206 ConflictingCanicInitializer { name: String },
207
208 #[error("retained pool asset {name} is missing; no replacement was created")]
209 MissingPoolAsset { name: String },
210
211 #[error("retained pool asset {name} has no exact Root-owned lifecycle observation")]
212 MissingPoolLifecycle { name: String },
213
214 #[error("canister {name} is not an exact present infrastructure reinstall target")]
215 InvalidInfrastructureReinstall { name: String },
216
217 #[error("Root-owned canister {name} is still awaiting exact current balance observation")]
218 PendingRootOwnedBalance { name: String },
219
220 #[error("desired Fleet topology is invalid for {name}: {reason}")]
221 InvalidTopology { name: String, reason: &'static str },
222
223 #[error("management observation for retained Root {name} is missing")]
224 MissingRootManagementObservation { name: String },
225
226 #[error("management observation for retained Root {name} has invalid {field}")]
227 RootManagementAuthorityMismatch { field: &'static str, name: String },
228
229 #[error("retained Root {name} is stopping; wait for terminal Stopped state")]
230 RootStopping { name: String },
231
232 #[error(transparent)]
233 PoolImportCapacity(#[from] RootPoolImportCapacityError),
234}
235
236#[derive(Clone, Copy)]
237struct CycleBounds {
238 ledger_fee: u128,
239 management_creation_fee: u128,
240 material_threshold: u128,
241 observation_burn: u128,
242 update_burn: u128,
243}
244
245struct PlanAccumulator {
246 canisters: Vec<CanisterPlan>,
247 execution_burn: u128,
248 fees: u128,
249 new_funding: u128,
250 retained: u128,
251 transfers: u128,
252}
253
254impl PlanAccumulator {
255 const fn new() -> Self {
256 Self {
257 canisters: Vec::new(),
258 execution_burn: 0,
259 fees: 0,
260 new_funding: 0,
261 retained: 0,
262 transfers: 0,
263 }
264 }
265
266 fn add_burn(&mut self, value: u128) -> Result<(), EnsurePolicyError> {
267 self.execution_burn = checked_add(self.execution_burn, value, "execution burn")?;
268 Ok(())
269 }
270
271 fn add_fee(&mut self, value: u128) -> Result<(), EnsurePolicyError> {
272 self.fees = checked_add(self.fees, value, "unavoidable fees")?;
273 Ok(())
274 }
275
276 fn add_funding(&mut self, value: u128) -> Result<(), EnsurePolicyError> {
277 self.new_funding = checked_add(self.new_funding, value, "new funding")?;
278 Ok(())
279 }
280}
281
282#[expect(
284 clippy::too_many_lines,
285 reason = "one pure compiler keeps the complete reviewed conservation and action authority visible"
286)]
287#[expect(
288 clippy::too_many_arguments,
289 reason = "explicit operation identity also binds deliberate same-release resets"
290)]
291pub fn compile_plan(
292 desired: &DesiredFleet,
293 artifacts: &DesiredFleetArtifacts,
294 compiled_protocol_actions: &[EnsureAction],
295 desired_sha256: &str,
296 requested_fleet: &str,
297 observation: &FleetObservation,
298 created_at_time: u64,
299 operation_id: &str,
300 reinstall: Option<&crate::fleet_ensure::model::FleetReinstallRecord>,
301) -> Result<FleetEnsurePlan, EnsurePolicyError> {
302 validate_authority(desired, requested_fleet)?;
303 validate_observation_authority(desired, observation)?;
304 let bounds = cycle_bounds(desired)?;
305 if observation.ledger_fee_cycles != bounds.ledger_fee {
306 return Err(EnsurePolicyError::LedgerFeeDrift {
307 actual: observation.ledger_fee_cycles,
308 expected: bounds.ledger_fee,
309 });
310 }
311 let operation_id = operation_id.to_string();
312 let mut accumulator = PlanAccumulator::new();
313 let reviewed_reinstalls = if let Some(intent) = reinstall {
314 reinstall::validate_reset(desired, artifacts, observation, intent, &operation_id)?
315 } else {
316 reviewed_reinstall_canisters(desired, observation)?
317 };
318
319 for (index, configured) in desired.canisters.iter().enumerate() {
320 let observed = observation
321 .canisters
322 .get(&configured.name)
323 .ok_or_else(|| EnsurePolicyError::MissingObservation {
324 name: configured.name.clone(),
325 })?
326 .as_ref();
327 let cycle_policy = canister_cycle_policy(configured)?;
328 let action_time = created_at_time
329 .checked_add(u64::try_from(index).unwrap_or(u64::MAX))
330 .ok_or(EnsurePolicyError::ArithmeticOverflow {
331 field: "action timestamp",
332 })?;
333 let mut plan = compile_canister(
334 desired,
335 artifacts,
336 configured,
337 observed,
338 observation,
339 cycle_policy,
340 bounds,
341 action_time,
342 reviewed_reinstalls.contains(&configured.name),
343 &mut accumulator,
344 )?;
345 if let Some(intent) = reinstall {
346 reinstall::bind_history_witness(desired, artifacts, intent, configured, &mut plan)?;
347 }
348 accumulator.canisters.push(plan);
349 }
350 for name in &reviewed_reinstalls {
351 let has_exact_reinstall = accumulator.canisters.iter().any(|canister| {
352 canister.name == *name
353 && canister.actions.iter().any(|action| {
354 matches!(
355 action,
356 EnsureAction::Install {
357 mode: InstallMode::Reinstall,
358 ..
359 }
360 )
361 })
362 });
363 if !has_exact_reinstall {
364 return Err(EnsurePolicyError::InvalidInfrastructureReinstall { name: name.clone() });
365 }
366 }
367
368 let mut protocol_actions = Vec::new();
369 let mut protocol_names = BTreeSet::new();
370 for step in &desired.protocol_steps {
371 let ready = observation
372 .protocol_ready
373 .get(&step.name)
374 .copied()
375 .ok_or_else(|| EnsurePolicyError::MissingProtocolObservation(step.name.clone()))?;
376 if ready {
377 continue;
378 }
379 if !protocol_names.insert(step.name.as_str()) {
380 return Err(EnsurePolicyError::InvalidProtocolStep(step.name.clone()));
381 }
382 let configured = desired
383 .canisters
384 .iter()
385 .find(|canister| canister.name == step.canister)
386 .ok_or_else(|| EnsurePolicyError::InvalidProtocolTarget {
387 canister: step.canister.clone(),
388 step: step.name.clone(),
389 })?;
390 let principal = observation
391 .canisters
392 .get(&step.canister)
393 .and_then(Option::as_ref)
394 .map_or_else(
395 || format!("created:{}", step.canister),
396 |live| live.principal.clone(),
397 );
398 if configured.presence != DesiredPresence::Present {
399 return Err(EnsurePolicyError::InvalidProtocolTarget {
400 canister: step.canister.clone(),
401 step: step.name.clone(),
402 });
403 }
404 let identities = artifacts.protocol_by_step.get(&step.name).ok_or_else(|| {
405 EnsurePolicyError::MissingArtifactIdentity {
406 kind: "protocol contract",
407 name: step.name.clone(),
408 }
409 })?;
410 let maximum_execution_burn_cycles = parse_canister_cycles(
411 &step.canister,
412 "protocol.maximum_execution_burn_cycles",
413 &step.maximum_execution_burn_cycles,
414 )?;
415 protocol_actions.push(EnsureAction::Protocol {
416 candid: step.candid.clone(),
417 candid_sha256: identities.candid_sha256.clone(),
418 command_args: step.command_args.clone(),
419 command_args_sha256: identities.command_args_sha256.clone(),
420 command_method: step.command_method.clone(),
421 expected_status: step.expected_status.clone(),
422 expected_status_sha256: identities.expected_status_sha256.clone(),
423 maximum_execution_burn_cycles,
424 name: step.name.clone(),
425 principal,
426 status_args: step.status_args.clone(),
427 status_args_sha256: identities.status_args_sha256.clone(),
428 status_method: step.status_method.clone(),
429 });
430 }
431 let expected_typed_protocol = desired.protocol.as_ref();
432 for action in compiled_protocol_actions {
433 let EnsureAction::FleetProtocol {
434 action: current_action,
435 candid,
436 name,
437 principal,
438 ..
439 } = action
440 else {
441 return Err(EnsurePolicyError::InvalidProtocolStep(
442 action.name().to_string(),
443 ));
444 };
445 let target_kind = current_action.target_kind();
446 let target_is_exact = desired.canisters.iter().any(|canister| {
447 canister.kind == target_kind
448 && canister.presence == DesiredPresence::Present
449 && observation
450 .canisters
451 .get(&canister.name)
452 .and_then(Option::as_ref)
453 .is_some_and(|live| live.principal == *principal)
454 });
455 let operation_matches = match current_action.as_ref() {
456 CurrentFleetProtocolAction::MaintainPoolReadiness {
457 maximum_updates,
458 minimum_ready,
459 readiness_floor,
460 } => pool_readiness_authority_matches(
461 desired,
462 observation,
463 principal,
464 *minimum_ready,
465 readiness_floor,
466 Some(*maximum_updates),
467 ),
468 CurrentFleetProtocolAction::ObservePoolReadiness {
469 minimum_ready,
470 readiness_floor,
471 } => pool_readiness_authority_matches(
472 desired,
473 observation,
474 principal,
475 *minimum_ready,
476 readiness_floor,
477 None,
478 ),
479 CurrentFleetProtocolAction::ReconcilePoolAsset {
480 request,
481 minimum_cycles,
482 } => observation
483 .estate_funding_domains
484 .iter()
485 .any(|(_root, domain)| {
486 domain.root_principal.as_deref() == Some(principal)
487 && domain.pool.as_ref().is_some_and(|pool| {
488 minimum_cycles.to_u128() == pool.readiness_floor_cycles
489 && pool.assets.iter().any(|asset| {
490 asset.principal == request.canister_id.to_text()
491 && matches!(
492 asset.lifecycle,
493 EstatePoolAssetLifecycle::PendingReset
494 | EstatePoolAssetLifecycle::Failed
495 )
496 })
497 })
498 }),
499 CurrentFleetProtocolAction::ProvisionComponents { request, .. } => {
500 canic_core::cdk::utils::hash::decode_hex(&operation_id)
501 .ok()
502 .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok())
503 .is_some_and(|operation_id| request.operation_id == operation_id)
504 }
505 current_action => current_action
506 .operation_id()
507 .is_none_or(|identity| identity != [0; 32]),
508 };
509 let protocol_matches = expected_typed_protocol.is_some_and(|protocol| {
510 let expected_candid = match target_kind {
511 DesiredCanisterKind::Coordinator => protocol.coordinator_candid.as_str(),
512 DesiredCanisterKind::Root => protocol.root_candid.as_str(),
513 DesiredCanisterKind::Store => protocol.store_candid.as_str(),
514 DesiredCanisterKind::Auxiliary
515 | DesiredCanisterKind::Component
516 | DesiredCanisterKind::Pool => return false,
517 };
518 candid == expected_candid
519 });
520 if name.is_empty()
521 || !protocol_names.insert(name.as_str())
522 || !target_is_exact
523 || !operation_matches
524 || !protocol_matches
525 {
526 return Err(EnsurePolicyError::InvalidProtocolStep(name.clone()));
527 }
528 protocol_actions.push(action.clone());
529 }
530 if protocol_actions.len() > MAX_FLEET_ENSURE_PROTOCOL_STEPS {
531 return Err(EnsurePolicyError::TooManyProtocolSteps {
532 actual: protocol_actions.len(),
533 maximum: MAX_FLEET_ENSURE_PROTOCOL_STEPS,
534 });
535 }
536 if reviewed_reinstalls.is_empty() && protocol_actions.is_empty() {
537 let pending = desired.canisters.iter().find_map(|configured| {
538 observation
539 .canisters
540 .get(&configured.name)
541 .and_then(Option::as_ref)
542 .is_some_and(|live| {
543 live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Retained)
544 })
545 .then(|| configured.name.clone())
546 });
547 if let Some(name) = pending
548 && !retained_root_observation_is_start_only(
549 desired,
550 &accumulator.canisters,
551 observation,
552 )
553 {
554 return Err(EnsurePolicyError::PendingRootOwnedBalance { name });
555 }
556 }
557
558 let observed_native_cycles = observation
559 .canisters
560 .values()
561 .filter_map(Option::as_ref)
562 .try_fold(0_u128, |total, live| {
563 checked_add(total, live.cycles, "observed controlled cycles")
564 })?;
565 let observed_native_cycles = observation
566 .additional_controlled_cycles
567 .values()
568 .try_fold(observed_native_cycles, |total, cycles| {
569 checked_add(total, *cycles, "observed controlled cycles")
570 })?;
571 tranche_protocol_actions(
572 desired,
573 observation,
574 bounds,
575 observed_native_cycles,
576 &mut accumulator,
577 &mut protocol_actions,
578 )?;
579
580 let continuation = artifacts.continuation.clone().filter(|_| {
581 !accumulator.canisters.is_empty()
582 && (accumulator
583 .canisters
584 .iter()
585 .all(|canister| canister.disposition == CanisterDisposition::Create)
586 || (accumulator
587 .canisters
588 .iter()
589 .any(|canister| canister.disposition == CanisterDisposition::Reinstall)
590 && accumulator.canisters.iter().all(|canister| {
591 matches!(
592 canister.disposition,
593 CanisterDisposition::Reuse | CanisterDisposition::Reinstall
594 )
595 })))
596 && desired.protocol_steps.is_empty()
597 && protocol_actions.is_empty()
598 });
599 let reconciliation_only = !protocol_actions.is_empty() && protocol_actions.iter().all(|action| matches!(action,
600 EnsureAction::FleetProtocol { action, .. } if matches!(action.as_ref(), CurrentFleetProtocolAction::ReconcilePoolAsset { .. })
601 ));
602 append_pool_reconciliation_funding(
603 desired,
604 observation,
605 &protocol_actions,
606 bounds,
607 created_at_time,
608 &mut accumulator,
609 )?;
610 let defer_pool_creation = reconciliation_only || continuation.is_some();
613 let estate_funding_domains =
614 compile_estate_funding_domains(desired, observation, bounds, defer_pool_creation)?;
615 append_estate_funding_actions(
616 &estate_funding_domains,
617 created_at_time,
618 desired.canisters.len(),
619 &mut accumulator,
620 )?;
621 let observation_count = maximum_observation_count(
622 desired,
623 &accumulator.canisters,
624 &protocol_actions,
625 observation.additional_controlled_cycles.len(),
626 )?;
627 accumulator.add_burn(
628 bounds
629 .observation_burn
630 .checked_mul(observation_count)
631 .ok_or(EnsurePolicyError::ArithmeticOverflow {
632 field: "observation burn",
633 })?,
634 )?;
635
636 let observed_estate_funding_cycles =
637 estate_funding_domains
638 .iter()
639 .try_fold(0_u128, |total, domain| {
640 checked_add(
641 total,
642 domain.available_cycles.unwrap_or_default(),
643 "observed estate funding cycles",
644 )
645 })?;
646 let maximum_estate_creation_fee_cycles =
647 estate_funding_domains
648 .iter()
649 .try_fold(0_u128, |total, domain| {
650 checked_add(
651 total,
652 domain.maximum_creation_fee_cycles,
653 "maximum estate creation fees",
654 )
655 })?;
656 let observed_controlled_cycles = checked_add(
657 observed_native_cycles,
658 observed_estate_funding_cycles,
659 "observed controlled cycles",
660 )?;
661 let maximum_operator_debit_cycles = checked_add(
662 accumulator.new_funding,
663 accumulator.fees,
664 "maximum operator debit",
665 )?;
666 if observation.operator_cycles < maximum_operator_debit_cycles {
667 }
670 let available_after_operator_funding = checked_add(
671 observed_controlled_cycles,
672 accumulator.new_funding,
673 "cycle conservation available balance",
674 )?;
675 let available_after_estate_fees = available_after_operator_funding
676 .checked_sub(maximum_estate_creation_fee_cycles)
677 .ok_or(EnsurePolicyError::ArithmeticOverflow {
678 field: "estate creation fees",
679 })?;
680 let recovery_review = continuation
681 .as_ref()
682 .map(|authority| {
683 recovery::review(
684 observation,
685 bounds,
686 authority.maximum_successor_actions,
687 accumulator.execution_burn,
688 available_after_estate_fees,
689 )
690 })
691 .transpose()?
692 .map(Box::new);
693 if let Some(review) = &recovery_review {
694 accumulator.add_burn(review.continuation_reserve_cycles)?;
695 }
696 let expected_post_operation_cycles = available_after_estate_fees
697 .checked_sub(accumulator.execution_burn)
698 .ok_or_else(|| {
699 insufficient_cycle_conservation(
700 &accumulator,
701 protocol_actions.len(),
702 available_after_operator_funding,
703 accumulator.execution_burn,
704 )
705 })?;
706 let conservation = CycleConservation {
707 estate_funding_domains,
708 expected_post_operation_cycles,
709 maximum_execution_burn_cycles: accumulator.execution_burn,
710 maximum_new_funding_cycles: accumulator.new_funding,
711 maximum_operator_debit_cycles,
712 maximum_unavoidable_fee_cycles: accumulator.fees,
713 observed_controlled_cycles,
714 retained_in_reused_canisters_cycles: accumulator.retained,
715 scheduled_transfer_cycles: accumulator.transfers,
716 };
717 let mut plan = FleetEnsurePlan {
718 continuation,
719 canisters: accumulator.canisters,
720 conservation,
721 desired_sha256: desired_sha256.to_string(),
722 environment: desired.environment.clone(),
723 fleet: requested_fleet.to_string(),
724 operation_id,
725 plan_sha256: String::new(),
726 planned_at_time: created_at_time,
727 protocol_actions,
728 recovery_review,
729 reinstall: reinstall.cloned().map(Box::new),
730 root_reinstall_bindings: Vec::new(),
731 root_start_authority: None,
732 reviewed_desired: Some(Box::new(
733 crate::fleet_ensure::model::ReviewedDesiredFleetRecord::capture(desired),
734 )),
735 schema_version: FLEET_ENSURE_SCHEMA_VERSION,
736 scope: FleetEnsurePlanScope::Full,
737 terminal_inventory_operation_id: None,
738 };
739 plan.plan_sha256 = expected_plan_sha256(&plan);
740 Ok(plan)
741}
742
743fn pool_readiness_authority_matches(
744 desired: &DesiredFleet,
745 observation: &FleetObservation,
746 principal: &str,
747 minimum_ready: u32,
748 readiness_floor: &Cycles,
749 maximum_updates: Option<u32>,
750) -> bool {
751 let network_matches = desired.bootstrap.as_ref().is_some_and(|bootstrap| {
752 let mainnet =
753 bootstrap.canonical_network_id == canic_core::ids::CanonicalNetworkId::ic_mainnet();
754 (mainnet && !bootstrap.fresh_estate) == maximum_updates.is_some()
755 });
756 network_matches
757 && observation
758 .estate_funding_domains
759 .iter()
760 .any(|(root, domain)| {
761 domain.root_principal.as_deref() == Some(principal)
762 && desired_root_pool_policy(desired, root).is_ok_and(|pool| {
763 pool.minimum_size == minimum_ready
764 && pool.canister_cycles == *readiness_floor
765 && maximum_updates.is_none_or(|maximum| {
766 maximum == pool_maintenance_update_bound(minimum_ready)
767 })
768 })
769 })
770}
771
772fn append_pool_reconciliation_funding(
774 desired: &DesiredFleet,
775 observation: &FleetObservation,
776 protocol_actions: &[EnsureAction],
777 bounds: CycleBounds,
778 created_at_time: u64,
779 accumulator: &mut PlanAccumulator,
780) -> Result<(), EnsurePolicyError> {
781 for (index, action) in protocol_actions.iter().enumerate() {
782 let EnsureAction::FleetProtocol {
783 action,
784 principal: root,
785 name,
786 ..
787 } = action
788 else {
789 continue;
790 };
791 let CurrentFleetProtocolAction::ReconcilePoolAsset { request, .. } = action.as_ref() else {
792 continue;
793 };
794 let principal = request.canister_id.to_text();
795 let pool = observation
796 .estate_funding_domains
797 .values()
798 .find(|domain| domain.root_principal.as_deref() == Some(root))
799 .and_then(|domain| domain.pool.as_ref())
800 .ok_or_else(|| EnsurePolicyError::InvalidProtocolStep(name.clone()))?;
801 let asset = pool
802 .assets
803 .iter()
804 .find(|asset| asset.principal == principal)
805 .ok_or_else(|| EnsurePolicyError::InvalidProtocolStep(name.clone()))?;
806 let Some(funding) = recovery::pool_funding(pool, asset, root, bounds)? else {
807 continue;
808 };
809 let amount = funding.amount_cycles;
810 let deficit = funding.funding_deficit_cycles;
811 let margin = funding.funding_margin_cycles;
812 let expected_post_cycles = funding.expected_post_cycles;
813 let offset = desired
814 .canisters
815 .len()
816 .checked_add(index)
817 .and_then(|value| u64::try_from(value).ok())
818 .ok_or(EnsurePolicyError::ArithmeticOverflow {
819 field: "pool funding timestamp",
820 })?;
821 let action_time =
822 created_at_time
823 .checked_add(offset)
824 .ok_or(EnsurePolicyError::ArithmeticOverflow {
825 field: "pool funding timestamp",
826 })?;
827 let plan_index = if let Some(index) = accumulator
828 .canisters
829 .iter()
830 .position(|plan| plan.principal.as_deref() == Some(&principal))
831 {
832 index
833 } else {
834 accumulator.canisters.push(CanisterPlan {
835 actions: Vec::new(),
836 disposition: CanisterDisposition::Reuse,
837 name: name.clone(),
838 observed_cycles: asset.cycles,
839 principal: Some(principal.clone()),
840 });
841 accumulator.canisters.len() - 1
842 };
843 let target_name = accumulator.canisters[plan_index].name.clone();
844 if accumulator.canisters[plan_index]
846 .actions
847 .iter()
848 .any(|action| matches!(action, EnsureAction::Fund { .. }))
849 {
850 return Err(EnsurePolicyError::InvalidProtocolStep(name.clone()));
851 }
852 accumulator.canisters[plan_index]
853 .actions
854 .push(EnsureAction::Fund {
855 pool_funding: Some(PoolFundingAuthority {
856 root: root.clone(),
857 lifecycle: asset.lifecycle,
858 }),
859 amount,
860 created_at_time: action_time,
861 expected_post_cycles,
862 funding_deficit_cycles: deficit,
863 funding_margin_cycles: margin,
864 ledger: desired.cycles_ledger.clone(),
865 name: target_name,
866 principal,
867 });
868 accumulator.add_funding(amount)?;
869 accumulator.add_fee(bounds.ledger_fee)?;
870 }
871 Ok(())
872}
873
874fn append_estate_funding_actions(
875 domains: &[EstateFundingDomainPlan],
876 created_at_time: u64,
877 canister_count: usize,
878 accumulator: &mut PlanAccumulator,
879) -> Result<(), EnsurePolicyError> {
880 for (index, domain) in domains.iter().enumerate() {
881 if domain.shortfall_cycles == 0 {
882 continue;
883 }
884 let offset = canister_count
885 .checked_add(index)
886 .and_then(|value| u64::try_from(value).ok())
887 .ok_or(EnsurePolicyError::ArithmeticOverflow {
888 field: "estate funding action timestamp",
889 })?;
890 let action_time =
891 created_at_time
892 .checked_add(offset)
893 .ok_or(EnsurePolicyError::ArithmeticOverflow {
894 field: "estate funding action timestamp",
895 })?;
896 let expected_post_cycles = domain
897 .available_cycles
898 .unwrap_or_default()
899 .checked_add(domain.shortfall_cycles)
900 .ok_or(EnsurePolicyError::ArithmeticOverflow {
901 field: "estate funding destination balance",
902 })?;
903 let principal = domain
904 .root_principal
905 .clone()
906 .unwrap_or_else(|| format!("created:{}", domain.root));
907 let root_plan = accumulator
908 .canisters
909 .iter_mut()
910 .find(|canister| canister.name == domain.root)
911 .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
912 reason: format!("Root {} has no canister plan", domain.root),
913 })?;
914 root_plan.actions.push(EnsureAction::FundEstate {
915 amount: domain.shortfall_cycles,
916 created_at_time: action_time,
917 expected_post_cycles,
918 ledger: domain.cycles_ledger.clone(),
919 ledger_fee_cycles: domain.ledger_fee_cycles,
920 name: domain.root.clone(),
921 principal,
922 });
923 accumulator.add_funding(domain.shortfall_cycles)?;
924 accumulator.add_fee(domain.ledger_fee_cycles)?;
925 }
926 Ok(())
927}
928
929pub(crate) struct RootStartPlanInput<'a> {
931 pub state: &'a FleetEnsureStateRecord,
932 pub authority: Option<&'a RetainedRootStartAuthorityRecord>,
933 pub created_at_time: u64,
934 pub desired: &'a DesiredFleet,
935 pub desired_sha256: &'a str,
936 pub observation: &'a RootManagementObservation,
937 pub requested_fleet: &'a str,
938}
939
940fn root_management_principal<'a>(
941 configured: &'a crate::fleet_ensure::model::DesiredCanister,
942 state: &'a FleetEnsureStateRecord,
943 fleet: &str,
944) -> Result<&'a str, EnsurePolicyError> {
945 let retained = state
946 .pending_principals
947 .get(&configured.name)
948 .or_else(|| state.principals.get(&configured.name))
949 .map(String::as_str);
950 let configured_principal = configured.principal.as_deref();
951 let conflict = state.fleet != fleet
952 || matches!((configured_principal, retained), (Some(configured), Some(retained)) if configured != retained);
953 if conflict {
954 return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
955 field: "retained Principal",
956 name: configured.name.clone(),
957 });
958 }
959 configured_principal.or(retained).ok_or_else(|| {
960 EnsurePolicyError::RootManagementAuthorityMismatch {
961 field: "Principal",
962 name: configured.name.clone(),
963 }
964 })
965}
966
967pub(crate) fn compile_root_start_prerequisite_plan(
969 input: RootStartPlanInput<'_>,
970) -> Result<Option<FleetEnsurePlan>, EnsurePolicyError> {
971 compile_root_start_plan(&input, None)
972}
973
974pub(crate) fn recompile_root_start_prerequisite_plan(
976 input: RootStartPlanInput<'_>,
977 reviewed_targets: &BTreeSet<String>,
978) -> Result<FleetEnsurePlan, EnsurePolicyError> {
979 compile_root_start_plan(&input, Some(reviewed_targets))?.ok_or_else(|| {
980 EnsurePolicyError::MissingRootManagementObservation {
981 name: "reviewed Root-start target".to_string(),
982 }
983 })
984}
985
986#[expect(
987 clippy::too_many_lines,
988 reason = "one pure boundary validates and compiles the complete Root-start authority"
989)]
990fn compile_root_start_plan(
991 input: &RootStartPlanInput<'_>,
992 reviewed_targets: Option<&BTreeSet<String>>,
993) -> Result<Option<FleetEnsurePlan>, EnsurePolicyError> {
994 let RootStartPlanInput {
995 state,
996 authority,
997 created_at_time,
998 desired,
999 desired_sha256,
1000 observation,
1001 requested_fleet,
1002 } = *input;
1003 validate_authority(desired, requested_fleet)?;
1004 let bounds = cycle_bounds(desired)?;
1005 let configured_roots = desired
1006 .canisters
1007 .iter()
1008 .filter(|configured| {
1009 configured.kind == DesiredCanisterKind::Root
1010 && configured.presence == DesiredPresence::Present
1011 })
1012 .collect::<Vec<_>>();
1013 if observation.roots.len() != configured_roots.len() {
1014 return Err(EnsurePolicyError::MissingRootManagementObservation {
1015 name: "configured Root set".to_string(),
1016 });
1017 }
1018
1019 let mut canisters = Vec::new();
1020 let mut root_start_bindings = Vec::new();
1021 let mut observed_controlled_cycles = 0_u128;
1022 for configured in configured_roots {
1023 let observed = observation.roots.get(&configured.name).ok_or_else(|| {
1024 EnsurePolicyError::MissingRootManagementObservation {
1025 name: configured.name.clone(),
1026 }
1027 })?;
1028 let expected_principal = root_management_principal(configured, state, requested_fleet)?;
1029 if observed.name != configured.name || observed.live.principal != expected_principal {
1030 return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1031 field: "Principal",
1032 name: configured.name.clone(),
1033 });
1034 }
1035 if observed.subnet != configured.subnet {
1036 return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1037 field: "Subnet",
1038 name: configured.name.clone(),
1039 });
1040 }
1041 if observed.live.status == CanisterRuntimeStatus::Stopping {
1042 return Err(EnsurePolicyError::RootStopping {
1043 name: configured.name.clone(),
1044 });
1045 }
1046 let reviewed = reviewed_targets.is_some_and(|targets| targets.contains(&configured.name));
1047 let needs_start = observed.live.status == CanisterRuntimeStatus::Stopped;
1048 if reviewed_targets.is_some() && needs_start && !reviewed {
1049 return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1050 field: "reviewed runtime transition",
1051 name: configured.name.clone(),
1052 });
1053 }
1054 if !needs_start && !reviewed {
1055 continue;
1056 }
1057 if !configured.controller_canisters.is_empty() {
1058 return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1059 field: "controller authority",
1060 name: configured.name.clone(),
1061 });
1062 }
1063 let mut expected_controllers = configured.controllers.clone();
1064 expected_controllers.sort();
1065 let mut actual_controllers = observed.live.controllers.clone();
1066 actual_controllers.sort();
1067 if actual_controllers != expected_controllers {
1068 return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1069 field: "controllers",
1070 name: configured.name.clone(),
1071 });
1072 }
1073 let observed_module = observed.live.module_sha256.as_deref().ok_or_else(|| {
1074 EnsurePolicyError::RootManagementAuthorityMismatch {
1075 field: "module SHA-256",
1076 name: configured.name.clone(),
1077 }
1078 })?;
1079 root_start_bindings.push(RootManagementBinding {
1080 controllers: actual_controllers.clone(),
1081 name: configured.name.clone(),
1082 module_sha256: observed_module.to_string(),
1083 principal: observed.live.principal.clone(),
1084 subnet: observed.subnet.clone(),
1085 });
1086 if observed.live.root_owned_lifecycle.is_some() || observed.live.reinstall_required {
1087 return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1088 field: "runtime authority",
1089 name: configured.name.clone(),
1090 });
1091 }
1092 observed_controlled_cycles = checked_add(
1093 observed_controlled_cycles,
1094 observed.live.cycles,
1095 "Root-start observed cycles",
1096 )?;
1097 let actions = vec![EnsureAction::Start {
1098 name: configured.name.clone(),
1099 principal: observed.live.principal.clone(),
1100 }];
1101 canisters.push(CanisterPlan {
1102 actions,
1103 disposition: CanisterDisposition::Reuse,
1104 name: configured.name.clone(),
1105 observed_cycles: observed.live.cycles,
1106 principal: Some(observed.live.principal.clone()),
1107 });
1108 }
1109 if canisters.is_empty() {
1110 return Ok(None);
1111 }
1112 let retained_authority = validate_root_start_authority(
1113 desired,
1114 requested_fleet,
1115 authority,
1116 &root_start_bindings,
1117 true,
1118 )?;
1119 if let Some(reviewed_targets) = reviewed_targets
1120 && (reviewed_targets.len() != canisters.len()
1121 || canisters
1122 .iter()
1123 .any(|canister| !reviewed_targets.contains(&canister.name)))
1124 {
1125 return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1126 field: "reviewed Root set",
1127 name: "Root-start prerequisite".to_string(),
1128 });
1129 }
1130 let target_count = u128::try_from(
1131 canisters
1132 .iter()
1133 .map(|canister| canister.actions.len())
1134 .sum::<usize>(),
1135 )
1136 .map_err(|_| EnsurePolicyError::ArithmeticOverflow {
1137 field: "Root-start target count",
1138 })?;
1139 let maximum_execution_burn_cycles = bounds.update_burn.checked_mul(target_count).ok_or(
1140 EnsurePolicyError::ArithmeticOverflow {
1141 field: "Root-start execution burn",
1142 },
1143 )?;
1144 let expected_post_operation_cycles = observed_controlled_cycles
1145 .checked_sub(maximum_execution_burn_cycles)
1146 .ok_or(EnsurePolicyError::ArithmeticOverflow {
1147 field: "Root-start conservation equation",
1148 })?;
1149 let mut plan = FleetEnsurePlan {
1150 canisters,
1151 continuation: None,
1152 conservation: CycleConservation {
1153 estate_funding_domains: Vec::new(),
1154 expected_post_operation_cycles,
1155 maximum_execution_burn_cycles,
1156 maximum_new_funding_cycles: 0,
1157 maximum_operator_debit_cycles: 0,
1158 maximum_unavoidable_fee_cycles: 0,
1159 observed_controlled_cycles,
1160 retained_in_reused_canisters_cycles: observed_controlled_cycles,
1161 scheduled_transfer_cycles: 0,
1162 },
1163 desired_sha256: desired_sha256.to_string(),
1164 environment: desired.environment.clone(),
1165 fleet: requested_fleet.to_string(),
1166 operation_id: operation_id(desired_sha256, &desired.environment, requested_fleet),
1167 plan_sha256: String::new(),
1168 planned_at_time: created_at_time,
1169 protocol_actions: Vec::new(),
1170 recovery_review: None,
1171 reinstall: None,
1172 root_reinstall_bindings: Vec::new(),
1173 root_start_authority: retained_authority.map(|authority| Box::new(authority.clone())),
1174 reviewed_desired: Some(Box::new(
1175 crate::fleet_ensure::model::ReviewedDesiredFleetRecord::capture(desired),
1176 )),
1177 schema_version: FLEET_ENSURE_SCHEMA_VERSION,
1178 scope: FleetEnsurePlanScope::RootStartPrerequisite,
1179 terminal_inventory_operation_id: None,
1180 };
1181 plan.plan_sha256 = expected_plan_sha256(&plan);
1182 Ok(Some(plan))
1183}
1184
1185fn validate_root_start_authority<'a>(
1186 desired: &DesiredFleet,
1187 requested_fleet: &str,
1188 authority: Option<&'a RetainedRootStartAuthorityRecord>,
1189 root_start_bindings: &[RootManagementBinding],
1190 authority_required: bool,
1191) -> Result<Option<&'a RetainedRootStartAuthorityRecord>, EnsurePolicyError> {
1192 if !authority_required {
1193 return Ok(None);
1194 }
1195 if root_start_bindings.is_empty() {
1196 return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1197 field: "retained module authority",
1198 name: "Root-start prerequisite".to_string(),
1199 });
1200 }
1201 let authority =
1202 authority.ok_or_else(|| EnsurePolicyError::RootManagementAuthorityMismatch {
1203 field: "retained module authority",
1204 name: root_start_bindings[0].name.clone(),
1205 })?;
1206 let bootstrap = desired.bootstrap.as_ref().ok_or_else(|| {
1207 EnsurePolicyError::RootManagementAuthorityMismatch {
1208 field: "Fleet bootstrap authority",
1209 name: root_start_bindings[0].name.clone(),
1210 }
1211 })?;
1212 let mut expected_bindings = root_start_bindings.to_vec();
1213 expected_bindings.sort_by(|left, right| left.name.cmp(&right.name));
1214 let mut actual_bindings = authority.roots.clone();
1215 actual_bindings.sort_by(|left, right| left.name.cmp(&right.name));
1216 let path_identity_matches =
1217 authority.environment == desired.environment && authority.fleet == requested_fleet;
1218 let fleet_identity_matches = authority.fleet_id == bootstrap.fleet_id;
1219 let record_identity_matches =
1220 authority.schema_version == FLEET_ENSURE_SCHEMA_VERSION && authority.has_valid_digest();
1221 let bindings_match = actual_bindings == expected_bindings;
1222 if !(path_identity_matches
1223 && fleet_identity_matches
1224 && record_identity_matches
1225 && bindings_match)
1226 {
1227 return Err(EnsurePolicyError::RootManagementAuthorityMismatch {
1228 field: "retained module authority",
1229 name: root_start_bindings[0].name.clone(),
1230 });
1231 }
1232 Ok(Some(authority))
1233}
1234
1235fn retained_root_observation_is_start_only(
1236 desired: &DesiredFleet,
1237 plans: &[CanisterPlan],
1238 observation: &FleetObservation,
1239) -> bool {
1240 let only_start_effects = plans
1241 .iter()
1242 .flat_map(|plan| &plan.actions)
1243 .all(|action| matches!(action, EnsureAction::Start { .. }));
1244 only_start_effects
1245 && desired
1246 .canisters
1247 .iter()
1248 .filter(|configured| {
1249 observation
1250 .canisters
1251 .get(&configured.name)
1252 .and_then(Option::as_ref)
1253 .is_some_and(|live| {
1254 live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Retained)
1255 })
1256 })
1257 .all(|configured| {
1258 configured.parent.as_ref().is_some_and(|parent| {
1259 plans.iter().any(|plan| {
1260 plan.name == *parent
1261 && plan.actions.iter().any(|action| {
1262 matches!(action, EnsureAction::Start { name, .. } if name == parent)
1263 })
1264 })
1265 })
1266 })
1267}
1268
1269pub(crate) fn validate_path_identity(
1271 desired: &DesiredFleet,
1272 requested_fleet: &str,
1273) -> Result<(), EnsurePolicyError> {
1274 if desired.schema_version != FLEET_ENSURE_SCHEMA_VERSION {
1275 return Err(EnsurePolicyError::WrongSchema {
1276 actual: desired.schema_version,
1277 expected: FLEET_ENSURE_SCHEMA_VERSION,
1278 });
1279 }
1280 if desired.fleet != requested_fleet {
1281 return Err(EnsurePolicyError::FleetMismatch {
1282 actual: desired.fleet.clone(),
1283 expected: requested_fleet.to_string(),
1284 });
1285 }
1286 validate_path_labels(&desired.environment, requested_fleet)
1287}
1288
1289pub(crate) fn validate_path_labels(
1290 environment: &str,
1291 requested_fleet: &str,
1292) -> Result<(), EnsurePolicyError> {
1293 requested_fleet
1294 .parse::<FleetName>()
1295 .map_err(|_| EnsurePolicyError::UnsafePathLabel {
1296 field: "fleet",
1297 value: requested_fleet.to_string(),
1298 })?;
1299 if environment.is_empty()
1300 || environment.len() > 64
1301 || !environment
1302 .bytes()
1303 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1304 {
1305 return Err(EnsurePolicyError::UnsafePathLabel {
1306 field: "environment",
1307 value: environment.to_string(),
1308 });
1309 }
1310 Ok(())
1311}
1312
1313#[expect(
1314 clippy::too_many_arguments,
1315 reason = "pure compiler receives one complete authority tuple"
1316)]
1317fn compile_canister(
1318 desired: &DesiredFleet,
1319 artifacts: &DesiredFleetArtifacts,
1320 configured: &crate::fleet_ensure::model::DesiredCanister,
1321 observed: Option<&LiveCanister>,
1322 observation: &FleetObservation,
1323 cycle_policy: CanisterCyclePolicy,
1324 bounds: CycleBounds,
1325 created_at_time: u64,
1326 force_reinstall: bool,
1327 accumulator: &mut PlanAccumulator,
1328) -> Result<CanisterPlan, EnsurePolicyError> {
1329 match (configured.presence, observed) {
1330 (DesiredPresence::Absent, None) => Ok(CanisterPlan {
1331 actions: Vec::new(),
1332 disposition: CanisterDisposition::Delete,
1333 name: configured.name.clone(),
1334 observed_cycles: 0,
1335 principal: configured.principal.clone(),
1336 }),
1337 (DesiredPresence::Absent, Some(live)) => retire_plan(
1338 desired,
1339 artifacts,
1340 configured,
1341 live,
1342 bounds,
1343 CanisterDisposition::Delete,
1344 accumulator,
1345 ),
1346 (DesiredPresence::Present, None) => {
1347 let fresh_pool = configured.kind == DesiredCanisterKind::Pool
1348 && desired
1349 .bootstrap
1350 .as_ref()
1351 .is_some_and(|bootstrap| bootstrap.fresh_estate)
1352 && configured.principal.is_none();
1353 if configured.kind == DesiredCanisterKind::Pool && !fresh_pool {
1354 return Err(EnsurePolicyError::MissingPoolAsset {
1355 name: configured.name.clone(),
1356 });
1357 }
1358 if configured.principal.is_some() {
1359 return Err(EnsurePolicyError::MissingObservation {
1360 name: configured.name.clone(),
1361 });
1362 }
1363 create_plan(
1364 desired,
1365 artifacts,
1366 configured,
1367 cycle_policy,
1368 bounds,
1369 created_at_time,
1370 CanisterDisposition::Create,
1371 accumulator,
1372 None,
1373 )
1374 }
1375 (DesiredPresence::Present, Some(live))
1376 if configured.replace
1377 && configured.principal.as_deref() == Some(live.principal.as_str()) =>
1378 {
1379 create_plan(
1380 desired,
1381 artifacts,
1382 configured,
1383 cycle_policy,
1384 bounds,
1385 created_at_time,
1386 CanisterDisposition::Replace,
1387 accumulator,
1388 Some(live),
1389 )
1390 }
1391 (DesiredPresence::Present, Some(live)) => reuse_plan(
1392 desired,
1393 artifacts,
1394 configured,
1395 live,
1396 observation,
1397 cycle_policy,
1398 bounds,
1399 created_at_time,
1400 force_reinstall,
1401 accumulator,
1402 ),
1403 }
1404}
1405
1406#[expect(
1407 clippy::too_many_arguments,
1408 reason = "the pure create compiler receives the complete reviewed authority tuple"
1409)]
1410fn create_plan(
1411 desired: &DesiredFleet,
1412 artifacts: &DesiredFleetArtifacts,
1413 configured: &crate::fleet_ensure::model::DesiredCanister,
1414 cycle_policy: CanisterCyclePolicy,
1415 bounds: CycleBounds,
1416 created_at_time: u64,
1417 disposition: CanisterDisposition,
1418 accumulator: &mut PlanAccumulator,
1419 replaced: Option<&LiveCanister>,
1420) -> Result<CanisterPlan, EnsurePolicyError> {
1421 let temporary_pool_observation_controller = configured.kind == DesiredCanisterKind::Pool
1422 && desired
1423 .bootstrap
1424 .as_ref()
1425 .is_some_and(|bootstrap| bootstrap.fresh_estate)
1426 && configured.principal.is_none()
1427 && configured.controllers.is_empty()
1428 && configured.parent.as_ref().is_some_and(|parent| {
1429 configured.controller_canisters.as_slice() == std::slice::from_ref(parent)
1430 });
1431 if temporary_pool_observation_controller {
1432 validate_fresh_pool_creation_funding(&configured.name, cycle_policy, bounds)?;
1433 }
1434 accumulator.add_burn(bounds.update_burn)?;
1437 accumulator.add_funding(cycle_policy.initial_cycles)?;
1438 accumulator.add_fee(bounds.ledger_fee)?;
1439 accumulator.add_fee(bounds.management_creation_fee)?;
1440 let symbolic = format!("created:{}", configured.name);
1441 let mut creation_controllers = configured.controllers.clone();
1442 if temporary_pool_observation_controller {
1443 creation_controllers.push(desired.operator.clone());
1444 creation_controllers.sort();
1445 creation_controllers.dedup();
1446 }
1447 let mut actions = vec![EnsureAction::Create {
1448 controller_canisters: configured.controller_canisters.clone(),
1449 controllers: creation_controllers,
1450 created_at_time,
1451 ledger: desired.cycles_ledger.clone(),
1452 name: configured.name.clone(),
1453 requested_initial_cycles: cycle_policy.initial_cycles,
1454 subnet: configured.subnet.clone(),
1455 }];
1456 if let Some(wasm) = &configured.wasm {
1457 require_install_initializer(desired, configured)?;
1458 let wasm_sha256 = wasm_sha256(artifacts, &configured.name)?;
1459 actions.push(EnsureAction::Install {
1460 canic_init: configured.canic_init.clone(),
1461 reinstall_witness: None,
1462 init_arg: configured.init_arg.clone(),
1463 init_arg_sha256: optional_init_arg_sha256(artifacts, configured)?,
1464 init_candid: configured.init_candid.clone(),
1465 init_candid_sha256: optional_init_candid_sha256(artifacts, configured)?,
1466 mode: InstallMode::Install,
1467 name: configured.name.clone(),
1468 principal: symbolic.clone(),
1469 wasm: wasm.clone(),
1470 wasm_sha256,
1471 });
1472 accumulator.add_burn(bounds.update_burn)?;
1473 }
1474 if temporary_pool_observation_controller {
1475 actions.push(EnsureAction::SetControllers {
1476 controller_canisters: configured.controller_canisters.clone(),
1477 controllers: configured.controllers.clone(),
1478 name: configured.name.clone(),
1479 principal: symbolic,
1480 });
1481 accumulator.add_burn(bounds.update_burn)?;
1482 }
1483 if let Some(live) = replaced {
1484 append_retirement_actions(
1485 desired,
1486 artifacts,
1487 configured,
1488 live,
1489 bounds,
1490 &mut actions,
1491 accumulator,
1492 )?;
1493 }
1494 Ok(CanisterPlan {
1495 actions,
1496 disposition,
1497 name: configured.name.clone(),
1498 observed_cycles: replaced.map_or(0, |live| live.cycles),
1499 principal: replaced
1500 .map(|live| live.principal.clone())
1501 .or_else(|| configured.principal.clone()),
1502 })
1503}
1504
1505fn validate_fresh_pool_creation_funding(
1506 name: &str,
1507 cycle_policy: CanisterCyclePolicy,
1508 bounds: CycleBounds,
1509) -> Result<(), EnsurePolicyError> {
1510 let creation_burn = crate::fleet_ensure::model::creation_observation_burn(
1511 bounds.observation_burn,
1512 bounds.update_burn,
1513 )
1514 .ok_or(EnsurePolicyError::ArithmeticOverflow {
1515 field: "Create execution and observation burn",
1516 })?;
1517 let admissible_burn_cycles = checked_add(
1518 creation_burn,
1519 bounds.update_burn,
1520 "fresh pool pre-import burn",
1521 )?;
1522 let required_creation_funding_cycles = checked_add(
1523 cycle_policy.minimum_cycles,
1524 admissible_burn_cycles,
1525 "fresh pool creation funding",
1526 )?;
1527 if cycle_policy.initial_cycles < required_creation_funding_cycles {
1528 return Err(EnsurePolicyError::FreshPoolCreationFundingInsufficient {
1529 admissible_burn_cycles,
1530 creation_funding_cycles: cycle_policy.initial_cycles,
1531 name: name.to_string(),
1532 readiness_floor_cycles: cycle_policy.minimum_cycles,
1533 required_creation_funding_cycles,
1534 shortfall_cycles: required_creation_funding_cycles - cycle_policy.initial_cycles,
1535 });
1536 }
1537 Ok(())
1538}
1539
1540#[expect(
1541 clippy::too_many_arguments,
1542 reason = "the pure reuse compiler receives the complete reviewed authority tuple"
1543)]
1544fn reuse_plan(
1545 desired: &DesiredFleet,
1546 artifacts: &DesiredFleetArtifacts,
1547 configured: &crate::fleet_ensure::model::DesiredCanister,
1548 live: &LiveCanister,
1549 observation: &FleetObservation,
1550 cycle_policy: CanisterCyclePolicy,
1551 bounds: CycleBounds,
1552 created_at_time: u64,
1553 force_reinstall: bool,
1554 accumulator: &mut PlanAccumulator,
1555) -> Result<CanisterPlan, EnsurePolicyError> {
1556 accumulator.retained = checked_add(accumulator.retained, live.cycles, "retained cycles")?;
1557 let mut actions = Vec::new();
1558 if configured.kind == DesiredCanisterKind::Pool && live.root_owned_lifecycle.is_none() {
1559 return Err(EnsurePolicyError::MissingPoolLifecycle {
1560 name: configured.name.clone(),
1561 });
1562 }
1563 let active_pool_asset = configured.kind == DesiredCanisterKind::Pool
1564 && matches!(
1565 live.root_owned_lifecycle,
1566 Some(RootOwnedCanisterLifecycle::Claimed | RootOwnedCanisterLifecycle::Workload)
1567 );
1568 let retained_balance_evidence =
1569 live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Retained);
1570 let mut disposition = CanisterDisposition::Reuse;
1571 if let Some(wasm) = &configured.wasm {
1572 let wasm_sha256 = wasm_sha256(artifacts, &configured.name)?;
1573 if force_reinstall || live.module_sha256.as_deref() != Some(wasm_sha256.as_str()) {
1574 require_install_initializer(desired, configured)?;
1575 actions.push(EnsureAction::Install {
1576 canic_init: configured.canic_init.clone(),
1577 reinstall_witness: None,
1578 init_arg: configured.init_arg.clone(),
1579 init_arg_sha256: optional_init_arg_sha256(artifacts, configured)?,
1580 init_candid: configured.init_candid.clone(),
1581 init_candid_sha256: optional_init_candid_sha256(artifacts, configured)?,
1582 mode: if force_reinstall || live.module_sha256.is_some() {
1583 InstallMode::Reinstall
1584 } else {
1585 InstallMode::Install
1586 },
1587 name: configured.name.clone(),
1588 principal: live.principal.clone(),
1589 wasm: wasm.clone(),
1590 wasm_sha256,
1591 });
1592 accumulator.add_burn(bounds.update_burn)?;
1593 disposition = CanisterDisposition::Reinstall;
1594 }
1595 }
1596 if configured.kind != DesiredCanisterKind::Pool && live.status != CanisterRuntimeStatus::Running
1597 {
1598 actions.push(EnsureAction::Start {
1599 name: configured.name.clone(),
1600 principal: live.principal.clone(),
1601 });
1602 accumulator.add_burn(bounds.update_burn)?;
1603 }
1604 let mut actual_controllers = live.controllers.clone();
1605 actual_controllers.sort();
1606 let mut desired_controllers = resolved_controllers(configured, observation)?;
1607 desired_controllers.sort();
1608 if configured.kind != DesiredCanisterKind::Pool && actual_controllers != desired_controllers {
1609 actions.push(EnsureAction::SetControllers {
1610 controller_canisters: configured.controller_canisters.clone(),
1611 controllers: desired_controllers,
1612 name: configured.name.clone(),
1613 principal: live.principal.clone(),
1614 });
1615 accumulator.add_burn(bounds.update_burn)?;
1616 }
1617 append_target_funding(
1618 desired,
1619 configured,
1620 live,
1621 cycle_policy,
1622 bounds,
1623 created_at_time,
1624 active_pool_asset || retained_balance_evidence,
1625 &mut actions,
1626 accumulator,
1627 )?;
1628 Ok(CanisterPlan {
1629 actions,
1630 disposition,
1631 name: configured.name.clone(),
1632 observed_cycles: live.cycles,
1633 principal: Some(live.principal.clone()),
1634 })
1635}
1636
1637fn resolved_controllers(
1638 configured: &crate::fleet_ensure::model::DesiredCanister,
1639 observation: &FleetObservation,
1640) -> Result<Vec<String>, EnsurePolicyError> {
1641 let mut controllers = configured.controllers.clone();
1642 for controller in &configured.controller_canisters {
1643 let principal = observation
1644 .canisters
1645 .get(controller)
1646 .and_then(Option::as_ref)
1647 .map(|live| live.principal.clone())
1648 .ok_or_else(|| EnsurePolicyError::ControllerCanisterUnavailable {
1649 controller: controller.clone(),
1650 name: configured.name.clone(),
1651 })?;
1652 controllers.push(principal);
1653 }
1654 controllers.sort();
1655 controllers.dedup();
1656 Ok(controllers)
1657}
1658
1659#[expect(
1660 clippy::too_many_arguments,
1661 reason = "the funding step receives one complete target-local economic authority tuple"
1662)]
1663fn append_target_funding(
1664 desired: &DesiredFleet,
1665 configured: &crate::fleet_ensure::model::DesiredCanister,
1666 live: &LiveCanister,
1667 cycle_policy: CanisterCyclePolicy,
1668 bounds: CycleBounds,
1669 created_at_time: u64,
1670 funding_fenced: bool,
1671 actions: &mut Vec<EnsureAction>,
1672 accumulator: &mut PlanAccumulator,
1673) -> Result<(), EnsurePolicyError> {
1674 if funding_fenced || live.cycles >= cycle_policy.minimum_cycles {
1675 return Ok(());
1676 }
1677 let target_updates =
1678 u128::try_from(actions.len()).map_err(|_| EnsurePolicyError::ArithmeticOverflow {
1679 field: "target update count",
1680 })?;
1681 let update_margin = bounds.update_burn.checked_mul(target_updates).ok_or(
1682 EnsurePolicyError::ArithmeticOverflow {
1683 field: "target update margin",
1684 },
1685 )?;
1686 let target_margin = bounds.observation_burn.checked_add(update_margin).ok_or(
1687 EnsurePolicyError::ArithmeticOverflow {
1688 field: "target funding margin",
1689 },
1690 )?;
1691 let funding_deficit_cycles = cycle_policy.minimum_cycles - live.cycles;
1692 let amount = funding_deficit_cycles.checked_add(target_margin).ok_or(
1693 EnsurePolicyError::ArithmeticOverflow {
1694 field: "funding observation margin",
1695 },
1696 )?;
1697 let expected_post_cycles =
1698 live.cycles
1699 .checked_add(amount)
1700 .ok_or(EnsurePolicyError::ArithmeticOverflow {
1701 field: "funding post-balance",
1702 })?;
1703 actions.insert(
1704 0,
1705 EnsureAction::Fund {
1706 pool_funding: if configured.kind == DesiredCanisterKind::Pool {
1707 live.controllers
1708 .first()
1709 .cloned()
1710 .map(|root| PoolFundingAuthority {
1711 root,
1712 lifecycle: EstatePoolAssetLifecycle::Ready,
1713 })
1714 } else {
1715 None
1716 },
1717 amount,
1718 created_at_time,
1719 expected_post_cycles,
1720 funding_deficit_cycles,
1721 funding_margin_cycles: target_margin,
1722 ledger: desired.cycles_ledger.clone(),
1723 name: configured.name.clone(),
1724 principal: live.principal.clone(),
1725 },
1726 );
1727 accumulator.add_funding(amount)?;
1728 accumulator.add_fee(bounds.ledger_fee)
1729}
1730
1731fn reviewed_reinstall_canisters(
1732 desired: &DesiredFleet,
1733 observation: &FleetObservation,
1734) -> Result<BTreeSet<String>, EnsurePolicyError> {
1735 let mut reinstalls = BTreeSet::new();
1736 for configured in &desired.canisters {
1737 let required = observation
1738 .canisters
1739 .get(&configured.name)
1740 .and_then(Option::as_ref)
1741 .is_some_and(|live| live.reinstall_required);
1742 if !required {
1743 continue;
1744 }
1745 let exact_infrastructure = matches!(
1746 configured.kind,
1747 DesiredCanisterKind::Coordinator
1748 | DesiredCanisterKind::Root
1749 | DesiredCanisterKind::Store
1750 ) && configured.presence == DesiredPresence::Present;
1751 if !exact_infrastructure {
1752 return Err(EnsurePolicyError::InvalidInfrastructureReinstall {
1753 name: configured.name.clone(),
1754 });
1755 }
1756 reinstalls.insert(configured.name.clone());
1757 }
1758 Ok(reinstalls)
1759}
1760
1761fn retire_plan(
1762 desired: &DesiredFleet,
1763 artifacts: &DesiredFleetArtifacts,
1764 configured: &crate::fleet_ensure::model::DesiredCanister,
1765 live: &LiveCanister,
1766 bounds: CycleBounds,
1767 disposition: CanisterDisposition,
1768 accumulator: &mut PlanAccumulator,
1769) -> Result<CanisterPlan, EnsurePolicyError> {
1770 let mut actions = Vec::new();
1771 append_retirement_actions(
1772 desired,
1773 artifacts,
1774 configured,
1775 live,
1776 bounds,
1777 &mut actions,
1778 accumulator,
1779 )?;
1780 Ok(CanisterPlan {
1781 actions,
1782 disposition,
1783 name: configured.name.clone(),
1784 observed_cycles: live.cycles,
1785 principal: Some(live.principal.clone()),
1786 })
1787}
1788
1789fn append_retirement_actions(
1790 desired: &DesiredFleet,
1791 artifacts: &DesiredFleetArtifacts,
1792 configured: &crate::fleet_ensure::model::DesiredCanister,
1793 live: &LiveCanister,
1794 bounds: CycleBounds,
1795 actions: &mut Vec<EnsureAction>,
1796 accumulator: &mut PlanAccumulator,
1797) -> Result<(), EnsurePolicyError> {
1798 if live.cycles > bounds.material_threshold {
1799 let drain = configured
1800 .drain
1801 .as_ref()
1802 .ok_or_else(|| EnsurePolicyError::NoSafeDrain {
1803 name: configured.name.clone(),
1804 cycles: live.cycles,
1805 })?;
1806 if drain.destination != desired.treasury {
1807 return Err(EnsurePolicyError::WrongDrainDestination {
1808 name: configured.name.clone(),
1809 });
1810 }
1811 let maximum_execution_burn_cycles = parse_canister_cycles(
1812 &configured.name,
1813 "drain.maximum_execution_burn_cycles",
1814 &drain.maximum_execution_burn_cycles,
1815 )?;
1816 let reserved = checked_add(
1817 bounds.material_threshold,
1818 maximum_execution_burn_cycles,
1819 "retirement reserve",
1820 )?;
1821 let amount =
1822 live.cycles
1823 .checked_sub(reserved)
1824 .ok_or_else(|| EnsurePolicyError::NoSafeDrain {
1825 name: configured.name.clone(),
1826 cycles: live.cycles,
1827 })?;
1828 if amount == 0 {
1829 return Err(EnsurePolicyError::NoSafeDrain {
1830 name: configured.name.clone(),
1831 cycles: live.cycles,
1832 });
1833 }
1834 actions.push(EnsureAction::Transfer {
1835 amount,
1836 candid: drain.candid.clone(),
1837 candid_sha256: artifacts
1838 .drain_candid_sha256_by_canister
1839 .get(&configured.name)
1840 .cloned()
1841 .ok_or_else(|| EnsurePolicyError::MissingArtifactIdentity {
1842 kind: "drain Candid",
1843 name: configured.name.clone(),
1844 })?,
1845 destination: drain.destination.clone(),
1846 maximum_execution_burn_cycles,
1847 method: drain.method.clone(),
1848 name: configured.name.clone(),
1849 principal: live.principal.clone(),
1850 });
1851 accumulator.transfers = checked_add(accumulator.transfers, amount, "scheduled transfer")?;
1852 accumulator.add_burn(maximum_execution_burn_cycles)?;
1853 }
1854 actions.push(EnsureAction::Stop {
1855 name: configured.name.clone(),
1856 principal: live.principal.clone(),
1857 });
1858 actions.push(EnsureAction::Delete {
1859 maximum_remaining_cycles: bounds.material_threshold,
1860 name: configured.name.clone(),
1861 principal: live.principal.clone(),
1862 });
1863 accumulator.add_burn(bounds.material_threshold)?;
1864 accumulator.add_burn(bounds.update_burn)?;
1865 accumulator.add_burn(bounds.update_burn)?;
1866 Ok(())
1867}
1868
1869#[expect(
1870 clippy::too_many_lines,
1871 reason = "one pure validation boundary checks the complete desired Fleet authority"
1872)]
1873fn validate_authority(
1874 desired: &DesiredFleet,
1875 requested_fleet: &str,
1876) -> Result<(), EnsurePolicyError> {
1877 validate_path_identity(desired, requested_fleet)?;
1878 validate_funding_policy(desired)?;
1879 validate_terminal_pool_capacity(desired)?;
1880 if let Some(bootstrap) = &desired.bootstrap {
1881 for root in &bootstrap.roots {
1882 validate_bootstrap_root_pool_import_capacity(root)?;
1883 }
1884 }
1885 if desired.canisters.len() > MAX_FLEET_ENSURE_CANISTERS {
1886 return Err(EnsurePolicyError::TooManyCanisters {
1887 actual: desired.canisters.len(),
1888 maximum: MAX_FLEET_ENSURE_CANISTERS,
1889 });
1890 }
1891 if desired.protocol_steps.len() > MAX_FLEET_ENSURE_PROTOCOL_STEPS {
1892 return Err(EnsurePolicyError::TooManyProtocolSteps {
1893 actual: desired.protocol_steps.len(),
1894 maximum: MAX_FLEET_ENSURE_PROTOCOL_STEPS,
1895 });
1896 }
1897 if desired.maximum_stalled_observations == 0 {
1898 return Err(EnsurePolicyError::InvalidStallBound);
1899 }
1900 validate_principal("operator", &desired.operator)?;
1901 validate_principal("cycles_ledger", &desired.cycles_ledger)?;
1902 let mut names = BTreeSet::new();
1903 let mut principals = BTreeSet::new();
1904 for configured in &desired.canisters {
1905 if configured.canic_init.is_some()
1906 && (configured.init_arg.is_some() || configured.init_candid.is_some())
1907 {
1908 return Err(EnsurePolicyError::ConflictingCanicInitializer {
1909 name: configured.name.clone(),
1910 });
1911 }
1912 if configured.init_arg.is_some() != configured.init_candid.is_some() {
1913 return Err(EnsurePolicyError::IncompleteInitTemplate(
1914 configured.name.clone(),
1915 ));
1916 }
1917 if configured.name.is_empty()
1918 || configured
1919 .drain
1920 .as_ref()
1921 .is_some_and(|drain| drain.method.is_empty())
1922 {
1923 return Err(EnsurePolicyError::EmptyName);
1924 }
1925 validate_principal(
1926 &format!("canisters.{}.subnet", configured.name),
1927 &configured.subnet,
1928 )?;
1929 for controller in &configured.controllers {
1930 validate_principal(
1931 &format!("canisters.{}.controllers", configured.name),
1932 controller,
1933 )?;
1934 }
1935 for controller in &configured.controller_canisters {
1936 if controller.is_empty() {
1937 return Err(EnsurePolicyError::InvalidTopology {
1938 name: configured.name.clone(),
1939 reason: "controller canister name must not be empty",
1940 });
1941 }
1942 }
1943 if let Some(principal) = &configured.principal {
1944 validate_principal(
1945 &format!("canisters.{}.principal", configured.name),
1946 principal,
1947 )?;
1948 }
1949 let unique_name = names.insert(configured.name.as_str());
1950 let unique_principal = configured
1951 .principal
1952 .as_deref()
1953 .is_none_or(|principal| principals.insert(principal));
1954 if !unique_name || !unique_principal {
1955 return Err(EnsurePolicyError::DuplicateAuthority {
1956 name: configured.name.clone(),
1957 });
1958 }
1959 if configured.presence == DesiredPresence::Present
1960 && !configured.controllers.contains(&desired.operator)
1961 && configured.kind != DesiredCanisterKind::Pool
1962 {
1963 return Err(EnsurePolicyError::MissingOperatorController {
1964 name: configured.name.clone(),
1965 });
1966 }
1967 validate_canic_initializer(desired, configured)?;
1968 let fresh_pool = configured.kind == DesiredCanisterKind::Pool
1969 && desired
1970 .bootstrap
1971 .as_ref()
1972 .is_some_and(|bootstrap| bootstrap.fresh_estate)
1973 && configured.principal.is_none();
1974 if configured.kind == DesiredCanisterKind::Pool
1975 && (configured.presence != DesiredPresence::Present
1976 || (configured.principal.is_none() && !fresh_pool)
1977 || configured.replace
1978 || configured.wasm.is_some()
1979 || configured.drain.is_some()
1980 || configured.init_arg.is_some()
1981 || configured.init_candid.is_some()
1982 || configured.canic_init.is_some())
1983 {
1984 return Err(EnsurePolicyError::InvalidTopology {
1985 name: configured.name.clone(),
1986 reason: "pool assets must be exact present non-replaceable identities without independent runtime authority",
1987 });
1988 }
1989 }
1990 for (index, configured) in desired.canisters.iter().enumerate() {
1991 for controller in &configured.controller_canisters {
1992 let Some(controller_index) = desired
1993 .canisters
1994 .iter()
1995 .position(|candidate| candidate.name == *controller)
1996 else {
1997 return Err(EnsurePolicyError::ControllerCanisterUnavailable {
1998 controller: controller.clone(),
1999 name: configured.name.clone(),
2000 });
2001 };
2002 if controller_index >= index || controller == &configured.name {
2003 return Err(EnsurePolicyError::InvalidTopology {
2004 name: configured.name.clone(),
2005 reason: "controller canister must be a distinct earlier desired role",
2006 });
2007 }
2008 }
2009 }
2010 validate_typed_topology(desired)?;
2011 let mut protocol_names = BTreeSet::new();
2012 for step in &desired.protocol_steps {
2013 if step.name.is_empty()
2014 || step.command_method.is_empty()
2015 || step.status_method.is_empty()
2016 || !protocol_names.insert(step.name.as_str())
2017 || !desired.canisters.iter().any(|canister| {
2018 canister.name == step.canister && canister.presence == DesiredPresence::Present
2019 })
2020 {
2021 return Err(EnsurePolicyError::InvalidProtocolStep(step.name.clone()));
2022 }
2023 }
2024 if !desired.canisters.iter().any(|configured| {
2025 configured.presence == DesiredPresence::Present && configured.name == desired.treasury
2026 }) {
2027 return Err(EnsurePolicyError::MissingTreasury {
2028 treasury: desired.treasury.clone(),
2029 });
2030 }
2031 if desired
2032 .canisters
2033 .iter()
2034 .any(|configured| configured.name == desired.treasury && configured.replace)
2035 {
2036 return Err(EnsurePolicyError::TreasuryReplacement {
2037 treasury: desired.treasury.clone(),
2038 });
2039 }
2040 Ok(())
2041}
2042
2043pub fn validate_funding_policy(desired: &DesiredFleet) -> Result<(), EnsurePolicyError> {
2045 let Some(bootstrap) = &desired.bootstrap else {
2046 return Ok(());
2047 };
2048 let ic_mainnet =
2049 bootstrap.canonical_network_id == canic_core::ids::CanonicalNetworkId::ic_mainnet();
2050 for root in &bootstrap.roots {
2051 validate_fleet_subnet_root_funding_authority(&root.funding, ic_mainnet)?;
2052 }
2053 if let Some(coordinator) = &bootstrap.root_funding {
2054 validate_coordinator_root_funding_policy(coordinator)?;
2055 validate_fleet_root_funding_capacity(
2056 coordinator,
2057 bootstrap.roots.iter().map(|root| &root.funding),
2058 )?;
2059 }
2060 Ok(())
2061}
2062
2063pub fn validate_terminal_pool_capacity(desired: &DesiredFleet) -> Result<(), EnsurePolicyError> {
2065 let Some(bootstrap) = &desired.bootstrap else {
2066 return Ok(());
2067 };
2068 if desired.protocol.is_none() {
2069 return Ok(());
2070 }
2071 for root in &bootstrap.roots {
2072 let workloads = desired_initial_root_workloads(desired, &root.root)?;
2073 let pool = &root.limits.canister_pool;
2074 terminal_pool_supply(&root.root, workloads, pool.minimum_size, pool.maximum_size)?;
2075 }
2076 Ok(())
2077}
2078
2079pub(super) fn terminal_pool_supply(
2081 root: &str,
2082 workloads: u32,
2083 ready_floor: u32,
2084 maximum_size: u32,
2085) -> Result<u32, EnsurePolicyError> {
2086 workloads
2087 .checked_add(ready_floor)
2088 .filter(|total| *total <= maximum_size)
2089 .ok_or_else(|| EnsurePolicyError::TerminalPoolCapacity {
2090 root: root.to_string(),
2091 workloads,
2092 ready_floor,
2093 maximum_size,
2094 })
2095}
2096
2097fn validate_bootstrap_root_pool_import_capacity(
2098 root: &crate::fleet_ensure::model::DesiredFleetBootstrapRoot,
2099) -> Result<(), EnsurePolicyError> {
2100 validate_root_pool_import_capacity(&RootPoolImportCapacityInput {
2101 import_count: root.canister_pool_imports.len(),
2102 maximum_size: root.limits.canister_pool.maximum_size,
2103 root: root.root.clone(),
2104 })?;
2105 Ok(())
2106}
2107
2108fn validate_canic_initializer(
2109 desired: &DesiredFleet,
2110 configured: &crate::fleet_ensure::model::DesiredCanister,
2111) -> Result<(), EnsurePolicyError> {
2112 use crate::fleet_ensure::model::DesiredCanisterInit;
2113
2114 let matches_kind = matches!(
2115 (&configured.kind, &configured.canic_init),
2116 (_, None)
2117 | (
2118 DesiredCanisterKind::Coordinator,
2119 Some(DesiredCanisterInit::Coordinator)
2120 )
2121 | (
2122 DesiredCanisterKind::Root,
2123 Some(DesiredCanisterInit::Root { .. })
2124 )
2125 | (
2126 DesiredCanisterKind::Store,
2127 Some(DesiredCanisterInit::Store { .. })
2128 )
2129 );
2130 let infrastructure_artifact = desired.protocol.is_some()
2131 && configured.wasm.is_some()
2132 && matches!(
2133 configured.kind,
2134 DesiredCanisterKind::Coordinator
2135 | DesiredCanisterKind::Root
2136 | DesiredCanisterKind::Store
2137 );
2138 if !matches_kind
2139 || (configured.canic_init.is_some() && desired.bootstrap.is_none())
2140 || (infrastructure_artifact && configured.canic_init.is_none())
2141 {
2142 return Err(EnsurePolicyError::MissingCanicInitializer {
2143 name: configured.name.clone(),
2144 });
2145 }
2146 if let (Some(bootstrap), Some(initializer)) = (&desired.bootstrap, &configured.canic_init) {
2147 let bound = match initializer {
2148 DesiredCanisterInit::Coordinator => configured.name == bootstrap.coordinator,
2149 DesiredCanisterInit::Root { root } => {
2150 root == &configured.name && bootstrap.roots.iter().any(|entry| entry.root == *root)
2151 }
2152 DesiredCanisterInit::Store { root } => bootstrap
2153 .roots
2154 .iter()
2155 .any(|entry| entry.root == *root && entry.store == configured.name),
2156 };
2157 if !bound {
2158 return Err(EnsurePolicyError::MissingCanicInitializer {
2159 name: configured.name.clone(),
2160 });
2161 }
2162 }
2163 Ok(())
2164}
2165
2166fn require_install_initializer(
2167 desired: &DesiredFleet,
2168 configured: &crate::fleet_ensure::model::DesiredCanister,
2169) -> Result<(), EnsurePolicyError> {
2170 if desired.protocol.is_some()
2171 && matches!(
2172 configured.kind,
2173 DesiredCanisterKind::Coordinator
2174 | DesiredCanisterKind::Root
2175 | DesiredCanisterKind::Store
2176 )
2177 && configured.canic_init.is_none()
2178 {
2179 return Err(EnsurePolicyError::MissingCanicInitializer {
2180 name: configured.name.clone(),
2181 });
2182 }
2183 Ok(())
2184}
2185
2186fn validate_typed_topology(desired: &DesiredFleet) -> Result<(), EnsurePolicyError> {
2187 let coordinators = desired
2188 .canisters
2189 .iter()
2190 .filter(|canister| {
2191 canister.presence == DesiredPresence::Present
2192 && canister.kind == DesiredCanisterKind::Coordinator
2193 })
2194 .collect::<Vec<_>>();
2195 let [coordinator] = coordinators.as_slice() else {
2196 return Err(EnsurePolicyError::InvalidTopology {
2197 name: desired.fleet.clone(),
2198 reason: "exactly one present Coordinator role is required",
2199 });
2200 };
2201 if coordinator.parent.is_some() {
2202 return Err(EnsurePolicyError::InvalidTopology {
2203 name: coordinator.name.clone(),
2204 reason: "Coordinator cannot have a topology parent",
2205 });
2206 }
2207 let has_managed_topology = desired.canisters.iter().any(|canister| {
2208 canister.presence == DesiredPresence::Present
2209 && matches!(
2210 canister.kind,
2211 DesiredCanisterKind::Root
2212 | DesiredCanisterKind::Store
2213 | DesiredCanisterKind::Component
2214 )
2215 });
2216 if has_managed_topology && desired.protocol.is_none() && desired.protocol_steps.is_empty() {
2217 return Err(EnsurePolicyError::InvalidTopology {
2218 name: desired.fleet.clone(),
2219 reason: "managed Fleet roles require complete typed protocol intent",
2220 });
2221 }
2222 for canister in desired
2223 .canisters
2224 .iter()
2225 .filter(|canister| canister.presence == DesiredPresence::Present)
2226 {
2227 let required_parent_kind = match canister.kind {
2228 DesiredCanisterKind::Root => Some(DesiredCanisterKind::Coordinator),
2229 DesiredCanisterKind::Store | DesiredCanisterKind::Component => {
2230 Some(DesiredCanisterKind::Root)
2231 }
2232 DesiredCanisterKind::Pool => Some(DesiredCanisterKind::Root),
2233 DesiredCanisterKind::Auxiliary | DesiredCanisterKind::Coordinator => None,
2234 };
2235 let Some(required_parent_kind) = required_parent_kind else {
2236 continue;
2237 };
2238 let Some(parent) = canister.parent.as_deref().and_then(|parent| {
2239 desired.canisters.iter().find(|candidate| {
2240 candidate.name == parent && candidate.presence == DesiredPresence::Present
2241 })
2242 }) else {
2243 return Err(EnsurePolicyError::InvalidTopology {
2244 name: canister.name.clone(),
2245 reason: "required present topology parent is missing",
2246 });
2247 };
2248 if parent.kind != required_parent_kind
2249 || (canister.kind == DesiredCanisterKind::Root && parent.name != coordinator.name)
2250 {
2251 return Err(EnsurePolicyError::InvalidTopology {
2252 name: canister.name.clone(),
2253 reason: "topology parent has the wrong Canic role",
2254 });
2255 }
2256 }
2257 Ok(())
2258}
2259
2260fn validate_principal(field: &str, value: &str) -> Result<(), EnsurePolicyError> {
2261 let principal =
2262 Principal::from_text(value).map_err(|_| EnsurePolicyError::InvalidPrincipal {
2263 field: field.to_string(),
2264 value: value.to_string(),
2265 })?;
2266 if principal == Principal::anonymous() {
2267 return Err(EnsurePolicyError::InvalidPrincipal {
2268 field: field.to_string(),
2269 value: value.to_string(),
2270 });
2271 }
2272 Ok(())
2273}
2274
2275fn validate_observation_authority(
2276 desired: &DesiredFleet,
2277 observation: &FleetObservation,
2278) -> Result<(), EnsurePolicyError> {
2279 let mut principals = BTreeSet::new();
2280 for configured in &desired.canisters {
2281 let Some(live) = observation
2282 .canisters
2283 .get(&configured.name)
2284 .ok_or_else(|| EnsurePolicyError::MissingObservation {
2285 name: configured.name.clone(),
2286 })?
2287 .as_ref()
2288 else {
2289 continue;
2290 };
2291 if !configured.replace
2292 && let Some(expected) = &configured.principal
2293 && expected != &live.principal
2294 {
2295 return Err(EnsurePolicyError::ObservationAuthorityMismatch {
2296 actual: live.principal.clone(),
2297 expected: expected.clone(),
2298 name: configured.name.clone(),
2299 });
2300 }
2301 if !principals.insert(live.principal.as_str()) {
2302 return Err(EnsurePolicyError::DuplicateAuthority {
2303 name: configured.name.clone(),
2304 });
2305 }
2306 }
2307 for principal in observation.additional_controlled_cycles.keys() {
2308 validate_principal("additional_controlled_cycles", principal)?;
2309 if !principals.insert(principal.as_str()) {
2310 return Err(EnsurePolicyError::DuplicateAuthority {
2311 name: principal.clone(),
2312 });
2313 }
2314 }
2315 Ok(())
2316}
2317
2318fn cycle_bounds(desired: &DesiredFleet) -> Result<CycleBounds, EnsurePolicyError> {
2319 Ok(CycleBounds {
2320 ledger_fee: parse_fleet_cycles("ledger_fee_cycles", &desired.ledger_fee_cycles)?,
2321 management_creation_fee: parse_fleet_cycles(
2322 "management_creation_fee_cycles",
2323 &desired.management_creation_fee_cycles,
2324 )?,
2325 material_threshold: parse_fleet_cycles(
2326 "material_cycle_threshold",
2327 &desired.material_cycle_threshold,
2328 )?,
2329 observation_burn: parse_fleet_cycles(
2330 "maximum_observation_burn_cycles",
2331 &desired.maximum_observation_burn_cycles,
2332 )?,
2333 update_burn: parse_fleet_cycles(
2334 "maximum_update_burn_cycles",
2335 &desired.maximum_update_burn_cycles,
2336 )?,
2337 })
2338}
2339
2340fn compile_estate_funding_domains(
2341 desired: &DesiredFleet,
2342 observation: &FleetObservation,
2343 bounds: CycleBounds,
2344 defer_pool_creation: bool,
2345) -> Result<Vec<EstateFundingDomainPlan>, EnsurePolicyError> {
2346 let Some(bootstrap) = desired.bootstrap.as_ref() else {
2347 if observation.estate_funding_domains.is_empty() {
2348 return Ok(Vec::new());
2349 }
2350 return Err(EnsurePolicyError::EstateFundingTopology {
2351 reason: "observed Root funding accounts without bootstrap authority".to_string(),
2352 });
2353 };
2354 if observation.estate_funding_domains.len() != bootstrap.roots.len() {
2355 return Err(EnsurePolicyError::EstateFundingTopology {
2356 reason: "observed Root funding-account set differs from bootstrap authority"
2357 .to_string(),
2358 });
2359 }
2360
2361 let mut domains = Vec::with_capacity(bootstrap.roots.len());
2362 for root in &bootstrap.roots {
2363 domains.push(compile_estate_funding_domain(
2364 desired,
2365 root,
2366 observation,
2367 bounds,
2368 defer_pool_creation,
2369 )?);
2370 }
2371 Ok(domains)
2372}
2373
2374fn compile_estate_funding_domain(
2375 desired: &DesiredFleet,
2376 root: &DesiredFleetBootstrapRoot,
2377 observation: &FleetObservation,
2378 bounds: CycleBounds,
2379 defer_pool_creation: bool,
2380) -> Result<EstateFundingDomainPlan, EnsurePolicyError> {
2381 let observed = observation
2382 .estate_funding_domains
2383 .get(&root.root)
2384 .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2385 reason: format!("Root {} has no funding-account observation", root.root),
2386 })?;
2387 validate_estate_funding_owner(desired, observation, root, observed)?;
2388 let asset_cycles = root.limits.canister_pool.canister_cycles.to_u128();
2389 let pool_forecast = required_root_pool_creations(
2390 desired,
2391 &root.root,
2392 root.limits.canister_pool.minimum_size,
2393 asset_cycles,
2394 bounds.management_creation_fee,
2395 observation,
2396 defer_pool_creation,
2397 )?;
2398 let creation_execution_margin_cycles = root
2399 .limits
2400 .canister_pool
2401 .creation_execution_margin
2402 .to_u128();
2403 if creation_execution_margin_cycles == 0 {
2404 return Err(EnsurePolicyError::EstateFundingTopology {
2405 reason: format!(
2406 "Root {} has no autonomous-creation execution margin",
2407 root.root
2408 ),
2409 });
2410 }
2411 let creation_amount_cycles = asset_cycles
2412 .checked_add(creation_execution_margin_cycles)
2413 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2414 field: "estate creation margin",
2415 })?
2416 .checked_add(bounds.management_creation_fee)
2417 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2418 field: "estate creation amount",
2419 })?;
2420 let debit_per_creation = creation_amount_cycles
2421 .checked_add(bounds.ledger_fee)
2422 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2423 field: "estate creation debit",
2424 })?;
2425 let fee_per_creation = bounds
2426 .management_creation_fee
2427 .checked_add(bounds.ledger_fee)
2428 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2429 field: "estate creation fees",
2430 })?;
2431 let count = u128::from(pool_forecast.required_creation_count);
2432 let maximum_creation_debit_cycles =
2433 debit_per_creation
2434 .checked_mul(count)
2435 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2436 field: "estate creation debit",
2437 })?;
2438 let maximum_creation_fee_cycles =
2439 fee_per_creation
2440 .checked_mul(count)
2441 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2442 field: "estate creation fees",
2443 })?;
2444 let available_cycles = observed.balance_cycles;
2445 let shortfall_cycles =
2446 maximum_creation_debit_cycles.saturating_sub(available_cycles.unwrap_or_default());
2447 let initial_pool_assets = initial_pool_asset_authority(desired, root, observed);
2448 Ok(EstateFundingDomainPlan {
2449 allocated_workloads: pool_forecast.allocated_workloads,
2450 available_cycles,
2451 available_pool_slots: pool_forecast.available_slots,
2452 creation_amount_cycles,
2453 creation_execution_margin_cycles,
2454 readiness_floor_cycles: asset_cycles,
2455 cycles_ledger: observed.cycles_ledger.clone(),
2456 eligible_ready_pool_assets: pool_forecast.eligible_ready_assets,
2457 initial_pool_assets,
2458 ledger_fee_cycles: bounds.ledger_fee,
2459 management_creation_fee_cycles: bounds.management_creation_fee,
2460 maximum_creation_debit_cycles,
2461 maximum_creation_fee_cycles,
2462 maximum_funding_cycles: shortfall_cycles,
2463 occupied_pool_assets: pool_forecast.occupied_assets,
2464 pending_creation_count: pool_forecast.pending_creations,
2465 pending_creation: pool_forecast.pending_creation,
2466 planned_initial_workloads: pool_forecast.planned_workloads,
2467 pool_maximum_size: pool_forecast.maximum_size,
2468 required_creation_count: pool_forecast.required_creation_count,
2469 root: root.root.clone(),
2470 root_principal: observed.root_principal.clone(),
2471 shortfall_cycles,
2472 })
2473}
2474
2475fn initial_pool_asset_authority(
2476 desired: &DesiredFleet,
2477 root: &DesiredFleetBootstrapRoot,
2478 observed: &crate::fleet_ensure::model::EstateFundingDomainObservation,
2479) -> Vec<String> {
2480 let mut assets = desired
2481 .canisters
2482 .iter()
2483 .filter(|canister| {
2484 canister.kind == DesiredCanisterKind::Pool
2485 && canister.presence == DesiredPresence::Present
2486 && canister.parent.as_deref() == Some(&root.root)
2487 })
2488 .map(|canister| {
2489 canister
2490 .principal
2491 .clone()
2492 .unwrap_or_else(|| format!("created:{}", canister.name))
2493 })
2494 .collect::<BTreeSet<_>>();
2495 if let Some(pool) = observed.pool.as_ref() {
2496 assets.extend(pool.assets.iter().map(|asset| asset.principal.clone()));
2497 if let Some(created) = pool
2498 .pending_creation
2499 .as_ref()
2500 .and_then(|pending| pending.created_principal.clone())
2501 {
2502 assets.insert(created);
2503 }
2504 }
2505 assets.into_iter().collect()
2506}
2507
2508fn validate_estate_funding_owner(
2509 desired: &DesiredFleet,
2510 observation: &FleetObservation,
2511 root: &DesiredFleetBootstrapRoot,
2512 observed: &crate::fleet_ensure::model::EstateFundingDomainObservation,
2513) -> Result<(), EnsurePolicyError> {
2514 if observed.cycles_ledger != desired.cycles_ledger {
2515 return Err(EnsurePolicyError::EstateFundingTopology {
2516 reason: format!("Root {} funding-account Ledger differs", root.root),
2517 });
2518 }
2519 let Some(principal) = observed.root_principal.as_deref() else {
2520 return Ok(());
2521 };
2522 validate_principal("estate_funding_domains.root_principal", principal)?;
2523 let configured = desired
2524 .canisters
2525 .iter()
2526 .find(|canister| canister.name == root.root)
2527 .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2528 reason: format!("Root {} is absent from desired canisters", root.root),
2529 })?;
2530 let live = observation
2531 .canisters
2532 .get(&root.root)
2533 .and_then(Option::as_ref);
2534 if live.is_some_and(|live| live.principal != principal)
2535 || configured
2536 .principal
2537 .as_deref()
2538 .is_some_and(|expected| expected != principal)
2539 {
2540 return Err(EnsurePolicyError::EstateFundingTopology {
2541 reason: format!("Root {} funding-account owner differs", root.root),
2542 });
2543 }
2544 Ok(())
2545}
2546
2547fn required_root_pool_creations(
2548 desired: &DesiredFleet,
2549 root: &str,
2550 ready_floor: u32,
2551 required_asset_cycles: u128,
2552 management_creation_fee: u128,
2553 observation: &FleetObservation,
2554 defer_pool_creation: bool,
2555) -> Result<RootPoolCreationForecast, EnsurePolicyError> {
2556 let workload_count = desired_initial_root_workloads(desired, root)?;
2557 let desired_pool = desired_root_pool_policy(desired, root)?;
2558 let domain = observation
2559 .estate_funding_domains
2560 .get(root)
2561 .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2562 reason: format!("Root {root} has no funding-account observation"),
2563 })?;
2564 if let Some(pool) = domain.pool.as_ref() {
2565 if defer_pool_creation {
2566 let pending = pending_pool_creation_forecast(
2567 root,
2568 pool,
2569 desired_pool,
2570 required_asset_cycles,
2571 management_creation_fee,
2572 )?;
2573 let counts = observed_pool_counts(pool, pending.unmaterialized, required_asset_cycles)?;
2574 if counts.occupied_assets > desired_pool.maximum_size {
2575 return Err(EnsurePolicyError::EstatePoolCapacity {
2576 allocated_workloads: counts.allocated_workloads,
2577 available_slots: 0,
2578 capacity_shortfall: counts.occupied_assets - desired_pool.maximum_size,
2579 eligible_ready_assets: counts.eligible_ready_assets,
2580 maximum_size: desired_pool.maximum_size,
2581 occupied_assets: counts.occupied_assets,
2582 pending_creations: pending.unmaterialized,
2583 required_creation_count: 0,
2584 root: root.to_string(),
2585 });
2586 }
2587 return Ok(RootPoolCreationForecast {
2590 allocated_workloads: counts.allocated_workloads,
2591 available_slots: desired_pool.maximum_size - counts.occupied_assets,
2592 eligible_ready_assets: counts.eligible_ready_assets,
2593 maximum_size: desired_pool.maximum_size,
2594 occupied_assets: counts.occupied_assets,
2595 pending_creations: pending.unmaterialized,
2596 pending_creation: pool.pending_creation.clone(),
2597 planned_workloads: workload_count,
2598 required_creation_count: 0,
2599 });
2600 }
2601 return forecast_observed_root_pool(
2602 root,
2603 pool,
2604 desired_pool,
2605 workload_count,
2606 ready_floor,
2607 required_asset_cycles,
2608 management_creation_fee,
2609 );
2610 }
2611 forecast_configured_root_pool(
2612 desired,
2613 observation,
2614 root,
2615 workload_count,
2616 ready_floor,
2617 required_asset_cycles,
2618 desired_pool.maximum_size,
2619 )
2620}
2621
2622fn desired_root_pool_policy<'a>(
2623 desired: &'a DesiredFleet,
2624 root: &str,
2625) -> Result<&'a FleetSubnetCanisterPoolConfig, EnsurePolicyError> {
2626 desired
2627 .bootstrap
2628 .as_ref()
2629 .and_then(|bootstrap| {
2630 bootstrap
2631 .roots
2632 .iter()
2633 .find(|candidate| candidate.root == root)
2634 })
2635 .map(|root| &root.limits.canister_pool)
2636 .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2637 reason: format!("Root {root} has no desired pool authority"),
2638 })
2639}
2640
2641fn forecast_configured_root_pool(
2642 desired: &DesiredFleet,
2643 observation: &FleetObservation,
2644 root: &str,
2645 workload_count: u32,
2646 _ready_floor: u32,
2647 required_asset_cycles: u128,
2648 maximum_size: u32,
2649) -> Result<RootPoolCreationForecast, EnsurePolicyError> {
2650 let (reusable_ready_assets, completed_workloads) = desired
2651 .canisters
2652 .iter()
2653 .filter(|canister| {
2654 canister.kind == DesiredCanisterKind::Pool
2655 && canister.parent.as_deref() == Some(root)
2656 && canister.presence == DesiredPresence::Present
2657 })
2658 .try_fold(
2659 (0_u32, 0_u32),
2660 |(ready, workload), canister| -> Result<(u32, u32), EnsurePolicyError> {
2661 let live = observation
2662 .canisters
2663 .get(&canister.name)
2664 .and_then(Option::as_ref);
2665 match live.map(|live| (live.root_owned_lifecycle, live.cycles)) {
2666 Some((Some(RootOwnedCanisterLifecycle::Idle), cycles))
2667 if cycles >= required_asset_cycles =>
2668 {
2669 Ok((
2670 ready
2671 .checked_add(1)
2672 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2673 field: "reusable Ready-pool asset count",
2674 })?,
2675 workload,
2676 ))
2677 }
2678 Some((
2679 Some(
2680 RootOwnedCanisterLifecycle::Claimed
2681 | RootOwnedCanisterLifecycle::Workload,
2682 ),
2683 _,
2684 )) => Ok((
2685 ready,
2686 workload
2687 .checked_add(1)
2688 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2689 field: "completed Root workload count",
2690 })?,
2691 )),
2692 _ => Ok((ready, workload)),
2693 }
2694 },
2695 )?;
2696 let required_creation_count = 0;
2699 let occupied_assets = reusable_ready_assets
2700 .checked_add(completed_workloads)
2701 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2702 field: "configured Root pool asset count",
2703 })?;
2704 Ok(RootPoolCreationForecast {
2705 allocated_workloads: completed_workloads,
2706 available_slots: maximum_size.saturating_sub(occupied_assets),
2707 eligible_ready_assets: reusable_ready_assets,
2708 maximum_size,
2709 occupied_assets,
2710 pending_creations: 0,
2711 pending_creation: None,
2712 planned_workloads: workload_count,
2713 required_creation_count,
2714 })
2715}
2716
2717fn forecast_observed_root_pool(
2718 root: &str,
2719 pool: &crate::fleet_ensure::model::EstatePoolInventoryObservation,
2720 desired_pool: &FleetSubnetCanisterPoolConfig,
2721 workload_count: u32,
2722 ready_floor: u32,
2723 required_asset_cycles: u128,
2724 management_creation_fee: u128,
2725) -> Result<RootPoolCreationForecast, EnsurePolicyError> {
2726 let pending = pending_pool_creation_forecast(
2727 root,
2728 pool,
2729 desired_pool,
2730 required_asset_cycles,
2731 management_creation_fee,
2732 )?;
2733 let counts = observed_pool_counts(pool, pending.unmaterialized, required_asset_cycles)?;
2734 let available_supply = counts
2735 .eligible_ready_assets
2736 .checked_add(pending.unmaterialized)
2737 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2738 field: "available Root pool supply",
2739 })?;
2740 let required_additional_creations = required_estate_creation_count(
2741 workload_count,
2742 ready_floor,
2743 available_supply,
2744 counts.allocated_workloads,
2745 )?;
2746 let required_creation_count = required_additional_creations
2747 .checked_add(pending.debit)
2748 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2749 field: "required Root pool creation debit count",
2750 })?;
2751 let available_slots = desired_pool
2752 .maximum_size
2753 .saturating_sub(counts.occupied_assets);
2754 let inventory_exceeds_capacity = counts.occupied_assets > desired_pool.maximum_size;
2755 let demand_exceeds_capacity = required_additional_creations > available_slots;
2756 if inventory_exceeds_capacity || demand_exceeds_capacity {
2757 return Err(EnsurePolicyError::EstatePoolCapacity {
2758 allocated_workloads: counts.allocated_workloads,
2759 available_slots,
2760 capacity_shortfall: required_additional_creations.saturating_sub(available_slots),
2761 eligible_ready_assets: counts.eligible_ready_assets,
2762 maximum_size: desired_pool.maximum_size,
2763 occupied_assets: counts.occupied_assets,
2764 pending_creations: pending.unmaterialized,
2765 required_creation_count: required_additional_creations,
2766 root: root.to_string(),
2767 });
2768 }
2769 Ok(RootPoolCreationForecast {
2770 allocated_workloads: counts.allocated_workloads,
2771 available_slots,
2772 eligible_ready_assets: counts.eligible_ready_assets,
2773 maximum_size: desired_pool.maximum_size,
2774 occupied_assets: counts.occupied_assets,
2775 pending_creations: pending.unmaterialized,
2776 pending_creation: pool.pending_creation.clone(),
2777 planned_workloads: workload_count,
2778 required_creation_count,
2779 })
2780}
2781
2782#[derive(Clone, Copy)]
2783struct PendingPoolCreationForecast {
2784 debit: u32,
2785 unmaterialized: u32,
2786}
2787
2788fn pending_pool_creation_forecast(
2789 root: &str,
2790 pool: &crate::fleet_ensure::model::EstatePoolInventoryObservation,
2791 desired: &FleetSubnetCanisterPoolConfig,
2792 required_asset_cycles: u128,
2793 management_creation_fee: u128,
2794) -> Result<PendingPoolCreationForecast, EnsurePolicyError> {
2795 let Some(pending) = pool.pending_creation.as_ref() else {
2796 return Ok(PendingPoolCreationForecast {
2797 debit: 0,
2798 unmaterialized: 0,
2799 });
2800 };
2801 if pending.uncertain_result {
2802 return Err(EnsurePolicyError::EstateFundingTopology {
2803 reason: format!(
2804 "Root {root} has an unresolved creation response; reconcile that exact operation before funding"
2805 ),
2806 });
2807 }
2808 let expected_amount = required_asset_cycles
2809 .checked_add(desired.creation_execution_margin.to_u128())
2810 .and_then(|amount| amount.checked_add(management_creation_fee))
2811 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2812 field: "pending estate creation amount",
2813 })?;
2814 if pending.creation_amount_cycles != expected_amount {
2815 return Err(EnsurePolicyError::EstateFundingTopology {
2816 reason: format!(
2817 "Root {root} pending creation amount {} differs from current authority {expected_amount}",
2818 pending.creation_amount_cycles
2819 ),
2820 });
2821 }
2822 let materialized = pending.created_principal.as_ref().is_some_and(|principal| {
2823 pool.assets
2824 .iter()
2825 .any(|asset| &asset.principal == principal)
2826 });
2827 Ok(PendingPoolCreationForecast {
2828 debit: u32::from(pending.created_principal.is_none()),
2829 unmaterialized: u32::from(!materialized),
2830 })
2831}
2832
2833#[derive(Clone, Copy)]
2834struct ObservedPoolCounts {
2835 allocated_workloads: u32,
2836 eligible_ready_assets: u32,
2837 occupied_assets: u32,
2838}
2839
2840fn observed_pool_counts(
2841 pool: &crate::fleet_ensure::model::EstatePoolInventoryObservation,
2842 pending_unmaterialized: u32,
2843 required_asset_cycles: u128,
2844) -> Result<ObservedPoolCounts, EnsurePolicyError> {
2845 let bounded_count = |count, field| {
2846 u32::try_from(count).map_err(|_| EnsurePolicyError::ArithmeticOverflow { field })
2847 };
2848 let asset_count = bounded_count(pool.assets.len(), "observed Root pool asset count")?;
2849 let occupied_assets = asset_count.checked_add(pending_unmaterialized).ok_or(
2850 EnsurePolicyError::ArithmeticOverflow {
2851 field: "observed Root pool capacity",
2852 },
2853 )?;
2854 let eligible_ready_assets = bounded_count(
2855 pool.assets
2856 .iter()
2857 .filter(|asset| {
2858 asset.lifecycle == EstatePoolAssetLifecycle::Ready
2859 && asset.cycles >= required_asset_cycles
2860 })
2861 .count(),
2862 "eligible Ready-pool asset count",
2863 )?;
2864 let allocated_workloads = bounded_count(
2865 pool.assets
2866 .iter()
2867 .filter(|asset| {
2868 matches!(
2869 asset.lifecycle,
2870 EstatePoolAssetLifecycle::Claimed | EstatePoolAssetLifecycle::Workload
2871 )
2872 })
2873 .count(),
2874 "allocated Root workload count",
2875 )?;
2876 Ok(ObservedPoolCounts {
2877 allocated_workloads,
2878 eligible_ready_assets,
2879 occupied_assets,
2880 })
2881}
2882
2883#[derive(Clone, Debug, Eq, PartialEq)]
2884struct RootPoolCreationForecast {
2885 allocated_workloads: u32,
2886 available_slots: u32,
2887 eligible_ready_assets: u32,
2888 maximum_size: u32,
2889 occupied_assets: u32,
2890 pending_creations: u32,
2891 pending_creation: Option<crate::fleet_ensure::model::EstatePoolPendingCreationObservation>,
2892 planned_workloads: u32,
2893 required_creation_count: u32,
2894}
2895
2896fn desired_initial_root_workloads(
2897 desired: &DesiredFleet,
2898 root: &str,
2899) -> Result<u32, EnsurePolicyError> {
2900 let bootstrap = desired
2901 .bootstrap
2902 .as_ref()
2903 .expect("estate funding is compiled only with bootstrap authority");
2904 let protocol =
2905 desired
2906 .protocol
2907 .as_ref()
2908 .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2909 reason: "estate workload forecast requires typed Fleet protocol".to_string(),
2910 })?;
2911 initial_workload_count(
2912 &bootstrap.component_deployment_configuration,
2913 protocol
2914 .component_group_placements
2915 .iter()
2916 .filter(|placement| placement.root == root)
2917 .map(|placement| placement.deployment.as_str()),
2918 root,
2919 )
2920}
2921
2922pub(super) fn initial_workload_count<'a>(
2924 configuration: &canic_core::control_plane_support::config::ComponentDeploymentConfiguration,
2925 mut deployments: impl Iterator<Item = &'a str>,
2926 root: &str,
2927) -> Result<u32, EnsurePolicyError> {
2928 deployments.try_fold(0_u32, |total, deployment_name| {
2929 let deployment = configuration
2930 .deployment_topology
2931 .component_group_deployments
2932 .iter()
2933 .find(|deployment| deployment.deployment.as_str() == deployment_name)
2934 .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2935 reason: format!(
2936 "Root {root} placement references unknown deployment {deployment_name}"
2937 ),
2938 })?;
2939 deployment.members.iter().try_fold(total, |total, member| {
2940 let spec = configuration
2941 .component_topology
2942 .component_specs
2943 .iter()
2944 .find(|spec| spec.component_spec == member.component_spec)
2945 .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
2946 reason: format!(
2947 "Root {root} deployment references unknown Component Spec {}",
2948 member.component_spec
2949 ),
2950 })?;
2951 total.checked_add(initial_role_tree_size(spec)?).ok_or(
2952 EnsurePolicyError::ArithmeticOverflow {
2953 field: "estate initial workload count",
2954 },
2955 )
2956 })
2957 })
2958}
2959
2960fn required_estate_creation_count(
2961 workload_count: u32,
2962 ready_floor: u32,
2963 reusable_ready_assets: u32,
2964 completed_workloads: u32,
2965) -> Result<u32, EnsurePolicyError> {
2966 workload_count
2967 .saturating_sub(completed_workloads)
2968 .checked_add(ready_floor)
2969 .map(|required_assets| required_assets.saturating_sub(reusable_ready_assets))
2970 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2971 field: "estate creation count",
2972 })
2973}
2974
2975fn initial_role_tree_size(
2976 spec: &canic_core::control_plane_support::config::ComponentSpec,
2977) -> Result<u32, EnsurePolicyError> {
2978 let mut instances = BTreeMap::from([(spec.component_role.clone(), 1_u32)]);
2979 let mut total = 0_u32;
2980 for _ in 0..=spec.spawn_grants.len() {
2981 let mut advanced = false;
2982 for grant in &spec.spawn_grants {
2983 if instances.contains_key(&grant.child_role) {
2984 continue;
2985 }
2986 let Some(parent_instances) = instances.get(&grant.parent_role).copied() else {
2987 continue;
2988 };
2989 let child_instances = parent_instances
2990 .checked_mul(grant.initial_instances_per_parent)
2991 .ok_or(EnsurePolicyError::ArithmeticOverflow {
2992 field: "estate initial child count",
2993 })?;
2994 instances.insert(grant.child_role.clone(), child_instances);
2995 advanced = true;
2996 }
2997 if !advanced {
2998 total = instances.values().try_fold(0_u32, |sum, count| {
2999 sum.checked_add(*count)
3000 .ok_or(EnsurePolicyError::ArithmeticOverflow {
3001 field: "estate initial workload count",
3002 })
3003 })?;
3004 break;
3005 }
3006 }
3007 if total == 0 {
3008 return Err(EnsurePolicyError::EstateFundingTopology {
3009 reason: format!(
3010 "Component Spec {} initial child graph did not converge",
3011 spec.component_spec
3012 ),
3013 });
3014 }
3015 Ok(total)
3016}
3017
3018fn tranche_protocol_actions(
3019 desired: &DesiredFleet,
3020 observation: &FleetObservation,
3021 bounds: CycleBounds,
3022 observed_estate_cycles: u128,
3023 accumulator: &mut PlanAccumulator,
3024 protocol_actions: &mut Vec<EnsureAction>,
3025) -> Result<(), EnsurePolicyError> {
3026 let candidates = std::mem::take(protocol_actions);
3027 let mut selected = Vec::with_capacity(candidates.len());
3028 for action in candidates {
3029 let execution_burn = accumulator.execution_burn;
3030 let transfers = accumulator.transfers;
3031
3032 account_protocol_action(accumulator, &action)?;
3033 selected.push(action);
3034
3035 let observation_count = maximum_observation_count(
3036 desired,
3037 &accumulator.canisters,
3038 &selected,
3039 observation.additional_controlled_cycles.len(),
3040 )?;
3041 let observation_burn = bounds
3042 .observation_burn
3043 .checked_mul(observation_count)
3044 .ok_or(EnsurePolicyError::ArithmeticOverflow {
3045 field: "observation burn",
3046 })?;
3047 let required = checked_add(
3048 accumulator.execution_burn,
3049 observation_burn,
3050 "cycle-conservation required burn",
3051 )?;
3052 let available = checked_add(
3053 observed_estate_cycles,
3054 accumulator.new_funding,
3055 "cycle-conservation available balance",
3056 )?;
3057 if required > available {
3058 let error =
3059 insufficient_cycle_conservation(accumulator, selected.len(), available, required);
3060 accumulator.execution_burn = execution_burn;
3061 accumulator.transfers = transfers;
3062 selected.pop();
3063 if selected.is_empty() {
3064 return Err(error);
3065 }
3066 break;
3067 }
3068 }
3069 *protocol_actions = selected;
3070 Ok(())
3071}
3072
3073fn account_protocol_action(
3074 accumulator: &mut PlanAccumulator,
3075 action: &EnsureAction,
3076) -> Result<(), EnsurePolicyError> {
3077 match action {
3078 EnsureAction::FleetProtocol {
3079 maximum_execution_burn_cycles,
3080 ..
3081 }
3082 | EnsureAction::Protocol {
3083 maximum_execution_burn_cycles,
3084 ..
3085 } => accumulator.add_burn(*maximum_execution_burn_cycles),
3086 action => Err(EnsurePolicyError::InvalidProtocolStep(
3087 action.name().to_string(),
3088 )),
3089 }
3090}
3091
3092fn insufficient_cycle_conservation(
3093 accumulator: &PlanAccumulator,
3094 protocol_action_count: usize,
3095 available: u128,
3096 required: u128,
3097) -> EnsurePolicyError {
3098 let canister_action_count = accumulator
3099 .canisters
3100 .iter()
3101 .map(|canister| canister.actions.len())
3102 .sum::<usize>();
3103 EnsurePolicyError::InsufficientCycleConservation {
3104 action_count: canister_action_count.saturating_add(protocol_action_count),
3105 available,
3106 required,
3107 shortfall: required.saturating_sub(available),
3108 }
3109}
3110
3111fn maximum_observation_count(
3112 desired: &DesiredFleet,
3113 canisters: &[CanisterPlan],
3114 protocol_actions: &[EnsureAction],
3115 additional_controlled_canisters: usize,
3116) -> Result<u128, EnsurePolicyError> {
3117 let initially_present = u128::try_from(
3118 canisters
3119 .iter()
3120 .filter(|canister| canister.principal.is_some())
3121 .count(),
3122 )
3123 .map_err(|_| EnsurePolicyError::ArithmeticOverflow {
3124 field: "initial observation count",
3125 })?;
3126 let terminal_present = u128::try_from(
3127 desired
3128 .canisters
3129 .iter()
3130 .filter(|canister| canister.presence == DesiredPresence::Present)
3131 .count(),
3132 )
3133 .map_err(|_| EnsurePolicyError::ArithmeticOverflow {
3134 field: "terminal observation count",
3135 })?;
3136 let action_observations = canisters
3137 .iter()
3138 .flat_map(|canister| &canister.actions)
3139 .try_fold(0_u128, |total, action| {
3140 let count = match action {
3141 EnsureAction::Create { .. } => 1,
3142 EnsureAction::Delete { .. } => 4,
3143 EnsureAction::Fund { .. }
3144 | EnsureAction::FundEstate { .. }
3145 | EnsureAction::Transfer { .. } => 2,
3146 EnsureAction::SealAuthority { .. }
3147 | EnsureAction::Install { .. }
3148 | EnsureAction::FleetProtocol { .. }
3149 | EnsureAction::Protocol { .. }
3150 | EnsureAction::SetControllers { .. }
3151 | EnsureAction::Start { .. }
3152 | EnsureAction::Stop { .. } => 3,
3153 };
3154 checked_add(total, count, "effect observation count")
3155 })?;
3156 let protocol_observations = u128::try_from(protocol_actions.len())
3157 .map_err(|_| EnsurePolicyError::ArithmeticOverflow {
3158 field: "protocol observation count",
3159 })?
3160 .checked_mul(3)
3161 .ok_or(EnsurePolicyError::ArithmeticOverflow {
3162 field: "protocol observation count",
3163 })?;
3164 let terminal_protocol_observations = terminal_protocol_observation_bound(
3165 desired,
3166 protocol_actions,
3167 additional_controlled_canisters,
3168 )?;
3169 let stalled_observations = canisters
3170 .iter()
3171 .flat_map(|canister| &canister.actions)
3172 .chain(protocol_actions.iter())
3173 .filter(|action| {
3174 matches!(action, EnsureAction::FleetProtocol { action, .. }
3175 if matches!(action.as_ref(), CurrentFleetProtocolAction::ProvisionComponents { .. }))
3176 })
3177 .try_fold(desired.maximum_stalled_observations, |bound, action| {
3178 effect_observation_policy(desired, action)
3179 .map(|policy| bound.max(policy.maximum_stalled_observations))
3180 })?;
3181 initially_present
3182 .checked_mul(2)
3183 .and_then(|value| value.checked_add(terminal_present))
3184 .and_then(|value| value.checked_add(action_observations))
3185 .and_then(|value| value.checked_add(protocol_observations))
3186 .and_then(|value| value.checked_add(terminal_protocol_observations))
3187 .and_then(|value| value.checked_add(u128::from(stalled_observations)))
3188 .ok_or(EnsurePolicyError::ArithmeticOverflow {
3189 field: "maximum observation count",
3190 })
3191}
3192
3193pub(super) fn successor_phase_burn(
3195 desired: &DesiredFleet,
3196 plan: &FleetEnsurePlan,
3197) -> Result<u128, EnsurePolicyError> {
3198 let bounds = cycle_bounds(desired)?;
3199 let observations =
3200 maximum_observation_count(desired, &plan.canisters, &plan.protocol_actions, 0)?;
3201 let mut total = bounds.observation_burn.checked_mul(observations).ok_or(
3202 EnsurePolicyError::ArithmeticOverflow {
3203 field: "successor observation burn",
3204 },
3205 )?;
3206 for action in &plan.protocol_actions {
3207 let EnsureAction::FleetProtocol {
3208 maximum_execution_burn_cycles,
3209 ..
3210 } = action
3211 else {
3212 return Err(EnsurePolicyError::InvalidProtocolStep(
3213 action.name().to_string(),
3214 ));
3215 };
3216 total = checked_add(
3217 total,
3218 *maximum_execution_burn_cycles,
3219 "successor execution burn",
3220 )?;
3221 }
3222 Ok(total)
3223}
3224
3225fn terminal_protocol_observation_bound(
3226 desired: &DesiredFleet,
3227 protocol_actions: &[EnsureAction],
3228 additional_controlled_canisters: usize,
3229) -> Result<u128, EnsurePolicyError> {
3230 if desired.protocol.is_none() {
3231 return Ok(0);
3232 }
3233 let configured = u128::try_from(desired.canisters.len()).map_err(|_| {
3234 EnsurePolicyError::ArithmeticOverflow {
3235 field: "terminal protocol observation count",
3236 }
3237 })?;
3238 let additional = u128::try_from(additional_controlled_canisters).map_err(|_| {
3239 EnsurePolicyError::ArithmeticOverflow {
3240 field: "terminal protocol observation count",
3241 }
3242 })?;
3243 let observed_bound = configured
3244 .checked_mul(4)
3245 .and_then(|count| {
3246 additional
3247 .checked_mul(3)
3248 .and_then(|extra| count.checked_add(extra))
3249 })
3250 .and_then(|count| count.checked_add(4))
3251 .ok_or(EnsurePolicyError::ArithmeticOverflow {
3252 field: "terminal protocol observation count",
3253 })?;
3254 let planned_bound = protocol_actions.iter().try_fold(0_u128, |bound, action| {
3255 let EnsureAction::FleetProtocol {
3256 action: current, ..
3257 } = action
3258 else {
3259 return Ok(bound);
3260 };
3261 let crate::fleet_ensure::model::CurrentFleetProtocolAction::ProvisionComponents {
3262 request,
3263 ..
3264 } = current.as_ref()
3265 else {
3266 return Ok(bound);
3267 };
3268 component_provisioning_observation_bound(&request.plan.batches)
3269 .map(|current_bound| bound.max(current_bound))
3270 })?;
3271 Ok(observed_bound.max(planned_bound))
3272}
3273
3274pub(super) struct EffectObservationPolicy {
3276 pub maximum_stalled_observations: u32,
3277 pub paced: bool,
3278}
3279
3280pub(super) const fn pool_maintenance_update_bound(minimum_ready: u32) -> u32 {
3282 minimum_ready.saturating_mul(2).saturating_add(16)
3283}
3284
3285pub(super) fn effect_observation_policy(
3288 desired: &DesiredFleet,
3289 action: &EnsureAction,
3290) -> Result<EffectObservationPolicy, EnsurePolicyError> {
3291 let EnsureAction::FleetProtocol {
3292 action: current, ..
3293 } = action
3294 else {
3295 return Ok(EffectObservationPolicy {
3296 maximum_stalled_observations: desired.maximum_stalled_observations,
3297 paced: false,
3298 });
3299 };
3300 if matches!(
3301 current.as_ref(),
3302 CurrentFleetProtocolAction::MaintainPoolReadiness { .. }
3303 | CurrentFleetProtocolAction::ObservePoolReadiness { .. }
3304 ) {
3305 return Ok(EffectObservationPolicy {
3306 maximum_stalled_observations: desired
3307 .maximum_stalled_observations
3308 .max(MAXIMUM_PACED_PROTOCOL_STALL_OBSERVATIONS),
3309 paced: true,
3310 });
3311 }
3312 let crate::fleet_ensure::model::CurrentFleetProtocolAction::ProvisionComponents {
3313 request, ..
3314 } = current.as_ref()
3315 else {
3316 return Ok(EffectObservationPolicy {
3317 maximum_stalled_observations: desired.maximum_stalled_observations,
3318 paced: false,
3319 });
3320 };
3321 let topology_bound = component_provisioning_stall_bound(desired, &request.plan.batches)?;
3322 Ok(EffectObservationPolicy {
3323 maximum_stalled_observations: paced_protocol_stall_limit(
3324 desired.maximum_stalled_observations,
3325 topology_bound,
3326 ),
3327 paced: true,
3328 })
3329}
3330
3331const MAXIMUM_PACED_PROTOCOL_STALL_OBSERVATIONS: u32 = 64;
3332
3333fn paced_protocol_stall_limit(configured: u32, topology_bound: u128) -> u32 {
3334 let topology_bound = u32::try_from(topology_bound)
3335 .unwrap_or(MAXIMUM_PACED_PROTOCOL_STALL_OBSERVATIONS)
3336 .min(MAXIMUM_PACED_PROTOCOL_STALL_OBSERVATIONS);
3337 configured.max(topology_bound)
3338}
3339
3340fn component_provisioning_stall_bound(
3341 desired: &DesiredFleet,
3342 batches: &[canic_core::dto::component_provisioning::FleetSubnetRootProvisioningBatch],
3343) -> Result<u128, EnsurePolicyError> {
3344 let base = component_provisioning_observation_bound(batches)?;
3345 if batches.iter().all(|batch| {
3346 batch
3347 .placements
3348 .iter()
3349 .all(|placement| placement.entries.is_empty())
3350 }) {
3351 return Ok(base);
3352 }
3353 let bootstrap =
3354 desired
3355 .bootstrap
3356 .as_ref()
3357 .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
3358 reason: "Component provisioning has no compiled bootstrap configuration"
3359 .to_string(),
3360 })?;
3361 let specs = &bootstrap
3362 .component_deployment_configuration
3363 .component_topology
3364 .component_specs;
3365 let entries = batches
3366 .iter()
3367 .flat_map(|batch| &batch.placements)
3368 .flat_map(|placement| &placement.entries)
3369 .map(|entry| (&entry.component_spec, &entry.spec_hash));
3370 checked_add(
3371 base,
3372 initial_child_observation_bound(specs, entries)?,
3373 "Component observation count",
3374 )
3375}
3376
3377fn initial_child_observation_bound<'a>(
3378 specs: &[canic_core::control_plane_support::config::ComponentSpec],
3379 entries: impl IntoIterator<Item = (&'a canic_core::ids::ComponentSpecId, &'a [u8; 32])>,
3380) -> Result<u128, EnsurePolicyError> {
3381 entries.into_iter().try_fold(0, |bound, (id, hash)| {
3382 let spec = specs
3383 .iter()
3384 .find(|spec| &spec.component_spec == id && &spec.spec_hash == hash)
3385 .ok_or_else(|| EnsurePolicyError::EstateFundingTopology {
3386 reason: format!("provisioning Component Spec {id} differs from compiled authority"),
3387 })?;
3388 let children = initial_role_tree_size(spec)?.saturating_sub(1);
3389 checked_add(
3390 bound,
3391 u128::from(children) * 3,
3392 "initial child observation count",
3393 )
3394 })
3395}
3396
3397fn component_provisioning_observation_bound(
3398 batches: &[canic_core::dto::component_provisioning::FleetSubnetRootProvisioningBatch],
3399) -> Result<u128, EnsurePolicyError> {
3400 let root_count =
3401 u128::try_from(batches.len()).map_err(|_| EnsurePolicyError::ArithmeticOverflow {
3402 field: "terminal protocol observation count",
3403 })?;
3404 let component_count = batches.iter().try_fold(0_u128, |total, batch| {
3405 batch
3406 .placements
3407 .iter()
3408 .try_fold(total, |subtotal, placement| {
3409 u128::try_from(placement.entries.len())
3410 .ok()
3411 .and_then(|count| subtotal.checked_add(count))
3412 .ok_or(EnsurePolicyError::ArithmeticOverflow {
3413 field: "terminal protocol observation count",
3414 })
3415 })
3416 })?;
3417 component_provisioning_observation_bound_from_counts(root_count, component_count)
3418}
3419
3420fn component_provisioning_observation_bound_from_counts(
3421 root_count: u128,
3422 component_count: u128,
3423) -> Result<u128, EnsurePolicyError> {
3424 let per_component = terminal_initial_component_observation_count(0);
3425 root_count
3426 .checked_mul(5)
3427 .and_then(|root_observations| {
3428 component_count
3429 .checked_mul(per_component)
3430 .and_then(|component_observations| {
3431 root_observations.checked_add(component_observations)
3432 })
3433 })
3434 .and_then(|total| total.checked_add(1))
3435 .ok_or(EnsurePolicyError::ArithmeticOverflow {
3436 field: "terminal protocol observation count",
3437 })
3438}
3439
3440const fn terminal_initial_component_observation_count(_future_descendant_capacity: u32) -> u128 {
3441 3
3445}
3446
3447fn canister_cycle_policy(
3448 configured: &crate::fleet_ensure::model::DesiredCanister,
3449) -> Result<CanisterCyclePolicy, EnsurePolicyError> {
3450 let policy = CanisterCyclePolicy {
3451 initial_cycles: parse_canister_cycles(
3452 &configured.name,
3453 "initial_cycles",
3454 &configured.initial_cycles,
3455 )?,
3456 minimum_cycles: parse_canister_cycles(
3457 &configured.name,
3458 "minimum_cycles",
3459 &configured.minimum_cycles,
3460 )?,
3461 };
3462 if configured.presence == DesiredPresence::Present
3463 && policy.initial_cycles < policy.minimum_cycles
3464 {
3465 return Err(EnsurePolicyError::InitialCyclesBelowMinimum {
3466 name: configured.name.clone(),
3467 });
3468 }
3469 Ok(policy)
3470}
3471
3472fn parse_fleet_cycles(field: &'static str, value: &str) -> Result<u128, EnsurePolicyError> {
3473 value
3474 .parse::<Cycles>()
3475 .map(|cycles| cycles.to_u128())
3476 .map_err(|_| EnsurePolicyError::InvalidFleetCycles {
3477 field,
3478 value: value.to_string(),
3479 })
3480}
3481
3482fn parse_canister_cycles(
3483 name: &str,
3484 field: &'static str,
3485 value: &str,
3486) -> Result<u128, EnsurePolicyError> {
3487 value
3488 .parse::<Cycles>()
3489 .map(|cycles| cycles.to_u128())
3490 .map_err(|_| EnsurePolicyError::InvalidCanisterCycles {
3491 field,
3492 name: name.to_string(),
3493 value: value.to_string(),
3494 })
3495}
3496
3497fn checked_add(left: u128, right: u128, field: &'static str) -> Result<u128, EnsurePolicyError> {
3498 left.checked_add(right)
3499 .ok_or(EnsurePolicyError::ArithmeticOverflow { field })
3500}
3501
3502fn wasm_sha256(artifacts: &DesiredFleetArtifacts, name: &str) -> Result<String, EnsurePolicyError> {
3503 artifacts
3504 .wasm_sha256_by_canister
3505 .get(name)
3506 .cloned()
3507 .ok_or_else(|| EnsurePolicyError::MissingWasmIdentity {
3508 name: name.to_string(),
3509 })
3510}
3511
3512fn optional_init_arg_sha256(
3513 artifacts: &DesiredFleetArtifacts,
3514 configured: &crate::fleet_ensure::model::DesiredCanister,
3515) -> Result<Option<String>, EnsurePolicyError> {
3516 configured
3517 .init_arg
3518 .as_ref()
3519 .map(|_| {
3520 artifacts
3521 .init_arg_sha256_by_canister
3522 .get(&configured.name)
3523 .cloned()
3524 .ok_or_else(|| EnsurePolicyError::MissingArtifactIdentity {
3525 kind: "init argument",
3526 name: configured.name.clone(),
3527 })
3528 })
3529 .transpose()
3530}
3531
3532fn optional_init_candid_sha256(
3533 artifacts: &DesiredFleetArtifacts,
3534 configured: &crate::fleet_ensure::model::DesiredCanister,
3535) -> Result<Option<String>, EnsurePolicyError> {
3536 configured
3537 .init_candid
3538 .as_ref()
3539 .map(|_| {
3540 artifacts
3541 .init_candid_sha256_by_canister
3542 .get(&configured.name)
3543 .cloned()
3544 .ok_or_else(|| EnsurePolicyError::MissingArtifactIdentity {
3545 kind: "init Candid",
3546 name: configured.name.clone(),
3547 })
3548 })
3549 .transpose()
3550}
3551
3552pub(crate) fn operation_id(desired_sha256: &str, environment: &str, fleet: &str) -> String {
3553 let mut hasher = Sha256::new();
3554 hash_field(&mut hasher, b"canic:fleet-ensure:operation:v1");
3555 hash_field(&mut hasher, desired_sha256.as_bytes());
3556 hash_field(&mut hasher, environment.as_bytes());
3557 hash_field(&mut hasher, fleet.as_bytes());
3558 canic_core::cdk::utils::hash::hex_bytes(hasher.finalize())
3559}
3560
3561#[must_use]
3567pub fn expected_plan_sha256(plan: &FleetEnsurePlan) -> String {
3568 let mut canonical = plan.clone();
3569 canonical.plan_sha256.clear();
3570 let bytes = super::json::to_vec(&canonical).expect("Fleet ensure plan is JSON serializable");
3571 let mut hasher = Sha256::new();
3572 hash_field(&mut hasher, b"canic:fleet-ensure:plan:v1");
3573 hash_field(&mut hasher, &bytes);
3574 canic_core::cdk::utils::hash::hex_bytes(hasher.finalize())
3575}
3576
3577fn hash_field(hasher: &mut Sha256, value: &[u8]) {
3578 hasher.update(u64::try_from(value.len()).unwrap_or(u64::MAX).to_be_bytes());
3579 hasher.update(value);
3580}
3581
3582#[cfg(test)]
3583mod tests {
3584 use super::{
3585 CurrentFleetProtocolAction, CycleBounds, DesiredFleet, FLEET_ENSURE_SCHEMA_VERSION,
3586 FleetObservation, append_pool_reconciliation_funding,
3587 };
3588 use std::collections::BTreeMap;
3589
3590 use super::{
3591 EnsurePolicyError, PlanAccumulator, append_estate_funding_actions,
3592 component_provisioning_observation_bound_from_counts, forecast_observed_root_pool,
3593 initial_role_tree_size, paced_protocol_stall_limit, required_estate_creation_count,
3594 terminal_initial_component_observation_count, validate_bootstrap_root_pool_import_capacity,
3595 };
3596 use crate::fleet_ensure::model::{
3597 CanisterDisposition, CanisterPlan, DesiredFleetBootstrapRoot, EnsureAction,
3598 EstateFundingDomainPlan, EstatePoolAssetLifecycle, EstatePoolAssetObservation,
3599 EstatePoolAssetOrigin, EstatePoolInventoryObservation,
3600 EstatePoolPendingCreationObservation, PoolFundingAuthority,
3601 };
3602 use candid::Principal;
3603 use canic_core::{
3604 cdk::types::Cycles,
3605 control_plane_support::config::{ComponentLimits, ComponentSpawnGrant, ComponentSpec},
3606 ids::{
3607 CanisterRole, ComponentSpecId, ComponentTopologyDigest, CyclesFundingBudget,
3608 FleetSubnetCanisterPoolConfig, FleetSubnetRootLimits, SubnetId,
3609 },
3610 };
3611
3612 #[test]
3613 fn continuation_review_reserves_only_available_headroom_and_exposes_pending_discovery() {
3614 let observation = FleetObservation {
3615 additional_controlled_cycles: BTreeMap::new(),
3616 canisters: BTreeMap::new(),
3617 estate_funding_domains: BTreeMap::new(),
3618 ledger_fee_cycles: 0,
3619 operator_cycles: 0,
3620 protocol_ready: BTreeMap::new(),
3621 };
3622 let bounds = CycleBounds {
3623 observation_burn: 3,
3624 update_burn: 5,
3625 ledger_fee: 1,
3626 management_creation_fee: 0,
3627 material_threshold: 1,
3628 };
3629 let review = super::recovery::review(&observation, bounds, 32, 41, 369).unwrap();
3630 assert_eq!(review.base_execution_burn_cycles, 41);
3631 assert_eq!(review.whole_continuation_ceiling_cycles, 448);
3632 assert_eq!(review.continuation_reserve_cycles, 328);
3633 assert!(review.known_pool_funding.is_empty());
3634 assert_eq!(
3635 review.discovery,
3636 crate::fleet_ensure::model::RecoveryDiscovery::PendingCurrentProtocol
3637 );
3638 let no_headroom = super::recovery::review(&observation, bounds, 32, 41, 40).unwrap();
3639 assert_eq!(no_headroom.continuation_reserve_cycles, 0);
3640 }
3641
3642 #[test]
3643 fn estate_workload_forecast_includes_recursive_initial_children() {
3644 let mut spec = ComponentSpec {
3645 component_spec: ComponentSpecId::try_from(String::from("hub"))
3646 .expect("hub Component Spec ID"),
3647 spec_hash: [1; 32],
3648 component_role: CanisterRole::from("hub"),
3649 maximum_fleet_instances: 1,
3650 limits: ComponentLimits {
3651 maximum_descendants: 8,
3652 maximum_registry_bytes: 1,
3653 cycles_funding: CyclesFundingBudget {
3654 window_secs: 1,
3655 maximum_cycles: Cycles::new(1),
3656 },
3657 },
3658 children: Vec::new(),
3659 spawn_grants: vec![
3660 ComponentSpawnGrant {
3661 parent_role: CanisterRole::from("hub"),
3662 child_role: CanisterRole::from("shard"),
3663 initial_instances_per_parent: 2,
3664 maximum_instances_per_parent: 2,
3665 },
3666 ComponentSpawnGrant {
3667 parent_role: CanisterRole::from("shard"),
3668 child_role: CanisterRole::from("leaf"),
3669 initial_instances_per_parent: 3,
3670 maximum_instances_per_parent: 3,
3671 },
3672 ],
3673 };
3674
3675 assert_eq!(initial_role_tree_size(&spec), Ok(9));
3676 for capacity in [8, 10_000, u32::MAX] {
3677 spec.limits.maximum_descendants = capacity;
3678 let children = super::initial_child_observation_bound(
3679 std::slice::from_ref(&spec),
3680 [(&spec.component_spec, &spec.spec_hash); 2],
3681 )
3682 .unwrap();
3683 assert_eq!(children, 48);
3684 let base = component_provisioning_observation_bound_from_counts(1, 2).unwrap();
3685 assert_eq!(paced_protocol_stall_limit(8, base + children), 60);
3686 }
3687 assert!(matches!(
3688 super::initial_child_observation_bound(
3689 std::slice::from_ref(&spec),
3690 [(&spec.component_spec, &[2; 32])]
3691 ),
3692 Err(EnsurePolicyError::EstateFundingTopology { .. })
3693 ));
3694 }
3695
3696 #[test]
3697 fn estate_creation_forecast_retains_ready_floor_without_repeating_workloads() {
3698 assert_eq!(required_estate_creation_count(8, 10, 10, 0), Ok(8));
3699 assert_eq!(required_estate_creation_count(8, 10, 10, 8), Ok(0));
3700 assert_eq!(required_estate_creation_count(8, 2, 2, 0), Ok(8));
3701 assert_eq!(required_estate_creation_count(8, 2, 10, 0), Ok(0));
3702 }
3703
3704 #[test]
3705 fn estate_shortfall_compiles_one_exact_plan_owned_ledger_transfer() {
3706 let mut accumulator = PlanAccumulator::new();
3707 accumulator.canisters.push(CanisterPlan {
3708 actions: Vec::new(),
3709 disposition: CanisterDisposition::Reuse,
3710 name: "root-0".to_string(),
3711 observed_cycles: 30,
3712 principal: Some("rrkah-fqaaa-aaaaa-aaaaq-cai".to_string()),
3713 });
3714 let domain = EstateFundingDomainPlan {
3715 allocated_workloads: 1,
3716 available_cycles: Some(40),
3717 available_pool_slots: 1,
3718 creation_amount_cycles: 45,
3719 creation_execution_margin_cycles: 5,
3720 readiness_floor_cycles: 35,
3721 cycles_ledger: "aaaaa-aa".to_string(),
3722 eligible_ready_pool_assets: 1,
3723 initial_pool_assets: vec!["existing-pool".to_string()],
3724 ledger_fee_cycles: 5,
3725 management_creation_fee_cycles: 5,
3726 maximum_creation_debit_cycles: 100,
3727 maximum_creation_fee_cycles: 20,
3728 maximum_funding_cycles: 60,
3729 occupied_pool_assets: 1,
3730 pending_creation_count: 0,
3731 pending_creation: None,
3732 planned_initial_workloads: 2,
3733 pool_maximum_size: 2,
3734 required_creation_count: 2,
3735 root: "root-0".to_string(),
3736 root_principal: Some("rrkah-fqaaa-aaaaa-aaaaq-cai".to_string()),
3737 shortfall_cycles: 60,
3738 };
3739
3740 append_estate_funding_actions(&[domain], 100, 4, &mut accumulator)
3741 .expect("compile exact estate funding action");
3742
3743 assert_eq!(accumulator.new_funding, 60);
3744 assert_eq!(accumulator.fees, 5);
3745 assert!(matches!(
3746 accumulator.canisters[0].actions.as_slice(),
3747 [EnsureAction::FundEstate {
3748 amount: 60,
3749 created_at_time: 104,
3750 expected_post_cycles: 100,
3751 ledger,
3752 ledger_fee_cycles: 5,
3753 name,
3754 principal,
3755 }] if ledger == "aaaaa-aa"
3756 && name == "root-0"
3757 && principal == "rrkah-fqaaa-aaaaa-aaaaq-cai"
3758 ));
3759 }
3760
3761 #[test]
3762 fn complete_pool_inventory_owns_creation_capacity_forecast() {
3763 let policy = pool_policy(4, 8);
3764 let exhausted = observed_pool([
3765 EstatePoolAssetLifecycle::Workload,
3766 EstatePoolAssetLifecycle::Workload,
3767 EstatePoolAssetLifecycle::Workload,
3768 EstatePoolAssetLifecycle::Workload,
3769 EstatePoolAssetLifecycle::Failed,
3770 EstatePoolAssetLifecycle::Failed,
3771 EstatePoolAssetLifecycle::Failed,
3772 EstatePoolAssetLifecycle::Failed,
3773 ]);
3774 assert!(matches!(
3775 forecast_observed_root_pool("root", &exhausted, &policy, 4, 4, 1_900, 500),
3776 Err(EnsurePolicyError::EstatePoolCapacity {
3777 allocated_workloads: 4,
3778 available_slots: 0,
3779 capacity_shortfall: 4,
3780 eligible_ready_assets: 0,
3781 maximum_size: 8,
3782 occupied_assets: 8,
3783 pending_creations: 0,
3784 required_creation_count: 4,
3785 ..
3786 })
3787 ));
3788
3789 let recoverable = observed_pool([
3790 EstatePoolAssetLifecycle::Workload,
3791 EstatePoolAssetLifecycle::Workload,
3792 EstatePoolAssetLifecycle::Workload,
3793 EstatePoolAssetLifecycle::Workload,
3794 EstatePoolAssetLifecycle::Ready,
3795 EstatePoolAssetLifecycle::Ready,
3796 ]);
3797 let forecast = forecast_observed_root_pool("root", &recoverable, &policy, 4, 4, 1_900, 500)
3798 .expect("two free slots can restore the four-Ready floor");
3799 assert_eq!(forecast.occupied_assets, 6);
3800 assert_eq!(forecast.eligible_ready_assets, 2);
3801 assert_eq!(forecast.required_creation_count, 2);
3802 assert_eq!(forecast.available_slots, 2);
3803 }
3804
3805 #[test]
3806 fn every_retained_pool_lifecycle_and_pending_creation_consumes_capacity_once() {
3807 let policy = pool_policy(2, 10);
3808 let mut observed = observed_pool([
3809 EstatePoolAssetLifecycle::Claimed,
3810 EstatePoolAssetLifecycle::Failed,
3811 EstatePoolAssetLifecycle::HandingOff,
3812 EstatePoolAssetLifecycle::PendingReset,
3813 EstatePoolAssetLifecycle::Ready,
3814 EstatePoolAssetLifecycle::Recycling,
3815 EstatePoolAssetLifecycle::Workload,
3816 ]);
3817 observed.minimum_size = 2;
3818 observed.maximum_size = 10;
3819 observed.pending_creation = Some(EstatePoolPendingCreationObservation {
3820 attempt_count: 1,
3821 available_cycles: None,
3822 creation_amount_cycles: 2_500,
3823 created_principal: None,
3824 diagnostic: None,
3825 last_attempt_at_ns: Some(1),
3826 operation_id: "11".repeat(32),
3827 required_cycles: None,
3828 retry_at_ns: Some(2),
3829 shortfall_cycles: Some(2_500),
3830 uncertain_result: false,
3831 });
3832 let forecast = forecast_observed_root_pool("root", &observed, &policy, 2, 2, 1_900, 500)
3833 .expect("all bounded lifecycles fit with one free slot");
3834 assert_eq!(forecast.occupied_assets, 8);
3835 assert_eq!(forecast.allocated_workloads, 2);
3836 assert_eq!(forecast.eligible_ready_assets, 1);
3837 assert_eq!(forecast.pending_creations, 1);
3838 assert_eq!(forecast.required_creation_count, 1);
3839
3840 observed.maximum_size = 9;
3841 let drifted = forecast_observed_root_pool("root", &observed, &policy, 2, 2, 1_900, 500)
3842 .expect("observed policy drift retains exact assets under desired limits");
3843 assert_eq!(drifted, forecast);
3844 }
3845
3846 #[test]
3847 fn unresolved_pool_creation_rejects_before_funding_authority() {
3848 let policy = pool_policy(1, 2);
3849 let mut observed = observed_pool([]);
3850 observed.pending_creation = Some(EstatePoolPendingCreationObservation {
3851 attempt_count: 1,
3852 available_cycles: None,
3853 creation_amount_cycles: 2_500,
3854 created_principal: None,
3855 diagnostic: None,
3856 last_attempt_at_ns: Some(1),
3857 operation_id: "11".repeat(32),
3858 required_cycles: None,
3859 retry_at_ns: None,
3860 shortfall_cycles: None,
3861 uncertain_result: true,
3862 });
3863 assert!(matches!(
3864 forecast_observed_root_pool("root", &observed, &policy, 1, 1, 1_900, 500),
3865 Err(EnsurePolicyError::EstateFundingTopology { reason })
3866 if reason.contains("unresolved creation response")
3867 ));
3868 }
3869
3870 #[test]
3871 #[expect(
3872 clippy::too_many_lines,
3873 reason = "one complete inventory proof binds funding, retained identities and the no-creation outcome"
3874 )]
3875 fn full_failed_inventory_funds_exact_retained_assets_without_creating_replacements() {
3876 let root = Principal::from_slice(&[10]).to_text();
3877 let desired = DesiredFleet {
3878 bootstrap: None,
3879 canisters: Vec::new(),
3880 cycles_ledger: Principal::from_slice(&[11]).to_text(),
3881 environment: "local".into(),
3882 fleet: "repair".into(),
3883 ledger_fee_cycles: "5".into(),
3884 management_creation_fee_cycles: "500".into(),
3885 material_cycle_threshold: "1".into(),
3886 maximum_observation_burn_cycles: "10".into(),
3887 maximum_stalled_observations: 8,
3888 maximum_update_burn_cycles: "20".into(),
3889 operator: Principal::from_slice(&[12]).to_text(),
3890 protocol: None,
3891 protocol_steps: Vec::new(),
3892 schema_version: FLEET_ENSURE_SCHEMA_VERSION,
3893 treasury: "coordinator".into(),
3894 };
3895 let mut pool = observed_pool([
3896 EstatePoolAssetLifecycle::Workload,
3897 EstatePoolAssetLifecycle::Workload,
3898 EstatePoolAssetLifecycle::Workload,
3899 EstatePoolAssetLifecycle::Workload,
3900 EstatePoolAssetLifecycle::Failed,
3901 EstatePoolAssetLifecycle::Failed,
3902 EstatePoolAssetLifecycle::Failed,
3903 EstatePoolAssetLifecycle::Failed,
3904 ]);
3905 let mut actions = Vec::new();
3906 for (index, asset) in pool.assets.iter_mut().enumerate() {
3907 let canister_id =
3908 Principal::from_slice(&[u8::try_from(index + 20).expect("bounded asset")]);
3909 asset.principal = canister_id.to_text();
3910 if asset.lifecycle == EstatePoolAssetLifecycle::Failed {
3911 asset.cycles -= 1;
3912 actions.push(EnsureAction::FleetProtocol {
3913 action: Box::new(CurrentFleetProtocolAction::ReconcilePoolAsset {
3914 request: canic_core::dto::pool::PoolCanisterRequest { canister_id },
3915 minimum_cycles: Cycles::new(pool.readiness_floor_cycles),
3916 }),
3917 candid: "root.did".into(),
3918 candid_sha256: "42".repeat(32),
3919 maximum_execution_burn_cycles: 20,
3920 name: format!("pool-reconcile-{canister_id}"),
3921 principal: root.clone(),
3922 });
3923 }
3924 }
3925 assert!(matches!(
3926 forecast_observed_root_pool("root", &pool, &pool_policy(4, 8), 4, 4, 1_900, 500),
3927 Err(EnsurePolicyError::EstatePoolCapacity {
3928 capacity_shortfall: 4,
3929 ..
3930 })
3931 ));
3932 let observation = FleetObservation {
3933 additional_controlled_cycles: pool
3934 .assets
3935 .iter()
3936 .map(|asset| (asset.principal.clone(), asset.cycles))
3937 .collect(),
3938 canisters: BTreeMap::new(),
3939 estate_funding_domains: BTreeMap::from([(
3940 "root".into(),
3941 crate::fleet_ensure::model::EstateFundingDomainObservation {
3942 balance_cycles: Some(0),
3943 cycles_ledger: desired.cycles_ledger.clone(),
3944 pool: Some(pool.clone()),
3945 root_principal: Some(root.clone()),
3946 },
3947 )]),
3948 ledger_fee_cycles: 5,
3949 operator_cycles: 10_000,
3950 protocol_ready: BTreeMap::new(),
3951 };
3952 let mut accumulator = PlanAccumulator::new();
3953 append_pool_reconciliation_funding(
3954 &desired,
3955 &observation,
3956 &actions,
3957 CycleBounds {
3958 ledger_fee: 5,
3959 management_creation_fee: 500,
3960 material_threshold: 1,
3961 observation_burn: 10,
3962 update_burn: 20,
3963 },
3964 100,
3965 &mut accumulator,
3966 )
3967 .expect("review recovery funding");
3968 let preview = super::recovery::review(
3969 &observation,
3970 CycleBounds {
3971 ledger_fee: 5,
3972 management_creation_fee: 500,
3973 material_threshold: 1,
3974 observation_burn: 10,
3975 update_burn: 20,
3976 },
3977 32,
3978 40,
3979 10_000,
3980 )
3981 .unwrap();
3982 assert_eq!(preview.known_pool_funding.len(), 4);
3983 assert_eq!(
3984 preview
3985 .known_pool_funding
3986 .iter()
3987 .map(|funding| funding.amount_cycles)
3988 .sum::<u128>(),
3989 accumulator.new_funding
3990 );
3991 assert_eq!(
3992 preview
3993 .known_pool_funding
3994 .iter()
3995 .map(|funding| funding.ledger_fee_cycles)
3996 .sum::<u128>(),
3997 accumulator.fees
3998 );
3999 assert_eq!(accumulator.new_funding, 4 * 131);
4000 assert_eq!(accumulator.fees, 4 * 5);
4001 for plan in &accumulator.canisters {
4002 assert!(matches!(plan.actions.as_slice(), [EnsureAction::Fund {
4003 pool_funding: Some(PoolFundingAuthority { root: bound_root, lifecycle: EstatePoolAssetLifecycle::Failed }), principal, amount: 131, expected_post_cycles: 2_030,
4004 funding_deficit_cycles: 101, funding_margin_cycles: 30, ..
4005 }] if bound_root == &root && Some(principal) == plan.principal.as_ref()));
4006 }
4007 for asset in &mut pool.assets {
4008 if asset.lifecycle == EstatePoolAssetLifecycle::Failed {
4009 asset.cycles = 2_000;
4010 asset.lifecycle = EstatePoolAssetLifecycle::Ready;
4011 }
4012 }
4013 let recovered =
4014 forecast_observed_root_pool("root", &pool, &pool_policy(4, 8), 4, 4, 1_900, 500)
4015 .expect("recovered full pool satisfies the same demand");
4016 assert_eq!(recovered.eligible_ready_assets, 4);
4017 assert_eq!(recovered.required_creation_count, 0);
4018 }
4019
4020 fn pool_policy(minimum_size: u32, maximum_size: u32) -> FleetSubnetCanisterPoolConfig {
4021 FleetSubnetCanisterPoolConfig {
4022 minimum_size,
4023 maximum_size,
4024 canister_cycles: Cycles::new(1_900),
4025 creation_execution_margin: Cycles::new(100),
4026 }
4027 }
4028
4029 fn observed_pool(
4030 lifecycles: impl IntoIterator<Item = EstatePoolAssetLifecycle>,
4031 ) -> EstatePoolInventoryObservation {
4032 EstatePoolInventoryObservation {
4033 assets: lifecycles
4034 .into_iter()
4035 .enumerate()
4036 .map(|(index, lifecycle)| EstatePoolAssetObservation {
4037 creation_receipt: None,
4038 cycles: 1_900,
4039 lifecycle,
4040 origin: EstatePoolAssetOrigin::Imported,
4041 principal: format!("asset-{index}"),
4042 })
4043 .collect(),
4044 maximum_size: 8,
4045 minimum_size: 4,
4046 pending_creation: None,
4047 readiness_floor_cycles: 1_900,
4048 creation_execution_margin_cycles: 100,
4049 }
4050 }
4051
4052 #[test]
4053 fn fresh_fleet_descendant_capacity_does_not_multiply_terminal_proof() {
4054 for maximum_descendants in [0, 1, 10_000, u32::MAX] {
4055 assert_eq!(
4056 terminal_initial_component_observation_count(maximum_descendants),
4057 3,
4058 );
4059 }
4060 }
4061
4062 #[test]
4063 fn component_provisioning_stall_floor_scales_and_caps() {
4064 let topology_bound = component_provisioning_observation_bound_from_counts(1, 11)
4065 .expect("bounded one-Root eleven-Component topology");
4066 assert_eq!(paced_protocol_stall_limit(8, topology_bound), 39);
4067 assert_eq!(paced_protocol_stall_limit(8, 10_000), 64);
4068 assert_eq!(paced_protocol_stall_limit(80, topology_bound), 80);
4069 }
4070
4071 #[test]
4072 fn apply_policy_rejects_bootstrap_imports_above_the_root_maximum() {
4073 let root = DesiredFleetBootstrapRoot {
4074 canister_pool_imports: vec![
4075 "pool-0".to_string(),
4076 "pool-1".to_string(),
4077 "pool-2".to_string(),
4078 ],
4079 component_admissions: Vec::new(),
4080 component_topology_digest: ComponentTopologyDigest::from_bytes([1; 32]),
4081 funding: crate::test_support::fleet_subnet_root_funding_authority(),
4082 limits: FleetSubnetRootLimits {
4083 maximum_component_instances: 1,
4084 maximum_registry_bytes: 1,
4085 maximum_wasm_store_bytes: 1,
4086 canister_pool: FleetSubnetCanisterPoolConfig {
4087 minimum_size: 2,
4088 maximum_size: 2,
4089 canister_cycles: Cycles::new(1),
4090 creation_execution_margin: Cycles::new(1),
4091 },
4092 cycles_funding: CyclesFundingBudget {
4093 window_secs: 1,
4094 maximum_cycles: Cycles::new(1),
4095 },
4096 maximum_group_placements: 1,
4097 },
4098 placement_subnet: SubnetId::from_principal(Principal::from_slice(&[2])),
4099 root: "root-0".to_string(),
4100 store: "store-0".to_string(),
4101 };
4102
4103 assert!(matches!(
4104 validate_bootstrap_root_pool_import_capacity(&root),
4105 Err(EnsurePolicyError::PoolImportCapacity(error))
4106 if error.import_count == 3
4107 && error.maximum_size == 2
4108 && error.root == "root-0"
4109 ));
4110 }
4111}