1use crate::{
8 canister_protocol::{CanisterProtocolError, call_with_candid, query_with_candid},
9 fleet_ensure::{
10 dto::{FleetEnsureProgress, FleetObservationStage, FleetObservationTiming},
11 model::{
12 CanisterRuntimeStatus, DesiredCanisterKind, DesiredFleet, EffectRecord, EnsureAction,
13 EstateFundingDomainObservation, EstatePoolAssetLifecycle, EstatePoolAssetObservation,
14 EstatePoolAssetOrigin, EstatePoolCreationDiagnostic,
15 EstatePoolCreationReceiptObservation, EstatePoolInventoryObservation,
16 EstatePoolPendingCreationObservation, FleetEnsureStateRecord, FleetObservation,
17 InstallMode, LiveCanister, MAX_FLEET_ENSURE_CANISTERS, RetirementTransferBalances,
18 RetirementTransferInvariantError, RetirementTransferReconciliation,
19 RootManagementCanisterObservation, RootManagementObservation,
20 RootOwnedCanisterLifecycle, create_balance_is_terminal, reconcile_retirement_transfer,
21 },
22 ops::{
23 EffectObservation, EffectOutcome, EffectRetry, EnsurePlatform, EnsureStateError,
24 TerminalFleetInventory, canic_init, current_protocol, protocol, root_owned_lifecycle,
25 },
26 },
27 icp::{
28 IcpCandidCallError, IcpCanisterStatusReport, IcpCli, IcpCommandError, IcpDiagnostic,
29 IcpManagementCallError, LocalReplicaTarget, run_status,
30 },
31 icp_config::resolve_icp_build_network_from_root,
32 subnet_catalog::load_mainnet_subnet_catalog,
33};
34use candid::{CandidType, Nat, Principal};
35use canic_core::{
36 cdk::{types::Cycles, utils::hash::hex_bytes},
37 dto::canister::{CanisterInspectionRequest, CanisterStatusResponse},
38 dto::pool::{
39 CanisterPoolAsset, CanisterPoolAssetOrigin, CanisterPoolAssetStatus,
40 CanisterPoolCreationFailure, CanisterPoolCreationProgress, CanisterPoolHandoff,
41 CanisterPoolResponse, CanisterPoolStatusRequest,
42 },
43 ids::BuildNetwork,
44 protocol as canic_protocol,
45};
46use serde::Deserialize;
47use std::{
48 cell::RefCell,
49 collections::{BTreeMap, BTreeSet},
50 path::{Path, PathBuf},
51 thread,
52 time::{Duration, SystemTime, UNIX_EPOCH},
53};
54use thiserror::Error as ThisError;
55
56#[derive(CandidType)]
57struct ManagementCanisterStatusRequest {
58 canister_id: Principal,
59}
60
61#[derive(CandidType)]
62struct CyclesLedgerAccount {
63 owner: Principal,
64 subaccount: Option<[u8; 32]>,
65}
66
67#[derive(CandidType)]
68struct CyclesLedgerTransferArgs {
69 amount: Nat,
70 created_at_time: Option<u64>,
71 fee: Option<Nat>,
72 from_subaccount: Option<[u8; 32]>,
73 memo: Option<Vec<u8>>,
74 to: CyclesLedgerAccount,
75}
76
77#[derive(CandidType, Deserialize)]
78enum CyclesLedgerTransferError {
79 BadBurn { min_burn_amount: Nat },
80 BadFee { expected_fee: Nat },
81 CreatedInFuture { ledger_time: u64 },
82 Duplicate { duplicate_of: Nat },
83 GenericError { error_code: Nat, message: String },
84 InsufficientFunds { balance: Nat },
85 TemporarilyUnavailable,
86 TooOld,
87}
88
89#[derive(CandidType, Deserialize)]
90struct ManagementCanisterStatusResponse {
91 version: u64,
92 module_hash: Option<Vec<u8>>,
93}
94
95#[derive(CandidType, Deserialize)]
96struct ManagementCanisterObservationSettings {
97 controllers: Vec<Principal>,
98}
99
100#[derive(CandidType, Deserialize)]
101enum ManagementCanisterRuntimeStatus {
102 #[serde(rename = "running")]
103 Running,
104 #[serde(rename = "stopping")]
105 Stopping,
106 #[serde(rename = "stopped")]
107 Stopped,
108}
109
110#[derive(CandidType, Deserialize)]
111struct ManagementCanisterObservationResponse {
112 status: ManagementCanisterRuntimeStatus,
113 settings: ManagementCanisterObservationSettings,
114 module_hash: Option<Vec<u8>>,
115 cycles: Nat,
116 version: u64,
117}
118
119#[derive(Debug, Eq, PartialEq)]
120struct ExactInstallCanisterStatus {
121 canister_version: u64,
122 module_sha256: Option<String>,
123}
124
125fn is_unallocated_fresh_root(
126 desired: &DesiredFleet,
127 configured: &crate::fleet_ensure::model::DesiredCanister,
128 reviewed_targets: &BTreeSet<String>,
129) -> bool {
130 reviewed_targets.is_empty()
131 && configured.principal.is_none()
132 && desired.bootstrap.as_ref().is_some_and(|bootstrap| {
133 bootstrap.fresh_estate
134 && bootstrap
135 .roots
136 .iter()
137 .any(|root| root.root == configured.name)
138 })
139}
140
141#[derive(CandidType)]
144struct DrainRequest {
145 operation_id: String,
146 destination: Principal,
147 cycles: Nat,
148}
149
150#[derive(CandidType, Deserialize)]
151enum DrainResponse {
152 Accepted { transferred_cycles: Nat },
153 Replayed { transferred_cycles: Nat },
154}
155
156#[derive(CandidType)]
157enum RootPoolStatusRequest {
158 Pool(CanisterPoolStatusRequest),
159}
160
161#[derive(CandidType, Deserialize)]
162enum RootPoolStatusResponse {
163 Pool(Box<CanisterPoolResponse>),
164}
165
166#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
167struct EstatePoolLifecycleCounts {
168 claimed: u32,
169 failed: u32,
170 handing_off: u32,
171 pending_reset: u32,
172 ready: u32,
173 recycling: u32,
174 store: u32,
175 store_deletion_pending: u32,
176 tracked: u32,
177 workload: u32,
178}
179
180impl EstatePoolLifecycleCounts {
181 const fn declared(response: &CanisterPoolResponse) -> Self {
182 Self {
183 claimed: response.claimed,
184 failed: response.failed,
185 handing_off: response.handing_off,
186 pending_reset: response.pending_reset,
187 ready: response.ready,
188 recycling: response.recycling,
189 store: response.store,
190 store_deletion_pending: response.store_deletion_pending,
191 tracked: response.tracked,
192 workload: response.workload,
193 }
194 }
195
196 fn observe(&mut self, status: &CanisterPoolAssetStatus) -> Option<()> {
197 self.tracked = self.tracked.checked_add(1)?;
198 let count = match status {
199 CanisterPoolAssetStatus::Store => &mut self.store,
200 CanisterPoolAssetStatus::StoreDeletionPending { .. } => {
201 &mut self.store_deletion_pending
202 }
203 CanisterPoolAssetStatus::PendingReset => &mut self.pending_reset,
204 CanisterPoolAssetStatus::Ready => &mut self.ready,
205 CanisterPoolAssetStatus::Claimed { .. } => &mut self.claimed,
206 CanisterPoolAssetStatus::Workload { .. } => &mut self.workload,
207 CanisterPoolAssetStatus::Recycling { .. } => &mut self.recycling,
208 CanisterPoolAssetStatus::HandingOff { .. } => &mut self.handing_off,
209 CanisterPoolAssetStatus::Failed { .. } => &mut self.failed,
210 };
211 *count = count.checked_add(1)?;
212 Some(())
213 }
214
215 fn pooled(self) -> Option<u32> {
216 self.pending_reset
217 .checked_add(self.ready)?
218 .checked_add(self.handing_off)?
219 .checked_add(self.failed)
220 }
221}
222
223#[derive(Clone, Debug, Eq, PartialEq)]
224struct EstatePoolPageAuthority {
225 completed_handoffs: u64,
226 counts: EstatePoolLifecycleCounts,
227 pending_handoff: Option<CanisterPoolHandoff>,
228 pooled: u32,
229 surplus: u32,
230}
231
232impl EstatePoolPageAuthority {
233 fn from_response(response: &CanisterPoolResponse) -> Self {
234 Self {
235 completed_handoffs: response.completed_handoffs,
236 counts: EstatePoolLifecycleCounts::declared(response),
237 pending_handoff: response.pending_handoff.clone(),
238 pooled: response.pooled,
239 surplus: response.surplus,
240 }
241 }
242
243 fn matches_complete_inventory(
244 &self,
245 observed: EstatePoolLifecycleCounts,
246 maximum_size: u32,
247 ) -> bool {
248 let Some(pooled) = observed.pooled() else {
249 return false;
250 };
251 self.counts == observed
252 && self.pooled == pooled
253 && self.surplus == pooled.saturating_sub(maximum_size)
254 }
255}
256
257#[derive(Default)]
258struct EstatePoolInventoryAccumulator {
259 assets: Vec<EstatePoolAssetObservation>,
260 expected_config: Option<canic_core::ids::FleetSubnetCanisterPoolConfig>,
261 expected_page_authority: Option<EstatePoolPageAuthority>,
262 expected_pending: Option<canic_core::dto::pool::CanisterPoolCreation>,
263 observed_counts: EstatePoolLifecycleCounts,
264 seen: BTreeSet<Principal>,
265}
266
267impl EstatePoolInventoryAccumulator {
268 fn observe_page(
269 &mut self,
270 root_name: &str,
271 page: CanisterPoolResponse,
272 ) -> Result<Option<Principal>, IcpEnsurePlatformError> {
273 let page_authority = EstatePoolPageAuthority::from_response(&page);
274 let first_page = self.expected_config.is_none();
275 if !first_page
276 && (self.expected_config.as_ref() != Some(&page.config)
277 || self.expected_pending != page.pending_creation
278 || self.expected_page_authority.as_ref() != Some(&page_authority))
279 {
280 return Err(pool_configuration_error(format!(
281 "Root {root_name} pool authority changed during pagination"
282 )));
283 }
284 if first_page {
285 self.expected_config = Some(page.config.clone());
286 self.expected_pending.clone_from(&page.pending_creation);
287 self.expected_page_authority = Some(page_authority);
288 }
289
290 for asset in page.entries {
291 if !self.seen.insert(asset.canister_id) {
292 return Err(pool_configuration_error(format!(
293 "Root {root_name} pool repeats canister {}",
294 asset.canister_id
295 )));
296 }
297 if self.seen.len() > MAX_FLEET_ENSURE_CANISTERS
298 || self.observed_counts.observe(&asset.status).is_none()
299 {
300 return Err(pool_configuration_error(format!(
301 "Root {root_name} pool exceeds the Fleet observation bound"
302 )));
303 }
304 if let Some(lifecycle) = estate_pool_lifecycle(&asset.status) {
305 self.assets.push(EstatePoolAssetObservation {
306 creation_receipt: asset.creation_receipt.map(|receipt| {
307 EstatePoolCreationReceiptObservation {
308 block_index: receipt.block_index,
309 operation_id: hex_bytes(receipt.operation_id),
310 cycles_ledger: receipt.cycles_ledger.to_text(),
311 ledger_amount_cycles: receipt.ledger_amount.to_u128(),
312 ledger_fee_cycles: receipt.ledger_fee.to_u128(),
313 readiness_floor_cycles: receipt.readiness_floor.to_u128(),
314 creation_execution_margin_cycles: receipt
315 .creation_execution_margin
316 .to_u128(),
317 management_creation_fee_cycles: receipt
318 .management_creation_fee
319 .to_u128(),
320 first_observed_cycles: receipt
321 .first_observed_cycles
322 .map(|cycles| cycles.to_u128()),
323 }
324 }),
325 cycles: asset.cycles.to_u128(),
326 lifecycle,
327 origin: match asset.origin {
328 CanisterPoolAssetOrigin::InfrastructureStore => {
329 EstatePoolAssetOrigin::InfrastructureStore
330 }
331 CanisterPoolAssetOrigin::Created => EstatePoolAssetOrigin::Created,
332 CanisterPoolAssetOrigin::Imported => EstatePoolAssetOrigin::Imported,
333 CanisterPoolAssetOrigin::Recycled => EstatePoolAssetOrigin::Recycled,
334 },
335 principal: asset.canister_id.to_text(),
336 });
337 }
338 }
339 Ok(page.next_start_after)
340 }
341
342 fn finish(
343 self,
344 root_name: &str,
345 ) -> Result<EstatePoolInventoryObservation, IcpEnsurePlatformError> {
346 let config = self.expected_config.ok_or_else(|| {
347 pool_configuration_error(format!("Root {root_name} returned no pool authority"))
348 })?;
349 if !self.expected_page_authority.is_some_and(|authority| {
350 authority.matches_complete_inventory(self.observed_counts, config.maximum_size)
351 }) {
352 return Err(pool_configuration_error(format!(
353 "Root {root_name} pool lifecycle totals differ from its complete paged inventory"
354 )));
355 }
356 Ok(EstatePoolInventoryObservation {
357 assets: self.assets,
358 maximum_size: config.maximum_size,
359 minimum_size: config.minimum_size,
360 pending_creation: self.expected_pending.map(estate_pool_pending_creation),
361 readiness_floor_cycles: config.canister_cycles.to_u128(),
362 creation_execution_margin_cycles: config.creation_execution_margin.to_u128(),
363 })
364 }
365}
366
367fn pool_configuration_error(reason: String) -> IcpEnsurePlatformError {
368 current_protocol::CurrentProtocolError::Configuration(reason).into()
369}
370
371fn inactive_source_pool_has_full_inventory(
373 pool: &CanisterPoolResponse,
374 root: &crate::fleet_ensure::model::RootActivationResetRecord,
375) -> bool {
376 [
377 pool.config.maximum_size > 0,
378 pool.ready.checked_add(pool.workload) == Some(pool.config.maximum_size),
379 pool.config.maximum_size.checked_add(1) == Some(pool.tracked),
380 pool.ready >= pool.config.minimum_size,
381 root.component_count.checked_add(root.managed_descendants) == Some(pool.workload),
382 pool.store == 1,
383 pool.store_deletion_pending == 0,
384 pool.pending_reset == 0,
385 pool.claimed == 0,
386 pool.recycling == 0,
387 pool.handing_off == 0,
388 pool.failed == 0,
389 pool.pending_creation.is_none(),
390 pool.pending_handoff.is_none(),
391 ]
392 .into_iter()
393 .all(|fact| fact)
394}
395
396const fn pool_policy_is_current(
397 observed: &EstatePoolInventoryObservation,
398 desired: &canic_core::ids::FleetSubnetCanisterPoolConfig,
399) -> bool {
400 observed.maximum_size == desired.maximum_size
401 && observed.minimum_size == desired.minimum_size
402 && observed.readiness_floor_cycles == desired.canister_cycles.to_u128()
403 && observed.creation_execution_margin_cycles == desired.creation_execution_margin.to_u128()
404}
405
406fn estate_pool_pending_creation(
407 creation: canic_core::dto::pool::CanisterPoolCreation,
408) -> EstatePoolPendingCreationObservation {
409 let (
410 available_cycles,
411 created_principal,
412 diagnostic,
413 required_cycles,
414 retry_at_ns,
415 shortfall_cycles,
416 uncertain_result,
417 ) = match creation.progress {
418 CanisterPoolCreationProgress::Intent { uncertain_result } => {
419 (None, None, None, None, None, None, uncertain_result)
420 }
421 CanisterPoolCreationProgress::Created { canister_id, .. } => (
422 None,
423 Some(canister_id.to_text()),
424 None,
425 None,
426 None,
427 None,
428 false,
429 ),
430 CanisterPoolCreationProgress::WaitingForFunding {
431 available,
432 required,
433 retry_at_ns,
434 shortfall,
435 ..
436 } => (
437 Some(available.to_u128()),
438 None,
439 Some(EstatePoolCreationDiagnostic::FundingRequired),
440 Some(required.to_u128()),
441 Some(retry_at_ns),
442 Some(shortfall.to_u128()),
443 false,
444 ),
445 CanisterPoolCreationProgress::Blocked { failure } => (
446 None,
447 None,
448 Some(match failure {
449 CanisterPoolCreationFailure::UnresolvedAfterLedgerWindow => {
450 EstatePoolCreationDiagnostic::UnresolvedAfterLedgerWindow
451 }
452 CanisterPoolCreationFailure::LedgerCreationFailed => {
453 EstatePoolCreationDiagnostic::LedgerCreationFailed
454 }
455 CanisterPoolCreationFailure::LedgerRejected => {
456 EstatePoolCreationDiagnostic::LedgerRejected
457 }
458 }),
459 None,
460 None,
461 None,
462 false,
463 ),
464 };
465 EstatePoolPendingCreationObservation {
466 attempt_count: creation.attempt_count,
467 available_cycles,
468 creation_amount_cycles: creation.ledger_amount.to_u128(),
469 created_principal,
470 diagnostic,
471 last_attempt_at_ns: creation.last_attempt_at_ns,
472 operation_id: hex_bytes(creation.operation_id),
473 required_cycles,
474 retry_at_ns,
475 shortfall_cycles,
476 uncertain_result,
477 }
478}
479
480const fn estate_pool_lifecycle(
481 status: &CanisterPoolAssetStatus,
482) -> Option<EstatePoolAssetLifecycle> {
483 match status {
484 CanisterPoolAssetStatus::Store | CanisterPoolAssetStatus::StoreDeletionPending { .. } => {
485 None
486 }
487 CanisterPoolAssetStatus::PendingReset => Some(EstatePoolAssetLifecycle::PendingReset),
488 CanisterPoolAssetStatus::Ready => Some(EstatePoolAssetLifecycle::Ready),
489 CanisterPoolAssetStatus::Claimed { .. } => Some(EstatePoolAssetLifecycle::Claimed),
490 CanisterPoolAssetStatus::Workload { .. } => Some(EstatePoolAssetLifecycle::Workload),
491 CanisterPoolAssetStatus::Recycling { .. } => Some(EstatePoolAssetLifecycle::Recycling),
492 CanisterPoolAssetStatus::HandingOff { .. } => Some(EstatePoolAssetLifecycle::HandingOff),
493 CanisterPoolAssetStatus::Failed { .. } => Some(EstatePoolAssetLifecycle::Failed),
494 }
495}
496
497#[derive(CandidType)]
498enum RootInspectionCommand {
499 InspectCanister(CanisterInspectionRequest),
500}
501
502#[derive(CandidType, Deserialize)]
503enum RootInspectionResponse {
504 InspectCanister(CanisterStatusResponse),
505}
506
507#[derive(CandidType, Deserialize)]
509enum RootFundingInspectionResponse {
510 InspectCanister(RootFundingInspectionStatus),
511}
512
513#[derive(CandidType, Deserialize)]
514struct RootFundingInspectionStatus {
515 status: canic_core::dto::canister::CanisterStatusType,
516 settings: ManagementCanisterObservationSettings,
517 module_hash: Option<Vec<u8>>,
518 cycles: Nat,
519}
520
521#[derive(CandidType)]
522enum ManagedCanisterStatusRequest {
523 CycleBalance,
524}
525
526#[derive(CandidType, Deserialize)]
527enum ManagedCanisterStatusResponse {
528 CycleBalance(canic_core::dto::role::CycleBalanceStatusResponse),
529}
530
531#[derive(CandidType)]
532struct CreateCanisterArgs {
533 amount: Nat,
534 created_at_time: Option<u64>,
535 creation_args: Option<CmcCreateCanisterArgs>,
536 from_subaccount: Option<[u8; 32]>,
537}
538
539#[derive(CandidType)]
540struct CmcCreateCanisterArgs {
541 settings: Option<CanisterSettings>,
542 subnet_selection: Option<SubnetSelection>,
543}
544
545#[derive(CandidType)]
546struct CanisterSettings {
547 compute_allocation: Option<Nat>,
548 controllers: Option<Vec<Principal>>,
549 freezing_threshold: Option<Nat>,
550 memory_allocation: Option<Nat>,
551 reserved_cycles_limit: Option<Nat>,
552}
553
554struct CreateCanisterAuthority<'a> {
555 controller_canisters: &'a [String],
556 controllers: &'a [String],
557 created_at_time: u64,
558 ledger: &'a str,
559 requested_initial_cycles: u128,
560 subnet: &'a str,
561}
562
563#[derive(CandidType)]
564enum SubnetSelection {
565 Subnet { subnet: Principal },
566}
567
568#[derive(CandidType, Deserialize)]
569struct CreateCanisterSuccess {
570 block_id: Nat,
571 canister_id: Principal,
572}
573
574#[derive(CandidType, Deserialize)]
575enum CreateCanisterError {
576 CreatedInFuture {
577 ledger_time: u64,
578 },
579 Duplicate {
580 duplicate_of: Nat,
581 canister_id: Option<Principal>,
582 },
583 FailedToCreate {
584 error: String,
585 fee_block: Option<Nat>,
586 refund_block: Option<Nat>,
587 },
588 GenericError {
589 error_code: Nat,
590 message: String,
591 },
592 InsufficientFunds {
593 balance: Nat,
594 },
595 TemporarilyUnavailable,
596 TooOld,
597}
598
599#[derive(CandidType)]
600struct WithdrawArgs {
601 amount: Nat,
602 created_at_time: Option<u64>,
603 from_subaccount: Option<[u8; 32]>,
604 to: Principal,
605}
606
607#[derive(CandidType, Deserialize)]
608enum WithdrawError {
609 BadFee {
610 expected_fee: Nat,
611 },
612 CreatedInFuture {
613 ledger_time: u64,
614 },
615 Duplicate {
616 duplicate_of: Nat,
617 },
618 FailedToWithdraw {
619 fee_block: Option<Nat>,
620 rejection_code: RejectionCode,
621 rejection_reason: String,
622 },
623 GenericError {
624 error_code: Nat,
625 message: String,
626 },
627 InsufficientFunds {
628 balance: Nat,
629 },
630 InvalidReceiver {
631 receiver: Principal,
632 },
633 TemporarilyUnavailable,
634 TooOld,
635}
636
637#[derive(CandidType, Deserialize)]
638enum RejectionCode {
639 CanisterError,
640 CanisterReject,
641 DestinationInvalid,
642 NoError,
643 SysFatal,
644 SysTransient,
645 Unknown,
646}
647
648#[derive(Debug, ThisError)]
651pub enum IcpEnsurePlatformError {
652 #[error("cycle arithmetic overflow while executing {0}")]
653 Arithmetic(&'static str),
654
655 #[error(
656 "management history does not prove the issued reinstall of {canister}; refusing another reset"
657 )]
658 ReinstallHistoryConflict { canister: String },
659
660 #[error("configured Candid file is not a regular file: {}", .0.display())]
661 CandidUnavailable(PathBuf),
662
663 #[error("configured effect artifact is unavailable: {}", .0.display())]
664 ArtifactUnavailable(PathBuf),
665
666 #[error("configured {kind} changed after plan review: expected {expected}, observed {actual}")]
667 ArtifactDigestMismatch {
668 actual: String,
669 expected: String,
670 kind: &'static str,
671 },
672
673 #[error("configured Principal is invalid for {field}: {value}")]
674 InvalidPrincipal { field: &'static str, value: String },
675
676 #[error("Cycles Ledger returned an unsupported fee value: {0}")]
677 InvalidLedgerFee(String),
678
679 #[error("active ICP identity is {actual}, but reviewed Fleet operator is {expected}")]
680 OperatorMismatch { actual: String, expected: String },
681
682 #[error("management status for configured canister {expected} returned identity {actual}")]
683 StatusIdentityMismatch { actual: String, expected: String },
684
685 #[error("retained Root management observation failed: {0}")]
686 RootManagement(String),
687
688 #[error("ICP status has invalid cycle balance for {canister}: {value}")]
689 InvalidStatusCycles { canister: String, value: String },
690
691 #[error("ICP status omitted exact {field} required for controlled canister {canister}")]
692 IncompleteCanisterStatus {
693 canister: String,
694 field: &'static str,
695 },
696
697 #[error(
698 "ICP returned only public non-controller status for {canister}; exact runtime and cycle evidence is unavailable"
699 )]
700 PublicCanisterStatusUnavailable { canister: String },
701
702 #[error(
703 "Root-owned observation for {canister} conflicts with reviewed {field}; no paid effect was repeated"
704 )]
705 RootOwnedObservationAuthorityConflict {
706 canister: String,
707 field: &'static str,
708 },
709
710 #[error(
711 "ICP CLI status JSON omitted canister_version for {canister}, and the exact typed \
712 effective-ID-routed management-canister status call failed: {source}; no install was \
713 issued. Restore the selected controller identity and management-status access, then \
714 resume the same reviewed plan"
715 )]
716 InstallVersionProofUnavailable {
717 canister: String,
718 #[source]
719 source: Box<IcpManagementCallError>,
720 },
721
722 #[error("typed local management-status observation failed: {0}")]
723 LocalManagementStatus(#[source] Box<IcpManagementCallError>),
724
725 #[error(
726 "completed reinstall proof for {canister} conflicts with current {field}; no install was authorized"
727 )]
728 CompletedReinstallAuthorityConflict {
729 canister: String,
730 field: &'static str,
731 },
732
733 #[error(transparent)]
734 Candid(#[from] IcpCandidCallError),
735
736 #[error(transparent)]
737 Icp(#[from] IcpCommandError),
738
739 #[error("Cycles Ledger create_canister failed: {0}")]
740 LedgerCreate(String),
741
742 #[error("Cycles Ledger duplicate does not yet identify its created canister")]
743 LedgerCreatePending,
744
745 #[error(
746 "Root {root} pool policy differs from desired input; explicitly review infrastructure reinstall before resetting it"
747 )]
748 PoolPolicyReinstallReviewRequired { root: String },
749
750 #[error("Cycles Ledger withdraw failed: {0}")]
751 LedgerWithdraw(String),
752
753 #[error("completed withdrawal balance conflicts with reviewed funding bounds: {observation:?}")]
754 NativeFundingBalanceDrift {
755 observation: Box<NativeFundingObservation>,
756 },
757
758 #[error("Cycles Ledger estate funding transfer failed: {0}")]
759 LedgerTransfer(String),
760
761 #[error(
762 "Cycles Ledger transfer fee changed from reviewed {reviewed_fee_cycles} to {expected_fee_cycles}; no transfer was accepted"
763 )]
764 LedgerTransferFeeChanged {
765 expected_fee_cycles: u128,
766 reviewed_fee_cycles: u128,
767 },
768
769 #[error(
770 "Root-authorized funding inspection for {canister} conflicts with reviewed {field}; no Ledger withdrawal was repeated"
771 )]
772 FundingInspectionAuthorityConflict {
773 canister: String,
774 field: &'static str,
775 },
776
777 #[error(
778 "canister {canister} retains {actual} cycles above deletion limit {maximum}; left untouched"
779 )]
780 MaterialCycles {
781 actual: u128,
782 canister: String,
783 maximum: u128,
784 },
785
786 #[error("canister {canister} is not stopped; deletion was not attempted")]
787 NotStopped { canister: String },
788
789 #[error("effect references unresolved created canister {0}")]
790 UnresolvedCreated(String),
791
792 #[error("configured retirement endpoint transferred {actual} cycles, expected {expected}")]
793 WrongTransferAmount { actual: Nat, expected: u128 },
794
795 #[error(
796 "retirement transfer for {canister} is not conserved: source debit {source_debit}, treasury credit {destination_credit}, expected credit {expected}, maximum source debit {maximum_source_debit}; source and treasury were left untouched by later retirement steps"
797 )]
798 UnbalancedTransfer {
799 canister: String,
800 destination_credit: u128,
801 expected: u128,
802 maximum_source_debit: u128,
803 source_debit: u128,
804 },
805
806 #[error("retirement transfer for {canister} is missing its durable {side} balance")]
807 MissingTransferBalance {
808 canister: String,
809 side: &'static str,
810 },
811
812 #[error("retirement transfer balance for {canister} moved in an invalid direction")]
813 InvalidTransferBalance { canister: String },
814
815 #[error(transparent)]
816 CurrentProtocol(#[from] current_protocol::CurrentProtocolError),
817
818 #[error(transparent)]
819 CanicInit(#[from] canic_init::CanicInitError),
820
821 #[error(transparent)]
822 Protocol(#[from] protocol::ProtocolEffectError),
823
824 #[error("retained Root status authority is invalid: {0}")]
825 RetainedRootStatusAuthority(#[source] Box<EnsureStateError>),
826}
827
828#[derive(Default)]
830struct FleetObservationSnapshot {
831 pool_pages: BTreeMap<(Principal, Option<Principal>), CanisterPoolResponse>,
832 statuses: BTreeMap<String, Option<LiveCanister>>,
833}
834
835enum StatusCacheEntry {
837 Miss,
838 Hit(Option<LiveCanister>),
839}
840
841pub struct IcpEnsurePlatform {
843 desired: DesiredFleet,
844 icp: IcpCli,
845 initial_observation_delay: Duration,
846 maximum_observation_delay: Duration,
847 observation_snapshot: RefCell<Option<FleetObservationSnapshot>>,
848 progress_handler: Option<Box<dyn FnMut(FleetEnsureProgress)>>,
849 observation_handler: Option<Box<dyn FnMut(FleetObservationTiming)>>,
850 estate_observations: BTreeMap<String, EstateFundingDomainObservation>,
851 root: PathBuf,
852}
853
854const INITIAL_PROTOCOL_OBSERVATION_DELAY: Duration = Duration::from_millis(250);
855const MAXIMUM_PROTOCOL_OBSERVATION_DELAY: Duration = Duration::from_secs(5);
856
857fn protocol_observation_delay(
858 consecutive_unchanged_observations: u32,
859 initial_observation_delay: Duration,
860 maximum_observation_delay: Duration,
861) -> Duration {
862 let exponent = consecutive_unchanged_observations.saturating_sub(1).min(5);
863 let multiplier = 1_u32.checked_shl(exponent).unwrap_or(u32::MAX);
864 initial_observation_delay
865 .checked_mul(multiplier)
866 .unwrap_or(MAXIMUM_PROTOCOL_OBSERVATION_DELAY)
867 .min(MAXIMUM_PROTOCOL_OBSERVATION_DELAY)
868 .min(maximum_observation_delay)
869}
870
871impl IcpEnsurePlatform {
872 #[must_use]
873 pub fn new(desired: DesiredFleet, icp_executable: &str, root: &Path) -> Self {
874 let icp = IcpCli::new(icp_executable, Some(desired.environment.clone()))
875 .with_cwd(root.to_path_buf());
876 Self {
877 desired,
878 icp,
879 initial_observation_delay: INITIAL_PROTOCOL_OBSERVATION_DELAY,
880 maximum_observation_delay: MAXIMUM_PROTOCOL_OBSERVATION_DELAY,
881 observation_snapshot: RefCell::new(None),
882 progress_handler: None,
883 observation_handler: None,
884 estate_observations: BTreeMap::new(),
885 root: root.to_path_buf(),
886 }
887 }
888
889 #[must_use]
891 pub fn with_progress_handler(
892 mut self,
893 handler: impl FnMut(FleetEnsureProgress) + 'static,
894 ) -> Self {
895 self.progress_handler = Some(Box::new(handler));
896 self
897 }
898
899 #[must_use]
901 pub fn with_observation_handler(
902 mut self,
903 handler: impl FnMut(FleetObservationTiming) + 'static,
904 ) -> Self {
905 self.observation_handler = Some(Box::new(handler));
906 self
907 }
908
909 fn finish_observation_timing(
910 &mut self,
911 stage: FleetObservationStage,
912 started: std::time::Instant,
913 calls_before: u64,
914 succeeded: bool,
915 ) {
916 let timing = FleetObservationTiming {
917 stage,
918 elapsed_millis: started.elapsed().as_millis(),
919 remote_call_attempts: self.icp.remote_call_count().saturating_sub(calls_before),
920 succeeded,
921 };
922 if let Some(handler) = &mut self.observation_handler {
923 handler(timing);
924 }
925 }
926
927 fn timed_observation<T>(
928 &mut self,
929 stage: FleetObservationStage,
930 observe: impl FnOnce(&mut Self) -> Result<T, IcpEnsurePlatformError>,
931 ) -> Result<T, IcpEnsurePlatformError> {
932 let started = std::time::Instant::now();
933 let calls = self.icp.remote_call_count();
934 let result = observe(self);
935 self.finish_observation_timing(stage, started, calls, result.is_ok());
936 result
937 }
938
939 #[must_use]
945 pub fn with_local_replica(mut self, target: LocalReplicaTarget) -> Self {
946 self.icp = self.icp.with_local_replica(Some(target));
947 self
948 }
949
950 #[doc(hidden)]
956 #[must_use]
957 pub fn with_observation_delay_bounds(mut self, initial: Duration, maximum: Duration) -> Self {
958 self.initial_observation_delay = initial.min(maximum);
959 self.maximum_observation_delay = maximum;
960 self
961 }
962
963 fn with_observation_snapshot<T>(
966 &mut self,
967 observe: impl FnOnce(&mut Self) -> Result<T, IcpEnsurePlatformError>,
968 ) -> Result<T, IcpEnsurePlatformError> {
969 self.observation_snapshot
970 .replace(Some(FleetObservationSnapshot::default()));
971 let result = observe(self);
972 self.observation_snapshot.take();
973 result
974 }
975
976 fn require_operator(&self) -> Result<(), IcpEnsurePlatformError> {
977 let actual = self.icp.identity_principal_text()?;
978 if actual != self.desired.operator {
979 return Err(IcpEnsurePlatformError::OperatorMismatch {
980 actual,
981 expected: self.desired.operator.clone(),
982 });
983 }
984 Ok(())
985 }
986
987 fn current_principal<'a>(
988 &'a self,
989 state: &'a FleetEnsureStateRecord,
990 name: &str,
991 ) -> Option<&'a str> {
992 state
993 .pending_principals
994 .get(name)
995 .or_else(|| state.principals.get(name))
996 .map(String::as_str)
997 .or_else(|| {
998 self.desired
999 .canisters
1000 .iter()
1001 .find(|configured| configured.name == name)
1002 .and_then(|configured| configured.principal.as_deref())
1003 })
1004 }
1005
1006 fn protocol_principals(&self, state: &FleetEnsureStateRecord) -> BTreeMap<String, String> {
1007 self.desired
1008 .canisters
1009 .iter()
1010 .filter_map(|configured| {
1011 state
1012 .pending_principals
1013 .get(&configured.name)
1014 .or_else(|| state.principals.get(&configured.name))
1015 .or(configured.principal.as_ref())
1016 .map(|principal| (configured.name.clone(), principal.clone()))
1017 })
1018 .collect()
1019 }
1020
1021 fn current_protocol_owners_are_ready(
1022 &self,
1023 state: &FleetEnsureStateRecord,
1024 ) -> Result<bool, IcpEnsurePlatformError> {
1025 let mut owners = Vec::new();
1026 for configured in self.desired.canisters.iter().filter(|configured| {
1027 configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
1028 && matches!(
1029 configured.kind,
1030 DesiredCanisterKind::Coordinator
1031 | DesiredCanisterKind::Root
1032 | DesiredCanisterKind::Store
1033 )
1034 }) {
1035 let live = self
1036 .current_principal(state, &configured.name)
1037 .map(|principal| self.status_optional(principal))
1038 .transpose()?
1039 .flatten();
1040 if live
1041 .as_ref()
1042 .is_some_and(|live| live.status != CanisterRuntimeStatus::Running)
1043 {
1044 return Ok(false);
1045 }
1046 owners.push((configured, live));
1047 }
1048 for (configured, live) in owners {
1052 let Some(live) = live else {
1053 return Ok(false);
1054 };
1055 let Some(wasm) = configured.wasm.as_deref() else {
1056 return Ok(false);
1057 };
1058 let expected = artifact_hash(&resolve_path(&self.root, wasm))?;
1059 if live.module_sha256.as_deref() != Some(expected.as_str()) {
1060 return Ok(false);
1061 }
1062 }
1063 Ok(true)
1064 }
1065
1066 fn observe_estate_funding_domains(
1067 &self,
1068 state: &FleetEnsureStateRecord,
1069 ) -> Result<BTreeMap<String, EstateFundingDomainObservation>, IcpEnsurePlatformError> {
1070 let Some(bootstrap) = self.desired.bootstrap.as_ref() else {
1071 return Ok(BTreeMap::new());
1072 };
1073 bootstrap
1074 .roots
1075 .iter()
1076 .map(|root| {
1077 let root_principal = self.current_principal(state, &root.root);
1078 let balance_cycles = root_principal
1079 .map(|principal| self.cycles_ledger_balance(principal))
1080 .transpose()?;
1081 let pool = root_principal
1082 .map(|principal| {
1083 self.observe_estate_pool_inventory(&root.root, principal, state)
1084 })
1085 .transpose()?
1086 .flatten();
1087 Ok((
1088 root.root.clone(),
1089 EstateFundingDomainObservation {
1090 balance_cycles,
1091 cycles_ledger: self.desired.cycles_ledger.clone(),
1092 pool,
1093 root_principal: root_principal.map(str::to_string),
1094 },
1095 ))
1096 })
1097 .collect()
1098 }
1099
1100 fn cycles_ledger_balance(&self, owner: &str) -> Result<u128, IcpEnsurePlatformError> {
1101 let balance: Nat = self.icp.canister_query_candid(
1102 &self.desired.cycles_ledger,
1103 "icrc1_balance_of",
1104 &CyclesLedgerAccount {
1105 owner: parse_principal("Cycles Ledger account owner", owner)?,
1106 subaccount: None,
1107 },
1108 None,
1109 )?;
1110 ledger_fee_cycles(balance)
1111 }
1112
1113 fn refresh_estate_funding_observation(
1114 &self,
1115 observation: &mut EffectObservation,
1116 state: &FleetEnsureStateRecord,
1117 ) -> Result<(), IcpEnsurePlatformError> {
1118 let Some(required) = observation.estate_funding_required.as_mut() else {
1119 return Ok(());
1120 };
1121 let root = required.root.to_text();
1122 let known_root = self.desired.canisters.iter().any(|canister| {
1123 canister.kind == DesiredCanisterKind::Root
1124 && self.current_principal(state, &canister.name) == Some(root.as_str())
1125 });
1126 if !known_root || required.cycles_ledger.to_text() != self.desired.cycles_ledger {
1127 return Err(current_protocol::CurrentProtocolError::ResponseMismatch.into());
1128 }
1129 let available = self.cycles_ledger_balance(&root)?;
1132 let debit = required.required.to_u128();
1133 if available >= debit {
1134 observation.estate_funding_required = None;
1135 } else {
1136 required.available = Cycles::new(available);
1137 required.shortfall = Cycles::new(debit - available);
1138 }
1139 Ok(())
1140 }
1141
1142 fn require_current_pool_policy(
1143 &self,
1144 domains: &BTreeMap<String, EstateFundingDomainObservation>,
1145 ) -> Result<(), IcpEnsurePlatformError> {
1146 let Some(bootstrap) = self.desired.bootstrap.as_ref() else {
1147 return Ok(());
1148 };
1149 for root in &bootstrap.roots {
1150 let Some(pool) = domains
1151 .get(&root.root)
1152 .and_then(|domain| domain.pool.as_ref())
1153 else {
1154 continue;
1155 };
1156 if !pool_policy_is_current(pool, &root.limits.canister_pool) {
1157 return Err(IcpEnsurePlatformError::PoolPolicyReinstallReviewRequired {
1158 root: root.root.clone(),
1159 });
1160 }
1161 }
1162 Ok(())
1163 }
1164
1165 fn observe_estate_pool_inventory(
1166 &self,
1167 root_name: &str,
1168 root: &str,
1169 _state: &FleetEnsureStateRecord,
1170 ) -> Result<Option<EstatePoolInventoryObservation>, IcpEnsurePlatformError> {
1171 if self.required_root_status(root_name, root)? != CanisterRuntimeStatus::Running {
1172 return Ok(None);
1173 }
1174 let candid = self.root_protocol_candid()?;
1175 let root_principal = parse_principal("Fleet Subnet Root", root)?;
1176 let mut start_after = None;
1177 let mut inventory = EstatePoolInventoryAccumulator::default();
1178 loop {
1179 let page = self.query_estate_pool_page(&candid, root_principal, start_after)?;
1180 let next = inventory.observe_page(root_name, page)?;
1181 if next.is_none() {
1182 break;
1183 }
1184 if next == start_after {
1185 return Err(pool_configuration_error(format!(
1186 "Root {root_name} pool cursor did not advance"
1187 )));
1188 }
1189 start_after = next;
1190 }
1191 inventory.finish(root_name).map(Some)
1192 }
1193
1194 fn query_estate_pool_page(
1195 &self,
1196 candid: &Path,
1197 root: Principal,
1198 start_after: Option<Principal>,
1199 ) -> Result<CanisterPoolResponse, IcpEnsurePlatformError> {
1200 let key = (root, start_after);
1201 if let Some(page) = self
1202 .observation_snapshot
1203 .borrow()
1204 .as_ref()
1205 .and_then(|snapshot| snapshot.pool_pages.get(&key).cloned())
1206 {
1207 return Ok(page);
1208 }
1209 let response: RootPoolStatusResponse = query_with_candid(
1210 &self.icp,
1211 candid,
1212 root,
1213 canic_protocol::CANIC_ROOT_STATUS,
1214 &RootPoolStatusRequest::Pool(CanisterPoolStatusRequest {
1215 start_after,
1216 limit: 256,
1217 }),
1218 )
1219 .map_err(current_protocol::CurrentProtocolError::from)?;
1220 let RootPoolStatusResponse::Pool(page) = response;
1221 if let Some(snapshot) = self.observation_snapshot.borrow_mut().as_mut() {
1222 snapshot.pool_pages.insert(key, (*page).clone());
1223 }
1224 Ok(*page)
1225 }
1226
1227 #[expect(
1228 clippy::too_many_lines,
1229 reason = "one inspection binds each source asset to exact management authority and cycles"
1230 )]
1231 fn inspect_reinstall_assets(
1232 &self,
1233 state: &FleetEnsureStateRecord,
1234 authorities: &BTreeMap<String, RootManagementCanisterObservation>,
1235 observation: &mut FleetObservation,
1236 candid_by_root: &BTreeMap<String, PathBuf>,
1237 allow_pending_reset: bool,
1238 ) -> Result<Vec<crate::fleet_ensure::model::FleetReinstallAssetRecord>, IcpEnsurePlatformError>
1239 {
1240 let mut assets = Vec::new();
1241 let mut seen = BTreeSet::new();
1242 for (root_name, domain) in &observation.estate_funding_domains {
1243 let root = domain.root_principal.as_deref().ok_or_else(|| {
1244 IcpEnsurePlatformError::RootManagement("missing Root principal".to_string())
1245 })?;
1246 let pool = domain.pool.as_ref().ok_or_else(|| {
1247 IcpEnsurePlatformError::RootManagement("missing complete pool".to_string())
1248 })?;
1249 if pool.pending_creation.is_some() {
1250 return Err(IcpEnsurePlatformError::RootManagement(
1251 "pool creation is still in flight".to_string(),
1252 ));
1253 }
1254 let root_authority = authorities.get(root_name).ok_or_else(|| {
1255 IcpEnsurePlatformError::RootManagement("missing Root binding".to_string())
1256 })?;
1257 for asset in &pool.assets {
1258 if !seen.insert(asset.principal.clone())
1259 || !(matches!(
1260 asset.lifecycle,
1261 EstatePoolAssetLifecycle::Ready | EstatePoolAssetLifecycle::Workload
1262 ) || (allow_pending_reset
1263 && matches!(
1264 asset.lifecycle,
1265 EstatePoolAssetLifecycle::PendingReset
1266 | EstatePoolAssetLifecycle::Failed
1267 )))
1268 {
1269 return Err(IcpEnsurePlatformError::RootManagement(
1270 "pool asset is duplicated or not terminal".to_string(),
1271 ));
1272 }
1273 let target = parse_principal("reset pool asset", &asset.principal)?;
1274 let RootFundingInspectionResponse::InspectCanister(response) = call_with_candid(
1275 &self.icp,
1276 candid_by_root.get(root_name).ok_or_else(|| {
1277 pool_configuration_error("missing reset read contract".to_string())
1278 })?,
1279 parse_principal("reset Root", root)?,
1280 canic_protocol::CANIC_ROOT_COMMAND,
1281 &RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
1282 canister_id: target,
1283 }),
1284 )
1285 .map_err(current_protocol::CurrentProtocolError::from)?;
1286 if !response
1287 .settings
1288 .controllers
1289 .iter()
1290 .any(|controller| controller.to_text() == root)
1291 {
1292 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1293 canister: asset.principal.clone(),
1294 field: "Root controller",
1295 });
1296 }
1297 let cycles = validate_inspected_cycles(
1298 &asset.principal,
1299 InspectedModule::Any,
1300 response.module_hash.as_deref(),
1301 &response.cycles,
1302 )?;
1303 let mut controllers = response
1304 .settings
1305 .controllers
1306 .iter()
1307 .map(Principal::to_text)
1308 .collect::<Vec<_>>();
1309 controllers.sort();
1310 if controllers != [root.to_string()] {
1311 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1312 canister: asset.principal.clone(),
1313 field: "exact Root controllers",
1314 });
1315 }
1316
1317 let module_sha256 = response
1318 .module_hash
1319 .as_ref()
1320 .map(canic_core::cdk::utils::hash::hex_bytes);
1321 let configured_name = self
1322 .desired
1323 .canisters
1324 .iter()
1325 .find(|c| {
1326 c.principal.as_deref() == Some(&asset.principal)
1327 || self.current_principal(state, &c.name) == Some(&asset.principal)
1328 })
1329 .map(|c| c.name.clone());
1330 let name = configured_name
1331 .unwrap_or_else(|| format!("reinstall-pool-{}", asset.principal));
1332 observation.canisters.insert(
1333 name,
1334 Some(LiveCanister {
1335 canister_version: None,
1336 controllers: controllers.clone(),
1337 cycles,
1338 module_sha256: module_sha256.clone(),
1339 principal: asset.principal.clone(),
1340 reinstall_required: false,
1341 root_owned_lifecycle: Some(match asset.lifecycle {
1342 EstatePoolAssetLifecycle::Ready => RootOwnedCanisterLifecycle::Idle,
1343 EstatePoolAssetLifecycle::Workload => {
1344 RootOwnedCanisterLifecycle::Workload
1345 }
1346 _ => RootOwnedCanisterLifecycle::Retained,
1347 }),
1348 status: match response.status {
1349 canic_core::dto::canister::CanisterStatusType::Running => {
1350 CanisterRuntimeStatus::Running
1351 }
1352 canic_core::dto::canister::CanisterStatusType::Stopped => {
1353 CanisterRuntimeStatus::Stopped
1354 }
1355 canic_core::dto::canister::CanisterStatusType::Stopping => {
1356 CanisterRuntimeStatus::Stopping
1357 }
1358 },
1359 }),
1360 );
1361 observation
1362 .additional_controlled_cycles
1363 .remove(&asset.principal);
1364 assets.push(crate::fleet_ensure::model::FleetReinstallAssetRecord {
1365 controllers,
1366 module_sha256,
1367 principal: asset.principal.clone(),
1368 root: root_name.clone(),
1369 subnet: root_authority.subnet.clone(),
1370 });
1371 }
1372 }
1373 assets.sort_by(|a, b| a.principal.cmp(&b.principal));
1374 Ok(assets)
1375 }
1376
1377 fn resolved_controllers(
1378 &self,
1379 state: &FleetEnsureStateRecord,
1380 controllers: &[String],
1381 controller_canisters: &[String],
1382 ) -> Result<Vec<String>, IcpEnsurePlatformError> {
1383 let mut resolved = controllers.to_vec();
1384 for name in controller_canisters {
1385 resolved.push(
1386 self.current_principal(state, name)
1387 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.clone()))?
1388 .to_string(),
1389 );
1390 }
1391 resolved.sort();
1392 resolved.dedup();
1393 Ok(resolved)
1394 }
1395
1396 fn observed_protocol_action(
1397 &self,
1398 step: &crate::fleet_ensure::model::DesiredProtocolStep,
1399 principal: String,
1400 ) -> Result<EnsureAction, IcpEnsurePlatformError> {
1401 Ok(EnsureAction::Protocol {
1402 candid: step.candid.clone(),
1403 candid_sha256: artifact_hash(&resolve_path(&self.root, &step.candid))?,
1404 command_args: step.command_args.clone(),
1405 command_args_sha256: artifact_hash(&resolve_path(&self.root, &step.command_args))?,
1406 command_method: step.command_method.clone(),
1407 expected_status: step.expected_status.clone(),
1408 expected_status_sha256: artifact_hash(&resolve_path(
1409 &self.root,
1410 &step.expected_status,
1411 ))?,
1412 maximum_execution_burn_cycles: step
1413 .maximum_execution_burn_cycles
1414 .parse()
1415 .map_err(|_| IcpEnsurePlatformError::Arithmetic("protocol burn"))?,
1416 name: step.name.clone(),
1417 principal,
1418 status_args: step.status_args.clone(),
1419 status_args_sha256: artifact_hash(&resolve_path(&self.root, &step.status_args))?,
1420 status_method: step.status_method.clone(),
1421 })
1422 }
1423
1424 fn action_principal<'a>(
1425 state: &'a FleetEnsureStateRecord,
1426 principal: &'a str,
1427 ) -> Result<&'a str, IcpEnsurePlatformError> {
1428 if let Some(name) = principal.strip_prefix("created:") {
1429 return state
1430 .pending_principals
1431 .get(name)
1432 .map(String::as_str)
1433 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.to_string()));
1434 }
1435 Ok(principal)
1436 }
1437
1438 fn status_optional(
1439 &self,
1440 principal: &str,
1441 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1442 if let Some(cached) = self
1443 .observation_snapshot
1444 .borrow()
1445 .as_ref()
1446 .and_then(|snapshot| snapshot.statuses.get(principal).cloned())
1447 {
1448 return Ok(cached);
1449 }
1450 let observed = self.read_status_optional(principal)?;
1451 if let Some(snapshot) = self.observation_snapshot.borrow_mut().as_mut() {
1452 snapshot
1453 .statuses
1454 .insert(principal.to_string(), observed.clone());
1455 }
1456 Ok(observed)
1457 }
1458
1459 fn read_status_optional(
1460 &self,
1461 principal: &str,
1462 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1463 Self::read_status_with(&self.icp, principal)
1464 }
1465
1466 fn read_status_with(
1467 icp: &IcpCli,
1468 principal: &str,
1469 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1470 if icp.uses_direct_local_replica() {
1471 return Self::direct_local_status_optional(icp, principal);
1472 }
1473 let report = match icp.canister_status_report(principal) {
1474 Ok(report) => report,
1475 Err(error)
1476 if matches!(
1477 error.diagnostic(),
1478 Some(IcpDiagnostic::CanisterNotFound { .. })
1479 ) =>
1480 {
1481 return Ok(None);
1482 }
1483 Err(error) => return Err(error.into()),
1484 };
1485 if report.id != principal {
1486 return Err(IcpEnsurePlatformError::StatusIdentityMismatch {
1487 actual: report.id,
1488 expected: principal.to_string(),
1489 });
1490 }
1491 reject_public_status_projection(&report, principal)?;
1492 let status_text =
1493 report
1494 .status
1495 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
1496 canister: principal.to_string(),
1497 field: "status",
1498 })?;
1499 let cycles_text =
1500 report
1501 .cycles
1502 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
1503 canister: principal.to_string(),
1504 field: "cycles",
1505 })?;
1506 let cycles = parse_status_cycles(&cycles_text).ok_or_else(|| {
1507 IcpEnsurePlatformError::InvalidStatusCycles {
1508 canister: principal.to_string(),
1509 value: cycles_text,
1510 }
1511 })?;
1512 let mut controllers = report
1513 .settings
1514 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
1515 canister: principal.to_string(),
1516 field: "controllers",
1517 })?
1518 .controllers;
1519 controllers.sort();
1520 let status = match status_text.to_ascii_lowercase().as_str() {
1521 "stopped" => CanisterRuntimeStatus::Stopped,
1522 "stopping" => CanisterRuntimeStatus::Stopping,
1523 _ => CanisterRuntimeStatus::Running,
1524 };
1525 Ok(Some(LiveCanister {
1526 canister_version: report.canister_version,
1527 controllers,
1528 cycles,
1529 module_sha256: report.module_hash.map(|hash| normalize_hash(&hash)),
1530 principal: principal.to_string(),
1531 reinstall_required: false,
1532 root_owned_lifecycle: None,
1533 status,
1534 }))
1535 }
1536
1537 fn direct_local_status_optional(
1538 icp: &IcpCli,
1539 principal: &str,
1540 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1541 let canister_id = parse_principal("local canister status target", principal)?;
1542 let response = icp
1543 .management_canister_status_candid::<_, ManagementCanisterObservationResponse>(
1544 canister_id,
1545 &ManagementCanisterStatusRequest { canister_id },
1546 );
1547 let response = match response {
1548 Ok(response) => response,
1549 Err(error)
1550 if matches!(
1551 crate::icp::classify_icp_diagnostic(&error.to_string()),
1552 Some(IcpDiagnostic::CanisterNotFound { .. })
1553 ) =>
1554 {
1555 return Ok(None);
1556 }
1557 Err(error) => {
1558 return Err(IcpEnsurePlatformError::LocalManagementStatus(Box::new(
1559 error,
1560 )));
1561 }
1562 };
1563 let cycles_text = response.cycles.to_string();
1564 let cycles = u128::try_from(response.cycles.0).map_err(|_| {
1565 IcpEnsurePlatformError::InvalidStatusCycles {
1566 canister: principal.to_string(),
1567 value: cycles_text,
1568 }
1569 })?;
1570 let mut controllers = response
1571 .settings
1572 .controllers
1573 .into_iter()
1574 .map(|controller| controller.to_text())
1575 .collect::<Vec<_>>();
1576 controllers.sort();
1577 controllers.dedup();
1578 let status = match response.status {
1579 ManagementCanisterRuntimeStatus::Running => CanisterRuntimeStatus::Running,
1580 ManagementCanisterRuntimeStatus::Stopping => CanisterRuntimeStatus::Stopping,
1581 ManagementCanisterRuntimeStatus::Stopped => CanisterRuntimeStatus::Stopped,
1582 };
1583 Ok(Some(LiveCanister {
1584 canister_version: Some(response.version),
1585 controllers,
1586 cycles,
1587 module_sha256: response
1588 .module_hash
1589 .map(|hash| canic_core::cdk::utils::hash::hex_bytes(&hash)),
1590 principal: principal.to_string(),
1591 reinstall_required: false,
1592 root_owned_lifecycle: None,
1593 status,
1594 }))
1595 }
1596
1597 fn inspect_root_owned_canister(
1598 &self,
1599 configured: &crate::fleet_ensure::model::DesiredCanister,
1600 principal: &str,
1601 state: &FleetEnsureStateRecord,
1602 ) -> Result<Option<CanisterStatusResponse>, IcpEnsurePlatformError> {
1603 let parent = configured.parent.as_deref().ok_or_else(|| {
1604 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1605 canister: configured.name.clone(),
1606 field: "Root parent",
1607 }
1608 })?;
1609 let root = self.current_principal(state, parent).ok_or_else(|| {
1610 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1611 canister: configured.name.clone(),
1612 field: "Root Principal",
1613 }
1614 })?;
1615 let root_configured = self
1616 .desired
1617 .canisters
1618 .iter()
1619 .find(|candidate| {
1620 candidate.name == parent && candidate.kind == DesiredCanisterKind::Root
1621 })
1622 .ok_or_else(
1623 || IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1624 canister: configured.name.clone(),
1625 field: "Root desired authority",
1626 },
1627 )?;
1628 let root_wasm = root_configured.wasm.as_deref().ok_or_else(|| {
1629 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1630 canister: configured.name.clone(),
1631 field: "Root artifact",
1632 }
1633 })?;
1634 let expected_root_module = artifact_hash(&resolve_path(&self.root, root_wasm))?;
1635 let root_live = self.status_optional(root)?.ok_or_else(|| {
1636 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1637 canister: configured.name.clone(),
1638 field: "live Root",
1639 }
1640 })?;
1641 let expected_root_controllers = self.resolved_controllers(
1642 state,
1643 &root_configured.controllers,
1644 &root_configured.controller_canisters,
1645 )?;
1646 if root_live.controllers != expected_root_controllers {
1647 return Err(
1648 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1649 canister: configured.name.clone(),
1650 field: "Root controllers",
1651 },
1652 );
1653 }
1654 let Some(root_module) = root_live.module_sha256.as_deref() else {
1655 return Ok(None);
1656 };
1657 if root_module != expected_root_module {
1658 return Err(
1659 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1660 canister: configured.name.clone(),
1661 field: "Root module SHA-256",
1662 },
1663 );
1664 }
1665 if root_live.status != CanisterRuntimeStatus::Running {
1666 return Ok(None);
1667 }
1668 self.require_operator()?;
1669 let response: RootInspectionResponse = call_with_candid(
1670 &self.icp,
1671 &self.root_protocol_candid()?,
1672 parse_principal("Fleet Subnet Root", root)?,
1673 canic_protocol::CANIC_ROOT_COMMAND,
1674 &RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
1675 canister_id: parse_principal("Root-owned canister", principal)?,
1676 }),
1677 )
1678 .map_err(current_protocol::CurrentProtocolError::from)?;
1679 let RootInspectionResponse::InspectCanister(response) = response;
1680 Ok(Some(response))
1681 }
1682
1683 fn created_canister_cycles(
1684 &self,
1685 name: &str,
1686 principal: &str,
1687 state: &FleetEnsureStateRecord,
1688 ) -> Result<(Option<u128>, bool), IcpEnsurePlatformError> {
1689 match self.status_optional(principal) {
1690 Ok(live) => return Ok((live.map(|live| live.cycles), false)),
1691 Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable { .. }) => {}
1692 Err(error) => return Err(error),
1693 }
1694 let configured = self
1695 .desired
1696 .canisters
1697 .iter()
1698 .find(|configured| configured.name == name)
1699 .ok_or_else(
1700 || IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1701 canister: name.to_string(),
1702 field: "desired canister",
1703 },
1704 )?;
1705 if configured.kind != DesiredCanisterKind::Pool {
1706 return Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable {
1707 canister: principal.to_string(),
1708 });
1709 }
1710 let Some(response) = self.inspect_root_owned_canister(configured, principal, state)? else {
1711 return Ok((None, true));
1712 };
1713 let parent = configured.parent.as_deref().ok_or_else(|| {
1714 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1715 canister: name.to_string(),
1716 field: "Root parent",
1717 }
1718 })?;
1719 let root = self.current_principal(state, parent).ok_or_else(|| {
1720 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1721 canister: name.to_string(),
1722 field: "Root Principal",
1723 }
1724 })?;
1725 let cycles = validate_root_controlled_inspection(
1726 name,
1727 InspectedModule::Empty,
1728 root,
1729 &response.settings.controllers,
1730 response.module_hash.as_deref(),
1731 &response.cycles,
1732 )?;
1733 Ok((Some(cycles), false))
1734 }
1735
1736 fn install_status_optional(
1737 &self,
1738 principal: &str,
1739 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1740 let Some(mut live) = self.status_optional(principal)? else {
1741 return Ok(None);
1742 };
1743 let exact = exact_install_canister_status(
1744 &self.icp,
1745 principal,
1746 live.canister_version,
1747 live.module_sha256.clone(),
1748 )?;
1749 live.canister_version = Some(exact.canister_version);
1750 live.module_sha256 = exact.module_sha256;
1751 Ok(Some(live))
1752 }
1753
1754 fn observe_configured_canister(
1755 &self,
1756 configured: &crate::fleet_ensure::model::DesiredCanister,
1757 principal: &str,
1758 state: &FleetEnsureStateRecord,
1759 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1760 if configured.kind == DesiredCanisterKind::Pool {
1761 return self.observe_root_owned_canister(configured, principal, state);
1762 }
1763 let status = self.status_optional(principal);
1764 self.configured_status_or_root(configured, principal, state, status)
1765 }
1766
1767 fn configured_status_or_root(
1768 &self,
1769 configured: &crate::fleet_ensure::model::DesiredCanister,
1770 principal: &str,
1771 state: &FleetEnsureStateRecord,
1772 status: Result<Option<LiveCanister>, IcpEnsurePlatformError>,
1773 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1774 match status {
1775 Ok(observed) => Ok(observed),
1776 Err(error)
1777 if matches!(
1778 configured.kind,
1779 DesiredCanisterKind::Store | DesiredCanisterKind::Pool
1780 ) && !matches!(
1781 error,
1782 IcpEnsurePlatformError::Icp(ref source)
1783 if matches!(
1784 source.diagnostic(),
1785 Some(IcpDiagnostic::CanisterNotFound { .. })
1786 )
1787 ) =>
1788 {
1789 self.observe_root_owned_canister(configured, principal, state)
1790 }
1791 Err(error) => Err(error),
1792 }
1793 }
1794
1795 fn observe_root_owned_canister(
1796 &self,
1797 configured: &crate::fleet_ensure::model::DesiredCanister,
1798 principal: &str,
1799 state: &FleetEnsureStateRecord,
1800 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1801 let candid = self.root_protocol_candid()?;
1802 let parent = configured.parent.as_deref().ok_or_else(|| {
1803 current_protocol::CurrentProtocolError::Configuration(format!(
1804 "Root-owned canister {} has no Root parent",
1805 configured.name
1806 ))
1807 })?;
1808 let root = self.current_principal(state, parent).ok_or_else(|| {
1809 current_protocol::CurrentProtocolError::Configuration(format!(
1810 "Root-owned canister {} has no resolved Root",
1811 configured.name
1812 ))
1813 })?;
1814 if self.required_root_status(&configured.name, root)? == CanisterRuntimeStatus::Stopped {
1815 return self
1816 .retained_root_owned_observation(configured, principal, parent, root, state);
1817 }
1818 let target = parse_principal("Root-owned canister", principal)?;
1819 let mut start_after = None;
1820 loop {
1821 let page = self.query_estate_pool_page(
1822 &candid,
1823 parse_principal("Fleet Subnet Root", root)?,
1824 start_after,
1825 )?;
1826 if let Some(mut asset) = page
1827 .entries
1828 .into_iter()
1829 .find(|asset| asset.canister_id == target)
1830 {
1831 if matches!(
1832 asset.status,
1833 canic_core::dto::pool::CanisterPoolAssetStatus::PendingReset
1834 ) && asset.cycles.to_u128() == 0
1835 {
1836 let cycles = self.inspect_pending_pool_balance(configured, principal, state)?;
1837 asset.cycles = Cycles::new(cycles);
1838 }
1839 return Self::observed_root_owned_asset(configured, principal, root, asset);
1840 }
1841 let next = page.next_start_after;
1842 if next.is_none() {
1843 return Ok(None);
1844 }
1845 if next == start_after {
1846 return Err(
1847 current_protocol::CurrentProtocolError::Configuration(format!(
1848 "Root {parent} pool cursor did not advance"
1849 ))
1850 .into(),
1851 );
1852 }
1853 start_after = next;
1854 }
1855 }
1856
1857 fn inspect_pending_pool_balance(
1858 &self,
1859 configured: &crate::fleet_ensure::model::DesiredCanister,
1860 principal: &str,
1861 state: &FleetEnsureStateRecord,
1862 ) -> Result<u128, IcpEnsurePlatformError> {
1863 let parent = configured.parent.as_deref().ok_or_else(|| {
1864 IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1865 canister: configured.name.clone(),
1866 field: "Root parent",
1867 }
1868 })?;
1869 let root = self.current_principal(state, parent).ok_or_else(|| {
1870 IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1871 canister: configured.name.clone(),
1872 field: "Root Principal",
1873 }
1874 })?;
1875 let pending_fresh_pool = self
1876 .desired
1877 .bootstrap
1878 .as_ref()
1879 .is_some_and(|bootstrap| bootstrap.fresh_estate)
1880 && configured.principal.is_none()
1881 && state
1882 .pending_principals
1883 .get(&configured.name)
1884 .map(String::as_str)
1885 == Some(principal);
1886 if pending_fresh_pool {
1887 let response = self
1888 .inspect_root_owned_canister(configured, principal, state)?
1889 .ok_or_else(
1890 || IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1891 canister: configured.name.clone(),
1892 field: "installed Root",
1893 },
1894 )?;
1895 return validate_pending_fresh_pool_inspection(
1896 &configured.name,
1897 root,
1898 &self.desired.operator,
1899 &response.settings.controllers,
1900 response.module_hash.as_deref(),
1901 &response.cycles,
1902 );
1903 }
1904 self.inspect_pool_balance(&configured.name, root, principal, InspectedModule::Any)
1905 }
1906
1907 fn inspect_pool_balance(
1908 &self,
1909 name: &str,
1910 root: &str,
1911 principal: &str,
1912 module: InspectedModule,
1913 ) -> Result<u128, IcpEnsurePlatformError> {
1914 if self.required_root_status(name, root)? != CanisterRuntimeStatus::Running {
1915 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1916 canister: name.to_string(),
1917 field: "running Root",
1918 });
1919 }
1920 self.require_operator()?;
1921 let target = parse_principal("Root-owned funding target", principal)?;
1922 let response: RootFundingInspectionResponse = call_with_candid(
1923 &self.icp,
1924 &self.root_protocol_candid()?,
1925 parse_principal("Fleet Subnet Root", root)?,
1926 canic_protocol::CANIC_ROOT_COMMAND,
1927 &RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
1928 canister_id: target,
1929 }),
1930 )
1931 .map_err(current_protocol::CurrentProtocolError::from)?;
1932 let RootFundingInspectionResponse::InspectCanister(response) = response;
1933 validate_root_controlled_inspection(
1934 name,
1935 module,
1936 root,
1937 &response.settings.controllers,
1938 response.module_hash.as_deref(),
1939 &response.cycles,
1940 )
1941 }
1942
1943 fn pool_funding_module(
1944 &self,
1945 name: &str,
1946 authority: &crate::fleet_ensure::model::PoolFundingAuthority,
1947 principal: &str,
1948 ) -> Result<InspectedModule, IcpEnsurePlatformError> {
1949 let candid = self.root_protocol_candid()?;
1950 let root = parse_principal("Fleet Subnet Root", &authority.root)?;
1951 let target = parse_principal("pool funding target", principal)?;
1952 let mut start_after = None;
1953 loop {
1954 let page = self.query_estate_pool_page(&candid, root, start_after)?;
1955 if let Some(asset) = page
1956 .entries
1957 .iter()
1958 .find(|asset| asset.canister_id == target)
1959 {
1960 return pool_funding_inspected_module(name, authority.lifecycle, &asset.status);
1961 }
1962 if page.next_start_after.is_none() || page.next_start_after == start_after {
1963 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1964 canister: name.to_string(),
1965 field: "reviewed pool membership",
1966 });
1967 }
1968 start_after = page.next_start_after;
1969 }
1970 }
1971
1972 fn observed_root_owned_asset(
1973 configured: &crate::fleet_ensure::model::DesiredCanister,
1974 principal: &str,
1975 root: &str,
1976 asset: CanisterPoolAsset,
1977 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1978 let Some(root_owned_lifecycle) = root_owned_lifecycle(configured.kind, &asset.status)
1979 else {
1980 return Err(
1981 current_protocol::CurrentProtocolError::Configuration(format!(
1982 "Root-owned canister {} has a live pool role incompatible with desired {:?}",
1983 configured.name, configured.kind
1984 ))
1985 .into(),
1986 );
1987 };
1988 let status = match root_owned_lifecycle {
1989 RootOwnedCanisterLifecycle::Store | RootOwnedCanisterLifecycle::Workload => {
1990 CanisterRuntimeStatus::Running
1991 }
1992 RootOwnedCanisterLifecycle::Claimed
1993 | RootOwnedCanisterLifecycle::Idle
1994 | RootOwnedCanisterLifecycle::Retained => CanisterRuntimeStatus::Stopped,
1995 };
1996 Ok(Some(LiveCanister {
1997 canister_version: None,
1998 controllers: vec![root.to_string()],
1999 cycles: asset.cycles.to_u128(),
2000 module_sha256: None,
2001 principal: principal.to_string(),
2002 reinstall_required: false,
2003 root_owned_lifecycle: Some(root_owned_lifecycle),
2004 status,
2005 }))
2006 }
2007
2008 fn root_protocol_candid(&self) -> Result<PathBuf, IcpEnsurePlatformError> {
2009 let protocol = self.desired.protocol.as_ref().ok_or_else(|| {
2010 current_protocol::CurrentProtocolError::Configuration(
2011 "Root-owned observation requires typed Fleet protocol".to_string(),
2012 )
2013 })?;
2014 Ok(resolve_path(&self.root, &protocol.root_candid))
2015 }
2016
2017 fn required_root_status(
2018 &self,
2019 configured_name: &str,
2020 root: &str,
2021 ) -> Result<CanisterRuntimeStatus, IcpEnsurePlatformError> {
2022 self.status_optional(root)?
2023 .map(|live| live.status)
2024 .ok_or_else(|| {
2025 current_protocol::CurrentProtocolError::Configuration(format!(
2026 "Root-owned canister {configured_name} has no live Root"
2027 ))
2028 .into()
2029 })
2030 }
2031
2032 fn retained_root_owned_observation(
2033 &self,
2034 configured: &crate::fleet_ensure::model::DesiredCanister,
2035 principal: &str,
2036 parent: &str,
2037 root: &str,
2038 state: &FleetEnsureStateRecord,
2039 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
2040 let Some(retained_topology) = exact_retained_root_owned_topology(
2041 state,
2042 &configured.name,
2043 configured.kind,
2044 parent,
2045 principal,
2046 root,
2047 ) else {
2048 return Err(
2049 current_protocol::CurrentProtocolError::Configuration(format!(
2050 "Root-owned canister {} has no exact retained topology authority",
2051 configured.name
2052 ))
2053 .into(),
2054 );
2055 };
2056 let cycles = self
2057 .controller_cycle_balance(principal)
2058 .or_else(|| state.retained_cycles_by_principal.get(principal).copied())
2059 .ok_or_else(|| {
2060 current_protocol::CurrentProtocolError::Configuration(format!(
2061 "Root-owned canister {} has no current or retained exact native balance",
2062 configured.name
2063 ))
2064 })?;
2065 if !matches!(
2066 configured.kind,
2067 DesiredCanisterKind::Pool | DesiredCanisterKind::Store
2068 ) || configured.presence != crate::fleet_ensure::model::DesiredPresence::Present
2069 || configured.replace
2070 || configured.drain.is_some()
2071 || (configured.kind == DesiredCanisterKind::Pool && configured.wasm.is_some())
2072 || (configured.kind == DesiredCanisterKind::Store && configured.wasm.is_none())
2073 {
2074 return Err(
2075 current_protocol::CurrentProtocolError::Configuration(format!(
2076 "Root-owned recovery evidence for {} cannot authorize a mutation",
2077 configured.name
2078 ))
2079 .into(),
2080 );
2081 }
2082 let root_config = self
2083 .desired
2084 .canisters
2085 .iter()
2086 .find(|canister| canister.name == parent && canister.kind == DesiredCanisterKind::Root)
2087 .ok_or_else(|| {
2088 current_protocol::CurrentProtocolError::Configuration(format!(
2089 "Root-owned canister {} has no exact configured Root",
2090 configured.name
2091 ))
2092 })?;
2093 let live_root = self.status_optional(root)?.ok_or_else(|| {
2094 current_protocol::CurrentProtocolError::Configuration(format!(
2095 "Root-owned canister {} has no live Root",
2096 configured.name
2097 ))
2098 })?;
2099 let mut expected_controllers = root_config.controllers.clone();
2100 expected_controllers.sort();
2101 if live_root.controllers != expected_controllers {
2102 return Err(
2103 current_protocol::CurrentProtocolError::Configuration(format!(
2104 "Root-owned canister {} Root controllers drifted",
2105 configured.name
2106 ))
2107 .into(),
2108 );
2109 }
2110 Ok(Some(LiveCanister {
2111 canister_version: None,
2112 controllers: vec![root.to_string()],
2113 cycles,
2114 module_sha256: retained_topology.module_hash.clone(),
2115 principal: principal.to_string(),
2116 reinstall_required: false,
2117 root_owned_lifecycle: Some(RootOwnedCanisterLifecycle::Retained),
2118 status: if configured.kind == DesiredCanisterKind::Store {
2119 CanisterRuntimeStatus::Running
2120 } else {
2121 CanisterRuntimeStatus::Stopped
2122 },
2123 }))
2124 }
2125
2126 fn completed_reinstall_is_current(
2127 &self,
2128 state: &FleetEnsureStateRecord,
2129 name: &str,
2130 live: &LiveCanister,
2131 ) -> Result<bool, IcpEnsurePlatformError> {
2132 if state.completed_reinstall_operation_id.is_none()
2133 || !state.completed_reinstall_action_sha256.contains_key(name)
2134 {
2135 return Ok(false);
2136 }
2137 let Some(pre_canister_version) = state.completed_reinstalls.get(name) else {
2138 return Ok(false);
2139 };
2140 let Some(configured) = self
2141 .desired
2142 .canisters
2143 .iter()
2144 .find(|configured| configured.name == name)
2145 else {
2146 return Ok(false);
2147 };
2148 let Some(wasm) = configured.wasm.as_deref() else {
2149 return Ok(false);
2150 };
2151 let principal_matches = state
2152 .principals
2153 .get(name)
2154 .is_some_and(|principal| principal == &live.principal);
2155 let retained_topology = state.topology.get(name);
2156 let topology_matches = retained_topology.is_some_and(|topology| {
2157 topology.kind == configured.kind && topology.parent == configured.parent
2158 });
2159 let desired_module_sha256 = artifact_hash(&resolve_path(&self.root, wasm))?;
2160 let root_owned_store_module = (live.module_sha256.is_none()
2161 && live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Store))
2162 .then(|| {
2163 configured
2164 .parent
2165 .as_deref()
2166 .and_then(|parent| state.principals.get(parent))
2167 .filter(|root| live.controllers.as_slice() == [root.as_str()])
2168 .and(retained_topology)
2169 .and_then(|topology| topology.module_hash.as_deref())
2170 })
2171 .flatten();
2172 completed_reinstall_continuity(
2173 *pre_canister_version,
2174 principal_matches,
2175 topology_matches,
2176 live.module_sha256.as_deref().or(root_owned_store_module),
2177 &desired_module_sha256,
2178 live.canister_version,
2179 )
2180 .map_err(
2181 |field| IcpEnsurePlatformError::CompletedReinstallAuthorityConflict {
2182 canister: name.to_string(),
2183 field,
2184 },
2185 )?;
2186 Ok(true)
2187 }
2188
2189 fn controller_cycle_balance(&self, principal: &str) -> Option<u128> {
2190 let response: Result<ManagedCanisterStatusResponse, canic_core::dto::error::Error> = self
2191 .icp
2192 .canister_query_candid(
2193 principal,
2194 canic_protocol::CANIC_OBSERVABILITY,
2195 &ManagedCanisterStatusRequest::CycleBalance,
2196 None,
2197 )
2198 .ok()?;
2199 let ManagedCanisterStatusResponse::CycleBalance(balance) = response.ok()?;
2200 Some(balance.cycles)
2201 }
2202
2203 fn apply_create(
2204 &self,
2205 authority: CreateCanisterAuthority<'_>,
2206 state: &FleetEnsureStateRecord,
2207 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2208 let creation_fee = self
2209 .desired
2210 .management_creation_fee_cycles
2211 .parse::<Cycles>()
2212 .map(|cycles| cycles.to_u128())
2213 .map_err(|_| IcpEnsurePlatformError::Arithmetic("management creation fee"))?;
2214 let amount = authority
2215 .requested_initial_cycles
2216 .checked_add(creation_fee)
2217 .ok_or(IcpEnsurePlatformError::Arithmetic("creation amount"))?;
2218 let controllers = self
2219 .resolved_controllers(state, authority.controllers, authority.controller_canisters)?
2220 .iter()
2221 .map(|value| parse_principal("controller", value))
2222 .collect::<Result<Vec<_>, _>>()?;
2223 let request = CreateCanisterArgs {
2224 amount: Nat::from(amount),
2225 created_at_time: Some(authority.created_at_time),
2226 creation_args: Some(CmcCreateCanisterArgs {
2227 settings: Some(CanisterSettings {
2228 compute_allocation: None,
2229 controllers: Some(controllers),
2230 freezing_threshold: None,
2231 memory_allocation: None,
2232 reserved_cycles_limit: None,
2233 }),
2234 subnet_selection: Some(SubnetSelection::Subnet {
2235 subnet: parse_principal("subnet", authority.subnet)?,
2236 }),
2237 }),
2238 from_subaccount: None,
2239 };
2240 let response: Result<CreateCanisterSuccess, CreateCanisterError> = self
2241 .icp
2242 .canister_call_candid(authority.ledger, "create_canister", &request, None)?;
2243 create_canister_response_outcome(response, authority.requested_initial_cycles)
2244 }
2245
2246 fn apply_fund(
2247 &self,
2248 amount: u128,
2249 created_at_time: u64,
2250 ledger: &str,
2251 principal: &str,
2252 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2253 let request = WithdrawArgs {
2254 amount: Nat::from(amount),
2255 created_at_time: Some(created_at_time),
2256 from_subaccount: None,
2257 to: parse_principal("funding target", principal)?,
2258 };
2259 let response: Result<Nat, WithdrawError> = self
2260 .icp
2261 .canister_call_candid(ledger, "withdraw", &request, None)?;
2262 match response {
2263 Ok(block) => Ok(EffectOutcome {
2264 created_principal: None,
2265 post_cycles: None,
2266 receipt: Some(block.to_string()),
2267 }),
2268 Err(WithdrawError::Duplicate { duplicate_of }) => Ok(EffectOutcome {
2269 created_principal: None,
2270 post_cycles: None,
2271 receipt: Some(duplicate_of.to_string()),
2272 }),
2273 Err(error) => Err(IcpEnsurePlatformError::LedgerWithdraw(
2274 render_withdraw_error(error),
2275 )),
2276 }
2277 }
2278
2279 fn apply_estate_fund(
2280 &self,
2281 amount: u128,
2282 created_at_time: u64,
2283 ledger: &str,
2284 principal: &str,
2285 reviewed_fee_cycles: u128,
2286 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2287 let request = CyclesLedgerTransferArgs {
2288 amount: Nat::from(amount),
2289 created_at_time: Some(created_at_time),
2290 fee: Some(Nat::from(reviewed_fee_cycles)),
2291 from_subaccount: None,
2292 memo: None,
2293 to: CyclesLedgerAccount {
2294 owner: parse_principal("estate funding target", principal)?,
2295 subaccount: None,
2296 },
2297 };
2298 let response: Result<Nat, CyclesLedgerTransferError> =
2299 self.icp
2300 .canister_call_candid(ledger, "icrc1_transfer", &request, None)?;
2301 match response {
2302 Ok(block) => Ok(EffectOutcome {
2303 created_principal: None,
2304 post_cycles: None,
2305 receipt: Some(block.to_string()),
2306 }),
2307 Err(CyclesLedgerTransferError::Duplicate { duplicate_of }) => Ok(EffectOutcome {
2308 created_principal: None,
2309 post_cycles: None,
2310 receipt: Some(duplicate_of.to_string()),
2311 }),
2312 Err(CyclesLedgerTransferError::BadFee { expected_fee }) => {
2313 Err(IcpEnsurePlatformError::LedgerTransferFeeChanged {
2314 expected_fee_cycles: ledger_fee_cycles(expected_fee)?,
2315 reviewed_fee_cycles,
2316 })
2317 }
2318 Err(error) => Err(IcpEnsurePlatformError::LedgerTransfer(
2319 render_ledger_transfer_error(error),
2320 )),
2321 }
2322 }
2323
2324 #[expect(
2325 clippy::too_many_arguments,
2326 reason = "the install boundary verifies one complete immutable action tuple"
2327 )]
2328 fn apply_install(
2329 &self,
2330 operation_id: &str,
2331 principals: &BTreeMap<String, String>,
2332 canic_init: Option<&crate::fleet_ensure::model::DesiredCanisterInit>,
2333 init_arg: Option<&str>,
2334 init_arg_sha256: Option<&str>,
2335 init_candid: Option<&str>,
2336 init_candid_sha256: Option<&str>,
2337 mode: InstallMode,
2338 principal: &str,
2339 wasm: &str,
2340 wasm_sha256: &str,
2341 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2342 verify_artifact(&resolve_path(&self.root, wasm), wasm_sha256, "Wasm")?;
2343 let mut command = self.icp.canister_command();
2344 command.args(["install", principal, "--mode"]);
2345 command.arg(match mode {
2346 InstallMode::Install => "install",
2347 InstallMode::Reinstall => "reinstall",
2348 });
2349 command.args(["--yes", "--wasm"]);
2350 command.arg(resolve_path(&self.root, wasm));
2351 let generated_init = if let Some(canic_init) = canic_init {
2352 let path = canic_init::write_arguments(canic_init::CanicInitRequest {
2353 desired: &self.desired,
2354 init: canic_init,
2355 operation_id,
2356 principals,
2357 root: &self.root,
2358 wasm,
2359 wasm_sha256,
2360 })?;
2361 command.arg("--args-file");
2362 command.arg(&path);
2363 command.args(["--args-format", "bin"]);
2364 Some(path)
2365 } else if let Some(init_arg) = init_arg {
2366 let expected =
2367 init_arg_sha256.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
2368 actual: "missing".to_string(),
2369 expected: "reviewed init argument identity".to_string(),
2370 kind: "init argument",
2371 })?;
2372 let init_candid =
2373 init_candid.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
2374 actual: "missing".to_string(),
2375 expected: "reviewed init Candid".to_string(),
2376 kind: "init Candid",
2377 })?;
2378 let init_candid_sha256 = init_candid_sha256.ok_or_else(|| {
2379 IcpEnsurePlatformError::ArtifactDigestMismatch {
2380 actual: "missing".to_string(),
2381 expected: "reviewed init Candid identity".to_string(),
2382 kind: "init Candid",
2383 }
2384 })?;
2385 let path = protocol::write_init_arguments(
2386 &self.root,
2387 operation_id,
2388 principals,
2389 init_candid,
2390 init_candid_sha256,
2391 init_arg,
2392 expected,
2393 )?;
2394 command.arg("--args-file");
2395 command.arg(&path);
2396 command.args(["--args-format", "bin"]);
2397 Some(path)
2398 } else {
2399 command.args(["--args", "()"]);
2400 None
2401 };
2402 self.icp.add_target_args(&mut command);
2403 let result = run_status(&mut command).map_err(IcpEnsurePlatformError::from);
2404 if let Some(path) = generated_init {
2405 std::fs::remove_file(&path)
2406 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path))?;
2407 }
2408 result?;
2409 Ok(empty_outcome())
2410 }
2411
2412 fn apply_controllers(
2413 &self,
2414 principal: &str,
2415 controllers: &[String],
2416 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2417 let mut command = self.icp.canister_command();
2418 command.args([
2419 "settings",
2420 "update",
2421 principal,
2422 "--force",
2423 "--remove-all-controllers",
2424 ]);
2425 for controller in controllers {
2426 parse_principal("controller", controller)?;
2427 command.args(["--add-controller", controller]);
2428 }
2429 self.icp.add_target_args(&mut command);
2430 run_status(&mut command)?;
2431 Ok(empty_outcome())
2432 }
2433
2434 fn observe_fleet_snapshot(
2435 &mut self,
2436 operation_id: &str,
2437 state: &FleetEnsureStateRecord,
2438 ) -> Result<FleetObservation, IcpEnsurePlatformError> {
2439 self.require_operator()?;
2440 let mut canisters = self
2441 .timed_observation(FleetObservationStage::ConfiguredCanisters, |platform| {
2442 platform.observe_configured_canisters(state)
2443 })?;
2444 self.reconcile_completed_reinstalls(state, &mut canisters)?;
2445 let mut estate_funding_domains = self
2446 .timed_observation(FleetObservationStage::EstateFunding, |platform| {
2447 platform.observe_estate_funding_domains(state)
2448 })?;
2449 self.timed_observation(FleetObservationStage::PoolBalances, |platform| {
2450 for domain in estate_funding_domains.values_mut() {
2451 if let (Some(root), Some(pool)) = (&domain.root_principal, &mut domain.pool) {
2452 for asset in &mut pool.assets {
2453 if matches!(
2454 asset.lifecycle,
2455 EstatePoolAssetLifecycle::PendingReset
2456 | EstatePoolAssetLifecycle::Failed
2457 ) {
2458 asset.cycles = platform.inspect_pool_balance(
2459 &asset.principal,
2460 root,
2461 &asset.principal,
2462 InspectedModule::Any,
2463 )?;
2464 for live in canisters.values_mut().filter_map(Option::as_mut) {
2465 if live.principal == asset.principal {
2466 live.cycles = asset.cycles;
2467 }
2468 }
2469 }
2470 }
2471 }
2472 }
2473 Ok(())
2474 })?;
2475 self.estate_observations.clone_from(&estate_funding_domains);
2476 self.require_current_pool_policy(&estate_funding_domains)?;
2477 self.reconcile_completed_reinstalls(state, &mut canisters)?;
2478 let additional_controlled_cycles =
2479 Self::additional_pool_cycles(&canisters, &estate_funding_domains)?;
2480 let protocol_ready = self
2481 .timed_observation(FleetObservationStage::ProtocolReadiness, |platform| {
2482 platform.observe_protocol_readiness(operation_id, state, &canisters)
2483 })?;
2484 let ledger_fee_cycles =
2485 self.timed_observation(FleetObservationStage::LedgerFee, |platform| {
2486 ledger_fee_cycles(platform.icp.canister_query_candid(
2487 &platform.desired.cycles_ledger,
2488 "icrc1_fee",
2489 &(),
2490 None,
2491 )?)
2492 })?;
2493 let operator_cycles =
2494 self.timed_observation(FleetObservationStage::OperatorBalance, |platform| {
2495 platform
2496 .icp
2497 .identity_cycles_balance()
2498 .map_err(|error| IcpEnsurePlatformError::LedgerWithdraw(error.to_string()))
2499 })?;
2500 Ok(FleetObservation {
2501 additional_controlled_cycles,
2502 canisters,
2503 estate_funding_domains,
2504 ledger_fee_cycles,
2505 operator_cycles,
2506 protocol_ready,
2507 })
2508 }
2509
2510 fn observe_configured_canisters(
2511 &self,
2512 state: &FleetEnsureStateRecord,
2513 ) -> Result<BTreeMap<String, Option<LiveCanister>>, IcpEnsurePlatformError> {
2514 let configured = &self.desired.canisters;
2515 let mut observed = BTreeMap::new();
2516 let mut cursor = 0;
2517 while cursor < configured.len() {
2518 if configured[cursor].kind == DesiredCanisterKind::Pool {
2519 let canister = &configured[cursor];
2520 let live = self
2521 .current_principal(state, &canister.name)
2522 .map(|principal| self.observe_root_owned_canister(canister, principal, state))
2523 .transpose()?
2524 .flatten();
2525 observed.insert(canister.name.clone(), live);
2526 cursor += 1;
2527 continue;
2528 }
2529 let end = configured[cursor..]
2532 .iter()
2533 .take(super::bounded_observations::MAX_IN_FLIGHT)
2534 .take_while(|canister| canister.kind != DesiredCanisterKind::Pool)
2535 .count()
2536 + cursor;
2537 self.observe_configured_status_batch(&configured[cursor..end], state, &mut observed)?;
2538 cursor = end;
2539 }
2540 Ok(observed)
2541 }
2542
2543 fn observe_configured_status_batch(
2544 &self,
2545 configured: &[crate::fleet_ensure::model::DesiredCanister],
2546 state: &FleetEnsureStateRecord,
2547 observed: &mut BTreeMap<String, Option<LiveCanister>>,
2548 ) -> Result<(), IcpEnsurePlatformError> {
2549 let mut seen = BTreeSet::new();
2550 let requests = configured
2551 .iter()
2552 .filter_map(|canister| self.current_principal(state, &canister.name))
2553 .filter(|principal| seen.insert(*principal))
2554 .filter(|principal| matches!(self.cached_status(principal), StatusCacheEntry::Miss))
2555 .collect::<Vec<_>>();
2556 let icp = &self.icp;
2557 let mut results = super::bounded_observations::collect(&requests, |principal| {
2558 Ok::<_, std::convert::Infallible>((*principal, Self::read_status_with(icp, principal)))
2559 })
2560 .expect("read outcomes are retained for ordered authority decisions")
2561 .into_iter()
2562 .collect::<BTreeMap<_, _>>();
2563 for canister in configured {
2564 let Some(principal) = self.current_principal(state, &canister.name) else {
2565 observed.insert(canister.name.clone(), None);
2566 continue;
2567 };
2568 let status = if let StatusCacheEntry::Hit(cached) = self.cached_status(principal) {
2569 Ok(cached)
2570 } else {
2571 results
2572 .remove(principal)
2573 .unwrap_or_else(|| self.status_optional(principal))
2574 };
2575 if let Ok(live) = &status
2576 && let Some(snapshot) = self.observation_snapshot.borrow_mut().as_mut()
2577 {
2578 snapshot
2579 .statuses
2580 .insert(principal.to_string(), live.clone());
2581 }
2582 let live = self.configured_status_or_root(canister, principal, state, status)?;
2583 observed.insert(canister.name.clone(), live);
2584 }
2585 Ok(())
2586 }
2587
2588 fn cached_status(&self, principal: &str) -> StatusCacheEntry {
2589 self.observation_snapshot
2590 .borrow()
2591 .as_ref()
2592 .and_then(|snapshot| snapshot.statuses.get(principal).cloned())
2593 .map_or(StatusCacheEntry::Miss, StatusCacheEntry::Hit)
2594 }
2595
2596 fn reconcile_completed_reinstalls(
2597 &self,
2598 state: &FleetEnsureStateRecord,
2599 canisters: &mut BTreeMap<String, Option<LiveCanister>>,
2600 ) -> Result<(), IcpEnsurePlatformError> {
2601 for (name, live) in canisters.iter_mut() {
2602 let Some(live) = live.as_mut() else {
2603 continue;
2604 };
2605 let completed = self.completed_reinstall_is_current(state, name, live)?;
2606 let store_module_unobserved = live.module_sha256.is_none()
2607 && live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Store);
2608 if completed && store_module_unobserved {
2609 live.module_sha256 = state
2610 .topology
2611 .get(name)
2612 .and_then(|topology| topology.module_hash.clone());
2613 }
2614 }
2615 Ok(())
2616 }
2617
2618 fn additional_pool_cycles(
2619 canisters: &BTreeMap<String, Option<LiveCanister>>,
2620 domains: &BTreeMap<String, EstateFundingDomainObservation>,
2621 ) -> Result<BTreeMap<String, u128>, IcpEnsurePlatformError> {
2622 let configured = canisters
2623 .values()
2624 .filter_map(|live| live.as_ref().map(|live| live.principal.as_str()))
2625 .collect::<BTreeSet<_>>();
2626 let mut additional = BTreeMap::new();
2627 for asset in domains
2628 .values()
2629 .filter_map(|domain| domain.pool.as_ref())
2630 .flat_map(|pool| &pool.assets)
2631 {
2632 if configured.contains(asset.principal.as_str()) {
2633 continue;
2634 }
2635 if additional
2636 .insert(asset.principal.clone(), asset.cycles)
2637 .is_some()
2638 {
2639 return Err(pool_configuration_error(format!(
2640 "controlled pool canister {} is retained by more than one Root",
2641 asset.principal
2642 )));
2643 }
2644 }
2645 Ok(additional)
2646 }
2647
2648 fn management_roots_are_current(
2649 &self,
2650 roots: &[(&crate::fleet_ensure::model::DesiredCanister, LiveCanister)],
2651 reviewed_targets: &BTreeSet<String>,
2652 ) -> Result<bool, IcpEnsurePlatformError> {
2653 let mut all_current = true;
2654 for (configured, live) in roots {
2655 if live.status != CanisterRuntimeStatus::Running || !reviewed_targets.is_empty() {
2656 all_current = false;
2657 continue;
2658 }
2659 if live.module_sha256.is_none()
2660 && is_unallocated_fresh_root(&self.desired, configured, reviewed_targets)
2661 {
2662 continue;
2663 }
2664 if let Some(wasm) = &configured.wasm {
2665 let expected = artifact_hash(&resolve_path(&self.root, wasm))?;
2666 if live.module_sha256.as_deref() != Some(expected.as_str()) {
2667 all_current = false;
2668 }
2669 }
2670 }
2671 Ok(all_current)
2672 }
2673
2674 fn observe_protocol_readiness(
2675 &self,
2676 operation_id: &str,
2677 state: &FleetEnsureStateRecord,
2678 canisters: &BTreeMap<String, Option<LiveCanister>>,
2679 ) -> Result<BTreeMap<String, bool>, IcpEnsurePlatformError> {
2680 let principals = self.protocol_principals(state);
2681 self.desired
2682 .protocol_steps
2683 .iter()
2684 .map(|step| {
2685 let Some(principal) = principals.get(&step.canister) else {
2686 return Ok((step.name.clone(), false));
2687 };
2688 let Some(live) = canisters.get(&step.canister).and_then(Option::as_ref) else {
2689 return Ok((step.name.clone(), false));
2690 };
2691 let configured = self
2692 .desired
2693 .canisters
2694 .iter()
2695 .find(|configured| configured.name == step.canister)
2696 .expect("protocol target was validated against desired canisters");
2697 if live.reinstall_required {
2698 return Ok((step.name.clone(), false));
2699 }
2700 if let Some(wasm) = &configured.wasm {
2701 let desired_hash = artifact_hash(&resolve_path(&self.root, wasm))?;
2702 if live.module_sha256.as_deref() != Some(desired_hash.as_str()) {
2703 return Ok((step.name.clone(), false));
2704 }
2705 }
2706 let action = self.observed_protocol_action(step, principal.clone())?;
2707 protocol::observe(&self.icp, &self.root, operation_id, &principals, &action)
2708 .map(|observation| (step.name.clone(), observation.applied))
2709 .map_err(IcpEnsurePlatformError::from)
2710 })
2711 .collect()
2712 }
2713
2714 #[expect(
2715 clippy::too_many_arguments,
2716 reason = "one typed transfer effect carries the complete reviewed authority tuple"
2717 )]
2718 fn apply_transfer(
2719 &self,
2720 amount: u128,
2721 candid: &str,
2722 candid_sha256: &str,
2723 destination: &str,
2724 method: &str,
2725 operation_id: &str,
2726 principal: &str,
2727 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2728 let candid = resolve_path(&self.root, candid);
2729 if !candid.is_file() {
2730 return Err(IcpEnsurePlatformError::CandidUnavailable(candid));
2731 }
2732 verify_artifact(&candid, candid_sha256, "drain Candid")?;
2733 let response: DrainResponse = self.icp.canister_call_candid(
2734 principal,
2735 method,
2736 &DrainRequest {
2737 operation_id: operation_id.to_string(),
2738 destination: parse_principal("treasury", destination)?,
2739 cycles: Nat::from(amount),
2740 },
2741 Some(&candid),
2742 )?;
2743 let transferred = match response {
2744 DrainResponse::Accepted { transferred_cycles }
2745 | DrainResponse::Replayed { transferred_cycles } => transferred_cycles,
2746 };
2747 if transferred != amount {
2748 return Err(IcpEnsurePlatformError::WrongTransferAmount {
2749 actual: transferred,
2750 expected: amount,
2751 });
2752 }
2753 Ok(EffectOutcome {
2754 created_principal: None,
2755 post_cycles: None,
2756 receipt: Some(operation_id.to_string()),
2757 })
2758 }
2759}
2760
2761impl EnsurePlatform for IcpEnsurePlatform {
2762 type Error = IcpEnsurePlatformError;
2763
2764 fn report_progress(&mut self, progress: FleetEnsureProgress) {
2765 if let Some(handler) = self.progress_handler.as_mut() {
2766 handler(progress);
2767 }
2768 }
2769
2770 fn require_retained_root_activation(
2771 &mut self,
2772 operation_id: &str,
2773 root: &str,
2774 state: &FleetEnsureStateRecord,
2775 ) -> Result<(), Self::Error> {
2776 let authorities = canic_init::compile_root_authorities(
2777 &self.root,
2778 &self.desired,
2779 &self.protocol_principals(state),
2780 )?;
2781 let (_, authority) = authorities
2782 .iter()
2783 .find(|(name, _)| name == root)
2784 .ok_or_else(|| canic_init::CanicInitError::MissingRoot(root.to_string()))?;
2785 current_protocol::require_store_installation_binding(
2786 &self.icp,
2787 &self.root_protocol_candid()?,
2788 root,
2789 authority,
2790 operation_id,
2791 )?;
2792 Ok(())
2793 }
2794
2795 fn bind_reviewed_desired(&mut self, desired: &DesiredFleet) -> Result<(), Self::Error> {
2796 self.desired = desired.clone();
2797 Ok(())
2798 }
2799
2800 fn pace_effect_observation(
2801 &mut self,
2802 _action: &EnsureAction,
2803 consecutive_unchanged_observations: u32,
2804 ) {
2805 thread::sleep(protocol_observation_delay(
2806 consecutive_unchanged_observations,
2807 self.initial_observation_delay,
2808 self.maximum_observation_delay,
2809 ));
2810 }
2811
2812 fn pace_root_owned_observation(
2813 &mut self,
2814 _target: &str,
2815 consecutive_retained_observations: u32,
2816 ) {
2817 thread::sleep(protocol_observation_delay(
2818 consecutive_retained_observations,
2819 self.initial_observation_delay,
2820 self.maximum_observation_delay,
2821 ));
2822 }
2823
2824 fn authority_sealed(
2825 &mut self,
2826 operation_id: &str,
2827 action: &EnsureAction,
2828 ) -> Result<bool, Self::Error> {
2829 Ok(super::authority_seal::observe(&self.icp, &self.root, operation_id, action)?.applied)
2830 }
2831
2832 fn reinstall_assets_match(
2833 &mut self,
2834 intent: &crate::fleet_ensure::model::FleetReinstallRecord,
2835 root_name: &str,
2836 check: super::ReinstallAssetCheck,
2837 ) -> Result<bool, Self::Error> {
2838 let binding = intent
2839 .authorities
2840 .iter()
2841 .find(|binding| binding.name == root_name)
2842 .ok_or_else(|| {
2843 IcpEnsurePlatformError::RootManagement("missing reset Root".to_string())
2844 })?;
2845 let principal = parse_principal("reset Root", &binding.principal)?;
2846 let candid = self.root_protocol_candid()?;
2847 for asset in intent.assets.iter().filter(|asset| asset.root == root_name) {
2848 let RootFundingInspectionResponse::InspectCanister(status) = call_with_candid(
2849 &self.icp,
2850 &candid,
2851 principal,
2852 canic_protocol::CANIC_ROOT_COMMAND,
2853 &RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
2854 canister_id: parse_principal("reset asset", &asset.principal)?,
2855 }),
2856 )
2857 .map_err(current_protocol::CurrentProtocolError::from)?;
2858 let mut controllers = status
2859 .settings
2860 .controllers
2861 .iter()
2862 .map(Principal::to_text)
2863 .collect::<Vec<_>>();
2864 controllers.sort();
2865 let module = status
2866 .module_hash
2867 .as_ref()
2868 .map(canic_core::cdk::utils::hash::hex_bytes);
2869 let module_matches =
2870 check == super::ReinstallAssetCheck::Terminal || module == asset.module_sha256;
2871 if controllers != asset.controllers || !module_matches {
2872 return Ok(false);
2873 }
2874 }
2875 Ok(true)
2876 }
2877
2878 fn reinstall_authorities(
2879 &mut self,
2880 state: &FleetEnsureStateRecord,
2881 ) -> Result<Option<BTreeMap<String, RootManagementCanisterObservation>>, Self::Error> {
2882 self.require_operator()?;
2883 let network = resolve_icp_build_network_from_root(&self.root, &self.desired.environment)
2884 .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?;
2885 let catalog = if network == BuildNetwork::Ic {
2886 let now = SystemTime::now()
2887 .duration_since(UNIX_EPOCH)
2888 .map_err(|_| {
2889 IcpEnsurePlatformError::RootManagement(
2890 "system clock is before the Unix epoch".to_string(),
2891 )
2892 })?
2893 .as_secs();
2894 Some(
2895 load_mainnet_subnet_catalog(&self.root, now)
2896 .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?,
2897 )
2898 } else {
2899 None
2900 };
2901 let mut authorities = BTreeMap::new();
2902 let root_targets = self
2903 .desired
2904 .canisters
2905 .iter()
2906 .filter(|c| c.kind == DesiredCanisterKind::Root)
2907 .map(|c| c.name.clone())
2908 .collect();
2909 let roots = self
2910 .observe_root_management(state, &root_targets)?
2911 .ok_or_else(|| {
2912 IcpEnsurePlatformError::RootManagement("missing Root authority".to_string())
2913 })?;
2914 for configured in &self.desired.canisters {
2915 if configured.kind == DesiredCanisterKind::Pool {
2916 continue;
2917 }
2918 let principal = self
2919 .current_principal(state, &configured.name)
2920 .ok_or_else(|| {
2921 IcpEnsurePlatformError::UnresolvedCreated(configured.name.clone())
2922 })?;
2923 let live = self.install_status_optional(principal)?.ok_or_else(|| {
2924 IcpEnsurePlatformError::RootManagement("missing infrastructure".to_string())
2925 })?;
2926 let subnet = if let Some(root) = roots.roots.get(&configured.name) {
2927 root.subnet.clone()
2928 } else if let Some(catalog) = &catalog {
2929 catalog
2930 .catalog
2931 .resolve_canister_route(principal)
2932 .map(|route| route.subnet.to_text())
2933 .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?
2934 } else {
2935 configured.subnet.clone()
2936 };
2937 authorities.insert(
2938 configured.name.clone(),
2939 RootManagementCanisterObservation {
2940 live,
2941 name: configured.name.clone(),
2942 subnet,
2943 },
2944 );
2945 }
2946 Ok(Some(authorities))
2947 }
2948
2949 fn reinstall_inventory(
2950 &mut self,
2951 source_operation_id: &str,
2952 state: &FleetEnsureStateRecord,
2953 ) -> Result<Option<super::FleetReinstallObservation>, Self::Error> {
2954 let authorities = self.reinstall_authorities(state)?.ok_or_else(|| {
2955 IcpEnsurePlatformError::RootManagement("missing infrastructure".to_string())
2956 })?;
2957 let terminal = self.terminal_inventory(source_operation_id, state)?;
2958 let mut observation = self.observe(source_operation_id, state)?;
2959 for (name, observed) in &authorities {
2960 observation
2961 .canisters
2962 .insert(name.clone(), Some(observed.live.clone()));
2963 }
2964 let candid = self.root_protocol_candid()?;
2965 let candid_by_root = observation
2966 .estate_funding_domains
2967 .keys()
2968 .map(|name| (name.clone(), candid.clone()))
2969 .collect();
2970 let assets = self.inspect_reinstall_assets(
2971 state,
2972 &authorities,
2973 &mut observation,
2974 &candid_by_root,
2975 false,
2976 )?;
2977 let seen = assets
2978 .iter()
2979 .map(|asset| asset.principal.clone())
2980 .collect::<BTreeSet<_>>();
2981 let infrastructure = authorities
2982 .values()
2983 .map(|a| a.live.principal.as_str())
2984 .collect::<BTreeSet<_>>();
2985 if terminal
2986 .controlled_cycles_by_principal
2987 .keys()
2988 .any(|pid| !infrastructure.contains(pid.as_str()) && !seen.contains(pid))
2989 {
2990 return Err(IcpEnsurePlatformError::RootManagement(
2991 "terminal inventory escapes the reviewed reset estate".to_string(),
2992 ));
2993 }
2994 Ok(Some(super::FleetReinstallObservation {
2995 authorities,
2996 assets,
2997 observation,
2998 }))
2999 }
3000
3001 #[expect(
3002 clippy::too_many_lines,
3003 reason = "one source observation binds old protocol receipts, full pagination and physical management inspection"
3004 )]
3005 fn activation_reset_inventory(
3006 &mut self,
3007 source: &crate::fleet_ensure::model::FleetActivationSourceRecord,
3008 state: &FleetEnsureStateRecord,
3009 ) -> Result<Option<super::FleetActivationResetObservation>, Self::Error> {
3010 if source.operator != self.desired.operator
3011 || source.cycles_ledger != self.desired.cycles_ledger
3012 {
3013 return Err(pool_configuration_error(
3014 "source operator or Cycles Ledger differs from the requested reset".to_string(),
3015 ));
3016 }
3017 let authorities = self
3018 .reinstall_authorities(state)?
3019 .ok_or_else(|| pool_configuration_error("missing source infrastructure".to_string()))?;
3020 let mut observation = FleetObservation {
3021 additional_controlled_cycles: BTreeMap::new(),
3022 canisters: authorities
3023 .iter()
3024 .map(|(name, observed)| (name.clone(), Some(observed.live.clone())))
3025 .collect(),
3026 estate_funding_domains: BTreeMap::new(),
3027 ledger_fee_cycles: self
3028 .desired
3029 .ledger_fee_cycles
3030 .parse::<Cycles>()
3031 .map_err(|_| pool_configuration_error("invalid source fee bound".to_string()))?
3032 .to_u128(),
3033 operator_cycles: 0,
3034 protocol_ready: BTreeMap::new(),
3035 };
3036 let mut roots = Vec::new();
3037 let mut candid_by_root = BTreeMap::new();
3038 for configured in &self.desired.canisters {
3039 if configured.kind != DesiredCanisterKind::Root {
3040 continue;
3041 }
3042 let live = &authorities
3043 .get(&configured.name)
3044 .ok_or_else(|| pool_configuration_error("missing source Root".to_string()))?
3045 .live;
3046 let root = parse_principal("source Root", &live.principal)?;
3047 let evidence = current_protocol::observe_inactive_activation(
3048 &self.icp,
3049 &self.root,
3050 source,
3051 &configured.name,
3052 root,
3053 )?;
3054 let candid = source
3055 .registry_preparations
3056 .iter()
3057 .find_map(|action| match action {
3058 EnsureAction::FleetProtocol {
3059 principal, candid, ..
3060 } if principal == &live.principal => Some(self.root.join(candid)),
3061 _ => None,
3062 })
3063 .ok_or_else(|| {
3064 pool_configuration_error("missing source Root read contract".to_string())
3065 })?;
3066 let mut inventory = EstatePoolInventoryAccumulator::default();
3067 let mut cursor = None;
3068 loop {
3069 let page = self.query_estate_pool_page(&candid, root, cursor)?;
3070 if !inactive_source_pool_has_full_inventory(&page, &evidence) {
3071 return Err(pool_configuration_error(
3072 "source pool has unsettled work or spare creation capacity".to_string(),
3073 ));
3074 }
3075 let next = inventory.observe_page(&configured.name, page)?;
3076 if next.is_none() {
3077 break;
3078 }
3079 if next == cursor {
3080 return Err(pool_configuration_error(
3081 "source pool cursor did not advance".to_string(),
3082 ));
3083 }
3084 cursor = next;
3085 }
3086 observation.estate_funding_domains.insert(
3087 configured.name.clone(),
3088 EstateFundingDomainObservation {
3089 balance_cycles: Some(self.cycles_ledger_balance(&live.principal)?),
3090 cycles_ledger: self.desired.cycles_ledger.clone(),
3091 pool: Some(inventory.finish(&configured.name)?),
3092 root_principal: Some(live.principal.clone()),
3093 },
3094 );
3095 candid_by_root.insert(configured.name.clone(), candid);
3096 roots.push(evidence);
3097 }
3098 if roots.is_empty() || roots.len() != source.registry_preparations.len() {
3099 return Err(pool_configuration_error(
3100 "source Root inventory is incomplete".to_string(),
3101 ));
3102 }
3103 let assets = self.inspect_reinstall_assets(
3104 state,
3105 &authorities,
3106 &mut observation,
3107 &candid_by_root,
3108 false,
3109 )?;
3110 Ok(Some(super::FleetActivationResetObservation {
3111 inventory: super::FleetReinstallObservation {
3112 authorities,
3113 assets,
3114 observation,
3115 },
3116 roots,
3117 }))
3118 }
3119
3120 fn activation_reset_inventory_after_reset(
3121 &mut self,
3122 intent: &crate::fleet_ensure::model::FleetReinstallRecord,
3123 state: &FleetEnsureStateRecord,
3124 ) -> Result<Option<super::FleetReinstallObservation>, Self::Error> {
3125 let authorities = self
3126 .reinstall_authorities(state)?
3127 .ok_or_else(|| pool_configuration_error("missing reset infrastructure".to_string()))?;
3128 let mut observation = FleetObservation {
3129 canisters: authorities
3130 .iter()
3131 .map(|(name, entry)| (name.clone(), Some(entry.live.clone())))
3132 .collect(),
3133 additional_controlled_cycles: BTreeMap::new(),
3134 estate_funding_domains: BTreeMap::new(),
3135 ledger_fee_cycles: self
3136 .desired
3137 .ledger_fee_cycles
3138 .parse::<Cycles>()
3139 .map_err(|_| pool_configuration_error("invalid reset Ledger fee".to_string()))?
3140 .to_u128(),
3141 operator_cycles: 0,
3142 protocol_ready: BTreeMap::new(),
3143 };
3144 let candid = self.root_protocol_candid()?;
3145 let mut candid_by_root = BTreeMap::new();
3146 for configured in &self.desired.canisters {
3147 if configured.kind != DesiredCanisterKind::Root {
3148 continue;
3149 }
3150 let binding = authorities
3151 .get(&configured.name)
3152 .ok_or_else(|| pool_configuration_error("missing reset Root".to_string()))?;
3153 let root = parse_principal("reset Root", &binding.live.principal)?;
3154 let mut inventory = EstatePoolInventoryAccumulator::default();
3155 let mut cursor = None;
3156 loop {
3157 let page = self.query_estate_pool_page(&candid, root, cursor)?;
3158 let next = inventory.observe_page(&configured.name, page)?;
3159 if next.is_none() {
3160 break;
3161 }
3162 if next == cursor {
3163 return Err(pool_configuration_error(
3164 "reset inventory cursor did not advance".to_string(),
3165 ));
3166 }
3167 cursor = next;
3168 }
3169 let pool = inventory.finish(&configured.name)?;
3170 let expected = intent
3171 .assets
3172 .iter()
3173 .filter(|asset| asset.root == configured.name)
3174 .map(|asset| asset.principal.as_str())
3175 .collect::<BTreeSet<_>>();
3176 let observed = pool
3177 .assets
3178 .iter()
3179 .map(|asset| asset.principal.as_str())
3180 .collect::<BTreeSet<_>>();
3181 if observed != expected || pool.pending_creation.is_some() {
3182 return Err(pool_configuration_error(
3183 "reset changed the complete physical estate".to_string(),
3184 ));
3185 }
3186 observation.estate_funding_domains.insert(
3187 configured.name.clone(),
3188 EstateFundingDomainObservation {
3189 balance_cycles: Some(self.cycles_ledger_balance(&binding.live.principal)?),
3190 cycles_ledger: self.desired.cycles_ledger.clone(),
3191 pool: Some(pool),
3192 root_principal: Some(binding.live.principal.clone()),
3193 },
3194 );
3195 candid_by_root.insert(configured.name.clone(), candid.clone());
3196 }
3197 let assets = self.inspect_reinstall_assets(
3198 state,
3199 &authorities,
3200 &mut observation,
3201 &candid_by_root,
3202 true,
3203 )?;
3204 Ok(Some(super::FleetReinstallObservation {
3205 authorities,
3206 assets,
3207 observation,
3208 }))
3209 }
3210
3211 fn observe_root_management(
3212 &mut self,
3213 state: &FleetEnsureStateRecord,
3214 reviewed_targets: &BTreeSet<String>,
3215 ) -> Result<Option<RootManagementObservation>, Self::Error> {
3216 self.timed_observation(FleetObservationStage::RootManagement, |platform| {
3217 let configured_roots = platform
3218 .desired
3219 .canisters
3220 .iter()
3221 .filter(|configured| {
3222 configured.kind == DesiredCanisterKind::Root
3223 && configured.presence
3224 == crate::fleet_ensure::model::DesiredPresence::Present
3225 })
3226 .collect::<Vec<_>>();
3227 if configured_roots.is_empty() {
3228 return Ok(None);
3229 }
3230 platform.require_operator()?;
3231 let mut observed_roots = Vec::new();
3232 for configured in configured_roots {
3233 let Some(principal) = platform.current_principal(state, &configured.name) else {
3234 if is_unallocated_fresh_root(&platform.desired, configured, reviewed_targets) {
3235 continue;
3236 }
3237 return Err(IcpEnsurePlatformError::RootManagement(format!(
3238 "configured Root {} has no exact Principal",
3239 configured.name
3240 )));
3241 };
3242 let live = platform.status_optional(principal)?.ok_or_else(|| {
3243 IcpEnsurePlatformError::RootManagement(format!(
3244 "configured Root {} is unavailable",
3245 configured.name
3246 ))
3247 })?;
3248 observed_roots.push((configured, live));
3249 }
3250 let all_current =
3251 platform.management_roots_are_current(&observed_roots, reviewed_targets)?;
3252 if reviewed_targets.is_empty() && all_current {
3253 return Ok(None);
3254 }
3255 let network =
3256 resolve_icp_build_network_from_root(&platform.root, &platform.desired.environment)
3257 .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?;
3258 let catalog = if network == BuildNetwork::Ic {
3259 let now = SystemTime::now()
3260 .duration_since(UNIX_EPOCH)
3261 .map_err(|_| {
3262 IcpEnsurePlatformError::RootManagement(
3263 "system clock is before the Unix epoch".to_string(),
3264 )
3265 })?
3266 .as_secs();
3267 Some(
3268 load_mainnet_subnet_catalog(&platform.root, now).map_err(|error| {
3269 IcpEnsurePlatformError::RootManagement(error.to_string())
3270 })?,
3271 )
3272 } else {
3273 None
3274 };
3275 let mut roots = BTreeMap::new();
3276 for (configured, live) in observed_roots {
3277 let principal = live.principal.clone();
3278 let subnet = catalog.as_ref().map_or_else(
3279 || Ok(configured.subnet.clone()),
3280 |catalog| {
3281 catalog
3282 .catalog
3283 .resolve_canister_route(&principal)
3284 .map(|route| route.subnet.to_text())
3285 .map_err(|error| {
3286 IcpEnsurePlatformError::RootManagement(error.to_string())
3287 })
3288 },
3289 )?;
3290 let name = configured.name.clone();
3291 if roots
3292 .insert(
3293 name.clone(),
3294 RootManagementCanisterObservation { live, name, subnet },
3295 )
3296 .is_some()
3297 {
3298 return Err(IcpEnsurePlatformError::RootManagement(
3299 "configured Root names are not unique".to_string(),
3300 ));
3301 }
3302 }
3303 let operator_cycles = platform
3304 .icp
3305 .identity_cycles_balance()
3306 .map_err(|error| IcpEnsurePlatformError::LedgerWithdraw(error.to_string()))?;
3307 Ok(Some(RootManagementObservation {
3308 operator_cycles,
3309 roots,
3310 }))
3311 })
3312 }
3313
3314 fn observe(
3315 &mut self,
3316 operation_id: &str,
3317 state: &FleetEnsureStateRecord,
3318 ) -> Result<FleetObservation, Self::Error> {
3319 self.with_observation_snapshot(|platform| {
3320 platform.observe_fleet_snapshot(operation_id, state)
3321 })
3322 }
3323
3324 fn protocol_actions(
3325 &mut self,
3326 operation_id: &str,
3327 state: &FleetEnsureStateRecord,
3328 ) -> Result<Vec<EnsureAction>, Self::Error> {
3329 self.timed_observation(FleetObservationStage::ProtocolActions, |platform| {
3330 if platform.desired.protocol.is_none() {
3331 return Ok(Vec::new());
3332 }
3333 current_protocol::validate_component_pool_capacity(&platform.root, &platform.desired)?;
3334 if !platform.current_protocol_owners_are_ready(state)? {
3335 return Ok(Vec::new());
3336 }
3337 let reconciliation = current_protocol::compile_pool_reconciliation(
3338 &platform.root,
3339 &platform.desired,
3340 state,
3341 &platform.estate_observations,
3342 )?;
3343 if !reconciliation.is_empty() {
3344 return Ok(reconciliation);
3345 }
3346 current_protocol::compile(
3347 &platform.icp,
3348 &platform.root,
3349 &platform.desired,
3350 operation_id,
3351 state,
3352 )
3353 .map_err(Into::into)
3354 })
3355 }
3356
3357 fn fresh_protocol_actions(
3358 &mut self,
3359 operation_id: &str,
3360 state: &FleetEnsureStateRecord,
3361 ) -> Result<Vec<EnsureAction>, Self::Error> {
3362 current_protocol::compile_fresh_protocol(&self.root, &self.desired, state, operation_id)
3363 .map_err(Into::into)
3364 }
3365
3366 fn terminal_inventory(
3367 &mut self,
3368 operation_id: &str,
3369 state: &FleetEnsureStateRecord,
3370 ) -> Result<TerminalFleetInventory, Self::Error> {
3371 self.timed_observation(FleetObservationStage::TerminalInventory, |platform| {
3372 if platform.desired.protocol.is_none() {
3373 if state.active_registry.is_some() {
3374 return Err(current_protocol::CurrentProtocolError::Configuration(
3375 "managed terminal Fleet cannot drop its typed protocol intent".to_string(),
3376 )
3377 .into());
3378 }
3379 return Ok(TerminalFleetInventory::default());
3380 }
3381 super::current_inventory::terminal_inventory(
3382 &platform.icp,
3383 &platform.root,
3384 &platform.desired,
3385 operation_id,
3386 state,
3387 )
3388 .map_err(Into::into)
3389 })
3390 }
3391
3392 #[expect(
3393 clippy::too_many_lines,
3394 reason = "the platform keeps every effect's exact live terminal predicate in one exhaustive match"
3395 )]
3396 fn observe_effect(
3397 &mut self,
3398 operation_id: &str,
3399 action: &EnsureAction,
3400 record: &EffectRecord,
3401 state: &FleetEnsureStateRecord,
3402 ) -> Result<EffectObservation, Self::Error> {
3403 if matches!(action, EnsureAction::SealAuthority { .. }) {
3404 return super::authority_seal::observe(&self.icp, &self.root, operation_id, action)
3405 .map_err(Into::into);
3406 }
3407 let mut retry = EffectRetry::None;
3408 let mut post_cycles = None;
3409 let (applied, progress_identity) = match action {
3410 EnsureAction::SealAuthority { .. } => {
3411 unreachable!("authority seals use their typed observer")
3412 }
3413 EnsureAction::Create {
3414 name,
3415 requested_initial_cycles,
3416 ..
3417 } => {
3418 let (live_cycles, deferred) =
3419 if record.created_principal.is_some() && record.receipt.is_some() {
3420 self.created_canister_cycles(
3421 name,
3422 record
3423 .created_principal
3424 .as_deref()
3425 .ok_or(IcpEnsurePlatformError::LedgerCreatePending)?,
3426 state,
3427 )?
3428 } else {
3429 (None, false)
3430 };
3431 post_cycles = live_cycles;
3432 let maximum_observation_burn_cycles =
3433 super::maximum_creation_observation_burn(&self.desired).ok_or(
3434 IcpEnsurePlatformError::Arithmetic("Create execution and observation burn"),
3435 )?;
3436 let applied = create_balance_is_terminal(
3437 live_cycles,
3438 *requested_initial_cycles,
3439 maximum_observation_burn_cycles,
3440 );
3441 if live_cycles.is_some() && !applied {
3442 retry = EffectRetry::ReplanRequiredAfterCreateBalanceDrift;
3443 } else if deferred {
3444 retry = EffectRetry::DeferUntilControllerObservation;
3445 }
3446 (
3447 applied,
3448 format!(
3449 "created:{:?}:actual:{live_cycles:?}:requested:{requested_initial_cycles}",
3450 record.created_principal,
3451 ),
3452 )
3453 }
3454 EnsureAction::Delete { principal, .. } => {
3455 let live = self.status_optional(Self::action_principal(state, principal)?)?;
3456 (live.is_none(), format!("delete:{live:?}"))
3457 }
3458 EnsureAction::Fund {
3459 amount,
3460 expected_post_cycles,
3461 funding_deficit_cycles,
3462 funding_margin_cycles,
3463 ..
3464 } => {
3465 let live_cycles = self.action_cycles(action, state)?;
3466 post_cycles = live_cycles;
3467 let observation = NativeFundingObservation {
3468 amount: *amount,
3469 expected_post_cycles: *expected_post_cycles,
3470 funding_deficit_cycles: *funding_deficit_cycles,
3471 funding_margin_cycles: *funding_margin_cycles,
3472 live_cycles,
3473 pre_cycles: record.pre_cycles,
3474 };
3475 (
3476 native_funding_completion(observation, record.receipt.is_some())?,
3477 format!(
3478 "native-topup:ledger-withdraw:{}:actual:{live_cycles:?}:expected:{expected_post_cycles}:margin:{funding_margin_cycles}",
3479 record.receipt.as_deref().unwrap_or("pending"),
3480 ),
3481 )
3482 }
3483 EnsureAction::FundEstate {
3484 amount,
3485 expected_post_cycles,
3486 ledger_fee_cycles,
3487 principal,
3488 ..
3489 } => {
3490 let source_cycles = self
3491 .icp
3492 .identity_cycles_balance()
3493 .map_err(|error| IcpEnsurePlatformError::LedgerTransfer(error.to_string()))?;
3494 let target = Self::action_principal(state, principal)?;
3495 let destination_cycles = self.cycles_ledger_balance(target)?;
3496 post_cycles = Some(source_cycles);
3497 (
3498 record.receipt.is_some()
3499 && estate_funding_applied(EstateFundingObservation {
3500 amount: *amount,
3501 destination_after: destination_cycles,
3502 destination_before: record.destination_pre_cycles,
3503 expected_destination_after: *expected_post_cycles,
3504 ledger_fee_cycles: *ledger_fee_cycles,
3505 source_after: source_cycles,
3506 source_before: record.pre_cycles,
3507 }),
3508 format!(
3509 "estate-funding:ledger-transfer:{}:source:{source_cycles}:destination:{destination_cycles}:expected:{expected_post_cycles}",
3510 record.receipt.as_deref().unwrap_or("pending"),
3511 ),
3512 )
3513 }
3514 EnsureAction::Install {
3515 mode,
3516 reinstall_witness,
3517 principal,
3518 wasm_sha256,
3519 ..
3520 } => {
3521 let live =
3522 self.install_status_optional(Self::action_principal(state, principal)?)?;
3523 let applied = if let Some(witness) = reinstall_witness {
3524 let current = self.reinstall_authorities(state)?;
3525 let authority = current
3526 .as_ref()
3527 .and_then(|a| a.get(&witness.authority.name))
3528 .and_then(super::reinstall::authority_binding);
3529 if authority.as_ref() != Some(&witness.authority) {
3530 return Err(IcpEnsurePlatformError::ReinstallHistoryConflict {
3531 canister: witness.authority.principal.clone(),
3532 });
3533 }
3534 let live = live.as_ref().ok_or_else(|| {
3535 IcpEnsurePlatformError::ReinstallHistoryConflict {
3536 canister: principal.clone(),
3537 }
3538 })?;
3539 let before = record.pre_canister_version.ok_or_else(|| {
3540 IcpEnsurePlatformError::ReinstallHistoryConflict {
3541 canister: principal.clone(),
3542 }
3543 })?;
3544 let history = super::install_history::observe(
3545 &self.icp,
3546 &self.root,
3547 witness,
3548 parse_principal("operator", &self.desired.operator)?,
3549 before,
3550 live,
3551 )?;
3552 match super::install_history::reconcile(
3553 history,
3554 wasm_sha256,
3555 &witness.prior_module_sha256,
3556 before,
3557 live,
3558 ) {
3559 super::install_history::HistoryEffect::Applied => true,
3560 super::install_history::HistoryEffect::NotApplied => false,
3561 super::install_history::HistoryEffect::Conflict => {
3562 return Err(IcpEnsurePlatformError::ReinstallHistoryConflict {
3563 canister: live.principal.clone(),
3564 });
3565 }
3566 }
3567 } else {
3568 live.as_ref().is_some_and(|live| {
3569 install_effect_applied(
3570 *mode,
3571 wasm_sha256,
3572 live.module_sha256.as_deref(),
3573 record.pre_canister_version,
3574 live.canister_version,
3575 )
3576 })
3577 };
3578 let progress_identity = live.as_ref().map_or_else(
3579 || "install:missing".to_string(),
3580 |live| {
3581 format!(
3582 "install:{:?}:version:{:?}",
3583 live.module_sha256, live.canister_version
3584 )
3585 },
3586 );
3587 (applied, progress_identity)
3588 }
3589 EnsureAction::FleetProtocol {
3590 action: current_action,
3591 ..
3592 } => {
3593 let mut observation = match current_protocol::observe(&self.icp, &self.root, action)
3594 {
3595 Ok(observation) => observation,
3596 Err(error)
3597 if matches!(
3598 current_action.as_ref(),
3599 crate::fleet_ensure::model::CurrentFleetProtocolAction::AdoptStore { .. }
3600 ) && recoverable_current_protocol_error(&error) =>
3601 {
3602 EffectObservation {
3603 provisioning_failure: None,
3604 applied: false,
3605 estate_funding_required: None,
3606 post_cycles: None,
3607 progress_identity: "store-adoption:protected-status-unavailable"
3608 .to_string(),
3609 retry: EffectRetry::None,
3610 }
3611 }
3612 Err(error) => return Err(error.into()),
3613 };
3614 self.refresh_estate_funding_observation(&mut observation, state)?;
3615 return Ok(observation);
3616 }
3617 EnsureAction::Protocol { .. } => {
3618 let observation = protocol::observe(
3619 &self.icp,
3620 &self.root,
3621 operation_id,
3622 &self.protocol_principals(state),
3623 &resolved_protocol_action(action, state)?,
3624 )?;
3625 (observation.applied, observation.progress_identity)
3626 }
3627 EnsureAction::SetControllers {
3628 controller_canisters,
3629 controllers,
3630 name,
3631 principal,
3632 ..
3633 } => {
3634 let expected =
3635 self.resolved_controllers(state, controllers, controller_canisters)?;
3636 let principal = Self::action_principal(state, principal)?;
3637 let configured = self
3638 .desired
3639 .canisters
3640 .iter()
3641 .find(|configured| configured.name == *name);
3642 let mut observed_controllers = if let Some(configured) =
3643 configured.filter(|configured| configured.kind == DesiredCanisterKind::Pool)
3644 {
3645 self.inspect_root_owned_canister(configured, principal, state)?
3646 .map(|response| {
3647 response
3648 .settings
3649 .controllers
3650 .into_iter()
3651 .map(|controller| controller.to_text())
3652 .collect::<Vec<_>>()
3653 })
3654 } else {
3655 self.status_optional(principal)?
3656 .map(|live| live.controllers)
3657 };
3658 if let Some(controllers) = &mut observed_controllers {
3659 controllers.sort();
3660 controllers.dedup();
3661 }
3662 (
3663 observed_controllers.as_ref() == Some(&expected),
3664 format!("controllers:{observed_controllers:?}"),
3665 )
3666 }
3667 EnsureAction::Start { principal, .. } => {
3668 let live = self.status_optional(Self::action_principal(state, principal)?)?;
3669 (
3670 live.as_ref()
3671 .is_some_and(|live| live.status == CanisterRuntimeStatus::Running),
3672 format!("start:{:?}", live.map(|live| live.status)),
3673 )
3674 }
3675 EnsureAction::Stop { principal, .. } => {
3676 let live = self.status_optional(Self::action_principal(state, principal)?)?;
3677 (
3678 live.as_ref()
3679 .is_some_and(|live| live.status == CanisterRuntimeStatus::Stopped),
3680 format!("stop:{:?}", live.map(|live| live.status)),
3681 )
3682 }
3683 EnsureAction::Transfer {
3684 amount,
3685 maximum_execution_burn_cycles,
3686 name,
3687 principal,
3688 destination,
3689 ..
3690 } => {
3691 let source = self
3692 .status_optional(Self::action_principal(state, principal)?)?
3693 .map(|live| live.cycles)
3694 .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
3695 canister: name.clone(),
3696 side: "live source",
3697 })?;
3698 let destination = self
3699 .status_optional(self.current_principal(state, destination).ok_or_else(
3700 || IcpEnsurePlatformError::UnresolvedCreated(destination.clone()),
3701 )?)?
3702 .map(|live| live.cycles)
3703 .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
3704 canister: name.clone(),
3705 side: "live treasury",
3706 })?;
3707 let source_pre = record.pre_cycles.ok_or_else(|| {
3708 IcpEnsurePlatformError::MissingTransferBalance {
3709 canister: name.clone(),
3710 side: "retained source",
3711 }
3712 })?;
3713 let destination_pre = record.destination_pre_cycles.ok_or_else(|| {
3714 IcpEnsurePlatformError::MissingTransferBalance {
3715 canister: name.clone(),
3716 side: "retained treasury",
3717 }
3718 })?;
3719 let reconciliation = reconcile_retirement_transfer(RetirementTransferBalances {
3720 destination_after: destination,
3721 destination_before: destination_pre,
3722 maximum_execution_burn: *maximum_execution_burn_cycles,
3723 source_after: source,
3724 source_before: source_pre,
3725 transfer_amount: *amount,
3726 });
3727 let (applied, source_debit, destination_credit) = match reconciliation {
3728 Ok(RetirementTransferReconciliation::Pending) => (false, 0, 0),
3729 Ok(RetirementTransferReconciliation::Conserved {
3730 destination_credit,
3731 source_debit,
3732 }) => (true, source_debit, destination_credit),
3733 Err(RetirementTransferInvariantError::ArithmeticOverflow) => {
3734 return Err(IcpEnsurePlatformError::Arithmetic(
3735 "retirement transfer debit",
3736 ));
3737 }
3738 Err(RetirementTransferInvariantError::BalanceMovedInInvalidDirection) => {
3739 return Err(IcpEnsurePlatformError::InvalidTransferBalance {
3740 canister: name.clone(),
3741 });
3742 }
3743 Err(RetirementTransferInvariantError::Unbalanced {
3744 destination_credit,
3745 maximum_source_debit,
3746 source_debit,
3747 }) => {
3748 return Err(IcpEnsurePlatformError::UnbalancedTransfer {
3749 canister: name.clone(),
3750 destination_credit,
3751 expected: *amount,
3752 maximum_source_debit,
3753 source_debit,
3754 });
3755 }
3756 };
3757 (
3758 applied,
3759 format!("transfer:{source_debit}:{destination_credit}"),
3760 )
3761 }
3762 };
3763 Ok(EffectObservation {
3764 provisioning_failure: None,
3765 applied,
3766 estate_funding_required: None,
3767 post_cycles,
3768 progress_identity,
3769 retry,
3770 })
3771 }
3772
3773 #[expect(
3774 clippy::too_many_lines,
3775 reason = "the platform keeps every approved single-step effect in one exhaustive match"
3776 )]
3777 fn apply(
3778 &mut self,
3779 operation_id: &str,
3780 action: &EnsureAction,
3781 _record: &EffectRecord,
3782 state: &FleetEnsureStateRecord,
3783 ) -> Result<EffectOutcome, Self::Error> {
3784 self.require_operator()?;
3785 match action {
3786 EnsureAction::SealAuthority { .. } => {
3787 super::authority_seal::apply(&self.icp, &self.root, operation_id, action)
3788 .map_err(Into::into)
3789 }
3790 EnsureAction::Create {
3791 controller_canisters,
3792 controllers,
3793 created_at_time,
3794 ledger,
3795 requested_initial_cycles,
3796 subnet,
3797 ..
3798 } => self.apply_create(
3799 CreateCanisterAuthority {
3800 controller_canisters,
3801 controllers,
3802 created_at_time: *created_at_time,
3803 ledger,
3804 requested_initial_cycles: *requested_initial_cycles,
3805 subnet,
3806 },
3807 state,
3808 ),
3809 EnsureAction::Delete {
3810 maximum_remaining_cycles,
3811 principal,
3812 ..
3813 } => {
3814 let principal = Self::action_principal(state, principal)?;
3815 if let Some(live) = self.status_optional(principal)? {
3816 if live.cycles > *maximum_remaining_cycles {
3817 return Err(IcpEnsurePlatformError::MaterialCycles {
3818 actual: live.cycles,
3819 canister: principal.to_string(),
3820 maximum: *maximum_remaining_cycles,
3821 });
3822 }
3823 if live.status != CanisterRuntimeStatus::Stopped {
3824 return Err(IcpEnsurePlatformError::NotStopped {
3825 canister: principal.to_string(),
3826 });
3827 }
3828 self.icp.delete_canister_without_cycle_recovery(principal)?;
3829 }
3830 Ok(empty_outcome())
3831 }
3832 EnsureAction::Fund {
3833 amount,
3834 created_at_time,
3835 ledger,
3836 principal,
3837 ..
3838 } => self.apply_fund(
3839 *amount,
3840 *created_at_time,
3841 ledger,
3842 Self::action_principal(state, principal)?,
3843 ),
3844 EnsureAction::FundEstate {
3845 amount,
3846 created_at_time,
3847 ledger,
3848 ledger_fee_cycles,
3849 principal,
3850 ..
3851 } => self.apply_estate_fund(
3852 *amount,
3853 *created_at_time,
3854 ledger,
3855 Self::action_principal(state, principal)?,
3856 *ledger_fee_cycles,
3857 ),
3858 EnsureAction::Install {
3859 canic_init,
3860 init_arg,
3861 init_arg_sha256,
3862 init_candid,
3863 init_candid_sha256,
3864 mode,
3865 principal,
3866 wasm,
3867 wasm_sha256,
3868 ..
3869 } => self.apply_install(
3870 operation_id,
3871 &self.protocol_principals(state),
3872 canic_init.as_ref(),
3873 init_arg.as_deref(),
3874 init_arg_sha256.as_deref(),
3875 init_candid.as_deref(),
3876 init_candid_sha256.as_deref(),
3877 *mode,
3878 Self::action_principal(state, principal)?,
3879 wasm,
3880 wasm_sha256,
3881 ),
3882 EnsureAction::FleetProtocol { .. } => {
3883 current_protocol::apply(&self.icp, &self.root, action).map_err(Into::into)
3884 }
3885 EnsureAction::Protocol { .. } => {
3886 let action = resolved_protocol_action(action, state)?;
3887 protocol::apply(
3888 &self.icp,
3889 &self.root,
3890 operation_id,
3891 &self.protocol_principals(state),
3892 &action,
3893 )?;
3894 Ok(EffectOutcome {
3895 created_principal: None,
3896 post_cycles: None,
3897 receipt: Some(operation_id.to_string()),
3898 })
3899 }
3900 EnsureAction::SetControllers {
3901 controller_canisters,
3902 controllers,
3903 principal,
3904 ..
3905 } => self.apply_controllers(
3906 Self::action_principal(state, principal)?,
3907 &self.resolved_controllers(state, controllers, controller_canisters)?,
3908 ),
3909 EnsureAction::Start { principal, .. } => {
3910 self.icp
3911 .start_canister(Self::action_principal(state, principal)?)?;
3912 Ok(empty_outcome())
3913 }
3914 EnsureAction::Stop { principal, .. } => {
3915 self.icp
3916 .stop_canister(Self::action_principal(state, principal)?)?;
3917 Ok(empty_outcome())
3918 }
3919 EnsureAction::Transfer {
3920 amount,
3921 candid,
3922 candid_sha256,
3923 destination,
3924 method,
3925 principal,
3926 ..
3927 } => self.apply_transfer(
3928 *amount,
3929 candid,
3930 candid_sha256,
3931 self.current_principal(state, destination).ok_or_else(|| {
3932 IcpEnsurePlatformError::UnresolvedCreated(destination.clone())
3933 })?,
3934 method,
3935 operation_id,
3936 Self::action_principal(state, principal)?,
3937 ),
3938 }
3939 }
3940
3941 fn action_cycles(
3942 &mut self,
3943 action: &EnsureAction,
3944 state: &FleetEnsureStateRecord,
3945 ) -> Result<Option<u128>, Self::Error> {
3946 if matches!(action, EnsureAction::FundEstate { .. }) {
3947 return self
3948 .icp
3949 .identity_cycles_balance()
3950 .map(Some)
3951 .map_err(|error| IcpEnsurePlatformError::LedgerTransfer(error.to_string()));
3952 }
3953 if let EnsureAction::Fund {
3954 pool_funding: Some(authority),
3955 name,
3956 principal,
3957 ..
3958 } = action
3959 {
3960 let principal = Self::action_principal(state, principal)?;
3961 let module = self.pool_funding_module(name, authority, principal)?;
3962 return self
3963 .inspect_pool_balance(name, &authority.root, principal, module)
3964 .map(Some);
3965 }
3966 let (name, principal) = match action {
3967 EnsureAction::Create { .. } => return Ok(None),
3968 EnsureAction::SealAuthority {
3969 name, principal, ..
3970 }
3971 | EnsureAction::Delete {
3972 name, principal, ..
3973 }
3974 | EnsureAction::FleetProtocol {
3975 name, principal, ..
3976 }
3977 | EnsureAction::Fund {
3978 name, principal, ..
3979 }
3980 | EnsureAction::FundEstate {
3981 name, principal, ..
3982 }
3983 | EnsureAction::Install {
3984 name, principal, ..
3985 }
3986 | EnsureAction::Protocol {
3987 name, principal, ..
3988 }
3989 | EnsureAction::SetControllers {
3990 name, principal, ..
3991 }
3992 | EnsureAction::Start {
3993 name, principal, ..
3994 }
3995 | EnsureAction::Stop {
3996 name, principal, ..
3997 }
3998 | EnsureAction::Transfer {
3999 name, principal, ..
4000 } => (name, Self::action_principal(state, principal)?),
4001 };
4002 let observed = self
4003 .desired
4004 .canisters
4005 .iter()
4006 .find(|configured| configured.name == *name)
4007 .map_or_else(
4008 || self.status_optional(principal),
4009 |configured| self.observe_configured_canister(configured, principal, state),
4010 )?;
4011 Ok(observed.map(|live| live.cycles))
4012 }
4013
4014 fn action_destination_cycles(
4015 &mut self,
4016 action: &EnsureAction,
4017 state: &FleetEnsureStateRecord,
4018 ) -> Result<Option<u128>, Self::Error> {
4019 if let EnsureAction::FundEstate { principal, .. } = action {
4020 let owner = Self::action_principal(state, principal)?;
4021 return self.cycles_ledger_balance(owner).map(Some);
4022 }
4023 let EnsureAction::Transfer { destination, .. } = action else {
4024 return Ok(None);
4025 };
4026 let destination = self
4027 .current_principal(state, destination)
4028 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(destination.clone()))?;
4029 Ok(self.status_optional(destination)?.map(|live| live.cycles))
4030 }
4031
4032 fn action_canister_version(
4033 &mut self,
4034 action: &EnsureAction,
4035 state: &FleetEnsureStateRecord,
4036 ) -> Result<Option<u64>, Self::Error> {
4037 let EnsureAction::Install { principal, .. } = action else {
4038 return Ok(None);
4039 };
4040 let principal = Self::action_principal(state, principal)?;
4041 let version = self
4042 .install_status_optional(principal)?
4043 .and_then(|live| live.canister_version)
4044 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
4045 canister: principal.to_string(),
4046 field: "canister_version",
4047 })?;
4048 Ok(Some(version))
4049 }
4050}
4051
4052fn exact_install_canister_status(
4053 icp: &IcpCli,
4054 canister: &str,
4055 projected_canister_version: Option<u64>,
4056 projected_module_sha256: Option<String>,
4057) -> Result<ExactInstallCanisterStatus, IcpEnsurePlatformError> {
4058 exact_install_canister_status_with(
4059 canister,
4060 projected_canister_version,
4061 projected_module_sha256,
4062 |canister_id| {
4063 icp.management_canister_status_candid::<_, ManagementCanisterStatusResponse>(
4064 canister_id,
4065 &ManagementCanisterStatusRequest { canister_id },
4066 )
4067 },
4068 )
4069}
4070
4071fn exact_install_canister_status_with(
4072 canister: &str,
4073 projected_canister_version: Option<u64>,
4074 projected_module_sha256: Option<String>,
4075 management_status: impl FnOnce(
4076 Principal,
4077 )
4078 -> Result<ManagementCanisterStatusResponse, IcpManagementCallError>,
4079) -> Result<ExactInstallCanisterStatus, IcpEnsurePlatformError> {
4080 if let Some(canister_version) = projected_canister_version {
4081 return Ok(ExactInstallCanisterStatus {
4082 canister_version,
4083 module_sha256: projected_module_sha256,
4084 });
4085 }
4086 let canister_id = parse_principal("install target", canister)?;
4087 let response = management_status(canister_id).map_err(|source| {
4088 IcpEnsurePlatformError::InstallVersionProofUnavailable {
4089 canister: canister.to_string(),
4090 source: Box::new(source),
4091 }
4092 })?;
4093 Ok(ExactInstallCanisterStatus {
4094 canister_version: response.version,
4095 module_sha256: response
4096 .module_hash
4097 .map(|hash| canic_core::cdk::utils::hash::hex_bytes(&hash)),
4098 })
4099}
4100
4101fn exact_retained_root_owned_topology<'a>(
4102 state: &'a FleetEnsureStateRecord,
4103 name: &str,
4104 kind: DesiredCanisterKind,
4105 parent: &str,
4106 principal: &str,
4107 root: &str,
4108) -> Option<&'a crate::fleet_ensure::model::FleetEnsureTopologyRecord> {
4109 let child_identity_matches = retained_state_principal_matches(state, name, principal);
4110 let root_identity_matches = retained_state_principal_matches(state, parent, root);
4111 let topology = state
4112 .topology
4113 .get(name)
4114 .filter(|topology| topology.kind == kind && topology.parent.as_deref() == Some(parent));
4115 (child_identity_matches && root_identity_matches)
4116 .then_some(topology)
4117 .flatten()
4118}
4119
4120fn retained_state_principal_matches(
4121 state: &FleetEnsureStateRecord,
4122 name: &str,
4123 principal: &str,
4124) -> bool {
4125 let pending = state.pending_principals.get(name).map(String::as_str);
4126 let terminal = state.principals.get(name).map(String::as_str);
4127 let retained = pending.is_some() || terminal.is_some();
4128 retained
4129 && pending.is_none_or(|retained| retained == principal)
4130 && terminal.is_none_or(|retained| retained == principal)
4131}
4132
4133fn completed_reinstall_continuity(
4134 pre_canister_version: u64,
4135 principal_matches: bool,
4136 topology_matches: bool,
4137 live_module_sha256: Option<&str>,
4138 desired_module_sha256: &str,
4139 live_canister_version: Option<u64>,
4140) -> Result<(), &'static str> {
4141 if !principal_matches {
4142 return Err("Principal");
4143 }
4144 if !topology_matches {
4145 return Err("Root/parent/kind topology");
4146 }
4147 if live_module_sha256 != Some(desired_module_sha256) {
4148 return Err("module SHA-256");
4149 }
4150 if live_canister_version.is_some_and(|version| version <= pre_canister_version) {
4151 return Err("canister version");
4152 }
4153 Ok(())
4154}
4155
4156fn recoverable_root_status_error(error: &CanisterProtocolError) -> bool {
4157 error.is_rejected_with(canic_core::diagnostics::codes::STATE_CONFLICT)
4158 || error.is_rejected_with(canic_core::diagnostics::codes::STATE_UNAVAILABLE)
4159}
4160
4161fn recoverable_current_protocol_error(error: ¤t_protocol::CurrentProtocolError) -> bool {
4162 matches!(
4163 error,
4164 current_protocol::CurrentProtocolError::Transport(source)
4165 if recoverable_root_status_error(source)
4166 )
4167}
4168
4169pub fn install_effect_applied(
4170 mode: InstallMode,
4171 expected_hash: &str,
4172 live_hash: Option<&str>,
4173 pre_canister_version: Option<u64>,
4174 live_canister_version: Option<u64>,
4175) -> bool {
4176 if live_hash != Some(expected_hash) {
4177 return false;
4178 }
4179 match mode {
4180 InstallMode::Install => true,
4181 InstallMode::Reinstall => pre_canister_version
4182 .zip(live_canister_version)
4183 .is_some_and(|(before, after)| after > before),
4184 }
4185}
4186
4187#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4189pub struct NativeFundingObservation {
4190 pub amount: u128,
4191 pub expected_post_cycles: u128,
4192 pub funding_deficit_cycles: u128,
4193 pub funding_margin_cycles: u128,
4194 pub live_cycles: Option<u128>,
4195 pub pre_cycles: Option<u128>,
4196}
4197
4198pub const fn native_funding_applied(observation: NativeFundingObservation) -> bool {
4200 let Some(pre_cycles) = observation.pre_cycles else {
4201 return false;
4202 };
4203 let Some(live_cycles) = observation.live_cycles else {
4204 return false;
4205 };
4206 let Some(maximum_live_cycles) = pre_cycles.checked_add(observation.amount) else {
4207 return false;
4208 };
4209 let Some(reviewed_pre_cycles) = observation
4210 .expected_post_cycles
4211 .checked_sub(observation.amount)
4212 else {
4213 return false;
4214 };
4215 let Some(minimum_live_cycles) = observation
4216 .expected_post_cycles
4217 .checked_sub(observation.funding_margin_cycles)
4218 else {
4219 return false;
4220 };
4221 let Some(minimum_from_deficit) =
4222 reviewed_pre_cycles.checked_add(observation.funding_deficit_cycles)
4223 else {
4224 return false;
4225 };
4226 let reviewed_amount_matches =
4227 observation.funding_deficit_cycles > 0 && minimum_live_cycles == minimum_from_deficit;
4228 let observation_is_bounded = pre_cycles <= reviewed_pre_cycles
4229 && live_cycles >= minimum_live_cycles
4230 && live_cycles <= maximum_live_cycles;
4231 reviewed_amount_matches && observation_is_bounded
4232}
4233
4234fn native_funding_completion(
4235 observation: NativeFundingObservation,
4236 has_receipt: bool,
4237) -> Result<bool, IcpEnsurePlatformError> {
4238 if !has_receipt || observation.live_cycles.is_none() {
4239 return Ok(false);
4240 }
4241 if !native_funding_applied(observation) {
4242 return Err(IcpEnsurePlatformError::NativeFundingBalanceDrift {
4243 observation: Box::new(observation),
4244 });
4245 }
4246 Ok(true)
4247}
4248
4249#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4251pub struct EstateFundingObservation {
4252 pub amount: u128,
4253 pub destination_after: u128,
4254 pub destination_before: Option<u128>,
4255 pub expected_destination_after: u128,
4256 pub ledger_fee_cycles: u128,
4257 pub source_after: u128,
4258 pub source_before: Option<u128>,
4259}
4260
4261#[must_use]
4263pub const fn estate_funding_applied(observation: EstateFundingObservation) -> bool {
4264 let Some(source_before) = observation.source_before else {
4265 return false;
4266 };
4267 let Some(destination_before) = observation.destination_before else {
4268 return false;
4269 };
4270 let Some(expected_source_debit) = observation
4271 .amount
4272 .checked_add(observation.ledger_fee_cycles)
4273 else {
4274 return false;
4275 };
4276 let Some(source_debit) = source_before.checked_sub(observation.source_after) else {
4277 return false;
4278 };
4279 let Some(destination_credit) = observation
4280 .destination_after
4281 .checked_sub(destination_before)
4282 else {
4283 return false;
4284 };
4285 source_debit == expected_source_debit
4286 && destination_credit == observation.amount
4287 && observation.destination_after == observation.expected_destination_after
4288}
4289
4290#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4292enum InspectedModule {
4293 Any,
4294 Empty,
4295}
4296
4297fn pool_funding_inspected_module(
4298 canister: &str,
4299 reviewed: EstatePoolAssetLifecycle,
4300 observed: &CanisterPoolAssetStatus,
4301) -> Result<InspectedModule, IcpEnsurePlatformError> {
4302 match (reviewed, observed) {
4303 (EstatePoolAssetLifecycle::Ready, CanisterPoolAssetStatus::Ready) => {
4304 Ok(InspectedModule::Empty)
4305 }
4306 (EstatePoolAssetLifecycle::PendingReset, CanisterPoolAssetStatus::PendingReset)
4307 | (EstatePoolAssetLifecycle::Failed, CanisterPoolAssetStatus::Failed { .. }) => {
4308 Ok(InspectedModule::Any)
4309 }
4310 _ => Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
4311 canister: canister.to_string(),
4312 field: "reviewed pool lifecycle",
4313 }),
4314 }
4315}
4316
4317fn validate_pending_fresh_pool_inspection(
4318 canister: &str,
4319 root: &str,
4320 operator: &str,
4321 controllers: &[Principal],
4322 module_hash: Option<&[u8]>,
4323 cycles: &Nat,
4324) -> Result<u128, IcpEnsurePlatformError> {
4325 let mut actual = controllers
4326 .iter()
4327 .map(Principal::to_text)
4328 .collect::<Vec<_>>();
4329 actual.sort();
4330 let mut temporary = vec![root.to_string(), operator.to_string()];
4331 temporary.sort();
4332 if actual != [root] && actual != temporary {
4333 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
4334 canister: canister.to_string(),
4335 field: "reviewed fresh pool controllers",
4336 });
4337 }
4338 validate_inspected_cycles(canister, InspectedModule::Empty, module_hash, cycles)
4341}
4342
4343fn validate_root_controlled_inspection(
4344 canister: &str,
4345 module: InspectedModule,
4346 root: &str,
4347 controllers: &[Principal],
4348 module_hash: Option<&[u8]>,
4349 cycles: &Nat,
4350) -> Result<u128, IcpEnsurePlatformError> {
4351 if controllers.len() != 1 || controllers[0].to_text() != root {
4352 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
4353 canister: canister.to_string(),
4354 field: "Root-only controllers",
4355 });
4356 }
4357 validate_inspected_cycles(canister, module, module_hash, cycles)
4358}
4359
4360fn validate_inspected_cycles(
4361 canister: &str,
4362 module: InspectedModule,
4363 module_hash: Option<&[u8]>,
4364 cycles: &Nat,
4365) -> Result<u128, IcpEnsurePlatformError> {
4366 if matches!(module, InspectedModule::Empty) && module_hash.is_some() {
4367 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
4368 canister: canister.to_string(),
4369 field: "module-free pool asset",
4370 });
4371 }
4372 u128::try_from(cycles.0.clone()).map_err(|_| IcpEnsurePlatformError::InvalidStatusCycles {
4373 canister: canister.to_string(),
4374 value: cycles.to_string(),
4375 })
4376}
4377
4378fn created_canister_outcome(
4379 canister_id: Principal,
4380 receipt: String,
4381 requested_initial_cycles: u128,
4382) -> EffectOutcome {
4383 EffectOutcome {
4384 created_principal: Some(canister_id.to_text()),
4385 post_cycles: Some(requested_initial_cycles),
4386 receipt: Some(receipt),
4387 }
4388}
4389
4390fn create_canister_response_outcome(
4391 response: Result<CreateCanisterSuccess, CreateCanisterError>,
4392 requested_initial_cycles: u128,
4393) -> Result<EffectOutcome, IcpEnsurePlatformError> {
4394 match response {
4395 Ok(success) => Ok(created_canister_outcome(
4396 success.canister_id,
4397 success.block_id.to_string(),
4398 requested_initial_cycles,
4399 )),
4400 Err(CreateCanisterError::Duplicate {
4401 duplicate_of,
4402 canister_id: Some(canister_id),
4403 }) => Ok(created_canister_outcome(
4404 canister_id,
4405 duplicate_of.to_string(),
4406 requested_initial_cycles,
4407 )),
4408 Err(CreateCanisterError::Duplicate {
4409 canister_id: None, ..
4410 }) => Err(IcpEnsurePlatformError::LedgerCreatePending),
4411 Err(error) => Err(IcpEnsurePlatformError::LedgerCreate(render_create_error(
4412 error,
4413 ))),
4414 }
4415}
4416
4417fn ledger_fee_cycles(value: Nat) -> Result<u128, IcpEnsurePlatformError> {
4418 let rendered = value.to_string();
4419 u128::try_from(value.0).map_err(|_| IcpEnsurePlatformError::InvalidLedgerFee(rendered))
4420}
4421
4422const fn empty_outcome() -> EffectOutcome {
4423 EffectOutcome {
4424 created_principal: None,
4425 post_cycles: None,
4426 receipt: None,
4427 }
4428}
4429
4430fn parse_status_cycles(value: &str) -> Option<u128> {
4431 value.replace('_', "").trim().parse().ok()
4432}
4433
4434fn normalize_hash(value: &str) -> String {
4435 value
4436 .strip_prefix("0x")
4437 .unwrap_or(value)
4438 .to_ascii_lowercase()
4439}
4440
4441fn parse_principal(field: &'static str, value: &str) -> Result<Principal, IcpEnsurePlatformError> {
4442 Principal::from_text(value).map_err(|_| IcpEnsurePlatformError::InvalidPrincipal {
4443 field,
4444 value: value.to_string(),
4445 })
4446}
4447
4448fn reject_public_status_projection(
4449 report: &IcpCanisterStatusReport,
4450 principal: &str,
4451) -> Result<(), IcpEnsurePlatformError> {
4452 if report.status.is_none()
4453 && report.settings.is_none()
4454 && report.cycles.is_none()
4455 && report.public_controllers.is_some()
4456 {
4457 return Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable {
4458 canister: principal.to_string(),
4459 });
4460 }
4461 Ok(())
4462}
4463
4464fn resolve_path(root: &Path, path: &str) -> PathBuf {
4465 let path = Path::new(path);
4466 if path.is_absolute() {
4467 path.to_path_buf()
4468 } else {
4469 root.join(path)
4470 }
4471}
4472
4473fn verify_artifact(
4474 path: &Path,
4475 expected: &str,
4476 kind: &'static str,
4477) -> Result<(), IcpEnsurePlatformError> {
4478 let bytes = std::fs::read(path)
4479 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
4480 let actual = canic_core::cdk::utils::hash::sha256_hex(&bytes);
4481 if actual != expected {
4482 return Err(IcpEnsurePlatformError::ArtifactDigestMismatch {
4483 actual,
4484 expected: expected.to_string(),
4485 kind,
4486 });
4487 }
4488 Ok(())
4489}
4490
4491fn artifact_hash(path: &Path) -> Result<String, IcpEnsurePlatformError> {
4492 let bytes = std::fs::read(path)
4493 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
4494 Ok(canic_core::cdk::utils::hash::sha256_hex(&bytes))
4495}
4496
4497fn resolved_protocol_action(
4498 action: &EnsureAction,
4499 state: &FleetEnsureStateRecord,
4500) -> Result<EnsureAction, IcpEnsurePlatformError> {
4501 let mut resolved = action.clone();
4502 if let EnsureAction::Protocol { principal, .. } = &mut resolved {
4503 *principal = IcpEnsurePlatform::action_principal(state, principal)?.to_string();
4504 return Ok(resolved);
4505 }
4506 Err(IcpEnsurePlatformError::Protocol(
4507 protocol::ProtocolEffectError::WrongAction,
4508 ))
4509}
4510
4511fn render_create_error(error: CreateCanisterError) -> String {
4512 match error {
4513 CreateCanisterError::CreatedInFuture { ledger_time } => {
4514 format!("created in future of ledger time {ledger_time}")
4515 }
4516 CreateCanisterError::Duplicate { duplicate_of, .. } => {
4517 format!("duplicate block {duplicate_of}")
4518 }
4519 CreateCanisterError::FailedToCreate {
4520 error,
4521 fee_block,
4522 refund_block,
4523 } => format!("failed: {error}; fee_block={fee_block:?}; refund_block={refund_block:?}"),
4524 CreateCanisterError::GenericError {
4525 error_code,
4526 message,
4527 } => format!("error {error_code}: {message}"),
4528 CreateCanisterError::InsufficientFunds { balance } => {
4529 format!("insufficient funds: balance={balance}")
4530 }
4531 CreateCanisterError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
4532 CreateCanisterError::TooOld => "request is too old".to_string(),
4533 }
4534}
4535
4536fn render_withdraw_error(error: WithdrawError) -> String {
4537 match error {
4538 WithdrawError::BadFee { expected_fee } => format!("bad fee; expected {expected_fee}"),
4539 WithdrawError::CreatedInFuture { ledger_time } => {
4540 format!("created in future of ledger time {ledger_time}")
4541 }
4542 WithdrawError::Duplicate { duplicate_of } => format!("duplicate block {duplicate_of}"),
4543 WithdrawError::FailedToWithdraw {
4544 fee_block,
4545 rejection_reason,
4546 ..
4547 } => format!("withdraw failed: {rejection_reason}; fee_block={fee_block:?}"),
4548 WithdrawError::GenericError {
4549 error_code,
4550 message,
4551 } => format!("error {error_code}: {message}"),
4552 WithdrawError::InsufficientFunds { balance } => {
4553 format!("insufficient funds: balance={balance}")
4554 }
4555 WithdrawError::InvalidReceiver { receiver } => format!("invalid receiver {receiver}"),
4556 WithdrawError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
4557 WithdrawError::TooOld => "request is too old".to_string(),
4558 }
4559}
4560
4561fn render_ledger_transfer_error(error: CyclesLedgerTransferError) -> String {
4562 match error {
4563 CyclesLedgerTransferError::BadBurn { min_burn_amount } => {
4564 format!("bad burn; minimum {min_burn_amount}")
4565 }
4566 CyclesLedgerTransferError::BadFee { expected_fee } => {
4567 format!("bad fee; expected {expected_fee}")
4568 }
4569 CyclesLedgerTransferError::CreatedInFuture { ledger_time } => {
4570 format!("created in future of ledger time {ledger_time}")
4571 }
4572 CyclesLedgerTransferError::Duplicate { duplicate_of } => {
4573 format!("duplicate block {duplicate_of}")
4574 }
4575 CyclesLedgerTransferError::GenericError {
4576 error_code,
4577 message,
4578 } => format!("error {error_code}: {message}"),
4579 CyclesLedgerTransferError::InsufficientFunds { balance } => {
4580 format!("insufficient funds: balance={balance}")
4581 }
4582 CyclesLedgerTransferError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
4583 CyclesLedgerTransferError::TooOld => "request is too old".to_string(),
4584 }
4585}
4586
4587#[expect(
4588 dead_code,
4589 reason = "Candid variant is retained for exact response decoding"
4590)]
4591const fn rejection_code_name(code: RejectionCode) -> &'static str {
4592 match code {
4593 RejectionCode::CanisterError => "canister_error",
4594 RejectionCode::CanisterReject => "canister_reject",
4595 RejectionCode::DestinationInvalid => "destination_invalid",
4596 RejectionCode::NoError => "no_error",
4597 RejectionCode::SysFatal => "sys_fatal",
4598 RejectionCode::SysTransient => "sys_transient",
4599 RejectionCode::Unknown => "unknown",
4600 }
4601}
4602
4603#[cfg(test)]
4604mod tests {
4605 use super::*;
4606 use canic_core::dto::pool::CanisterPoolAssetStatus;
4607
4608 #[test]
4609 fn inactive_source_inventory_rejects_spare_capacity_pending_work_and_omissions() {
4610 let root = crate::fleet_ensure::model::RootActivationResetRecord {
4611 root: "root".to_string(),
4612 activation_operation_id: [1; 32],
4613 inventory_hash: [2; 32],
4614 provisioning_receipt_hash: [3; 32],
4615 component_count: 1,
4616 managed_descendants: 2,
4617 };
4618 let page = inactive_source_pool_page();
4619 assert!(inactive_source_pool_has_full_inventory(&page, &root));
4620 let mut complete = EstatePoolInventoryAccumulator::default();
4621 assert_eq!(
4622 complete.observe_page("root", page.clone()).expect("page"),
4623 None
4624 );
4625 assert_eq!(complete.finish("root").expect("complete").assets.len(), 4);
4626
4627 let mut spare = page.clone();
4628 spare.config.maximum_size += 1;
4629 let mut insufficient_reserve = page.clone();
4630 insufficient_reserve.config.minimum_size = 2;
4631 let mut pending = page.clone();
4632 pending.pending_reset = 1;
4633 let mut wrong_workload = page.clone();
4634 wrong_workload.workload -= 1;
4635 wrong_workload.ready += 1;
4636 let mut overflow = page.clone();
4637 overflow.workload = u32::MAX;
4638 for changed in [
4639 spare,
4640 insufficient_reserve,
4641 pending,
4642 wrong_workload,
4643 overflow,
4644 ] {
4645 assert!(!inactive_source_pool_has_full_inventory(&changed, &root));
4646 }
4647
4648 let mut omitted = page;
4649 omitted.entries.pop();
4650 let mut inventory = EstatePoolInventoryAccumulator::default();
4651 inventory
4652 .observe_page("root", omitted)
4653 .expect("bounded page");
4654 assert!(matches!(
4655 inventory.finish("root"),
4656 Err(IcpEnsurePlatformError::CurrentProtocol(
4657 current_protocol::CurrentProtocolError::Configuration(_)
4658 ))
4659 ));
4660 }
4661
4662 fn inactive_source_pool_page() -> CanisterPoolResponse {
4663 let claim = canic_core::dto::pool::CanisterPoolClaim {
4664 component: canic_core::ids::ComponentInstanceId::from_generated_bytes([4; 32]),
4665 operation_id: [5; 32],
4666 };
4667 let entries = (0..5)
4668 .map(|index| CanisterPoolAsset {
4669 canister_id: Principal::from_slice(&[index]),
4670 creation_receipt: None,
4671 cycles: Cycles::new(100),
4672 origin: if index == 0 {
4673 CanisterPoolAssetOrigin::InfrastructureStore
4674 } else {
4675 CanisterPoolAssetOrigin::Imported
4676 },
4677 status: match index {
4678 0 => CanisterPoolAssetStatus::Store,
4679 1 => CanisterPoolAssetStatus::Ready,
4680 _ => CanisterPoolAssetStatus::Workload {
4681 claim: claim.clone(),
4682 },
4683 },
4684 added_at_ns: 1,
4685 updated_at_ns: 1,
4686 })
4687 .collect();
4688 CanisterPoolResponse {
4689 config: canic_core::ids::FleetSubnetCanisterPoolConfig {
4690 minimum_size: 1,
4691 maximum_size: 4,
4692 canister_cycles: Cycles::new(100),
4693 creation_execution_margin: Cycles::new(1),
4694 },
4695 tracked: 5,
4696 store: 1,
4697 store_deletion_pending: 0,
4698 pooled: 1,
4699 workload: 3,
4700 surplus: 0,
4701 ready: 1,
4702 pending_reset: 0,
4703 claimed: 0,
4704 recycling: 0,
4705 handing_off: 0,
4706 failed: 0,
4707 completed_handoffs: 0,
4708 pending_creation: None,
4709 pending_handoff: None,
4710 entries,
4711 next_start_after: None,
4712 }
4713 }
4714
4715 #[test]
4716 fn pool_funding_binds_reset_lifecycle_without_weakening_ready_inspection() {
4717 let failed = CanisterPoolAssetStatus::Failed {
4718 reason: "reset stopped".to_string(),
4719 };
4720 for (lifecycle, status, expected) in [
4721 (
4722 EstatePoolAssetLifecycle::Ready,
4723 CanisterPoolAssetStatus::Ready,
4724 InspectedModule::Empty,
4725 ),
4726 (
4727 EstatePoolAssetLifecycle::PendingReset,
4728 CanisterPoolAssetStatus::PendingReset,
4729 InspectedModule::Any,
4730 ),
4731 (
4732 EstatePoolAssetLifecycle::Failed,
4733 failed,
4734 InspectedModule::Any,
4735 ),
4736 ] {
4737 let module = pool_funding_inspected_module("pool", lifecycle, &status).unwrap();
4738 assert_eq!(module, expected);
4739 let installed =
4740 validate_inspected_cycles("pool", module, Some(&[1; 32]), &Nat::from(100_u8));
4741 assert_eq!(installed.is_ok(), module == InspectedModule::Any);
4742 for wrong in [
4743 EstatePoolAssetLifecycle::Ready,
4744 EstatePoolAssetLifecycle::PendingReset,
4745 EstatePoolAssetLifecycle::Failed,
4746 EstatePoolAssetLifecycle::Workload,
4747 ] {
4748 if wrong != lifecycle {
4749 assert!(matches!(
4750 pool_funding_inspected_module("pool", wrong, &status),
4751 Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { .. })
4752 ));
4753 }
4754 }
4755 }
4756 }
4757
4758 #[cfg(unix)]
4759 #[test]
4760 fn funding_pause_uses_fresh_ledger_balance_without_completing_protocol_work() {
4761 let mut fixture = ProtocolOwnersFixture::new();
4762 let root = Principal::from_slice(&[1]);
4763 let ledger = Principal::from_slice(&[2]);
4764 fixture.platform.desired.cycles_ledger = ledger.to_text();
4765 fixture.platform.desired.canisters[1].principal = Some(root.to_text());
4766 std::fs::write(
4767 fixture.root.join("icp"),
4768 format!(
4769 "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.3.0'; exit 0; fi\ncat '{}'/balance.json\n",
4770 fixture.root.display(),
4771 ),
4772 )
4773 .unwrap();
4774 let pause = canic_core::dto::component_provisioning::RootEstateFundingRequired {
4775 available: Cycles::new(0),
4776 attempt_count: 0,
4777 creation_amount: Cycles::new(100),
4778 cycles_ledger: ledger,
4779 execution_margin: Cycles::new(10),
4780 last_attempt_at_ns: None,
4781 ledger_fee: Cycles::new(1),
4782 management_creation_fee: Cycles::new(20),
4783 operation_id: [1; 32],
4784 readiness_floor: Cycles::new(70),
4785 required: Cycles::new(101),
4786 retry_at_ns: 60,
4787 root,
4788 shortfall: Cycles::new(101),
4789 };
4790 for available in [0_u128, 40, 101, 404] {
4791 std::fs::write(
4792 fixture.root.join("balance.json"),
4793 serde_json::json!({
4794 "response_bytes": hex_bytes(candid::encode_one(Nat::from(available)).unwrap()),
4795 })
4796 .to_string(),
4797 )
4798 .unwrap();
4799 let mut observed = EffectObservation {
4800 provisioning_failure: None,
4801 applied: false,
4802 estate_funding_required: Some(pause.clone()),
4803 post_cycles: None,
4804 progress_identity: "pending-creation".to_string(),
4805 retry: EffectRetry::ContinuePoolMaintenance,
4806 };
4807 fixture
4808 .platform
4809 .refresh_estate_funding_observation(&mut observed, &fixture.state)
4810 .unwrap();
4811 assert!(!observed.applied);
4812 assert_eq!(observed.retry, EffectRetry::ContinuePoolMaintenance);
4813 assert_eq!(observed.progress_identity, "pending-creation");
4814 let expected = (available < 101).then(|| {
4815 let mut expected = pause.clone();
4816 expected.available = Cycles::new(available);
4817 expected.shortfall = Cycles::new(101 - available);
4818 expected
4819 });
4820 assert_eq!(observed.estate_funding_required, expected);
4821 }
4822 for (root, ledger) in [
4823 (Principal::anonymous(), ledger),
4824 (root, Principal::anonymous()),
4825 ] {
4826 let mut changed = pause.clone();
4827 changed.root = root;
4828 changed.cycles_ledger = ledger;
4829 let mut observed = EffectObservation {
4830 provisioning_failure: None,
4831 applied: false,
4832 estate_funding_required: Some(changed),
4833 post_cycles: None,
4834 progress_identity: String::new(),
4835 retry: EffectRetry::None,
4836 };
4837 assert!(matches!(
4838 fixture
4839 .platform
4840 .refresh_estate_funding_observation(&mut observed, &fixture.state),
4841 Err(IcpEnsurePlatformError::CurrentProtocol(
4842 current_protocol::CurrentProtocolError::ResponseMismatch
4843 ))
4844 ));
4845 }
4846 std::fs::remove_dir_all(&fixture.root).unwrap();
4847 }
4848
4849 #[test]
4850 fn completed_withdrawal_outside_bounds_fails_without_endless_observation() {
4851 let observation = NativeFundingObservation {
4852 amount: 350,
4853 expected_post_cycles: 800,
4854 funding_deficit_cycles: 150,
4855 funding_margin_cycles: 200,
4856 live_cycles: Some(599),
4857 pre_cycles: Some(449),
4858 };
4859 assert!(!native_funding_completion(observation, false).unwrap());
4860 assert!(matches!(
4861 native_funding_completion(observation, true),
4862 Err(IcpEnsurePlatformError::NativeFundingBalanceDrift {
4863 observation: actual,
4864 }) if *actual == observation
4865 ));
4866 assert!(
4867 native_funding_completion(
4868 NativeFundingObservation {
4869 live_cycles: Some(600),
4870 ..observation
4871 },
4872 true
4873 )
4874 .unwrap()
4875 );
4876 }
4877
4878 #[cfg(unix)]
4879 struct ProtocolOwnersFixture {
4880 root: PathBuf,
4881 platform: IcpEnsurePlatform,
4882 state: FleetEnsureStateRecord,
4883 }
4884
4885 #[cfg(unix)]
4886 impl ProtocolOwnersFixture {
4887 fn new() -> Self {
4888 use std::{fs, os::unix::fs::PermissionsExt};
4889
4890 let root = crate::test_support::temp_dir("canic-protocol-owner-observations");
4891 fs::create_dir_all(&root).unwrap();
4892 fs::write(root.join("owner.wasm"), b"current-owner-module").unwrap();
4893 let executable = root.join("icp");
4894 fs::write(&executable, format!(
4895 "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.3.0'; exit 0; fi\nwhile [ \"$#\" -gt 0 ] && [ \"$1\" != status ]; do shift; done\nshift\nprintf '%s\\n' \"$1\" >> '{}'/calls\ncat '{}'/\"$1\".json\n",
4896 root.display(), root.display(),
4897 )).unwrap();
4898 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
4899 let canisters = ["coordinator", "root", "store"].map(|name| {
4900 serde_json::json!({
4901 "controller_canisters": [], "controllers": [], "drain": null,
4902 "initial_cycles": "1T", "init_arg": null, "init_candid": null,
4903 "kind": name, "minimum_cycles": "1T", "name": name,
4904 "parent": null, "presence": "present", "principal": name,
4905 "replace": false, "subnet": "subnet", "wasm": "owner.wasm",
4906 })
4907 });
4908 let desired = serde_json::from_value(serde_json::json!({
4909 "bootstrap": null, "canisters": canisters, "cycles_ledger": "ledger",
4910 "environment": "local", "fleet": "owners", "ledger_fee_cycles": "100M",
4911 "management_creation_fee_cycles": "500B", "material_cycle_threshold": "1B",
4912 "maximum_observation_burn_cycles": "1B", "maximum_stalled_observations": 4,
4913 "maximum_update_burn_cycles": "1B", "operator": "operator", "protocol": null,
4914 "schema_version": 1, "treasury": "treasury",
4915 }))
4916 .unwrap();
4917 let platform = IcpEnsurePlatform::new(desired, executable.to_str().unwrap(), &root);
4918 let paths = crate::fleet_ensure::ops::EnsurePaths::under(&root, "local", "owners");
4919 let state = crate::fleet_ensure::ops::read_state(&paths, "owners").unwrap();
4920 let fixture = Self {
4921 root,
4922 platform,
4923 state,
4924 };
4925 for name in ["coordinator", "root", "store"] {
4926 fixture.status(name, "Running", true);
4927 }
4928 fixture
4929 }
4930
4931 fn status(&self, name: &str, status: &str, current_module: bool) {
4932 let module = if current_module {
4933 artifact_hash(&self.root.join("owner.wasm")).unwrap()
4934 } else {
4935 "ff".repeat(32)
4936 };
4937 std::fs::write(
4938 self.root.join(format!("{name}.json")),
4939 serde_json::json!({
4940 "id": name, "status": status, "settings": { "controllers": [] },
4941 "module_hash": module, "cycles": "1000000000000",
4942 })
4943 .to_string(),
4944 )
4945 .unwrap();
4946 }
4947 }
4948
4949 #[cfg(unix)]
4950 #[test]
4951 #[expect(
4952 clippy::too_many_lines,
4953 reason = "one measured transport fixture keeps equivalent retained inventories and call accounting together"
4954 )]
4955 fn configured_observation_benchmark_retains_snapshot_call_bound() {
4956 use std::{
4957 fs,
4958 os::unix::fs::PermissionsExt,
4959 sync::{Arc, Mutex},
4960 };
4961 for pool_count in [0_u32, 8, 24, 96] {
4962 let mut fixture = ProtocolOwnersFixture::new();
4963 let root_pid = Principal::from_slice(&[2]);
4964 for (index, canister) in fixture.platform.desired.canisters.iter_mut().enumerate() {
4965 let pid = Principal::from_slice(&[u8::try_from(index + 1).unwrap()]).to_text();
4966 let mut status: serde_json::Value = serde_json::from_slice(
4967 &fs::read(fixture.root.join(format!("{}.json", canister.name))).unwrap(),
4968 )
4969 .unwrap();
4970 status["id"] = serde_json::Value::String(pid.clone());
4971 fs::write(fixture.root.join(format!("{pid}.json")), status.to_string()).unwrap();
4972 canister.principal = Some(pid);
4973 }
4974 fixture.platform.desired.protocol =
4975 Some(crate::fleet_ensure::model::DesiredFleetProtocol {
4976 app_config: "unused.toml".to_string(),
4977 component_group_placements: Vec::new(),
4978 root_candid: "root.did".to_string(),
4979 coordinator_candid: "unused.did".to_string(),
4980 store_candid: "unused.did".to_string(),
4981 });
4982 fs::write(fixture.root.join("root.did"), "service : {}").unwrap();
4983 let mut entries = Vec::new();
4984 for index in 0..pool_count {
4985 let pid = Principal::from_slice(&[u8::try_from(index + 10).unwrap()]);
4986 let mut configured = fixture.platform.desired.canisters[0].clone();
4987 configured.name = format!("pool-{index:03}");
4988 configured.kind = DesiredCanisterKind::Pool;
4989 configured.principal = Some(pid.to_text());
4990 configured.parent = Some("root".to_string());
4991 fixture.platform.desired.canisters.push(configured);
4992 entries.push(CanisterPoolAsset {
4993 canister_id: pid,
4994 creation_receipt: None,
4995 cycles: Cycles::new(1_000_000_000_000),
4996 origin: CanisterPoolAssetOrigin::Imported,
4997 status: CanisterPoolAssetStatus::Ready,
4998 added_at_ns: 1,
4999 updated_at_ns: 1,
5000 });
5001 }
5002 let page = CanisterPoolResponse {
5003 config: canic_core::ids::FleetSubnetCanisterPoolConfig {
5004 minimum_size: 5,
5005 maximum_size: 128,
5006 canister_cycles: Cycles::new(1_000_000_000_000),
5007 creation_execution_margin: Cycles::new(1_000_000_000),
5008 },
5009 tracked: pool_count,
5010 store: 0,
5011 store_deletion_pending: 0,
5012 pooled: pool_count,
5013 workload: 0,
5014 surplus: 0,
5015 ready: pool_count,
5016 pending_reset: 0,
5017 claimed: 0,
5018 recycling: 0,
5019 handing_off: 0,
5020 failed: 0,
5021 completed_handoffs: 0,
5022 pending_creation: None,
5023 pending_handoff: None,
5024 entries,
5025 next_start_after: None,
5026 };
5027 let response = Ok::<_, canic_core::dto::error::Error>(RootPoolStatusResponse::Pool(
5028 Box::new(page),
5029 ));
5030 fs::write(fixture.root.join("pool.json"), serde_json::json!({"response_bytes": hex_bytes(candid::encode_one(response).unwrap())}).to_string()).unwrap();
5031 let executable = fixture.root.join("icp");
5032 fs::write(&executable, format!(
5033 "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.3.0'; exit 0; fi\nwhile [ \"$#\" -gt 0 ] && [ \"$1\" != canister ]; do shift; done\nshift\nsleep 0.05\ncase \"$1\" in\nstatus) shift; cat '{}'/\"$1\".json;;\ncall) cat '{}'/pool.json;;\n*) exit 1;;\nesac\n",
5034 fixture.root.display(), fixture.root.display())).unwrap();
5035 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
5036 let timings = Arc::new(Mutex::new(Vec::new()));
5037 let sink = Arc::clone(&timings);
5038 fixture.platform = fixture
5039 .platform
5040 .with_observation_handler(move |timing| sink.lock().unwrap().push(timing));
5041 let observed = fixture
5042 .platform
5043 .with_observation_snapshot(|platform| {
5044 platform
5045 .timed_observation(FleetObservationStage::ConfiguredCanisters, |platform| {
5046 platform.observe_configured_canisters(&fixture.state)
5047 })
5048 })
5049 .unwrap();
5050 assert_eq!(observed.len(), usize::try_from(pool_count).unwrap() + 3);
5051 assert!(observed.values().all(Option::is_some));
5052 assert_eq!(
5053 observed["root"].as_ref().unwrap().principal,
5054 root_pid.to_text()
5055 );
5056 let timing = timings.lock().unwrap().pop().unwrap();
5057 assert_eq!(timing.remote_call_attempts, 3 + u64::from(pool_count > 0));
5058 assert!(timing.succeeded);
5059 println!(
5060 "configured_observation pool_assets={pool_count} elapsed_ms={} calls={}",
5061 timing.elapsed_millis, timing.remote_call_attempts
5062 );
5063 fs::remove_dir_all(fixture.root).unwrap();
5064 }
5065 }
5066
5067 #[cfg(unix)]
5068 #[test]
5069 fn configured_observation_failure_drains_batch_and_keeps_first_error() {
5070 use std::{
5071 fs,
5072 sync::{Arc, Mutex},
5073 };
5074 let mut fixture = ProtocolOwnersFixture::new();
5075 let timings = Arc::new(Mutex::new(Vec::new()));
5076 let sink = Arc::clone(&timings);
5077 fixture.platform = fixture
5078 .platform
5079 .with_observation_handler(move |timing| sink.lock().unwrap().push(timing));
5080 let mut first: serde_json::Value =
5081 serde_json::from_slice(&fs::read(fixture.root.join("coordinator.json")).unwrap())
5082 .unwrap();
5083 first["id"] = serde_json::json!("different-principal");
5084 fs::write(fixture.root.join("coordinator.json"), first.to_string()).unwrap();
5085 fs::remove_file(fixture.root.join("root.json")).unwrap();
5086 let failure = fixture.platform.with_observation_snapshot(|platform| {
5087 platform.timed_observation(FleetObservationStage::ConfiguredCanisters, |platform| {
5088 platform.observe_configured_canisters(&fixture.state)
5089 })
5090 });
5091 assert!(
5092 matches!(failure, Err(IcpEnsurePlatformError::StatusIdentityMismatch { expected, .. }) if expected == "coordinator")
5093 );
5094 let timing = timings.lock().unwrap().pop().unwrap();
5095 assert!(!timing.succeeded);
5096 assert_eq!(timing.remote_call_attempts, 3);
5097 assert!(fixture.platform.observation_snapshot.borrow().is_none());
5098 fixture.status("coordinator", "Running", true);
5099 fixture.status("root", "Running", true);
5100 assert_eq!(
5101 fixture
5102 .platform
5103 .with_observation_snapshot(
5104 |platform| platform.observe_configured_canisters(&fixture.state)
5105 )
5106 .unwrap()
5107 .len(),
5108 3
5109 );
5110 assert_eq!(fixture.platform.icp.remote_call_count(), 6);
5111 fs::remove_dir_all(fixture.root).unwrap();
5112 }
5113
5114 #[cfg(unix)]
5115 #[test]
5116 fn protocol_owner_observations_read_each_owner_once_and_refresh_between_calls() {
5117 let fixture = ProtocolOwnersFixture::new();
5118 assert!(
5119 fixture
5120 .platform
5121 .current_protocol_owners_are_ready(&fixture.state)
5122 .unwrap()
5123 );
5124 assert_eq!(
5125 std::fs::read_to_string(fixture.root.join("calls")).unwrap(),
5126 "coordinator\nroot\nstore\n"
5127 );
5128 fixture.status("store", "Stopping", true);
5129 assert!(
5130 !fixture
5131 .platform
5132 .current_protocol_owners_are_ready(&fixture.state)
5133 .unwrap()
5134 );
5135 fixture.status("store", "Stopped", true);
5136 assert!(
5137 !fixture
5138 .platform
5139 .current_protocol_owners_are_ready(&fixture.state)
5140 .unwrap()
5141 );
5142 fixture.status("store", "Running", false);
5143 assert!(
5144 !fixture
5145 .platform
5146 .current_protocol_owners_are_ready(&fixture.state)
5147 .unwrap()
5148 );
5149 fixture.status("store", "Running", true);
5150 assert!(
5151 fixture
5152 .platform
5153 .current_protocol_owners_are_ready(&fixture.state)
5154 .unwrap()
5155 );
5156 assert_eq!(
5157 std::fs::read_to_string(fixture.root.join("calls"))
5158 .unwrap()
5159 .lines()
5160 .count(),
5161 15
5162 );
5163 std::fs::remove_dir_all(fixture.root).unwrap();
5164 }
5165
5166 #[cfg(unix)]
5167 #[test]
5168 fn protocol_owner_observations_preserve_missing_owner_and_transport_failures() {
5169 let mut fixture = ProtocolOwnersFixture::new();
5170 fixture.platform.desired.canisters[0].principal = None;
5171 assert!(
5172 !fixture
5173 .platform
5174 .current_protocol_owners_are_ready(&fixture.state)
5175 .unwrap()
5176 );
5177 std::fs::remove_file(fixture.root.join("store.json")).unwrap();
5178 assert!(matches!(
5179 fixture
5180 .platform
5181 .current_protocol_owners_are_ready(&fixture.state),
5182 Err(IcpEnsurePlatformError::Icp(_))
5183 ));
5184 fixture.platform.desired.canisters[0].principal = Some("coordinator".to_string());
5185 fixture.status("coordinator", "Running", false);
5186 assert!(matches!(
5187 fixture
5188 .platform
5189 .current_protocol_owners_are_ready(&fixture.state),
5190 Err(IcpEnsurePlatformError::Icp(_))
5191 ));
5192 std::fs::remove_dir_all(fixture.root).unwrap();
5193 }
5194
5195 #[cfg(unix)]
5196 #[test]
5197 #[expect(
5198 clippy::too_many_lines,
5199 reason = "one transport sequence proves snapshot reuse and expiry across success, failure and effects"
5200 )]
5201 fn observation_snapshot_expires_before_effects_and_after_failed_observation() {
5202 use std::{fs, os::unix::fs::PermissionsExt};
5203
5204 let root = crate::test_support::temp_dir("canic-observation-snapshot");
5205 fs::create_dir_all(&root).expect("create observation fixture");
5206 let executable = root.join("icp");
5207 let commands = root.join("commands.log");
5208 let response = root.join("status.json");
5209 let canister = "rrkah-fqaaa-aaaaa-aaaaq-cai";
5210 let operator = "rdmx6-jaaaa-aaaaa-aaadq-cai";
5211 let status = |cycles: u128| {
5212 serde_json::json!({
5213 "id": canister,
5214 "status": "Running",
5215 "settings": { "controllers": [operator] },
5216 "module_hash": null,
5217 "cycles": cycles.to_string(),
5218 })
5219 .to_string()
5220 };
5221 fs::write(&response, status(1_000)).expect("write initial live balance");
5222 fs::write(&executable, format!(
5223 "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.3.0'; exit 0; fi\nprintf '%s\\n' \"$*\" >> '{}'\ncat '{}'\n",
5224 commands.display(), response.display(),
5225 )).expect("write observation transport");
5226 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))
5227 .expect("make observation transport executable");
5228 let desired = DesiredFleet {
5229 bootstrap: None,
5230 canisters: Vec::new(),
5231 cycles_ledger: "um5iw-rqaaa-aaaaq-qaaba-cai".to_string(),
5232 environment: "local".to_string(),
5233 fleet: "observation-snapshot".to_string(),
5234 ledger_fee_cycles: "100M".to_string(),
5235 management_creation_fee_cycles: "500B".to_string(),
5236 material_cycle_threshold: "1B".to_string(),
5237 maximum_observation_burn_cycles: "1B".to_string(),
5238 maximum_stalled_observations: 4,
5239 maximum_update_burn_cycles: "1B".to_string(),
5240 operator: operator.to_string(),
5241 protocol: None,
5242 protocol_steps: Vec::new(),
5243 schema_version: 1,
5244 treasury: "treasury".to_string(),
5245 };
5246 let mut platform =
5247 IcpEnsurePlatform::new(desired, executable.to_str().expect("transport path"), &root);
5248 let failed: Result<(), IcpEnsurePlatformError> =
5249 platform.with_observation_snapshot(|platform| {
5250 assert_eq!(
5251 platform
5252 .status_optional(canister)?
5253 .expect("live canister")
5254 .cycles,
5255 1_000
5256 );
5257 fs::write(&response, status(900)).expect("advance simulated live balance");
5258 assert_eq!(
5259 platform
5260 .status_optional(canister)?
5261 .expect("same snapshot")
5262 .cycles,
5263 1_000
5264 );
5265 Err(IcpEnsurePlatformError::Arithmetic(
5266 "injected observation failure",
5267 ))
5268 });
5269 assert!(matches!(failed, Err(IcpEnsurePlatformError::Arithmetic(_))));
5270 assert_eq!(
5271 fs::read_to_string(&commands)
5272 .expect("first observation calls")
5273 .lines()
5274 .count(),
5275 1
5276 );
5277 assert_eq!(
5278 platform
5279 .status_optional(canister)
5280 .expect("fresh action read")
5281 .expect("live canister")
5282 .cycles,
5283 900
5284 );
5285 platform
5286 .with_observation_snapshot(|platform| {
5287 assert_eq!(
5288 platform
5289 .status_optional(canister)?
5290 .expect("next observation")
5291 .cycles,
5292 900
5293 );
5294 fs::write(&response, status(800)).expect("advance live balance again");
5295 assert_eq!(
5296 platform
5297 .status_optional(canister)?
5298 .expect("next snapshot")
5299 .cycles,
5300 900
5301 );
5302 Ok(())
5303 })
5304 .expect("next observation succeeds");
5305 assert_eq!(
5306 platform
5307 .status_optional(canister)
5308 .expect("post-observation action read")
5309 .expect("live canister")
5310 .cycles,
5311 800
5312 );
5313 assert_eq!(
5314 fs::read_to_string(&commands)
5315 .expect("all observation calls")
5316 .lines()
5317 .count(),
5318 4
5319 );
5320 fs::remove_dir_all(root).expect("remove observation fixture");
5321 }
5322
5323 #[test]
5324 fn protocol_observation_delay_uses_bounded_exponential_backoff() {
5325 let production_cap = MAXIMUM_PROTOCOL_OBSERVATION_DELAY;
5326 assert_eq!(
5327 protocol_observation_delay(0, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
5328 Duration::from_millis(250)
5329 );
5330 assert_eq!(
5331 protocol_observation_delay(1, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
5332 Duration::from_millis(250)
5333 );
5334 assert_eq!(
5335 protocol_observation_delay(2, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
5336 Duration::from_millis(500)
5337 );
5338 assert_eq!(
5339 protocol_observation_delay(5, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
5340 Duration::from_secs(4)
5341 );
5342 assert_eq!(
5343 protocol_observation_delay(6, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
5344 Duration::from_secs(5)
5345 );
5346 assert_eq!(
5347 protocol_observation_delay(
5348 u32::MAX,
5349 INITIAL_PROTOCOL_OBSERVATION_DELAY,
5350 production_cap
5351 ),
5352 Duration::from_secs(5)
5353 );
5354 assert_eq!(
5355 protocol_observation_delay(u32::MAX, Duration::from_secs(1), Duration::from_secs(1)),
5356 Duration::from_secs(1)
5357 );
5358 }
5359
5360 #[cfg(unix)]
5361 #[test]
5362 fn icp_1_3_public_non_controller_status_is_typed_unavailable_evidence() {
5363 use std::{fs, os::unix::fs::PermissionsExt};
5364
5365 let canister = "rrkah-fqaaa-aaaaa-aaaaq-cai";
5366 let root = crate::test_support::temp_dir("canic-public-status-projection");
5367 fs::create_dir_all(&root).expect("create public status fixture");
5368 let executable = root.join("icp");
5369 let commands = root.join("commands.log");
5370 let script = format!(
5371 "#!/bin/sh\n\
5372 if [ \"$1\" = \"--version\" ]; then echo 'icp 1.3.0'; exit 0; fi\n\
5373 printf '%s\\n' \"$*\" >> '{}'\n\
5374 printf '%s\\n' '{{\"id\":\"{canister}\",\"controllers\":[\"rdmx6-jaaaa-aaaaa-aaadq-cai\"],\"module_hash\":null}}'\n",
5375 commands.display(),
5376 );
5377 fs::write(&executable, script).expect("write fake ICP 1.3.0");
5378 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))
5379 .expect("make fake ICP executable");
5380 let desired = DesiredFleet {
5381 bootstrap: None,
5382 canisters: Vec::new(),
5383 cycles_ledger: "um5iw-rqaaa-aaaaq-qaaba-cai".to_string(),
5384 environment: "local".to_string(),
5385 fleet: "public-status".to_string(),
5386 ledger_fee_cycles: "100M".to_string(),
5387 management_creation_fee_cycles: "500B".to_string(),
5388 material_cycle_threshold: "1B".to_string(),
5389 maximum_observation_burn_cycles: "1B".to_string(),
5390 maximum_stalled_observations: 4,
5391 maximum_update_burn_cycles: "1B".to_string(),
5392 operator: "rdmx6-jaaaa-aaaaa-aaadq-cai".to_string(),
5393 protocol: None,
5394 protocol_steps: Vec::new(),
5395 schema_version: 1,
5396 treasury: "treasury".to_string(),
5397 };
5398 let platform =
5399 IcpEnsurePlatform::new(desired, executable.to_string_lossy().as_ref(), &root);
5400
5401 assert!(matches!(
5402 platform.status_optional(canister),
5403 Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable {
5404 canister: unavailable,
5405 }) if unavailable == canister
5406 ));
5407 assert!(
5408 fs::read_to_string(commands)
5409 .expect("read fake ICP commands")
5410 .contains("canister status rrkah-fqaaa-aaaaa-aaaaq-cai --json")
5411 );
5412 }
5413
5414 #[cfg(unix)]
5415 #[test]
5416 fn versionless_icp_status_uses_exact_typed_management_version() {
5417 #[derive(CandidType)]
5418 struct CanonicalManagementCanisterStatusFixture {
5419 version: u64,
5420 module_hash: Option<Vec<u8>>,
5421 }
5422
5423 use std::{fs, os::unix::fs::PermissionsExt};
5424
5425 let root = crate::test_support::temp_dir("canic-install-version-fallback");
5426 fs::create_dir_all(&root).expect("create version fallback fixture");
5427 let executable = root.join("icp");
5428 let commands = root.join("commands.log");
5429 let canister = "rrkah-fqaaa-aaaaa-aaaaq-cai";
5430 let status_json = format!(
5431 r#"{{"id":"{canister}","name":"coordinator","status":"Running","settings":{{"controllers":["rdmx6-jaaaa-aaaaa-aaadq-cai"]}},"module_hash":"0x{}","memory_size":"1","cycles":"1000000000000","query_stats":{{}}}}"#,
5432 "11".repeat(32),
5433 );
5434 let response_bytes = candid::encode_one(CanonicalManagementCanisterStatusFixture {
5435 version: 42,
5436 module_hash: Some(vec![0x22; 32]),
5437 })
5438 .expect("encode independently modelled management status");
5439 let script = format!(
5440 "#!/bin/sh\n\
5441 if [ \"$1\" = \"--version\" ]; then printf '%s\\n' 'icp 1.3.0'; exit 0; fi\n\
5442 printf '%s\\n' \"$*\" >> '{}'\n\
5443 case \"$*\" in\n\
5444 *\"canister status {canister}\"*) printf '%s\\n' '{}' ;;\n\
5445 *) printf '%s\\n' 'unexpected fake ICP command' >&2; exit 23 ;;\n\
5446 esac\n",
5447 commands.display(),
5448 status_json,
5449 );
5450 fs::write(&executable, script).expect("write fake ICP 1.3.0");
5451 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))
5452 .expect("make fake ICP executable");
5453 let icp = IcpCli::new(executable.to_string_lossy(), Some("ic".to_string()));
5454
5455 let projected = icp
5456 .canister_status_report(canister)
5457 .expect("read ICP 1.3.0 status projection");
5458 assert_eq!(projected.canister_version, None);
5459 let exact = exact_install_canister_status_with(
5460 canister,
5461 projected.canister_version,
5462 projected.module_hash.map(|hash| normalize_hash(&hash)),
5463 |effective_canister_id| {
5464 assert_eq!(
5465 effective_canister_id,
5466 canister.parse().expect("target Principal")
5467 );
5468 candid::decode_one(&response_bytes).map_err(IcpManagementCallError::CandidResponse)
5469 },
5470 )
5471 .expect("obtain exact typed management status");
5472 assert_eq!(exact.canister_version, 42);
5473 assert_eq!(exact.module_sha256, Some("22".repeat(32)));
5474 let commands = fs::read_to_string(&commands).expect("read fake ICP commands");
5475 assert!(commands.contains("canister status rrkah-fqaaa-aaaaa-aaaaq-cai"));
5476 assert!(!commands.contains("canister call aaaaa-aa canister_status"));
5477
5478 fs::write(
5479 &executable,
5480 "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'icp 1.3.0'; exit 0; fi\necho '{\"not_response_bytes\":true}'\n",
5481 )
5482 .expect("replace unavailable management fixture");
5483 let error = exact_install_canister_status_with(canister, None, None, |_| {
5484 Err(IcpManagementCallError::MissingEnvironment)
5485 })
5486 .expect_err("missing projected and typed version must fail closed");
5487 assert!(matches!(
5488 error,
5489 IcpEnsurePlatformError::InstallVersionProofUnavailable {
5490 canister: observed_canister,
5491 source,
5492 } if observed_canister == canister
5493 && matches!(source.as_ref(), IcpManagementCallError::MissingEnvironment)
5494 ));
5495
5496 fs::remove_dir_all(root).expect("remove version fallback fixture");
5497 }
5498
5499 #[test]
5500 fn root_owned_observation_classifies_bootstrap_and_workload_lifecycle() {
5501 assert_eq!(
5502 root_owned_lifecycle(DesiredCanisterKind::Store, &CanisterPoolAssetStatus::Store),
5503 Some(RootOwnedCanisterLifecycle::Store)
5504 );
5505 assert_eq!(
5506 root_owned_lifecycle(DesiredCanisterKind::Pool, &CanisterPoolAssetStatus::Ready),
5507 Some(RootOwnedCanisterLifecycle::Idle)
5508 );
5509 assert_eq!(
5510 root_owned_lifecycle(
5511 DesiredCanisterKind::Pool,
5512 &CanisterPoolAssetStatus::Claimed {
5513 claim: canic_core::dto::pool::CanisterPoolClaim {
5514 component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
5515 [1; 32]
5516 ),
5517 operation_id: [2; 32],
5518 },
5519 }
5520 ),
5521 Some(RootOwnedCanisterLifecycle::Claimed)
5522 );
5523 assert_eq!(
5524 root_owned_lifecycle(
5525 DesiredCanisterKind::Pool,
5526 &CanisterPoolAssetStatus::Workload {
5527 claim: canic_core::dto::pool::CanisterPoolClaim {
5528 component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
5529 [1; 32]
5530 ),
5531 operation_id: [2; 32],
5532 },
5533 }
5534 ),
5535 Some(RootOwnedCanisterLifecycle::Workload)
5536 );
5537 assert_eq!(
5538 root_owned_lifecycle(
5539 DesiredCanisterKind::Pool,
5540 &CanisterPoolAssetStatus::HandingOff {
5541 recipient: Principal::anonymous(),
5542 }
5543 ),
5544 None
5545 );
5546 assert_eq!(
5547 root_owned_lifecycle(
5548 DesiredCanisterKind::Store,
5549 &CanisterPoolAssetStatus::PendingReset
5550 ),
5551 None
5552 );
5553 }
5554
5555 #[test]
5556 fn estate_pool_inventory_reconciles_every_lifecycle_and_declared_total() {
5557 let claim = canic_core::dto::pool::CanisterPoolClaim {
5558 component: canic_core::ids::ComponentInstanceId::from_generated_bytes([1; 32]),
5559 operation_id: [2; 32],
5560 };
5561 let statuses = [
5562 CanisterPoolAssetStatus::Store,
5563 CanisterPoolAssetStatus::StoreDeletionPending {
5564 operation_id: [3; 32],
5565 },
5566 CanisterPoolAssetStatus::PendingReset,
5567 CanisterPoolAssetStatus::Ready,
5568 CanisterPoolAssetStatus::Claimed {
5569 claim: claim.clone(),
5570 },
5571 CanisterPoolAssetStatus::Workload {
5572 claim: claim.clone(),
5573 },
5574 CanisterPoolAssetStatus::Recycling {
5575 claim,
5576 reset: canic_core::dto::pool::CanisterPoolRecycleReset::Pending,
5577 },
5578 CanisterPoolAssetStatus::HandingOff {
5579 recipient: Principal::anonymous(),
5580 },
5581 CanisterPoolAssetStatus::Failed {
5582 reason: "retained failure".to_string(),
5583 },
5584 ];
5585 let mut observed = EstatePoolLifecycleCounts::default();
5586 for status in &statuses {
5587 observed.observe(status).expect("bounded lifecycle count");
5588 }
5589 let authority = EstatePoolPageAuthority {
5590 completed_handoffs: 0,
5591 counts: observed,
5592 pending_handoff: None,
5593 pooled: 4,
5594 surplus: 1,
5595 };
5596
5597 assert!(authority.matches_complete_inventory(observed, 3));
5598 let mut incomplete = observed;
5599 incomplete.failed -= 1;
5600 incomplete.tracked -= 1;
5601 assert!(!authority.matches_complete_inventory(incomplete, 3));
5602
5603 assert_eq!(estate_pool_lifecycle(&statuses[0]), None);
5604 assert_eq!(estate_pool_lifecycle(&statuses[1]), None);
5605 assert_eq!(
5606 statuses[2..]
5607 .iter()
5608 .map(estate_pool_lifecycle)
5609 .collect::<Vec<_>>(),
5610 vec![
5611 Some(EstatePoolAssetLifecycle::PendingReset),
5612 Some(EstatePoolAssetLifecycle::Ready),
5613 Some(EstatePoolAssetLifecycle::Claimed),
5614 Some(EstatePoolAssetLifecycle::Workload),
5615 Some(EstatePoolAssetLifecycle::Recycling),
5616 Some(EstatePoolAssetLifecycle::HandingOff),
5617 Some(EstatePoolAssetLifecycle::Failed),
5618 ]
5619 );
5620 }
5621
5622 #[test]
5623 fn estate_funding_requires_exact_source_debit_and_destination_credit() {
5624 let exact = EstateFundingObservation {
5625 amount: 60,
5626 destination_after: 100,
5627 destination_before: Some(40),
5628 expected_destination_after: 100,
5629 ledger_fee_cycles: 5,
5630 source_after: 135,
5631 source_before: Some(200),
5632 };
5633 assert!(estate_funding_applied(exact));
5634
5635 for drifted in [
5636 EstateFundingObservation {
5637 source_after: 136,
5638 ..exact
5639 },
5640 EstateFundingObservation {
5641 destination_after: 99,
5642 ..exact
5643 },
5644 EstateFundingObservation {
5645 source_before: None,
5646 ..exact
5647 },
5648 EstateFundingObservation {
5649 destination_before: None,
5650 ..exact
5651 },
5652 ] {
5653 assert!(!estate_funding_applied(drifted));
5654 }
5655 }
5656
5657 #[test]
5658 #[expect(
5659 clippy::too_many_lines,
5660 reason = "the binding regression mutates each authority field independently"
5661 )]
5662 fn retained_root_owned_topology_requires_exact_child_root_and_parent_binding() {
5663 let mut state = FleetEnsureStateRecord {
5664 active_registry: None,
5665 completed_reinstall_action_sha256: BTreeMap::new(),
5666 completed_reinstall_operation_id: None,
5667 completed_reinstalls: BTreeMap::new(),
5668 fleet: "fleet".to_string(),
5669 pending_principals: BTreeMap::new(),
5670 principals: BTreeMap::from([
5671 ("root".to_string(), "root-principal".to_string()),
5672 ("store".to_string(), "store-principal".to_string()),
5673 ]),
5674 retained_cycles_by_principal: BTreeMap::new(),
5675 schema_version: crate::fleet_ensure::model::FLEET_ENSURE_SCHEMA_VERSION,
5676 topology: BTreeMap::from([(
5677 "store".to_string(),
5678 crate::fleet_ensure::model::FleetEnsureTopologyRecord {
5679 kind: DesiredCanisterKind::Store,
5680 module_hash: Some("11".repeat(32)),
5681 parent: Some("root".to_string()),
5682 protocol_binding: None,
5683 role: None,
5684 },
5685 )]),
5686 };
5687
5688 assert!(
5689 exact_retained_root_owned_topology(
5690 &state,
5691 "store",
5692 DesiredCanisterKind::Store,
5693 "root",
5694 "store-principal",
5695 "root-principal",
5696 )
5697 .is_some()
5698 );
5699 assert!(
5700 exact_retained_root_owned_topology(
5701 &state,
5702 "store",
5703 DesiredCanisterKind::Store,
5704 "root",
5705 "foreign-store",
5706 "root-principal",
5707 )
5708 .is_none()
5709 );
5710 state
5711 .principals
5712 .insert("store".to_string(), "store-principal".to_string());
5713 state
5714 .principals
5715 .insert("root".to_string(), "foreign-root".to_string());
5716 assert!(
5717 exact_retained_root_owned_topology(
5718 &state,
5719 "store",
5720 DesiredCanisterKind::Store,
5721 "root",
5722 "store-principal",
5723 "root-principal",
5724 )
5725 .is_none()
5726 );
5727 state
5728 .principals
5729 .insert("root".to_string(), "root-principal".to_string());
5730 state
5731 .topology
5732 .get_mut("store")
5733 .expect("Store topology")
5734 .kind = DesiredCanisterKind::Pool;
5735 assert!(
5736 exact_retained_root_owned_topology(
5737 &state,
5738 "store",
5739 DesiredCanisterKind::Store,
5740 "root",
5741 "store-principal",
5742 "root-principal",
5743 )
5744 .is_none()
5745 );
5746 state
5747 .topology
5748 .get_mut("store")
5749 .expect("Store topology")
5750 .kind = DesiredCanisterKind::Store;
5751 state
5752 .topology
5753 .get_mut("store")
5754 .expect("Store topology")
5755 .parent = Some("foreign-root".to_string());
5756 assert!(
5757 exact_retained_root_owned_topology(
5758 &state,
5759 "store",
5760 DesiredCanisterKind::Store,
5761 "root",
5762 "store-principal",
5763 "root-principal",
5764 )
5765 .is_none()
5766 );
5767 state
5768 .topology
5769 .get_mut("store")
5770 .expect("Store topology")
5771 .parent = Some("root".to_string());
5772 state.principals.clear();
5773 state.pending_principals = BTreeMap::from([
5774 ("root".to_string(), "root-principal".to_string()),
5775 ("store".to_string(), "store-principal".to_string()),
5776 ]);
5777 assert!(
5778 exact_retained_root_owned_topology(
5779 &state,
5780 "store",
5781 DesiredCanisterKind::Store,
5782 "root",
5783 "store-principal",
5784 "root-principal",
5785 )
5786 .is_some()
5787 );
5788 state
5789 .principals
5790 .insert("store".to_string(), "foreign-store".to_string());
5791 assert!(
5792 exact_retained_root_owned_topology(
5793 &state,
5794 "store",
5795 DesiredCanisterKind::Store,
5796 "root",
5797 "store-principal",
5798 "root-principal",
5799 )
5800 .is_none()
5801 );
5802 }
5803
5804 #[test]
5805 fn completed_reinstall_requires_exact_continuity_when_ordinary_status_has_no_version() {
5806 let desired = "11".repeat(32);
5807 assert_eq!(
5808 completed_reinstall_continuity(7, true, true, Some(&desired), &desired, None,),
5809 Ok(())
5810 );
5811 assert_eq!(
5812 completed_reinstall_continuity(7, true, true, Some(&desired), &desired, Some(8),),
5813 Ok(())
5814 );
5815 for (rejected, field) in [
5816 (
5817 completed_reinstall_continuity(7, false, true, Some(&desired), &desired, None),
5818 "Principal",
5819 ),
5820 (
5821 completed_reinstall_continuity(7, true, false, Some(&desired), &desired, None),
5822 "Root/parent/kind topology",
5823 ),
5824 (
5825 completed_reinstall_continuity(
5826 7,
5827 true,
5828 true,
5829 Some(&"22".repeat(32)),
5830 &desired,
5831 None,
5832 ),
5833 "module SHA-256",
5834 ),
5835 (
5836 completed_reinstall_continuity(7, true, true, Some(&desired), &desired, Some(7)),
5837 "canister version",
5838 ),
5839 ] {
5840 assert_eq!(rejected, Err(field));
5841 }
5842 }
5843
5844 #[test]
5845 fn fresh_fleet_create_responses_retain_the_exact_requested_balance() {
5846 let canister = Principal::from_slice(&[9; 29]);
5847 let canister_text = canister.to_text();
5848 for (response, receipt) in [
5849 (
5850 Ok(CreateCanisterSuccess {
5851 block_id: Nat::from(41_u8),
5852 canister_id: canister,
5853 }),
5854 "41",
5855 ),
5856 (
5857 Err(CreateCanisterError::Duplicate {
5858 duplicate_of: Nat::from(41_u8),
5859 canister_id: Some(canister),
5860 }),
5861 "41",
5862 ),
5863 ] {
5864 let outcome = create_canister_response_outcome(response, 5_000)
5865 .expect("successful or duplicate-with-Principal Create response");
5866 assert_eq!(
5867 outcome.created_principal.as_deref(),
5868 Some(canister_text.as_str())
5869 );
5870 assert_eq!(outcome.post_cycles, Some(5_000));
5871 assert_eq!(outcome.receipt.as_deref(), Some(receipt));
5872 }
5873 }
5874
5875 #[test]
5876 fn root_funding_inspection_requires_exact_controller_and_pool_shape() {
5877 let root = Principal::from_slice(&[7; 29]);
5878 let root_text = root.to_text();
5879 let cycles = Nat::from(2_898_749_313_788_u128);
5880 assert_eq!(
5881 validate_root_controlled_inspection(
5882 "pool-0",
5883 InspectedModule::Empty,
5884 &root_text,
5885 &[root],
5886 None,
5887 &cycles,
5888 )
5889 .expect("exact Root-authorized pool inspection"),
5890 2_898_749_313_788,
5891 );
5892
5893 assert_eq!(
5894 validate_root_controlled_inspection(
5895 "pending-reset",
5896 InspectedModule::Any,
5897 &root_text,
5898 &[root],
5899 Some(&[1]),
5900 &cycles,
5901 )
5902 .expect("installed controlled asset remains part of conservation"),
5903 2_898_749_313_788,
5904 );
5905 let foreign = Principal::from_slice(&[8; 29]);
5906 for rejected in [
5907 validate_root_controlled_inspection(
5908 "pending-reset",
5909 InspectedModule::Any,
5910 &root_text,
5911 &[foreign],
5912 Some(&[1]),
5913 &cycles,
5914 ),
5915 validate_root_controlled_inspection(
5916 "pool-0",
5917 InspectedModule::Empty,
5918 &root_text,
5919 &[foreign],
5920 None,
5921 &cycles,
5922 ),
5923 validate_root_controlled_inspection(
5924 "pool-0",
5925 InspectedModule::Empty,
5926 &root_text,
5927 &[root],
5928 Some(&[1]),
5929 &cycles,
5930 ),
5931 ] {
5932 assert!(matches!(
5933 rejected,
5934 Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { .. })
5935 ));
5936 }
5937 }
5938
5939 #[test]
5940 fn pending_fresh_pool_observation_accepts_only_its_reviewed_controller_transition() {
5941 let root = Principal::from_slice(&[7; 29]);
5942 let operator = Principal::from_slice(&[8; 29]);
5943 let foreign = Principal::from_slice(&[9; 29]);
5944 for controllers in [vec![root, operator], vec![root]] {
5945 assert_eq!(
5946 validate_pending_fresh_pool_inspection(
5947 "pool",
5948 &root.to_text(),
5949 &operator.to_text(),
5950 &controllers,
5951 None,
5952 &Nat::from(17_u8),
5953 )
5954 .expect("issued fresh controller transition"),
5955 17,
5956 );
5957 }
5958 for controllers in [
5959 vec![root, foreign],
5960 vec![operator],
5961 vec![root, operator, foreign],
5962 vec![root, root],
5963 ] {
5964 assert!(matches!(
5965 validate_pending_fresh_pool_inspection(
5966 "pool",
5967 &root.to_text(),
5968 &operator.to_text(),
5969 &controllers,
5970 None,
5971 &Nat::from(17_u8),
5972 ),
5973 Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { .. })
5974 ));
5975 }
5976 }
5977}