1use crate::icp::cycles_ledger::{
8 CanisterSettings, CmcCreateCanisterArgs, CreateCanisterArgs, CreateCanisterError,
9 CreateCanisterSuccess, SubnetSelection,
10};
11use crate::{
12 canister_protocol::{CanisterProtocolError, call_with_candid, query_with_candid},
13 fleet_ensure::{
14 dto::{FleetEnsureProgress, FleetObservationStage, FleetObservationTiming},
15 model::{
16 CanisterRuntimeStatus, DesiredCanisterKind, DesiredFleet, EffectRecord, EnsureAction,
17 EstateFundingDomainObservation, EstatePoolAssetLifecycle, EstatePoolAssetObservation,
18 EstatePoolAssetOrigin, EstatePoolCreationDiagnostic,
19 EstatePoolCreationReceiptObservation, EstatePoolInventoryObservation,
20 EstatePoolPendingCreationObservation, FleetEnsureStateRecord, FleetObservation,
21 InstallMode, LiveCanister, MAX_FLEET_ENSURE_CANISTERS, RetirementTransferBalances,
22 RetirementTransferInvariantError, RetirementTransferReconciliation,
23 RootManagementCanisterObservation, RootManagementObservation,
24 RootOwnedCanisterLifecycle, create_balance_is_terminal, reconcile_retirement_transfer,
25 },
26 ops::{
27 EffectObservation, EffectOutcome, EffectRetry, EnsurePlatform, EnsureStateError,
28 TerminalFleetInventory, canic_init, current_protocol, protocol, root_owned_lifecycle,
29 },
30 },
31 icp::{
32 IcpCandidCallError, IcpCanisterStatusReport, IcpCli, IcpCommandError, IcpDiagnostic,
33 IcpManagementCallError, LocalReplicaTarget, run_status,
34 },
35 icp_config::resolve_icp_build_network_from_root,
36 subnet_catalog::load_cached_mainnet_subnet_catalog,
37};
38use candid::{CandidType, Nat, Principal};
39use canic_core::{
40 cdk::{types::Cycles, utils::hash::hex_bytes},
41 dto::canister::CanisterInspectionRequest,
42 dto::pool::{
43 CanisterPoolAsset, CanisterPoolAssetOrigin, CanisterPoolAssetStatus,
44 CanisterPoolCreationFailure, CanisterPoolCreationProgress, CanisterPoolHandoff,
45 CanisterPoolResponse, CanisterPoolStatusRequest,
46 },
47 ids::BuildNetwork,
48 protocol as canic_protocol,
49};
50use serde::Deserialize;
51use std::{
52 cell::RefCell,
53 collections::{BTreeMap, BTreeSet},
54 path::{Path, PathBuf},
55 thread,
56 time::{Duration, SystemTime, UNIX_EPOCH},
57};
58use thiserror::Error as ThisError;
59
60#[derive(CandidType)]
61struct ManagementCanisterStatusRequest {
62 canister_id: Principal,
63}
64
65#[derive(CandidType)]
66struct CyclesLedgerAccount {
67 owner: Principal,
68 subaccount: Option<[u8; 32]>,
69}
70
71#[derive(CandidType)]
72struct CyclesLedgerTransferArgs {
73 amount: Nat,
74 created_at_time: Option<u64>,
75 fee: Option<Nat>,
76 from_subaccount: Option<[u8; 32]>,
77 memo: Option<Vec<u8>>,
78 to: CyclesLedgerAccount,
79}
80
81#[derive(CandidType, Deserialize)]
82enum CyclesLedgerTransferError {
83 BadBurn { min_burn_amount: Nat },
84 BadFee { expected_fee: Nat },
85 CreatedInFuture { ledger_time: u64 },
86 Duplicate { duplicate_of: Nat },
87 GenericError { error_code: Nat, message: String },
88 InsufficientFunds { balance: Nat },
89 TemporarilyUnavailable,
90 TooOld,
91}
92
93#[derive(CandidType, Deserialize)]
94struct ManagementCanisterStatusResponse {
95 version: u64,
96 module_hash: Option<Vec<u8>>,
97}
98
99#[derive(CandidType, Clone, Deserialize)]
100struct ManagementCanisterObservationSettings {
101 controllers: Vec<Principal>,
102}
103
104#[derive(CandidType, Deserialize)]
105enum ManagementCanisterRuntimeStatus {
106 #[serde(rename = "running")]
107 Running,
108 #[serde(rename = "stopping")]
109 Stopping,
110 #[serde(rename = "stopped")]
111 Stopped,
112}
113
114#[derive(CandidType, Deserialize)]
115struct ManagementCanisterObservationResponse {
116 status: ManagementCanisterRuntimeStatus,
117 settings: ManagementCanisterObservationSettings,
118 module_hash: Option<Vec<u8>>,
119 cycles: Nat,
120 version: u64,
121}
122
123#[derive(Debug, Eq, PartialEq)]
124struct ExactInstallCanisterStatus {
125 canister_version: u64,
126 module_sha256: Option<String>,
127}
128
129fn is_unallocated_fresh_root(
130 desired: &DesiredFleet,
131 configured: &crate::fleet_ensure::model::DesiredCanister,
132 reviewed_targets: &BTreeSet<String>,
133) -> bool {
134 reviewed_targets.is_empty()
135 && configured.principal.is_none()
136 && desired.bootstrap.as_ref().is_some_and(|bootstrap| {
137 bootstrap.fresh_estate
138 && bootstrap
139 .roots
140 .iter()
141 .any(|root| root.root == configured.name)
142 })
143}
144
145#[derive(CandidType)]
148struct DrainRequest {
149 operation_id: String,
150 destination: Principal,
151 cycles: Nat,
152}
153
154#[derive(CandidType, Deserialize)]
155enum DrainResponse {
156 Accepted { transferred_cycles: Nat },
157 Replayed { transferred_cycles: Nat },
158}
159
160#[derive(CandidType)]
161enum RootPoolStatusRequest {
162 Pool(CanisterPoolStatusRequest),
163}
164
165#[derive(CandidType, Deserialize)]
166enum RootPoolStatusResponse {
167 Pool(Box<CanisterPoolResponse>),
168}
169
170#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
171struct EstatePoolLifecycleCounts {
172 claimed: u32,
173 failed: u32,
174 handing_off: u32,
175 pending_reset: u32,
176 ready: u32,
177 recycling: u32,
178 store: u32,
179 store_deletion_pending: u32,
180 tracked: u32,
181 workload: u32,
182}
183
184impl EstatePoolLifecycleCounts {
185 const fn declared(response: &CanisterPoolResponse) -> Self {
186 Self {
187 claimed: response.claimed,
188 failed: response.failed,
189 handing_off: response.handing_off,
190 pending_reset: response.pending_reset,
191 ready: response.ready,
192 recycling: response.recycling,
193 store: response.store,
194 store_deletion_pending: response.store_deletion_pending,
195 tracked: response.tracked,
196 workload: response.workload,
197 }
198 }
199
200 fn observe(&mut self, status: &CanisterPoolAssetStatus) -> Option<()> {
201 self.tracked = self.tracked.checked_add(1)?;
202 let count = match status {
203 CanisterPoolAssetStatus::Store => &mut self.store,
204 CanisterPoolAssetStatus::StoreDeletionPending { .. } => {
205 &mut self.store_deletion_pending
206 }
207 CanisterPoolAssetStatus::PendingReset => &mut self.pending_reset,
208 CanisterPoolAssetStatus::Ready => &mut self.ready,
209 CanisterPoolAssetStatus::Claimed { .. } => &mut self.claimed,
210 CanisterPoolAssetStatus::Workload { .. } => &mut self.workload,
211 CanisterPoolAssetStatus::Recycling { .. } => &mut self.recycling,
212 CanisterPoolAssetStatus::HandingOff { .. } => &mut self.handing_off,
213 CanisterPoolAssetStatus::Failed { .. } => &mut self.failed,
214 };
215 *count = count.checked_add(1)?;
216 Some(())
217 }
218
219 fn pooled(self) -> Option<u32> {
220 self.pending_reset
221 .checked_add(self.ready)?
222 .checked_add(self.handing_off)?
223 .checked_add(self.failed)
224 }
225}
226
227#[derive(Clone, Debug, Eq, PartialEq)]
228struct EstatePoolPageAuthority {
229 completed_handoffs: u64,
230 counts: EstatePoolLifecycleCounts,
231 pending_handoff: Option<CanisterPoolHandoff>,
232 pooled: u32,
233 surplus: u32,
234}
235
236impl EstatePoolPageAuthority {
237 fn from_response(response: &CanisterPoolResponse) -> Self {
238 Self {
239 completed_handoffs: response.completed_handoffs,
240 counts: EstatePoolLifecycleCounts::declared(response),
241 pending_handoff: response.pending_handoff.clone(),
242 pooled: response.pooled,
243 surplus: response.surplus,
244 }
245 }
246
247 fn matches_complete_inventory(
248 &self,
249 observed: EstatePoolLifecycleCounts,
250 maximum_size: u32,
251 ) -> bool {
252 let Some(pooled) = observed.pooled() else {
253 return false;
254 };
255 self.counts == observed
256 && self.pooled == pooled
257 && self.surplus == pooled.saturating_sub(maximum_size)
258 }
259}
260
261#[derive(Default)]
262struct EstatePoolInventoryAccumulator {
263 assets: Vec<EstatePoolAssetObservation>,
264 expected_config: Option<canic_core::ids::FleetSubnetCanisterPoolConfig>,
265 expected_page_authority: Option<EstatePoolPageAuthority>,
266 expected_pending: Option<canic_core::dto::pool::CanisterPoolCreation>,
267 observed_counts: EstatePoolLifecycleCounts,
268 seen: BTreeSet<Principal>,
269}
270
271impl EstatePoolInventoryAccumulator {
272 fn observe_page(
273 &mut self,
274 root_name: &str,
275 page: CanisterPoolResponse,
276 ) -> Result<Option<Principal>, IcpEnsurePlatformError> {
277 let page_authority = EstatePoolPageAuthority::from_response(&page);
278 let first_page = self.expected_config.is_none();
279 if !first_page
280 && (self.expected_config.as_ref() != Some(&page.config)
281 || self.expected_pending != page.pending_creation
282 || self.expected_page_authority.as_ref() != Some(&page_authority))
283 {
284 return Err(pool_configuration_error(format!(
285 "Root {root_name} pool authority changed during pagination"
286 )));
287 }
288 if first_page {
289 self.expected_config = Some(page.config.clone());
290 self.expected_pending.clone_from(&page.pending_creation);
291 self.expected_page_authority = Some(page_authority);
292 }
293
294 for asset in page.entries {
295 if !self.seen.insert(asset.canister_id) {
296 return Err(pool_configuration_error(format!(
297 "Root {root_name} pool repeats canister {}",
298 asset.canister_id
299 )));
300 }
301 if self.seen.len() > MAX_FLEET_ENSURE_CANISTERS
302 || self.observed_counts.observe(&asset.status).is_none()
303 {
304 return Err(pool_configuration_error(format!(
305 "Root {root_name} pool exceeds the Fleet observation bound"
306 )));
307 }
308 if let Some(lifecycle) = estate_pool_lifecycle(&asset.status) {
309 self.assets.push(EstatePoolAssetObservation {
310 creation_receipt: asset.creation_receipt.map(|receipt| {
311 EstatePoolCreationReceiptObservation {
312 block_index: receipt.block_index,
313 operation_id: hex_bytes(receipt.operation_id),
314 cycles_ledger: receipt.cycles_ledger.to_text(),
315 ledger_amount_cycles: receipt.ledger_amount.to_u128(),
316 ledger_fee_cycles: receipt.ledger_fee.to_u128(),
317 readiness_floor_cycles: receipt.readiness_floor.to_u128(),
318 creation_execution_margin_cycles: receipt
319 .creation_execution_margin
320 .to_u128(),
321 management_creation_fee_cycles: receipt
322 .management_creation_fee
323 .to_u128(),
324 first_observed_cycles: receipt
325 .first_observed_cycles
326 .map(|cycles| cycles.to_u128()),
327 }
328 }),
329 cycles: asset.cycles.to_u128(),
330 lifecycle,
331 origin: match asset.origin {
332 CanisterPoolAssetOrigin::InfrastructureStore => {
333 EstatePoolAssetOrigin::InfrastructureStore
334 }
335 CanisterPoolAssetOrigin::Created => EstatePoolAssetOrigin::Created,
336 CanisterPoolAssetOrigin::Imported => EstatePoolAssetOrigin::Imported,
337 CanisterPoolAssetOrigin::Recycled => EstatePoolAssetOrigin::Recycled,
338 },
339 principal: asset.canister_id.to_text(),
340 });
341 }
342 }
343 Ok(page.next_start_after)
344 }
345
346 fn finish(
347 self,
348 root_name: &str,
349 ) -> Result<EstatePoolInventoryObservation, IcpEnsurePlatformError> {
350 let config = self.expected_config.ok_or_else(|| {
351 pool_configuration_error(format!("Root {root_name} returned no pool authority"))
352 })?;
353 if !self.expected_page_authority.is_some_and(|authority| {
354 authority.matches_complete_inventory(self.observed_counts, config.maximum_size)
355 }) {
356 return Err(pool_configuration_error(format!(
357 "Root {root_name} pool lifecycle totals differ from its complete paged inventory"
358 )));
359 }
360 Ok(EstatePoolInventoryObservation {
361 assets: self.assets,
362 maximum_size: config.maximum_size,
363 minimum_size: config.minimum_size,
364 pending_creation: self.expected_pending.map(estate_pool_pending_creation),
365 readiness_floor_cycles: config.canister_cycles.to_u128(),
366 creation_execution_margin_cycles: config.creation_execution_margin.to_u128(),
367 })
368 }
369}
370
371fn pool_configuration_error(reason: String) -> IcpEnsurePlatformError {
372 current_protocol::CurrentProtocolError::Configuration(reason).into()
373}
374
375fn inactive_source_pool_has_full_inventory(
377 pool: &CanisterPoolResponse,
378 root: &crate::fleet_ensure::model::RootActivationResetRecord,
379) -> bool {
380 [
381 pool.config.maximum_size > 0,
382 pool.ready.checked_add(pool.workload) == Some(pool.config.maximum_size),
383 pool.config.maximum_size.checked_add(1) == Some(pool.tracked),
384 pool.ready >= pool.config.minimum_size,
385 root.component_count.checked_add(root.managed_descendants) == Some(pool.workload),
386 pool.store == 1,
387 pool.store_deletion_pending == 0,
388 pool.pending_reset == 0,
389 pool.claimed == 0,
390 pool.recycling == 0,
391 pool.handing_off == 0,
392 pool.failed == 0,
393 pool.pending_creation.is_none(),
394 pool.pending_handoff.is_none(),
395 ]
396 .into_iter()
397 .all(|fact| fact)
398}
399
400const fn pool_policy_is_current(
401 observed: &EstatePoolInventoryObservation,
402 desired: &canic_core::ids::FleetSubnetCanisterPoolConfig,
403) -> bool {
404 observed.maximum_size == desired.maximum_size
405 && observed.minimum_size == desired.minimum_size
406 && observed.readiness_floor_cycles == desired.canister_cycles.to_u128()
407 && observed.creation_execution_margin_cycles == desired.creation_execution_margin.to_u128()
408}
409
410fn estate_pool_pending_creation(
411 creation: canic_core::dto::pool::CanisterPoolCreation,
412) -> EstatePoolPendingCreationObservation {
413 let (
414 available_cycles,
415 created_principal,
416 diagnostic,
417 required_cycles,
418 retry_at_ns,
419 shortfall_cycles,
420 uncertain_result,
421 ) = match creation.progress {
422 CanisterPoolCreationProgress::Intent { uncertain_result } => {
423 (None, None, None, None, None, None, uncertain_result)
424 }
425 CanisterPoolCreationProgress::Created { canister_id, .. } => (
426 None,
427 Some(canister_id.to_text()),
428 None,
429 None,
430 None,
431 None,
432 false,
433 ),
434 CanisterPoolCreationProgress::WaitingForFunding {
435 available,
436 required,
437 retry_at_ns,
438 shortfall,
439 ..
440 } => (
441 Some(available.to_u128()),
442 None,
443 Some(EstatePoolCreationDiagnostic::FundingRequired),
444 Some(required.to_u128()),
445 Some(retry_at_ns),
446 Some(shortfall.to_u128()),
447 false,
448 ),
449 CanisterPoolCreationProgress::Blocked { failure } => (
450 None,
451 None,
452 Some(match failure {
453 CanisterPoolCreationFailure::UnresolvedAfterLedgerWindow => {
454 EstatePoolCreationDiagnostic::UnresolvedAfterLedgerWindow
455 }
456 CanisterPoolCreationFailure::LedgerCreationFailed => {
457 EstatePoolCreationDiagnostic::LedgerCreationFailed
458 }
459 CanisterPoolCreationFailure::LedgerRejected => {
460 EstatePoolCreationDiagnostic::LedgerRejected
461 }
462 }),
463 None,
464 None,
465 None,
466 false,
467 ),
468 };
469 EstatePoolPendingCreationObservation {
470 attempt_count: creation.attempt_count,
471 available_cycles,
472 creation_amount_cycles: creation.ledger_amount.to_u128(),
473 created_principal,
474 diagnostic,
475 last_attempt_at_ns: creation.last_attempt_at_ns,
476 operation_id: hex_bytes(creation.operation_id),
477 required_cycles,
478 retry_at_ns,
479 shortfall_cycles,
480 uncertain_result,
481 }
482}
483
484const fn estate_pool_lifecycle(
485 status: &CanisterPoolAssetStatus,
486) -> Option<EstatePoolAssetLifecycle> {
487 match status {
488 CanisterPoolAssetStatus::Store | CanisterPoolAssetStatus::StoreDeletionPending { .. } => {
489 None
490 }
491 CanisterPoolAssetStatus::PendingReset => Some(EstatePoolAssetLifecycle::PendingReset),
492 CanisterPoolAssetStatus::Ready => Some(EstatePoolAssetLifecycle::Ready),
493 CanisterPoolAssetStatus::Claimed { .. } => Some(EstatePoolAssetLifecycle::Claimed),
494 CanisterPoolAssetStatus::Workload { .. } => Some(EstatePoolAssetLifecycle::Workload),
495 CanisterPoolAssetStatus::Recycling { .. } => Some(EstatePoolAssetLifecycle::Recycling),
496 CanisterPoolAssetStatus::HandingOff { .. } => Some(EstatePoolAssetLifecycle::HandingOff),
497 CanisterPoolAssetStatus::Failed { .. } => Some(EstatePoolAssetLifecycle::Failed),
498 }
499}
500
501#[derive(CandidType)]
502enum RootInspectionCommand {
503 InspectCanister(CanisterInspectionRequest),
504}
505
506#[derive(CandidType, Deserialize)]
508enum RootInspectionResponse {
509 InspectCanister(RootInspectionStatus),
510 InspectionReserveRequired(canic_core::dto::canister::CanisterInspectionReserveResponse),
511}
512
513impl RootInspectionResponse {
514 fn into_status(
515 self,
516 root: Principal,
517 target: Principal,
518 ) -> Result<RootInspectionStatus, crate::CanisterProtocolError> {
519 match self {
520 Self::InspectCanister(status) => Ok(status),
521 Self::InspectionReserveRequired(evidence) => Err(
522 crate::CanisterProtocolError::inspection_reserve(root, target, evidence),
523 ),
524 }
525 }
526}
527
528#[derive(CandidType, Clone, Deserialize)]
529struct RootInspectionStatus {
530 status: canic_core::dto::canister::CanisterStatusType,
531 settings: ManagementCanisterObservationSettings,
532 module_hash: Option<Vec<u8>>,
533 cycles: Nat,
534}
535
536struct ReinstallAssetStatus {
538 response: RootInspectionStatus,
539 controllers: Vec<String>,
540 cycles: u128,
541}
542
543#[derive(CandidType)]
544enum ManagedCanisterStatusRequest {
545 CycleBalance,
546}
547
548#[derive(CandidType, Deserialize)]
549enum ManagedCanisterStatusResponse {
550 CycleBalance(canic_core::dto::role::CycleBalanceStatusResponse),
551}
552
553struct CreateCanisterAuthority<'a> {
554 controller_canisters: &'a [String],
555 controllers: &'a [String],
556 created_at_time: u64,
557 ledger: &'a str,
558 requested_initial_cycles: u128,
559 subnet: &'a str,
560}
561
562#[derive(CandidType)]
563struct WithdrawArgs {
564 amount: Nat,
565 created_at_time: Option<u64>,
566 from_subaccount: Option<[u8; 32]>,
567 to: Principal,
568}
569
570#[derive(CandidType, Deserialize)]
571enum WithdrawError {
572 BadFee {
573 expected_fee: Nat,
574 },
575 CreatedInFuture {
576 ledger_time: u64,
577 },
578 Duplicate {
579 duplicate_of: Nat,
580 },
581 FailedToWithdraw {
582 fee_block: Option<Nat>,
583 rejection_code: RejectionCode,
584 rejection_reason: String,
585 },
586 GenericError {
587 error_code: Nat,
588 message: String,
589 },
590 InsufficientFunds {
591 balance: Nat,
592 },
593 InvalidReceiver {
594 receiver: Principal,
595 },
596 TemporarilyUnavailable,
597 TooOld,
598}
599
600#[derive(CandidType, Deserialize)]
601enum RejectionCode {
602 CanisterError,
603 CanisterReject,
604 DestinationInvalid,
605 NoError,
606 SysFatal,
607 SysTransient,
608 Unknown,
609}
610
611#[derive(Debug, ThisError)]
614pub enum IcpEnsurePlatformError {
615 #[error("mainnet Subnet Catalog observation failed: {0}")]
616 SubnetCatalog(#[source] Box<ic_query::subnet_catalog::SubnetCatalogLoadFailure>),
617
618 #[error("cycle arithmetic overflow while executing {0}")]
619 Arithmetic(&'static str),
620
621 #[error(
622 "management history does not prove the issued reinstall of {canister}; refusing another reset"
623 )]
624 ReinstallHistoryConflict { canister: String },
625
626 #[error("configured Candid file is not a regular file: {}", .0.display())]
627 CandidUnavailable(PathBuf),
628
629 #[error("configured effect artifact is unavailable: {}", .0.display())]
630 ArtifactUnavailable(PathBuf),
631
632 #[error("configured {kind} changed after plan review: expected {expected}, observed {actual}")]
633 ArtifactDigestMismatch {
634 actual: String,
635 expected: String,
636 kind: &'static str,
637 },
638
639 #[error("configured Principal is invalid for {field}: {value}")]
640 InvalidPrincipal { field: &'static str, value: String },
641
642 #[error("Cycles Ledger returned an unsupported fee value: {0}")]
643 InvalidLedgerFee(String),
644
645 #[error("active ICP identity is {actual}, but reviewed Fleet operator is {expected}")]
646 OperatorMismatch { actual: String, expected: String },
647
648 #[error("management status for configured canister {expected} returned identity {actual}")]
649 StatusIdentityMismatch { actual: String, expected: String },
650
651 #[error("retained Root management observation failed: {0}")]
652 RootManagement(String),
653
654 #[error("ICP status has invalid cycle balance for {canister}: {value}")]
655 InvalidStatusCycles { canister: String, value: String },
656
657 #[error("ICP status omitted exact {field} required for controlled canister {canister}")]
658 IncompleteCanisterStatus {
659 canister: String,
660 field: &'static str,
661 },
662
663 #[error(
664 "ICP returned only public non-controller status for {canister}; exact runtime and cycle evidence is unavailable"
665 )]
666 PublicCanisterStatusUnavailable { canister: String },
667
668 #[error(
669 "Root-owned observation for {canister} conflicts with reviewed {field}; no paid effect was repeated"
670 )]
671 RootOwnedObservationAuthorityConflict {
672 canister: String,
673 field: &'static str,
674 },
675
676 #[error(
677 "ICP CLI status JSON omitted canister_version for {canister}, and the exact typed \
678 effective-ID-routed management-canister status call failed: {source}; no install was \
679 issued. Restore the selected controller identity and management-status access, then \
680 resume the same reviewed plan"
681 )]
682 InstallVersionProofUnavailable {
683 canister: String,
684 #[source]
685 source: Box<IcpManagementCallError>,
686 },
687
688 #[error("typed local management-status observation failed: {0}")]
689 LocalManagementStatus(#[source] Box<IcpManagementCallError>),
690
691 #[error(
692 "completed reinstall proof for {canister} conflicts with current {field}; no install was authorized"
693 )]
694 CompletedReinstallAuthorityConflict {
695 canister: String,
696 field: &'static str,
697 },
698
699 #[error(transparent)]
700 Candid(#[from] IcpCandidCallError),
701
702 #[error(transparent)]
703 Icp(#[from] IcpCommandError),
704
705 #[error("Cycles Ledger create_canister failed: {0}")]
706 LedgerCreate(String),
707
708 #[error("Cycles Ledger duplicate does not yet identify its created canister")]
709 LedgerCreatePending,
710
711 #[error(
712 "Root {root} pool policy differs from desired input; explicitly review infrastructure reinstall before resetting it"
713 )]
714 PoolPolicyReinstallReviewRequired { root: String },
715
716 #[error("Cycles Ledger withdraw failed: {0}")]
717 LedgerWithdraw(String),
718
719 #[error("completed withdrawal balance conflicts with reviewed funding bounds: {observation:?}")]
720 NativeFundingBalanceDrift {
721 observation: Box<NativeFundingObservation>,
722 },
723
724 #[error("Cycles Ledger estate funding transfer failed: {0}")]
725 LedgerTransfer(String),
726
727 #[error(
728 "Cycles Ledger transfer fee changed from reviewed {reviewed_fee_cycles} to {expected_fee_cycles}; no transfer was accepted"
729 )]
730 LedgerTransferFeeChanged {
731 expected_fee_cycles: u128,
732 reviewed_fee_cycles: u128,
733 },
734
735 #[error(
736 "Root-authorized funding inspection for {canister} conflicts with reviewed {field}; no Ledger withdrawal was repeated"
737 )]
738 FundingInspectionAuthorityConflict {
739 canister: String,
740 field: &'static str,
741 },
742
743 #[error(
744 "canister {canister} retains {actual} cycles above deletion limit {maximum}; left untouched"
745 )]
746 MaterialCycles {
747 actual: u128,
748 canister: String,
749 maximum: u128,
750 },
751
752 #[error("canister {canister} is not stopped; deletion was not attempted")]
753 NotStopped { canister: String },
754
755 #[error("effect references unresolved created canister {0}")]
756 UnresolvedCreated(String),
757
758 #[error("configured retirement endpoint transferred {actual} cycles, expected {expected}")]
759 WrongTransferAmount { actual: Nat, expected: u128 },
760
761 #[error(
762 "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"
763 )]
764 UnbalancedTransfer {
765 canister: String,
766 destination_credit: u128,
767 expected: u128,
768 maximum_source_debit: u128,
769 source_debit: u128,
770 },
771
772 #[error("retirement transfer for {canister} is missing its durable {side} balance")]
773 MissingTransferBalance {
774 canister: String,
775 side: &'static str,
776 },
777
778 #[error("retirement transfer balance for {canister} moved in an invalid direction")]
779 InvalidTransferBalance { canister: String },
780
781 #[error(transparent)]
782 CurrentProtocol(#[from] current_protocol::CurrentProtocolError),
783
784 #[error(transparent)]
785 CanicInit(#[from] canic_init::CanicInitError),
786
787 #[error(transparent)]
788 Protocol(#[from] protocol::ProtocolEffectError),
789
790 #[error("retained Root status authority is invalid: {0}")]
791 RetainedRootStatusAuthority(#[source] Box<EnsureStateError>),
792}
793
794#[derive(Default)]
796struct FleetObservationSnapshot {
797 pool_inspections: BTreeMap<PoolInspectionAuthority, RootInspectionStatus>,
798 pool_pages: BTreeMap<(Principal, Option<Principal>), CanisterPoolResponse>,
799 statuses: BTreeMap<String, Option<LiveCanister>>,
800}
801
802#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
804struct PoolInspectionAuthority {
805 root: Principal,
806 target: Principal,
807}
808
809impl PoolInspectionAuthority {
810 fn parse(root: &str, target: &str) -> Result<Self, IcpEnsurePlatformError> {
811 Ok(Self {
812 root: parse_principal("Fleet Subnet Root", root)?,
813 target: parse_principal("Root-owned inspection target", target)?,
814 })
815 }
816}
817
818enum PoolInspectionRequirement {
820 PendingFresh,
821 RootControlled(InspectedModule),
822}
823
824struct PreparedPoolInspection<'a> {
826 authority: PoolInspectionAuthority,
827 name: &'a str,
828 root: &'a str,
829 requirement: PoolInspectionRequirement,
830}
831
832struct PendingPoolObservation<'a> {
834 configured: &'a crate::fleet_ensure::model::DesiredCanister,
835 principal: &'a str,
836 asset: CanisterPoolAsset,
837 inspection: PreparedPoolInspection<'a>,
838}
839
840enum PreparedRootOwnedObservation<'a> {
842 Observed(Option<LiveCanister>),
843 Pending(Box<PendingPoolObservation<'a>>),
844}
845
846#[derive(Default)]
848struct PoolInspectionBatch {
849 responses:
850 BTreeMap<PoolInspectionAuthority, Result<RootInspectionStatus, IcpEnsurePlatformError>>,
851}
852
853enum StatusCacheEntry {
855 Miss,
856 Hit(Option<LiveCanister>),
857}
858
859pub struct IcpEnsurePlatform {
861 desired: DesiredFleet,
862 icp: IcpCli,
863 initial_observation_delay: Duration,
864 maximum_observation_delay: Duration,
865 observation_snapshot: RefCell<Option<FleetObservationSnapshot>>,
866 progress_handler: Option<Box<dyn FnMut(FleetEnsureProgress)>>,
867 observation_handler: Option<Box<dyn FnMut(FleetObservationTiming)>>,
868 estate_observations: BTreeMap<String, EstateFundingDomainObservation>,
869 root: PathBuf,
870}
871
872const INITIAL_PROTOCOL_OBSERVATION_DELAY: Duration = Duration::from_millis(250);
873const MAXIMUM_PROTOCOL_OBSERVATION_DELAY: Duration = Duration::from_secs(5);
874
875fn protocol_observation_delay(
876 consecutive_unchanged_observations: u32,
877 initial_observation_delay: Duration,
878 maximum_observation_delay: Duration,
879) -> Duration {
880 let exponent = consecutive_unchanged_observations.saturating_sub(1).min(5);
881 let multiplier = 1_u32.checked_shl(exponent).unwrap_or(u32::MAX);
882 initial_observation_delay
883 .checked_mul(multiplier)
884 .unwrap_or(MAXIMUM_PROTOCOL_OBSERVATION_DELAY)
885 .min(MAXIMUM_PROTOCOL_OBSERVATION_DELAY)
886 .min(maximum_observation_delay)
887}
888
889impl IcpEnsurePlatform {
890 #[must_use]
891 pub fn new(desired: DesiredFleet, icp_executable: &str, root: &Path) -> Self {
892 let icp = IcpCli::new(icp_executable, Some(desired.environment.clone()))
893 .with_cwd(root.to_path_buf());
894 Self {
895 desired,
896 icp,
897 initial_observation_delay: INITIAL_PROTOCOL_OBSERVATION_DELAY,
898 maximum_observation_delay: MAXIMUM_PROTOCOL_OBSERVATION_DELAY,
899 observation_snapshot: RefCell::new(None),
900 progress_handler: None,
901 observation_handler: None,
902 estate_observations: BTreeMap::new(),
903 root: root.to_path_buf(),
904 }
905 }
906
907 #[must_use]
909 pub fn with_progress_handler(
910 mut self,
911 handler: impl FnMut(FleetEnsureProgress) + 'static,
912 ) -> Self {
913 self.progress_handler = Some(Box::new(handler));
914 self
915 }
916
917 #[must_use]
919 pub fn with_observation_handler(
920 mut self,
921 handler: impl FnMut(FleetObservationTiming) + 'static,
922 ) -> Self {
923 self.observation_handler = Some(Box::new(handler));
924 self
925 }
926
927 fn finish_observation_timing(
928 &mut self,
929 stage: FleetObservationStage,
930 started: std::time::Instant,
931 calls_before: u64,
932 succeeded: bool,
933 ) {
934 let timing = FleetObservationTiming {
935 stage,
936 elapsed_millis: started.elapsed().as_millis(),
937 remote_call_attempts: self.icp.remote_call_count().saturating_sub(calls_before),
938 succeeded,
939 };
940 if let Some(handler) = &mut self.observation_handler {
941 handler(timing);
942 }
943 }
944
945 fn timed_observation<T>(
946 &mut self,
947 stage: FleetObservationStage,
948 observe: impl FnOnce(&mut Self) -> Result<T, IcpEnsurePlatformError>,
949 ) -> Result<T, IcpEnsurePlatformError> {
950 let started = std::time::Instant::now();
951 let calls = self.icp.remote_call_count();
952 let result = observe(self);
953 self.finish_observation_timing(stage, started, calls, result.is_ok());
954 result
955 }
956
957 #[must_use]
963 pub fn with_local_replica(mut self, target: LocalReplicaTarget) -> Self {
964 self.icp = self.icp.with_local_replica(Some(target));
965 self
966 }
967
968 #[doc(hidden)]
974 #[must_use]
975 pub fn with_observation_delay_bounds(mut self, initial: Duration, maximum: Duration) -> Self {
976 self.initial_observation_delay = initial.min(maximum);
977 self.maximum_observation_delay = maximum;
978 self
979 }
980
981 fn with_observation_snapshot<T>(
984 &mut self,
985 observe: impl FnOnce(&mut Self) -> Result<T, IcpEnsurePlatformError>,
986 ) -> Result<T, IcpEnsurePlatformError> {
987 self.observation_snapshot
988 .replace(Some(FleetObservationSnapshot::default()));
989 let result = observe(self);
990 self.observation_snapshot.take();
991 result
992 }
993
994 fn require_operator(&self) -> Result<(), IcpEnsurePlatformError> {
995 self.icp.bind_selected_identity()?;
996 let actual = self.icp.identity_principal_text()?;
997 if actual != self.desired.operator {
998 return Err(IcpEnsurePlatformError::OperatorMismatch {
999 actual,
1000 expected: self.desired.operator.clone(),
1001 });
1002 }
1003 Ok(())
1004 }
1005
1006 fn current_principal<'a>(
1007 &'a self,
1008 state: &'a FleetEnsureStateRecord,
1009 name: &str,
1010 ) -> Option<&'a str> {
1011 state
1012 .pending_principals
1013 .get(name)
1014 .or_else(|| state.principals.get(name))
1015 .map(String::as_str)
1016 .or_else(|| {
1017 self.desired
1018 .canisters
1019 .iter()
1020 .find(|configured| configured.name == name)
1021 .and_then(|configured| configured.principal.as_deref())
1022 })
1023 }
1024
1025 fn protocol_principals(&self, state: &FleetEnsureStateRecord) -> BTreeMap<String, String> {
1026 self.desired
1027 .canisters
1028 .iter()
1029 .filter_map(|configured| {
1030 state
1031 .pending_principals
1032 .get(&configured.name)
1033 .or_else(|| state.principals.get(&configured.name))
1034 .or(configured.principal.as_ref())
1035 .map(|principal| (configured.name.clone(), principal.clone()))
1036 })
1037 .collect()
1038 }
1039
1040 fn current_protocol_owners_are_ready(
1041 &self,
1042 state: &FleetEnsureStateRecord,
1043 ) -> Result<bool, IcpEnsurePlatformError> {
1044 let mut owners = Vec::new();
1045 for configured in self.desired.canisters.iter().filter(|configured| {
1046 configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
1047 && matches!(
1048 configured.kind,
1049 DesiredCanisterKind::Coordinator
1050 | DesiredCanisterKind::Root
1051 | DesiredCanisterKind::Store
1052 )
1053 }) {
1054 let live = self
1055 .current_principal(state, &configured.name)
1056 .map(|principal| self.status_optional(principal))
1057 .transpose()?
1058 .flatten();
1059 if live
1060 .as_ref()
1061 .is_some_and(|live| live.status != CanisterRuntimeStatus::Running)
1062 {
1063 return Ok(false);
1064 }
1065 owners.push((configured, live));
1066 }
1067 for (configured, live) in owners {
1071 let Some(live) = live else {
1072 return Ok(false);
1073 };
1074 let Some(wasm) = configured.wasm.as_deref() else {
1075 return Ok(false);
1076 };
1077 let expected = artifact_hash(&resolve_path(&self.root, wasm))?;
1078 if live.module_sha256.as_deref() != Some(expected.as_str()) {
1079 return Ok(false);
1080 }
1081 }
1082 Ok(true)
1083 }
1084
1085 fn observe_estate_funding_domains(
1086 &self,
1087 state: &FleetEnsureStateRecord,
1088 ) -> Result<BTreeMap<String, EstateFundingDomainObservation>, IcpEnsurePlatformError> {
1089 let Some(bootstrap) = self.desired.bootstrap.as_ref() else {
1090 return Ok(BTreeMap::new());
1091 };
1092 bootstrap
1093 .roots
1094 .iter()
1095 .map(|root| {
1096 let root_principal = self.current_principal(state, &root.root);
1097 let balance_cycles = root_principal
1098 .map(|principal| self.cycles_ledger_balance(principal))
1099 .transpose()?;
1100 let pool = root_principal
1101 .map(|principal| {
1102 self.observe_estate_pool_inventory(&root.root, principal, state)
1103 })
1104 .transpose()?
1105 .flatten();
1106 Ok((
1107 root.root.clone(),
1108 EstateFundingDomainObservation {
1109 balance_cycles,
1110 cycles_ledger: self.desired.cycles_ledger.clone(),
1111 pool,
1112 root_principal: root_principal.map(str::to_string),
1113 },
1114 ))
1115 })
1116 .collect()
1117 }
1118
1119 fn cycles_ledger_balance(&self, owner: &str) -> Result<u128, IcpEnsurePlatformError> {
1120 let balance: Nat = self.icp.canister_query_candid(
1121 &self.desired.cycles_ledger,
1122 "icrc1_balance_of",
1123 &CyclesLedgerAccount {
1124 owner: parse_principal("Cycles Ledger account owner", owner)?,
1125 subaccount: None,
1126 },
1127 None,
1128 )?;
1129 ledger_fee_cycles(balance)
1130 }
1131
1132 fn refresh_estate_funding_observation(
1133 &self,
1134 observation: &mut EffectObservation,
1135 state: &FleetEnsureStateRecord,
1136 ) -> Result<(), IcpEnsurePlatformError> {
1137 let Some(required) = observation.estate_funding_required.as_mut() else {
1138 return Ok(());
1139 };
1140 let root = required.root.to_text();
1141 let known_root = self.desired.canisters.iter().any(|canister| {
1142 canister.kind == DesiredCanisterKind::Root
1143 && self.current_principal(state, &canister.name) == Some(root.as_str())
1144 });
1145 if !known_root || required.cycles_ledger.to_text() != self.desired.cycles_ledger {
1146 return Err(current_protocol::CurrentProtocolError::ResponseMismatch.into());
1147 }
1148 let available = self.cycles_ledger_balance(&root)?;
1151 let debit = required.required.to_u128();
1152 if available >= debit {
1153 observation.estate_funding_required = None;
1154 } else {
1155 required.available = Cycles::new(available);
1156 required.shortfall = Cycles::new(debit - available);
1157 }
1158 Ok(())
1159 }
1160
1161 fn require_current_pool_policy(
1162 &self,
1163 domains: &BTreeMap<String, EstateFundingDomainObservation>,
1164 ) -> Result<(), IcpEnsurePlatformError> {
1165 let Some(bootstrap) = self.desired.bootstrap.as_ref() else {
1166 return Ok(());
1167 };
1168 for root in &bootstrap.roots {
1169 let Some(pool) = domains
1170 .get(&root.root)
1171 .and_then(|domain| domain.pool.as_ref())
1172 else {
1173 continue;
1174 };
1175 if !pool_policy_is_current(pool, &root.limits.canister_pool) {
1176 return Err(IcpEnsurePlatformError::PoolPolicyReinstallReviewRequired {
1177 root: root.root.clone(),
1178 });
1179 }
1180 }
1181 Ok(())
1182 }
1183
1184 fn observe_estate_pool_inventory(
1185 &self,
1186 root_name: &str,
1187 root: &str,
1188 _state: &FleetEnsureStateRecord,
1189 ) -> Result<Option<EstatePoolInventoryObservation>, IcpEnsurePlatformError> {
1190 if self.required_root_status(root_name, root)? != CanisterRuntimeStatus::Running {
1191 return Ok(None);
1192 }
1193 let candid = self.root_protocol_candid()?;
1194 let root_principal = parse_principal("Fleet Subnet Root", root)?;
1195 let mut start_after = None;
1196 let mut inventory = EstatePoolInventoryAccumulator::default();
1197 loop {
1198 let page = self.query_estate_pool_page(&candid, root_principal, start_after)?;
1199 let next = inventory.observe_page(root_name, page)?;
1200 if next.is_none() {
1201 break;
1202 }
1203 if next == start_after {
1204 return Err(pool_configuration_error(format!(
1205 "Root {root_name} pool cursor did not advance"
1206 )));
1207 }
1208 start_after = next;
1209 }
1210 inventory.finish(root_name).map(Some)
1211 }
1212
1213 fn query_estate_pool_page(
1214 &self,
1215 candid: &Path,
1216 root: Principal,
1217 start_after: Option<Principal>,
1218 ) -> Result<CanisterPoolResponse, IcpEnsurePlatformError> {
1219 let key = (root, start_after);
1220 if let Some(page) = self
1221 .observation_snapshot
1222 .borrow()
1223 .as_ref()
1224 .and_then(|snapshot| snapshot.pool_pages.get(&key).cloned())
1225 {
1226 return Ok(page);
1227 }
1228 let response: RootPoolStatusResponse = query_with_candid(
1229 &self.icp,
1230 candid,
1231 root,
1232 canic_protocol::CANIC_ROOT_STATUS,
1233 &RootPoolStatusRequest::Pool(CanisterPoolStatusRequest {
1234 start_after,
1235 limit: 256,
1236 }),
1237 )
1238 .map_err(current_protocol::CurrentProtocolError::from)?;
1239 let RootPoolStatusResponse::Pool(page) = response;
1240 if let Some(snapshot) = self.observation_snapshot.borrow_mut().as_mut() {
1241 snapshot.pool_pages.insert(key, (*page).clone());
1242 }
1243 Ok(*page)
1244 }
1245
1246 #[expect(
1247 clippy::too_many_lines,
1248 reason = "one inspection binds each source asset to exact management authority and cycles"
1249 )]
1250 fn inspect_reinstall_assets(
1251 &self,
1252 state: &FleetEnsureStateRecord,
1253 authorities: &BTreeMap<String, RootManagementCanisterObservation>,
1254 observation: &mut FleetObservation,
1255 candid_by_root: &BTreeMap<String, PathBuf>,
1256 allow_pending_reset: bool,
1257 ) -> Result<Vec<crate::fleet_ensure::model::FleetReinstallAssetRecord>, IcpEnsurePlatformError>
1258 {
1259 let mut assets = Vec::new();
1260 let mut seen = BTreeSet::new();
1261 for (root_name, domain) in &observation.estate_funding_domains {
1262 let root = domain.root_principal.as_deref().ok_or_else(|| {
1263 IcpEnsurePlatformError::RootManagement("missing Root principal".to_string())
1264 })?;
1265 let pool = domain.pool.as_ref().ok_or_else(|| {
1266 IcpEnsurePlatformError::RootManagement("missing complete pool".to_string())
1267 })?;
1268 if pool.pending_creation.is_some() {
1269 return Err(IcpEnsurePlatformError::RootManagement(
1270 "pool creation is still in flight".to_string(),
1271 ));
1272 }
1273 let root_authority = authorities.get(root_name).ok_or_else(|| {
1274 IcpEnsurePlatformError::RootManagement("missing Root binding".to_string())
1275 })?;
1276 for batch in pool
1277 .assets
1278 .chunks(super::bounded_observations::MAX_IN_FLIGHT)
1279 {
1280 let requests = batch
1283 .iter()
1284 .map(|asset| (asset, seen.insert(asset.principal.clone())))
1285 .collect::<Vec<_>>();
1286 let icp = &self.icp;
1287 let responses =
1288 super::bounded_observations::collect(&requests, |(asset, unique)| {
1289 Self::inspect_reinstall_asset(
1290 icp,
1291 candid_by_root.get(root_name).map(PathBuf::as_path),
1292 root,
1293 asset,
1294 *unique,
1295 allow_pending_reset,
1296 )
1297 })?;
1298 for (asset, inspected) in batch.iter().zip(responses) {
1299 let ReinstallAssetStatus {
1300 response,
1301 controllers,
1302 cycles,
1303 } = inspected;
1304
1305 let module_sha256 = response
1306 .module_hash
1307 .as_ref()
1308 .map(canic_core::cdk::utils::hash::hex_bytes);
1309 let configured_name = self
1310 .desired
1311 .canisters
1312 .iter()
1313 .find(|c| {
1314 c.principal.as_deref() == Some(&asset.principal)
1315 || self.current_principal(state, &c.name) == Some(&asset.principal)
1316 })
1317 .map(|c| c.name.clone());
1318 let name = configured_name
1319 .unwrap_or_else(|| format!("reinstall-pool-{}", asset.principal));
1320 observation.canisters.insert(
1321 name,
1322 Some(LiveCanister {
1323 canister_version: None,
1324 controllers: controllers.clone(),
1325 cycles,
1326 module_sha256: module_sha256.clone(),
1327 principal: asset.principal.clone(),
1328 reinstall_required: false,
1329 root_owned_lifecycle: Some(match asset.lifecycle {
1330 EstatePoolAssetLifecycle::Ready => RootOwnedCanisterLifecycle::Idle,
1331 EstatePoolAssetLifecycle::Workload => {
1332 RootOwnedCanisterLifecycle::Workload
1333 }
1334 _ => RootOwnedCanisterLifecycle::Retained,
1335 }),
1336 status: match response.status {
1337 canic_core::dto::canister::CanisterStatusType::Running => {
1338 CanisterRuntimeStatus::Running
1339 }
1340 canic_core::dto::canister::CanisterStatusType::Stopped => {
1341 CanisterRuntimeStatus::Stopped
1342 }
1343 canic_core::dto::canister::CanisterStatusType::Stopping => {
1344 CanisterRuntimeStatus::Stopping
1345 }
1346 },
1347 }),
1348 );
1349 observation
1350 .additional_controlled_cycles
1351 .remove(&asset.principal);
1352 assets.push(crate::fleet_ensure::model::FleetReinstallAssetRecord {
1353 controllers,
1354 module_sha256,
1355 principal: asset.principal.clone(),
1356 root: root_name.clone(),
1357 subnet: root_authority.subnet.clone(),
1358 });
1359 }
1360 }
1361 }
1362 assets.sort_by(|a, b| a.principal.cmp(&b.principal));
1363 Ok(assets)
1364 }
1365
1366 fn inspect_reinstall_asset(
1367 icp: &IcpCli,
1368 candid: Option<&Path>,
1369 root: &str,
1370 asset: &EstatePoolAssetObservation,
1371 unique: bool,
1372 allow_pending_reset: bool,
1373 ) -> Result<ReinstallAssetStatus, IcpEnsurePlatformError> {
1374 let terminal = matches!(
1375 asset.lifecycle,
1376 EstatePoolAssetLifecycle::Ready | EstatePoolAssetLifecycle::Workload
1377 );
1378 let recoverable = allow_pending_reset
1379 && matches!(
1380 asset.lifecycle,
1381 EstatePoolAssetLifecycle::PendingReset | EstatePoolAssetLifecycle::Failed
1382 );
1383 if !unique || !(terminal || recoverable) {
1384 return Err(IcpEnsurePlatformError::RootManagement(
1385 "pool asset is duplicated or not terminal".to_string(),
1386 ));
1387 }
1388 let authority = PoolInspectionAuthority {
1389 target: parse_principal("reset pool asset", &asset.principal)?,
1390 root: parse_principal("reset Root", root)?,
1391 };
1392 let candid = candid
1393 .ok_or_else(|| pool_configuration_error("missing reset read contract".to_string()))?;
1394 let response = Self::fetch_pool_inspection(icp, candid, authority)?;
1397 let mut controllers = response
1398 .settings
1399 .controllers
1400 .iter()
1401 .map(Principal::to_text)
1402 .collect::<Vec<_>>();
1403 if !controllers.iter().any(|controller| controller == root) {
1404 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1405 canister: asset.principal.clone(),
1406 field: "Root controller",
1407 });
1408 }
1409 let cycles = validate_inspected_cycles(
1410 &asset.principal,
1411 InspectedModule::Any,
1412 response.module_hash.as_deref(),
1413 &response.cycles,
1414 )?;
1415 controllers.sort();
1416 if controllers != [root.to_string()] {
1417 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1418 canister: asset.principal.clone(),
1419 field: "exact Root controllers",
1420 });
1421 }
1422 Ok(ReinstallAssetStatus {
1423 response,
1424 controllers,
1425 cycles,
1426 })
1427 }
1428
1429 fn resolved_controllers(
1430 &self,
1431 state: &FleetEnsureStateRecord,
1432 controllers: &[String],
1433 controller_canisters: &[String],
1434 ) -> Result<Vec<String>, IcpEnsurePlatformError> {
1435 let mut resolved = controllers.to_vec();
1436 for name in controller_canisters {
1437 resolved.push(
1438 self.current_principal(state, name)
1439 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.clone()))?
1440 .to_string(),
1441 );
1442 }
1443 resolved.sort();
1444 resolved.dedup();
1445 Ok(resolved)
1446 }
1447
1448 fn observed_protocol_action(
1449 &self,
1450 step: &crate::fleet_ensure::model::DesiredProtocolStep,
1451 principal: String,
1452 ) -> Result<EnsureAction, IcpEnsurePlatformError> {
1453 Ok(EnsureAction::Protocol {
1454 candid: step.candid.clone(),
1455 candid_sha256: artifact_hash(&resolve_path(&self.root, &step.candid))?,
1456 command_args: step.command_args.clone(),
1457 command_args_sha256: artifact_hash(&resolve_path(&self.root, &step.command_args))?,
1458 command_method: step.command_method.clone(),
1459 expected_status: step.expected_status.clone(),
1460 expected_status_sha256: artifact_hash(&resolve_path(
1461 &self.root,
1462 &step.expected_status,
1463 ))?,
1464 maximum_execution_burn_cycles: step
1465 .maximum_execution_burn_cycles
1466 .parse()
1467 .map_err(|_| IcpEnsurePlatformError::Arithmetic("protocol burn"))?,
1468 name: step.name.clone(),
1469 principal,
1470 status_args: step.status_args.clone(),
1471 status_args_sha256: artifact_hash(&resolve_path(&self.root, &step.status_args))?,
1472 status_method: step.status_method.clone(),
1473 })
1474 }
1475
1476 fn action_principal<'a>(
1477 state: &'a FleetEnsureStateRecord,
1478 principal: &'a str,
1479 ) -> Result<&'a str, IcpEnsurePlatformError> {
1480 if let Some(name) = principal.strip_prefix("created:") {
1481 return state
1482 .pending_principals
1483 .get(name)
1484 .map(String::as_str)
1485 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.to_string()));
1486 }
1487 Ok(principal)
1488 }
1489
1490 fn status_optional(
1491 &self,
1492 principal: &str,
1493 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1494 if let Some(cached) = self
1495 .observation_snapshot
1496 .borrow()
1497 .as_ref()
1498 .and_then(|snapshot| snapshot.statuses.get(principal).cloned())
1499 {
1500 return Ok(cached);
1501 }
1502 let observed = self.read_status_optional(principal)?;
1503 if let Some(snapshot) = self.observation_snapshot.borrow_mut().as_mut() {
1504 snapshot
1505 .statuses
1506 .insert(principal.to_string(), observed.clone());
1507 }
1508 Ok(observed)
1509 }
1510
1511 fn read_status_optional(
1512 &self,
1513 principal: &str,
1514 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1515 Self::read_status_with(&self.icp, principal)
1516 }
1517
1518 fn read_status_with(
1519 icp: &IcpCli,
1520 principal: &str,
1521 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1522 if icp.uses_direct_local_replica() {
1523 return Self::direct_local_status_optional(icp, principal);
1524 }
1525 let report = match icp.canister_status_report(principal) {
1526 Ok(report) => report,
1527 Err(error)
1528 if matches!(
1529 error.diagnostic(),
1530 Some(IcpDiagnostic::CanisterNotFound { .. })
1531 ) =>
1532 {
1533 return Ok(None);
1534 }
1535 Err(error) => return Err(error.into()),
1536 };
1537 if report.id != principal {
1538 return Err(IcpEnsurePlatformError::StatusIdentityMismatch {
1539 actual: report.id,
1540 expected: principal.to_string(),
1541 });
1542 }
1543 reject_public_status_projection(&report, principal)?;
1544 let status_text =
1545 report
1546 .status
1547 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
1548 canister: principal.to_string(),
1549 field: "status",
1550 })?;
1551 let cycles_text =
1552 report
1553 .cycles
1554 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
1555 canister: principal.to_string(),
1556 field: "cycles",
1557 })?;
1558 let cycles = parse_status_cycles(&cycles_text).ok_or_else(|| {
1559 IcpEnsurePlatformError::InvalidStatusCycles {
1560 canister: principal.to_string(),
1561 value: cycles_text,
1562 }
1563 })?;
1564 let mut controllers = report
1565 .settings
1566 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
1567 canister: principal.to_string(),
1568 field: "controllers",
1569 })?
1570 .controllers;
1571 controllers.sort();
1572 let status = match status_text.to_ascii_lowercase().as_str() {
1573 "stopped" => CanisterRuntimeStatus::Stopped,
1574 "stopping" => CanisterRuntimeStatus::Stopping,
1575 _ => CanisterRuntimeStatus::Running,
1576 };
1577 Ok(Some(LiveCanister {
1578 canister_version: report.canister_version,
1579 controllers,
1580 cycles,
1581 module_sha256: report.module_hash.map(|hash| normalize_hash(&hash)),
1582 principal: principal.to_string(),
1583 reinstall_required: false,
1584 root_owned_lifecycle: None,
1585 status,
1586 }))
1587 }
1588
1589 fn direct_local_status_optional(
1590 icp: &IcpCli,
1591 principal: &str,
1592 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1593 let canister_id = parse_principal("local canister status target", principal)?;
1594 let response = icp
1595 .management_canister_status_candid::<_, ManagementCanisterObservationResponse>(
1596 canister_id,
1597 &ManagementCanisterStatusRequest { canister_id },
1598 );
1599 let response = match response {
1600 Ok(response) => response,
1601 Err(error)
1602 if matches!(
1603 crate::icp::classify_icp_diagnostic(&error.to_string()),
1604 Some(IcpDiagnostic::CanisterNotFound { .. })
1605 ) =>
1606 {
1607 return Ok(None);
1608 }
1609 Err(error) => {
1610 return Err(IcpEnsurePlatformError::LocalManagementStatus(Box::new(
1611 error,
1612 )));
1613 }
1614 };
1615 let cycles_text = response.cycles.to_string();
1616 let cycles = u128::try_from(response.cycles.0).map_err(|_| {
1617 IcpEnsurePlatformError::InvalidStatusCycles {
1618 canister: principal.to_string(),
1619 value: cycles_text,
1620 }
1621 })?;
1622 let mut controllers = response
1623 .settings
1624 .controllers
1625 .into_iter()
1626 .map(|controller| controller.to_text())
1627 .collect::<Vec<_>>();
1628 controllers.sort();
1629 controllers.dedup();
1630 let status = match response.status {
1631 ManagementCanisterRuntimeStatus::Running => CanisterRuntimeStatus::Running,
1632 ManagementCanisterRuntimeStatus::Stopping => CanisterRuntimeStatus::Stopping,
1633 ManagementCanisterRuntimeStatus::Stopped => CanisterRuntimeStatus::Stopped,
1634 };
1635 Ok(Some(LiveCanister {
1636 canister_version: Some(response.version),
1637 controllers,
1638 cycles,
1639 module_sha256: response
1640 .module_hash
1641 .map(|hash| canic_core::cdk::utils::hash::hex_bytes(&hash)),
1642 principal: principal.to_string(),
1643 reinstall_required: false,
1644 root_owned_lifecycle: None,
1645 status,
1646 }))
1647 }
1648
1649 fn inspect_root_owned_canister(
1650 &self,
1651 configured: &crate::fleet_ensure::model::DesiredCanister,
1652 principal: &str,
1653 state: &FleetEnsureStateRecord,
1654 ) -> Result<Option<RootInspectionStatus>, IcpEnsurePlatformError> {
1655 self.root_owned_inspection_authority(configured, principal, state)?
1656 .map(|authority| self.read_pool_inspection(authority))
1657 .transpose()
1658 }
1659
1660 fn root_owned_inspection_authority(
1661 &self,
1662 configured: &crate::fleet_ensure::model::DesiredCanister,
1663 principal: &str,
1664 state: &FleetEnsureStateRecord,
1665 ) -> Result<Option<PoolInspectionAuthority>, IcpEnsurePlatformError> {
1666 let parent = configured.parent.as_deref().ok_or_else(|| {
1667 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1668 canister: configured.name.clone(),
1669 field: "Root parent",
1670 }
1671 })?;
1672 let root = self.current_principal(state, parent).ok_or_else(|| {
1673 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1674 canister: configured.name.clone(),
1675 field: "Root Principal",
1676 }
1677 })?;
1678 let root_configured = self
1679 .desired
1680 .canisters
1681 .iter()
1682 .find(|candidate| {
1683 candidate.name == parent && candidate.kind == DesiredCanisterKind::Root
1684 })
1685 .ok_or_else(
1686 || IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1687 canister: configured.name.clone(),
1688 field: "Root desired authority",
1689 },
1690 )?;
1691 let root_wasm = root_configured.wasm.as_deref().ok_or_else(|| {
1692 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1693 canister: configured.name.clone(),
1694 field: "Root artifact",
1695 }
1696 })?;
1697 let expected_root_module = artifact_hash(&resolve_path(&self.root, root_wasm))?;
1698 let root_live = self.status_optional(root)?.ok_or_else(|| {
1699 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1700 canister: configured.name.clone(),
1701 field: "live Root",
1702 }
1703 })?;
1704 let expected_root_controllers = self.resolved_controllers(
1705 state,
1706 &root_configured.controllers,
1707 &root_configured.controller_canisters,
1708 )?;
1709 if root_live.controllers != expected_root_controllers {
1710 return Err(
1711 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1712 canister: configured.name.clone(),
1713 field: "Root controllers",
1714 },
1715 );
1716 }
1717 let Some(root_module) = root_live.module_sha256.as_deref() else {
1718 return Ok(None);
1719 };
1720 if root_module != expected_root_module {
1721 return Err(
1722 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1723 canister: configured.name.clone(),
1724 field: "Root module SHA-256",
1725 },
1726 );
1727 }
1728 if root_live.status != CanisterRuntimeStatus::Running {
1729 return Ok(None);
1730 }
1731 self.require_operator()?;
1732 PoolInspectionAuthority::parse(root, principal).map(Some)
1733 }
1734
1735 fn created_canister_cycles(
1736 &self,
1737 name: &str,
1738 principal: &str,
1739 state: &FleetEnsureStateRecord,
1740 ) -> Result<(Option<u128>, bool), IcpEnsurePlatformError> {
1741 match self.status_optional(principal) {
1742 Ok(live) => return Ok((live.map(|live| live.cycles), false)),
1743 Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable { .. }) => {}
1744 Err(error) => return Err(error),
1745 }
1746 let configured = self
1747 .desired
1748 .canisters
1749 .iter()
1750 .find(|configured| configured.name == name)
1751 .ok_or_else(
1752 || IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1753 canister: name.to_string(),
1754 field: "desired canister",
1755 },
1756 )?;
1757 if configured.kind != DesiredCanisterKind::Pool {
1758 return Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable {
1759 canister: principal.to_string(),
1760 });
1761 }
1762 let Some(response) = self.inspect_root_owned_canister(configured, principal, state)? else {
1763 return Ok((None, true));
1764 };
1765 let parent = configured.parent.as_deref().ok_or_else(|| {
1766 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1767 canister: name.to_string(),
1768 field: "Root parent",
1769 }
1770 })?;
1771 let root = self.current_principal(state, parent).ok_or_else(|| {
1772 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
1773 canister: name.to_string(),
1774 field: "Root Principal",
1775 }
1776 })?;
1777 let cycles = validate_root_controlled_inspection(
1778 name,
1779 InspectedModule::Empty,
1780 root,
1781 &response.settings.controllers,
1782 response.module_hash.as_deref(),
1783 &response.cycles,
1784 )?;
1785 Ok((Some(cycles), false))
1786 }
1787
1788 fn install_status_optional(
1789 &self,
1790 principal: &str,
1791 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1792 let Some(live) = self.status_optional(principal)? else {
1793 return Ok(None);
1794 };
1795 self.complete_install_status(principal, live).map(Some)
1796 }
1797
1798 fn complete_install_status(
1799 &self,
1800 principal: &str,
1801 mut live: LiveCanister,
1802 ) -> Result<LiveCanister, IcpEnsurePlatformError> {
1803 let exact = exact_install_canister_status(
1804 &self.icp,
1805 principal,
1806 live.canister_version,
1807 live.module_sha256.clone(),
1808 )?;
1809 live.canister_version = Some(exact.canister_version);
1810 live.module_sha256 = exact.module_sha256;
1811 Ok(live)
1812 }
1813
1814 fn observe_configured_canister(
1815 &self,
1816 configured: &crate::fleet_ensure::model::DesiredCanister,
1817 principal: &str,
1818 state: &FleetEnsureStateRecord,
1819 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1820 if configured.kind == DesiredCanisterKind::Pool {
1821 return self.observe_root_owned_canister(configured, principal, state);
1822 }
1823 let status = self.status_optional(principal);
1824 self.configured_status_or_root(configured, principal, state, status)
1825 }
1826
1827 fn configured_status_or_root(
1828 &self,
1829 configured: &crate::fleet_ensure::model::DesiredCanister,
1830 principal: &str,
1831 state: &FleetEnsureStateRecord,
1832 status: Result<Option<LiveCanister>, IcpEnsurePlatformError>,
1833 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1834 match status {
1835 Ok(observed) => Ok(observed),
1836 Err(error)
1837 if matches!(
1838 configured.kind,
1839 DesiredCanisterKind::Store | DesiredCanisterKind::Pool
1840 ) && !matches!(
1841 error,
1842 IcpEnsurePlatformError::Icp(ref source)
1843 if matches!(
1844 source.diagnostic(),
1845 Some(IcpDiagnostic::CanisterNotFound { .. })
1846 )
1847 ) =>
1848 {
1849 self.observe_root_owned_canister(configured, principal, state)
1850 }
1851 Err(error) => Err(error),
1852 }
1853 }
1854
1855 fn observe_root_owned_canister(
1856 &self,
1857 configured: &crate::fleet_ensure::model::DesiredCanister,
1858 principal: &str,
1859 state: &FleetEnsureStateRecord,
1860 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1861 match self.prepare_root_owned_observation(configured, principal, state)? {
1862 PreparedRootOwnedObservation::Observed(live) => Ok(live),
1863 PreparedRootOwnedObservation::Pending(pending) => {
1864 let response = self.read_pool_inspection(pending.inspection.authority)?;
1865 self.complete_pending_pool_observation(*pending, response)
1866 }
1867 }
1868 }
1869
1870 fn prepare_root_owned_observation<'a>(
1871 &'a self,
1872 configured: &'a crate::fleet_ensure::model::DesiredCanister,
1873 principal: &'a str,
1874 state: &'a FleetEnsureStateRecord,
1875 ) -> Result<PreparedRootOwnedObservation<'a>, IcpEnsurePlatformError> {
1876 let candid = self.root_protocol_candid()?;
1877 let parent = configured.parent.as_deref().ok_or_else(|| {
1878 current_protocol::CurrentProtocolError::Configuration(format!(
1879 "Root-owned canister {} has no Root parent",
1880 configured.name
1881 ))
1882 })?;
1883 let root = self.current_principal(state, parent).ok_or_else(|| {
1884 current_protocol::CurrentProtocolError::Configuration(format!(
1885 "Root-owned canister {} has no resolved Root",
1886 configured.name
1887 ))
1888 })?;
1889 if self.required_root_status(&configured.name, root)? == CanisterRuntimeStatus::Stopped {
1890 return self
1891 .retained_root_owned_observation(configured, principal, parent, root, state)
1892 .map(PreparedRootOwnedObservation::Observed);
1893 }
1894 let target = parse_principal("Root-owned canister", principal)?;
1895 let mut start_after = None;
1896 loop {
1897 let page = self.query_estate_pool_page(
1898 &candid,
1899 parse_principal("Fleet Subnet Root", root)?,
1900 start_after,
1901 )?;
1902 if let Some(asset) = page
1903 .entries
1904 .into_iter()
1905 .find(|asset| asset.canister_id == target)
1906 {
1907 if matches!(
1908 asset.status,
1909 canic_core::dto::pool::CanisterPoolAssetStatus::PendingReset
1910 ) && asset.cycles.to_u128() == 0
1911 {
1912 let inspection =
1913 self.prepare_pending_pool_inspection(configured, principal, state)?;
1914 return Ok(PreparedRootOwnedObservation::Pending(Box::new(
1915 PendingPoolObservation {
1916 configured,
1917 principal,
1918 asset,
1919 inspection,
1920 },
1921 )));
1922 }
1923 return Self::observed_root_owned_asset(configured, principal, root, asset)
1924 .map(PreparedRootOwnedObservation::Observed);
1925 }
1926 let next = page.next_start_after;
1927 if next.is_none() {
1928 return Ok(PreparedRootOwnedObservation::Observed(None));
1929 }
1930 if next == start_after {
1931 return Err(
1932 current_protocol::CurrentProtocolError::Configuration(format!(
1933 "Root {parent} pool cursor did not advance"
1934 ))
1935 .into(),
1936 );
1937 }
1938 start_after = next;
1939 }
1940 }
1941
1942 fn complete_pending_pool_observation(
1943 &self,
1944 mut pending: PendingPoolObservation<'_>,
1945 response: RootInspectionStatus,
1946 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1947 let root = pending.inspection.root;
1948 let cycles = self.complete_pool_inspection(pending.inspection, response)?;
1949 pending.asset.cycles = Cycles::new(cycles);
1950 Self::observed_root_owned_asset(pending.configured, pending.principal, root, pending.asset)
1951 }
1952
1953 fn prepare_pending_pool_inspection<'a>(
1954 &'a self,
1955 configured: &'a crate::fleet_ensure::model::DesiredCanister,
1956 principal: &str,
1957 state: &'a FleetEnsureStateRecord,
1958 ) -> Result<PreparedPoolInspection<'a>, IcpEnsurePlatformError> {
1959 let parent = configured.parent.as_deref().ok_or_else(|| {
1960 IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1961 canister: configured.name.clone(),
1962 field: "Root parent",
1963 }
1964 })?;
1965 let root = self.current_principal(state, parent).ok_or_else(|| {
1966 IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1967 canister: configured.name.clone(),
1968 field: "Root Principal",
1969 }
1970 })?;
1971 let pending_fresh_pool = self
1972 .desired
1973 .bootstrap
1974 .as_ref()
1975 .is_some_and(|bootstrap| bootstrap.fresh_estate)
1976 && configured.principal.is_none()
1977 && state
1978 .pending_principals
1979 .get(&configured.name)
1980 .map(String::as_str)
1981 == Some(principal);
1982 if pending_fresh_pool {
1983 let authority = self
1984 .root_owned_inspection_authority(configured, principal, state)?
1985 .ok_or_else(
1986 || IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1987 canister: configured.name.clone(),
1988 field: "installed Root",
1989 },
1990 )?;
1991 return Ok(PreparedPoolInspection {
1992 authority,
1993 name: &configured.name,
1994 root,
1995 requirement: PoolInspectionRequirement::PendingFresh,
1996 });
1997 }
1998 self.prepare_pool_balance_inspection(
1999 &configured.name,
2000 root,
2001 principal,
2002 InspectedModule::Any,
2003 )
2004 }
2005
2006 fn inspect_pool_balance(
2007 &self,
2008 name: &str,
2009 root: &str,
2010 principal: &str,
2011 module: InspectedModule,
2012 ) -> Result<u128, IcpEnsurePlatformError> {
2013 let inspection = self.prepare_pool_balance_inspection(name, root, principal, module)?;
2014 let response = self.read_pool_inspection(inspection.authority)?;
2015 self.complete_pool_inspection(inspection, response)
2016 }
2017
2018 fn refresh_pool_balances(
2019 &self,
2020 root: &str,
2021 assets: &mut [EstatePoolAssetObservation],
2022 ) -> Result<(), IcpEnsurePlatformError> {
2023 let indices = assets
2024 .iter()
2025 .enumerate()
2026 .filter_map(|(index, asset)| {
2027 matches!(
2028 asset.lifecycle,
2029 EstatePoolAssetLifecycle::PendingReset | EstatePoolAssetLifecycle::Failed
2030 )
2031 .then_some(index)
2032 })
2033 .collect::<Vec<_>>();
2034 for batch in indices.chunks(super::bounded_observations::MAX_IN_FLIGHT) {
2035 let prepared = batch
2036 .iter()
2037 .map(|index| {
2038 let target = &assets[*index].principal;
2039 self.prepare_pool_balance_inspection(target, root, target, InspectedModule::Any)
2040 })
2041 .collect::<Vec<_>>();
2042 let authorities = prepared
2043 .iter()
2044 .filter_map(|result| result.as_ref().ok().map(|inspection| inspection.authority))
2045 .collect::<Vec<_>>();
2046 let mut responses = self.read_pool_inspection_batch(&authorities)?;
2047 let balances = prepared
2050 .into_iter()
2051 .map(|inspection| {
2052 let inspection = inspection?;
2053 let response =
2054 self.pool_inspection_batch_response(&mut responses, inspection.authority)?;
2055 self.complete_pool_inspection(inspection, response)
2056 })
2057 .collect::<Result<Vec<_>, IcpEnsurePlatformError>>()?;
2058 for (index, cycles) in batch.iter().zip(balances) {
2059 assets[*index].cycles = cycles;
2060 }
2061 }
2062 Ok(())
2063 }
2064
2065 fn prepare_pool_balance_inspection<'a>(
2066 &self,
2067 name: &'a str,
2068 root: &'a str,
2069 principal: &str,
2070 module: InspectedModule,
2071 ) -> Result<PreparedPoolInspection<'a>, IcpEnsurePlatformError> {
2072 if self.required_root_status(name, root)? != CanisterRuntimeStatus::Running {
2073 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
2074 canister: name.to_string(),
2075 field: "running Root",
2076 });
2077 }
2078 self.require_operator()?;
2079 Ok(PreparedPoolInspection {
2080 authority: PoolInspectionAuthority::parse(root, principal)?,
2081 name,
2082 root,
2083 requirement: PoolInspectionRequirement::RootControlled(module),
2084 })
2085 }
2086
2087 fn complete_pool_inspection(
2088 &self,
2089 inspection: PreparedPoolInspection<'_>,
2090 response: RootInspectionStatus,
2091 ) -> Result<u128, IcpEnsurePlatformError> {
2092 let cycles = match inspection.requirement {
2093 PoolInspectionRequirement::PendingFresh => validate_pending_fresh_pool_inspection(
2094 inspection.name,
2095 inspection.root,
2096 &self.desired.operator,
2097 &response.settings.controllers,
2098 response.module_hash.as_deref(),
2099 &response.cycles,
2100 ),
2101 PoolInspectionRequirement::RootControlled(module) => {
2102 validate_root_controlled_inspection(
2103 inspection.name,
2104 module,
2105 inspection.root,
2106 &response.settings.controllers,
2107 response.module_hash.as_deref(),
2108 &response.cycles,
2109 )
2110 }
2111 }?;
2112 self.retain_pool_inspection(inspection.authority, response);
2113 Ok(cycles)
2114 }
2115
2116 fn read_pool_inspection(
2118 &self,
2119 authority: PoolInspectionAuthority,
2120 ) -> Result<RootInspectionStatus, IcpEnsurePlatformError> {
2121 if let Some(response) = self
2122 .observation_snapshot
2123 .borrow()
2124 .as_ref()
2125 .and_then(|snapshot| snapshot.pool_inspections.get(&authority).cloned())
2126 {
2127 return Ok(response);
2128 }
2129 Self::fetch_pool_inspection(&self.icp, &self.root_protocol_candid()?, authority)
2130 }
2131
2132 fn fetch_pool_inspection(
2133 icp: &IcpCli,
2134 candid: &Path,
2135 authority: PoolInspectionAuthority,
2136 ) -> Result<RootInspectionStatus, IcpEnsurePlatformError> {
2137 crate::canister_protocol::inspection::preflight_inspection(
2138 icp,
2139 candid,
2140 authority.root,
2141 authority.target,
2142 )
2143 .map_err(current_protocol::CurrentProtocolError::from)?;
2144 let response: RootInspectionResponse = call_with_candid(
2145 icp,
2146 candid,
2147 authority.root,
2148 canic_protocol::CANIC_ROOT_COMMAND,
2149 &RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
2150 canister_id: authority.target,
2151 }),
2152 )
2153 .map_err(current_protocol::CurrentProtocolError::from)?;
2154 response
2155 .into_status(authority.root, authority.target)
2156 .map_err(current_protocol::CurrentProtocolError::from)
2157 .map_err(Into::into)
2158 }
2159
2160 fn retain_pool_inspection(
2162 &self,
2163 authority: PoolInspectionAuthority,
2164 response: RootInspectionStatus,
2165 ) {
2166 if let Some(snapshot) = self.observation_snapshot.borrow_mut().as_mut() {
2167 snapshot.pool_inspections.insert(authority, response);
2168 }
2169 }
2170
2171 fn pool_funding_module(
2172 &self,
2173 name: &str,
2174 authority: &crate::fleet_ensure::model::PoolFundingAuthority,
2175 principal: &str,
2176 ) -> Result<InspectedModule, IcpEnsurePlatformError> {
2177 let candid = self.root_protocol_candid()?;
2178 let root = parse_principal("Fleet Subnet Root", &authority.root)?;
2179 let target = parse_principal("pool funding target", principal)?;
2180 let mut start_after = None;
2181 loop {
2182 let page = self.query_estate_pool_page(&candid, root, start_after)?;
2183 if let Some(asset) = page
2184 .entries
2185 .iter()
2186 .find(|asset| asset.canister_id == target)
2187 {
2188 return pool_funding_inspected_module(name, authority.lifecycle, &asset.status);
2189 }
2190 if page.next_start_after.is_none() || page.next_start_after == start_after {
2191 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
2192 canister: name.to_string(),
2193 field: "reviewed pool membership",
2194 });
2195 }
2196 start_after = page.next_start_after;
2197 }
2198 }
2199
2200 fn observed_root_owned_asset(
2201 configured: &crate::fleet_ensure::model::DesiredCanister,
2202 principal: &str,
2203 root: &str,
2204 asset: CanisterPoolAsset,
2205 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
2206 let Some(root_owned_lifecycle) = root_owned_lifecycle(configured.kind, &asset.status)
2207 else {
2208 return Err(
2209 current_protocol::CurrentProtocolError::Configuration(format!(
2210 "Root-owned canister {} has a live pool role incompatible with desired {:?}",
2211 configured.name, configured.kind
2212 ))
2213 .into(),
2214 );
2215 };
2216 let status = match root_owned_lifecycle {
2217 RootOwnedCanisterLifecycle::Store | RootOwnedCanisterLifecycle::Workload => {
2218 CanisterRuntimeStatus::Running
2219 }
2220 RootOwnedCanisterLifecycle::Claimed
2221 | RootOwnedCanisterLifecycle::Idle
2222 | RootOwnedCanisterLifecycle::Reconciling
2223 | RootOwnedCanisterLifecycle::Retained => CanisterRuntimeStatus::Stopped,
2224 };
2225 Ok(Some(LiveCanister {
2226 canister_version: None,
2227 controllers: vec![root.to_string()],
2228 cycles: asset.cycles.to_u128(),
2229 module_sha256: None,
2230 principal: principal.to_string(),
2231 reinstall_required: false,
2232 root_owned_lifecycle: Some(root_owned_lifecycle),
2233 status,
2234 }))
2235 }
2236
2237 fn root_protocol_candid(&self) -> Result<PathBuf, IcpEnsurePlatformError> {
2238 let protocol = self.desired.protocol.as_ref().ok_or_else(|| {
2239 current_protocol::CurrentProtocolError::Configuration(
2240 "Root-owned observation requires typed Fleet protocol".to_string(),
2241 )
2242 })?;
2243 Ok(resolve_path(&self.root, &protocol.root_candid))
2244 }
2245
2246 fn required_root_status(
2247 &self,
2248 configured_name: &str,
2249 root: &str,
2250 ) -> Result<CanisterRuntimeStatus, IcpEnsurePlatformError> {
2251 self.status_optional(root)?
2252 .map(|live| live.status)
2253 .ok_or_else(|| {
2254 current_protocol::CurrentProtocolError::Configuration(format!(
2255 "Root-owned canister {configured_name} has no live Root"
2256 ))
2257 .into()
2258 })
2259 }
2260
2261 fn retained_root_owned_observation(
2262 &self,
2263 configured: &crate::fleet_ensure::model::DesiredCanister,
2264 principal: &str,
2265 parent: &str,
2266 root: &str,
2267 state: &FleetEnsureStateRecord,
2268 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
2269 let Some(retained_topology) = exact_retained_root_owned_topology(
2270 state,
2271 &configured.name,
2272 configured.kind,
2273 parent,
2274 principal,
2275 root,
2276 ) else {
2277 return Err(
2278 current_protocol::CurrentProtocolError::Configuration(format!(
2279 "Root-owned canister {} has no exact retained topology authority",
2280 configured.name
2281 ))
2282 .into(),
2283 );
2284 };
2285 let cycles = self
2286 .controller_cycle_balance(principal)
2287 .or_else(|| state.retained_cycles_by_principal.get(principal).copied())
2288 .ok_or_else(|| {
2289 current_protocol::CurrentProtocolError::Configuration(format!(
2290 "Root-owned canister {} has no current or retained exact native balance",
2291 configured.name
2292 ))
2293 })?;
2294 if !matches!(
2295 configured.kind,
2296 DesiredCanisterKind::Pool | DesiredCanisterKind::Store
2297 ) || configured.presence != crate::fleet_ensure::model::DesiredPresence::Present
2298 || configured.replace
2299 || configured.drain.is_some()
2300 || (configured.kind == DesiredCanisterKind::Pool && configured.wasm.is_some())
2301 || (configured.kind == DesiredCanisterKind::Store && configured.wasm.is_none())
2302 {
2303 return Err(
2304 current_protocol::CurrentProtocolError::Configuration(format!(
2305 "Root-owned recovery evidence for {} cannot authorize a mutation",
2306 configured.name
2307 ))
2308 .into(),
2309 );
2310 }
2311 let root_config = self
2312 .desired
2313 .canisters
2314 .iter()
2315 .find(|canister| canister.name == parent && canister.kind == DesiredCanisterKind::Root)
2316 .ok_or_else(|| {
2317 current_protocol::CurrentProtocolError::Configuration(format!(
2318 "Root-owned canister {} has no exact configured Root",
2319 configured.name
2320 ))
2321 })?;
2322 let live_root = self.status_optional(root)?.ok_or_else(|| {
2323 current_protocol::CurrentProtocolError::Configuration(format!(
2324 "Root-owned canister {} has no live Root",
2325 configured.name
2326 ))
2327 })?;
2328 let mut expected_controllers = root_config.controllers.clone();
2329 expected_controllers.sort();
2330 if live_root.controllers != expected_controllers {
2331 return Err(
2332 current_protocol::CurrentProtocolError::Configuration(format!(
2333 "Root-owned canister {} Root controllers drifted",
2334 configured.name
2335 ))
2336 .into(),
2337 );
2338 }
2339 Ok(Some(LiveCanister {
2340 canister_version: None,
2341 controllers: vec![root.to_string()],
2342 cycles,
2343 module_sha256: retained_topology.module_hash.clone(),
2344 principal: principal.to_string(),
2345 reinstall_required: false,
2346 root_owned_lifecycle: Some(RootOwnedCanisterLifecycle::Retained),
2347 status: if configured.kind == DesiredCanisterKind::Store {
2348 CanisterRuntimeStatus::Running
2349 } else {
2350 CanisterRuntimeStatus::Stopped
2351 },
2352 }))
2353 }
2354
2355 fn completed_reinstall_is_current(
2356 &self,
2357 state: &FleetEnsureStateRecord,
2358 name: &str,
2359 live: &LiveCanister,
2360 ) -> Result<bool, IcpEnsurePlatformError> {
2361 if state.completed_reinstall_operation_id.is_none()
2362 || !state.completed_reinstall_action_sha256.contains_key(name)
2363 {
2364 return Ok(false);
2365 }
2366 let Some(pre_canister_version) = state.completed_reinstalls.get(name) else {
2367 return Ok(false);
2368 };
2369 let Some(configured) = self
2370 .desired
2371 .canisters
2372 .iter()
2373 .find(|configured| configured.name == name)
2374 else {
2375 return Ok(false);
2376 };
2377 let Some(wasm) = configured.wasm.as_deref() else {
2378 return Ok(false);
2379 };
2380 let principal_matches = state
2381 .principals
2382 .get(name)
2383 .is_some_and(|principal| principal == &live.principal);
2384 let retained_topology = state.topology.get(name);
2385 let topology_matches = retained_topology.is_some_and(|topology| {
2386 topology.kind == configured.kind && topology.parent == configured.parent
2387 });
2388 let desired_module_sha256 = artifact_hash(&resolve_path(&self.root, wasm))?;
2389 let root_owned_store_module = (live.module_sha256.is_none()
2390 && live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Store))
2391 .then(|| {
2392 configured
2393 .parent
2394 .as_deref()
2395 .and_then(|parent| state.principals.get(parent))
2396 .filter(|root| live.controllers.as_slice() == [root.as_str()])
2397 .and(retained_topology)
2398 .and_then(|topology| topology.module_hash.as_deref())
2399 })
2400 .flatten();
2401 completed_reinstall_continuity(
2402 *pre_canister_version,
2403 principal_matches,
2404 topology_matches,
2405 live.module_sha256.as_deref().or(root_owned_store_module),
2406 &desired_module_sha256,
2407 live.canister_version,
2408 )
2409 .map_err(
2410 |field| IcpEnsurePlatformError::CompletedReinstallAuthorityConflict {
2411 canister: name.to_string(),
2412 field,
2413 },
2414 )?;
2415 Ok(true)
2416 }
2417
2418 fn controller_cycle_balance(&self, principal: &str) -> Option<u128> {
2419 let response: Result<ManagedCanisterStatusResponse, canic_core::dto::error::Error> = self
2420 .icp
2421 .canister_query_candid(
2422 principal,
2423 canic_protocol::CANIC_OBSERVABILITY,
2424 &ManagedCanisterStatusRequest::CycleBalance,
2425 None,
2426 )
2427 .ok()?;
2428 let ManagedCanisterStatusResponse::CycleBalance(balance) = response.ok()?;
2429 Some(balance.cycles)
2430 }
2431
2432 fn apply_create(
2433 &self,
2434 authority: CreateCanisterAuthority<'_>,
2435 state: &FleetEnsureStateRecord,
2436 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2437 let creation_fee = self
2438 .desired
2439 .management_creation_fee_cycles
2440 .parse::<Cycles>()
2441 .map(|cycles| cycles.to_u128())
2442 .map_err(|_| IcpEnsurePlatformError::Arithmetic("management creation fee"))?;
2443 let amount = authority
2444 .requested_initial_cycles
2445 .checked_add(creation_fee)
2446 .ok_or(IcpEnsurePlatformError::Arithmetic("creation amount"))?;
2447 let controllers = self
2448 .resolved_controllers(state, authority.controllers, authority.controller_canisters)?
2449 .iter()
2450 .map(|value| parse_principal("controller", value))
2451 .collect::<Result<Vec<_>, _>>()?;
2452 let request = CreateCanisterArgs {
2453 amount: Nat::from(amount),
2454 created_at_time: Some(authority.created_at_time),
2455 creation_args: Some(CmcCreateCanisterArgs {
2456 settings: Some(CanisterSettings {
2457 compute_allocation: None,
2458 controllers: Some(controllers),
2459 freezing_threshold: None,
2460 memory_allocation: None,
2461 reserved_cycles_limit: None,
2462 }),
2463 subnet_selection: Some(SubnetSelection::Subnet {
2464 subnet: parse_principal("subnet", authority.subnet)?,
2465 }),
2466 }),
2467 from_subaccount: None,
2468 };
2469 let response: Result<CreateCanisterSuccess, CreateCanisterError> = self
2470 .icp
2471 .canister_call_candid(authority.ledger, "create_canister", &request, None)?;
2472 create_canister_response_outcome(response, authority.requested_initial_cycles)
2473 }
2474
2475 fn apply_fund(
2476 &self,
2477 amount: u128,
2478 created_at_time: u64,
2479 ledger: &str,
2480 principal: &str,
2481 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2482 let request = WithdrawArgs {
2483 amount: Nat::from(amount),
2484 created_at_time: Some(created_at_time),
2485 from_subaccount: None,
2486 to: parse_principal("funding target", principal)?,
2487 };
2488 let response: Result<Nat, WithdrawError> = self
2489 .icp
2490 .canister_call_candid(ledger, "withdraw", &request, None)?;
2491 match response {
2492 Ok(block) => Ok(EffectOutcome {
2493 created_principal: None,
2494 post_cycles: None,
2495 receipt: Some(block.to_string()),
2496 }),
2497 Err(WithdrawError::Duplicate { duplicate_of }) => Ok(EffectOutcome {
2498 created_principal: None,
2499 post_cycles: None,
2500 receipt: Some(duplicate_of.to_string()),
2501 }),
2502 Err(error) => Err(IcpEnsurePlatformError::LedgerWithdraw(
2503 render_withdraw_error(error),
2504 )),
2505 }
2506 }
2507
2508 fn apply_estate_fund(
2509 &self,
2510 amount: u128,
2511 created_at_time: u64,
2512 ledger: &str,
2513 principal: &str,
2514 reviewed_fee_cycles: u128,
2515 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2516 let request = CyclesLedgerTransferArgs {
2517 amount: Nat::from(amount),
2518 created_at_time: Some(created_at_time),
2519 fee: Some(Nat::from(reviewed_fee_cycles)),
2520 from_subaccount: None,
2521 memo: None,
2522 to: CyclesLedgerAccount {
2523 owner: parse_principal("estate funding target", principal)?,
2524 subaccount: None,
2525 },
2526 };
2527 let response: Result<Nat, CyclesLedgerTransferError> =
2528 self.icp
2529 .canister_call_candid(ledger, "icrc1_transfer", &request, None)?;
2530 match response {
2531 Ok(block) => Ok(EffectOutcome {
2532 created_principal: None,
2533 post_cycles: None,
2534 receipt: Some(block.to_string()),
2535 }),
2536 Err(CyclesLedgerTransferError::Duplicate { duplicate_of }) => Ok(EffectOutcome {
2537 created_principal: None,
2538 post_cycles: None,
2539 receipt: Some(duplicate_of.to_string()),
2540 }),
2541 Err(CyclesLedgerTransferError::BadFee { expected_fee }) => {
2542 Err(IcpEnsurePlatformError::LedgerTransferFeeChanged {
2543 expected_fee_cycles: ledger_fee_cycles(expected_fee)?,
2544 reviewed_fee_cycles,
2545 })
2546 }
2547 Err(error) => Err(IcpEnsurePlatformError::LedgerTransfer(
2548 render_ledger_transfer_error(error),
2549 )),
2550 }
2551 }
2552
2553 #[expect(
2554 clippy::too_many_arguments,
2555 reason = "the install boundary verifies one complete immutable action tuple"
2556 )]
2557 fn apply_install(
2558 &self,
2559 operation_id: &str,
2560 principals: &BTreeMap<String, String>,
2561 canic_init: Option<&crate::fleet_ensure::model::DesiredCanisterInit>,
2562 init_arg: Option<&str>,
2563 init_arg_sha256: Option<&str>,
2564 init_candid: Option<&str>,
2565 init_candid_sha256: Option<&str>,
2566 mode: InstallMode,
2567 principal: &str,
2568 wasm: &str,
2569 wasm_sha256: &str,
2570 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2571 verify_artifact(&resolve_path(&self.root, wasm), wasm_sha256, "Wasm")?;
2572 let mut command = self.icp.canister_command();
2573 command.args(["install", principal, "--mode"]);
2574 command.arg(match mode {
2575 InstallMode::Install => "install",
2576 InstallMode::Reinstall => "reinstall",
2577 });
2578 command.args(["--yes", "--wasm"]);
2579 command.arg(resolve_path(&self.root, wasm));
2580 let generated_init = if let Some(canic_init) = canic_init {
2581 let path = canic_init::write_arguments(canic_init::CanicInitRequest {
2582 desired: &self.desired,
2583 init: canic_init,
2584 operation_id,
2585 principals,
2586 root: &self.root,
2587 wasm,
2588 wasm_sha256,
2589 })?;
2590 command.arg("--args-file");
2591 command.arg(&path);
2592 command.args(["--args-format", "bin"]);
2593 Some(path)
2594 } else if let Some(init_arg) = init_arg {
2595 let expected =
2596 init_arg_sha256.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
2597 actual: "missing".to_string(),
2598 expected: "reviewed init argument identity".to_string(),
2599 kind: "init argument",
2600 })?;
2601 let init_candid =
2602 init_candid.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
2603 actual: "missing".to_string(),
2604 expected: "reviewed init Candid".to_string(),
2605 kind: "init Candid",
2606 })?;
2607 let init_candid_sha256 = init_candid_sha256.ok_or_else(|| {
2608 IcpEnsurePlatformError::ArtifactDigestMismatch {
2609 actual: "missing".to_string(),
2610 expected: "reviewed init Candid identity".to_string(),
2611 kind: "init Candid",
2612 }
2613 })?;
2614 let path = protocol::write_init_arguments(
2615 &self.root,
2616 operation_id,
2617 principals,
2618 init_candid,
2619 init_candid_sha256,
2620 init_arg,
2621 expected,
2622 )?;
2623 command.arg("--args-file");
2624 command.arg(&path);
2625 command.args(["--args-format", "bin"]);
2626 Some(path)
2627 } else {
2628 command.args(["--args", "()"]);
2629 None
2630 };
2631 self.icp.add_target_args(&mut command);
2632 let result = run_status(&mut command).map_err(IcpEnsurePlatformError::from);
2633 if let Some(path) = generated_init {
2634 std::fs::remove_file(&path)
2635 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path))?;
2636 }
2637 result?;
2638 Ok(empty_outcome())
2639 }
2640
2641 fn apply_controllers(
2642 &self,
2643 principal: &str,
2644 controllers: &[String],
2645 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2646 let mut command = self.icp.canister_command();
2647 command.args([
2648 "settings",
2649 "update",
2650 principal,
2651 "--force",
2652 "--remove-all-controllers",
2653 ]);
2654 for controller in controllers {
2655 parse_principal("controller", controller)?;
2656 command.args(["--add-controller", controller]);
2657 }
2658 self.icp.add_target_args(&mut command);
2659 run_status(&mut command)?;
2660 Ok(empty_outcome())
2661 }
2662
2663 fn observe_fleet_snapshot(
2664 &mut self,
2665 operation_id: &str,
2666 state: &FleetEnsureStateRecord,
2667 ) -> Result<FleetObservation, IcpEnsurePlatformError> {
2668 self.require_operator()?;
2669 let mut canisters = self
2670 .timed_observation(FleetObservationStage::ConfiguredCanisters, |platform| {
2671 platform.observe_configured_canisters(state)
2672 })?;
2673 self.reconcile_completed_reinstalls(state, &mut canisters)?;
2674 let mut estate_funding_domains = self
2675 .timed_observation(FleetObservationStage::EstateFunding, |platform| {
2676 platform.observe_estate_funding_domains(state)
2677 })?;
2678 self.timed_observation(FleetObservationStage::PoolBalances, |platform| {
2679 for domain in estate_funding_domains.values_mut() {
2680 if let (Some(root), Some(pool)) = (&domain.root_principal, &mut domain.pool) {
2681 platform.refresh_pool_balances(root, &mut pool.assets)?;
2682 for asset in &mut pool.assets {
2683 if matches!(
2684 asset.lifecycle,
2685 EstatePoolAssetLifecycle::PendingReset
2686 | EstatePoolAssetLifecycle::Failed
2687 ) {
2688 for live in canisters.values_mut().filter_map(Option::as_mut) {
2689 if live.principal == asset.principal {
2690 live.cycles = asset.cycles;
2691 }
2692 }
2693 }
2694 }
2695 }
2696 }
2697 Ok(())
2698 })?;
2699 self.estate_observations.clone_from(&estate_funding_domains);
2700 self.require_current_pool_policy(&estate_funding_domains)?;
2701 self.reconcile_completed_reinstalls(state, &mut canisters)?;
2702 let additional_controlled_cycles =
2703 Self::additional_pool_cycles(&canisters, &estate_funding_domains)?;
2704 let protocol_ready = self
2705 .timed_observation(FleetObservationStage::ProtocolReadiness, |platform| {
2706 platform.observe_protocol_readiness(operation_id, state, &canisters)
2707 })?;
2708 let ledger_fee_cycles =
2709 self.timed_observation(FleetObservationStage::LedgerFee, |platform| {
2710 ledger_fee_cycles(platform.icp.canister_query_candid(
2711 &platform.desired.cycles_ledger,
2712 "icrc1_fee",
2713 &(),
2714 None,
2715 )?)
2716 })?;
2717 let operator_cycles =
2718 self.timed_observation(FleetObservationStage::OperatorBalance, |platform| {
2719 platform
2720 .icp
2721 .identity_cycles_balance()
2722 .map_err(|error| IcpEnsurePlatformError::LedgerWithdraw(error.to_string()))
2723 })?;
2724 Ok(FleetObservation {
2725 additional_controlled_cycles,
2726 canisters,
2727 estate_funding_domains,
2728 ledger_fee_cycles,
2729 operator_cycles,
2730 protocol_ready,
2731 })
2732 }
2733
2734 fn observe_configured_canisters(
2735 &self,
2736 state: &FleetEnsureStateRecord,
2737 ) -> Result<BTreeMap<String, Option<LiveCanister>>, IcpEnsurePlatformError> {
2738 let configured = &self.desired.canisters;
2739 let mut observed = BTreeMap::new();
2740 let mut cursor = 0;
2741 while cursor < configured.len() {
2742 let pool_batch = configured[cursor].kind == DesiredCanisterKind::Pool;
2743 let end = configured[cursor..]
2744 .iter()
2745 .take(super::bounded_observations::MAX_IN_FLIGHT)
2746 .take_while(|canister| (canister.kind == DesiredCanisterKind::Pool) == pool_batch)
2747 .count()
2748 + cursor;
2749 if pool_batch {
2750 self.observe_configured_pool_batch(&configured[cursor..end], state, &mut observed)?;
2751 cursor = end;
2752 continue;
2753 }
2754 self.observe_configured_status_batch(&configured[cursor..end], state, &mut observed)?;
2755 cursor = end;
2756 }
2757 Ok(observed)
2758 }
2759
2760 fn observe_configured_pool_batch(
2761 &self,
2762 configured: &[crate::fleet_ensure::model::DesiredCanister],
2763 state: &FleetEnsureStateRecord,
2764 observed: &mut BTreeMap<String, Option<LiveCanister>>,
2765 ) -> Result<(), IcpEnsurePlatformError> {
2766 let prepared = configured
2769 .iter()
2770 .map(|canister| {
2771 self.current_principal(state, &canister.name).map_or(
2772 Ok(PreparedRootOwnedObservation::Observed(None)),
2773 |principal| self.prepare_root_owned_observation(canister, principal, state),
2774 )
2775 })
2776 .collect::<Vec<_>>();
2777 let authorities = prepared
2778 .iter()
2779 .filter_map(|result| match result {
2780 Ok(PreparedRootOwnedObservation::Pending(pending)) => {
2781 Some(pending.inspection.authority)
2782 }
2783 _ => None,
2784 })
2785 .collect::<Vec<_>>();
2786 let mut responses = self.read_pool_inspection_batch(&authorities)?;
2787 for (canister, prepared) in configured.iter().zip(prepared) {
2788 let live = match prepared? {
2789 PreparedRootOwnedObservation::Observed(live) => live,
2790 PreparedRootOwnedObservation::Pending(pending) => {
2791 let response = self.pool_inspection_batch_response(
2792 &mut responses,
2793 pending.inspection.authority,
2794 )?;
2795 self.complete_pending_pool_observation(*pending, response)?
2796 }
2797 };
2798 observed.insert(canister.name.clone(), live);
2799 }
2800 Ok(())
2801 }
2802
2803 fn read_pool_inspection_batch(
2804 &self,
2805 authorities: &[PoolInspectionAuthority],
2806 ) -> Result<PoolInspectionBatch, IcpEnsurePlatformError> {
2807 let mut seen = BTreeSet::new();
2808 let requests = authorities
2809 .iter()
2810 .copied()
2811 .filter(|authority| {
2812 seen.insert(*authority)
2813 && !self
2814 .observation_snapshot
2815 .borrow()
2816 .as_ref()
2817 .is_some_and(|snapshot| snapshot.pool_inspections.contains_key(authority))
2818 })
2819 .collect::<Vec<_>>();
2820 if requests.is_empty() {
2821 return Ok(PoolInspectionBatch::default());
2822 }
2823 let candid = self.root_protocol_candid()?;
2824 let icp = &self.icp;
2825 Ok(PoolInspectionBatch {
2826 responses: super::bounded_observations::collect(&requests, |authority| {
2827 Ok::<_, std::convert::Infallible>((
2828 *authority,
2829 Self::fetch_pool_inspection(icp, &candid, *authority),
2830 ))
2831 })
2832 .expect("inspection results are retained for ordered authority decisions")
2833 .into_iter()
2834 .collect(),
2835 })
2836 }
2837
2838 fn pool_inspection_batch_response(
2839 &self,
2840 batch: &mut PoolInspectionBatch,
2841 authority: PoolInspectionAuthority,
2842 ) -> Result<RootInspectionStatus, IcpEnsurePlatformError> {
2843 match batch.responses.get(&authority) {
2844 Some(Ok(response)) => Ok(response.clone()),
2845 Some(Err(_)) => batch
2846 .responses
2847 .remove(&authority)
2848 .expect("observed batch failure"),
2849 None => self.read_pool_inspection(authority),
2850 }
2851 }
2852
2853 fn observe_configured_status_batch(
2854 &self,
2855 configured: &[crate::fleet_ensure::model::DesiredCanister],
2856 state: &FleetEnsureStateRecord,
2857 observed: &mut BTreeMap<String, Option<LiveCanister>>,
2858 ) -> Result<(), IcpEnsurePlatformError> {
2859 let mut seen = BTreeSet::new();
2860 let requests = configured
2861 .iter()
2862 .filter_map(|canister| self.current_principal(state, &canister.name))
2863 .filter(|principal| seen.insert(*principal))
2864 .filter(|principal| matches!(self.cached_status(principal), StatusCacheEntry::Miss))
2865 .collect::<Vec<_>>();
2866 let icp = &self.icp;
2867 let mut results = super::bounded_observations::collect(&requests, |principal| {
2868 Ok::<_, std::convert::Infallible>((*principal, Self::read_status_with(icp, principal)))
2869 })
2870 .expect("read outcomes are retained for ordered authority decisions")
2871 .into_iter()
2872 .collect::<BTreeMap<_, _>>();
2873 for canister in configured {
2874 let Some(principal) = self.current_principal(state, &canister.name) else {
2875 observed.insert(canister.name.clone(), None);
2876 continue;
2877 };
2878 let status = if let StatusCacheEntry::Hit(cached) = self.cached_status(principal) {
2879 Ok(cached)
2880 } else {
2881 results
2882 .remove(principal)
2883 .unwrap_or_else(|| self.status_optional(principal))
2884 };
2885 if let Ok(live) = &status
2886 && let Some(snapshot) = self.observation_snapshot.borrow_mut().as_mut()
2887 {
2888 snapshot
2889 .statuses
2890 .insert(principal.to_string(), live.clone());
2891 }
2892 let live = self.configured_status_or_root(canister, principal, state, status)?;
2893 observed.insert(canister.name.clone(), live);
2894 }
2895 Ok(())
2896 }
2897
2898 fn cached_status(&self, principal: &str) -> StatusCacheEntry {
2899 self.observation_snapshot
2900 .borrow()
2901 .as_ref()
2902 .and_then(|snapshot| snapshot.statuses.get(principal).cloned())
2903 .map_or(StatusCacheEntry::Miss, StatusCacheEntry::Hit)
2904 }
2905
2906 fn reconcile_completed_reinstalls(
2907 &self,
2908 state: &FleetEnsureStateRecord,
2909 canisters: &mut BTreeMap<String, Option<LiveCanister>>,
2910 ) -> Result<(), IcpEnsurePlatformError> {
2911 for (name, live) in canisters.iter_mut() {
2912 let Some(live) = live.as_mut() else {
2913 continue;
2914 };
2915 let completed = self.completed_reinstall_is_current(state, name, live)?;
2916 let store_module_unobserved = live.module_sha256.is_none()
2917 && live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Store);
2918 if completed && store_module_unobserved {
2919 live.module_sha256 = state
2920 .topology
2921 .get(name)
2922 .and_then(|topology| topology.module_hash.clone());
2923 }
2924 }
2925 Ok(())
2926 }
2927
2928 fn additional_pool_cycles(
2929 canisters: &BTreeMap<String, Option<LiveCanister>>,
2930 domains: &BTreeMap<String, EstateFundingDomainObservation>,
2931 ) -> Result<BTreeMap<String, u128>, IcpEnsurePlatformError> {
2932 let configured = canisters
2933 .values()
2934 .filter_map(|live| live.as_ref().map(|live| live.principal.as_str()))
2935 .collect::<BTreeSet<_>>();
2936 let mut additional = BTreeMap::new();
2937 for asset in domains
2938 .values()
2939 .filter_map(|domain| domain.pool.as_ref())
2940 .flat_map(|pool| &pool.assets)
2941 {
2942 if configured.contains(asset.principal.as_str()) {
2943 continue;
2944 }
2945 if additional
2946 .insert(asset.principal.clone(), asset.cycles)
2947 .is_some()
2948 {
2949 return Err(pool_configuration_error(format!(
2950 "controlled pool canister {} is retained by more than one Root",
2951 asset.principal
2952 )));
2953 }
2954 }
2955 Ok(additional)
2956 }
2957
2958 fn management_roots_are_current(
2959 &self,
2960 roots: &[(&crate::fleet_ensure::model::DesiredCanister, LiveCanister)],
2961 reviewed_targets: &BTreeSet<String>,
2962 ) -> Result<bool, IcpEnsurePlatformError> {
2963 let mut all_current = true;
2964 for (configured, live) in roots {
2965 if live.status != CanisterRuntimeStatus::Running || !reviewed_targets.is_empty() {
2966 all_current = false;
2967 continue;
2968 }
2969 if live.module_sha256.is_none()
2970 && is_unallocated_fresh_root(&self.desired, configured, reviewed_targets)
2971 {
2972 continue;
2973 }
2974 if let Some(wasm) = &configured.wasm {
2975 let expected = artifact_hash(&resolve_path(&self.root, wasm))?;
2976 if live.module_sha256.as_deref() != Some(expected.as_str()) {
2977 all_current = false;
2978 }
2979 }
2980 }
2981 Ok(all_current)
2982 }
2983
2984 fn observe_protocol_readiness(
2985 &self,
2986 operation_id: &str,
2987 state: &FleetEnsureStateRecord,
2988 canisters: &BTreeMap<String, Option<LiveCanister>>,
2989 ) -> Result<BTreeMap<String, bool>, IcpEnsurePlatformError> {
2990 let principals = self.protocol_principals(state);
2991 self.desired
2992 .protocol_steps
2993 .iter()
2994 .map(|step| {
2995 let Some(principal) = principals.get(&step.canister) else {
2996 return Ok((step.name.clone(), false));
2997 };
2998 let Some(live) = canisters.get(&step.canister).and_then(Option::as_ref) else {
2999 return Ok((step.name.clone(), false));
3000 };
3001 let configured = self
3002 .desired
3003 .canisters
3004 .iter()
3005 .find(|configured| configured.name == step.canister)
3006 .expect("protocol target was validated against desired canisters");
3007 if live.reinstall_required {
3008 return Ok((step.name.clone(), false));
3009 }
3010 if let Some(wasm) = &configured.wasm {
3011 let desired_hash = artifact_hash(&resolve_path(&self.root, wasm))?;
3012 if live.module_sha256.as_deref() != Some(desired_hash.as_str()) {
3013 return Ok((step.name.clone(), false));
3014 }
3015 }
3016 let action = self.observed_protocol_action(step, principal.clone())?;
3017 protocol::observe(&self.icp, &self.root, operation_id, &principals, &action)
3018 .map(|observation| (step.name.clone(), observation.applied))
3019 .map_err(IcpEnsurePlatformError::from)
3020 })
3021 .collect()
3022 }
3023
3024 #[expect(
3025 clippy::too_many_arguments,
3026 reason = "one typed transfer effect carries the complete reviewed authority tuple"
3027 )]
3028 fn apply_transfer(
3029 &self,
3030 amount: u128,
3031 candid: &str,
3032 candid_sha256: &str,
3033 destination: &str,
3034 method: &str,
3035 operation_id: &str,
3036 principal: &str,
3037 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
3038 let candid = resolve_path(&self.root, candid);
3039 if !candid.is_file() {
3040 return Err(IcpEnsurePlatformError::CandidUnavailable(candid));
3041 }
3042 verify_artifact(&candid, candid_sha256, "drain Candid")?;
3043 let response: DrainResponse = self.icp.canister_call_candid(
3044 principal,
3045 method,
3046 &DrainRequest {
3047 operation_id: operation_id.to_string(),
3048 destination: parse_principal("treasury", destination)?,
3049 cycles: Nat::from(amount),
3050 },
3051 Some(&candid),
3052 )?;
3053 let transferred = match response {
3054 DrainResponse::Accepted { transferred_cycles }
3055 | DrainResponse::Replayed { transferred_cycles } => transferred_cycles,
3056 };
3057 if transferred != amount {
3058 return Err(IcpEnsurePlatformError::WrongTransferAmount {
3059 actual: transferred,
3060 expected: amount,
3061 });
3062 }
3063 Ok(EffectOutcome {
3064 created_principal: None,
3065 post_cycles: None,
3066 receipt: Some(operation_id.to_string()),
3067 })
3068 }
3069}
3070
3071impl EnsurePlatform for IcpEnsurePlatform {
3072 type Error = IcpEnsurePlatformError;
3073
3074 fn report_progress(&mut self, progress: FleetEnsureProgress) {
3075 if let Some(handler) = self.progress_handler.as_mut() {
3076 handler(progress);
3077 }
3078 }
3079
3080 fn require_retained_root_activation(
3081 &mut self,
3082 operation_id: &str,
3083 root: &str,
3084 state: &FleetEnsureStateRecord,
3085 ) -> Result<(), Self::Error> {
3086 let authorities = canic_init::compile_root_authorities(
3087 &self.root,
3088 &self.desired,
3089 &self.protocol_principals(state),
3090 )?;
3091 let (_, authority) = authorities
3092 .iter()
3093 .find(|(name, _)| name == root)
3094 .ok_or_else(|| canic_init::CanicInitError::MissingRoot(root.to_string()))?;
3095 current_protocol::require_store_installation_binding(
3096 &self.icp,
3097 &self.root_protocol_candid()?,
3098 root,
3099 authority,
3100 operation_id,
3101 )?;
3102 Ok(())
3103 }
3104
3105 fn bind_reviewed_desired(&mut self, desired: &DesiredFleet) -> Result<(), Self::Error> {
3106 self.desired = desired.clone();
3107 Ok(())
3108 }
3109
3110 fn pace_effect_observation(
3111 &mut self,
3112 _action: &EnsureAction,
3113 consecutive_unchanged_observations: u32,
3114 ) {
3115 thread::sleep(protocol_observation_delay(
3116 consecutive_unchanged_observations,
3117 self.initial_observation_delay,
3118 self.maximum_observation_delay,
3119 ));
3120 }
3121
3122 fn pace_root_owned_observation(
3123 &mut self,
3124 _target: &str,
3125 consecutive_retained_observations: u32,
3126 ) {
3127 thread::sleep(protocol_observation_delay(
3128 consecutive_retained_observations,
3129 self.initial_observation_delay,
3130 self.maximum_observation_delay,
3131 ));
3132 }
3133
3134 fn authority_sealed(
3135 &mut self,
3136 operation_id: &str,
3137 action: &EnsureAction,
3138 ) -> Result<bool, Self::Error> {
3139 Ok(super::authority_seal::observe(&self.icp, &self.root, operation_id, action)?.applied)
3140 }
3141
3142 fn reinstall_assets_match(
3143 &mut self,
3144 intent: &crate::fleet_ensure::model::FleetReinstallRecord,
3145 root_name: &str,
3146 check: super::ReinstallAssetCheck,
3147 ) -> Result<bool, Self::Error> {
3148 let binding = intent
3149 .authorities
3150 .iter()
3151 .find(|binding| binding.name == root_name)
3152 .ok_or_else(|| {
3153 IcpEnsurePlatformError::RootManagement("missing reset Root".to_string())
3154 })?;
3155 let principal = parse_principal("reset Root", &binding.principal)?;
3156 let candid = if let Some(source) = intent.source.as_deref()
3157 && check == super::ReinstallAssetCheck::BeforeReset
3158 {
3159 let path = source
3160 .reviewed_desired
3161 .desired()
3162 .protocol
3163 .as_ref()
3164 .map(|protocol| &protocol.root_candid)
3165 .ok_or_else(|| pool_configuration_error("missing source Root protocol".into()))?;
3166 let hash = super::artifact_sha256(&self.root, path)
3167 .map_err(|error| pool_configuration_error(error.to_string()))?;
3168 if source.candid_sha256_by_path.get(path) != Some(&hash) {
3169 return Err(pool_configuration_error(
3170 "source Root protocol changed".into(),
3171 ));
3172 }
3173 self.root.join(path)
3174 } else {
3175 self.root_protocol_candid()?
3176 };
3177 for asset in intent.assets.iter().filter(|asset| asset.root == root_name) {
3178 let target = parse_principal("reset asset", &asset.principal)?;
3179 crate::canister_protocol::inspection::preflight_inspection(
3180 &self.icp, &candid, principal, target,
3181 )
3182 .map_err(current_protocol::CurrentProtocolError::from)?;
3183 let response: RootInspectionResponse = call_with_candid(
3184 &self.icp,
3185 &candid,
3186 principal,
3187 canic_protocol::CANIC_ROOT_COMMAND,
3188 &RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
3189 canister_id: target,
3190 }),
3191 )
3192 .map_err(current_protocol::CurrentProtocolError::from)?;
3193 let status = response
3194 .into_status(principal, target)
3195 .map_err(current_protocol::CurrentProtocolError::from)?;
3196 let mut controllers = status
3197 .settings
3198 .controllers
3199 .iter()
3200 .map(Principal::to_text)
3201 .collect::<Vec<_>>();
3202 controllers.sort();
3203 let module = status
3204 .module_hash
3205 .as_ref()
3206 .map(canic_core::cdk::utils::hash::hex_bytes);
3207 let module_matches =
3208 check == super::ReinstallAssetCheck::Terminal || module == asset.module_sha256;
3209 if controllers != asset.controllers || !module_matches {
3210 return Ok(false);
3211 }
3212 }
3213 Ok(true)
3214 }
3215
3216 fn reinstall_authorities(
3217 &mut self,
3218 state: &FleetEnsureStateRecord,
3219 ) -> Result<Option<BTreeMap<String, RootManagementCanisterObservation>>, Self::Error> {
3220 self.require_operator()?;
3221 let network = resolve_icp_build_network_from_root(&self.root, &self.desired.environment)
3222 .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?;
3223 let catalog = if network == BuildNetwork::Ic {
3224 let now = SystemTime::now()
3225 .duration_since(UNIX_EPOCH)
3226 .map_err(|_| {
3227 IcpEnsurePlatformError::RootManagement(
3228 "system clock is before the Unix epoch".to_string(),
3229 )
3230 })?
3231 .as_secs();
3232 Some(
3233 load_cached_mainnet_subnet_catalog(&self.root, now)
3234 .map_err(IcpEnsurePlatformError::SubnetCatalog)?,
3235 )
3236 } else {
3237 None
3238 };
3239 let mut authorities = BTreeMap::new();
3240 let root_targets = self
3241 .desired
3242 .canisters
3243 .iter()
3244 .filter(|c| c.kind == DesiredCanisterKind::Root)
3245 .map(|c| c.name.clone())
3246 .collect();
3247 let roots = self
3248 .observe_root_management(state, &root_targets)?
3249 .ok_or_else(|| {
3250 IcpEnsurePlatformError::RootManagement("missing Root authority".to_string())
3251 })?;
3252 for configured in &self.desired.canisters {
3253 if configured.kind == DesiredCanisterKind::Pool {
3254 continue;
3255 }
3256 let principal = self
3257 .current_principal(state, &configured.name)
3258 .ok_or_else(|| {
3259 IcpEnsurePlatformError::UnresolvedCreated(configured.name.clone())
3260 })?;
3261 let live = if let Some(root) = roots.roots.get(&configured.name) {
3263 self.complete_install_status(principal, root.live.clone())?
3264 } else {
3265 self.install_status_optional(principal)?.ok_or_else(|| {
3266 IcpEnsurePlatformError::RootManagement("missing infrastructure".to_string())
3267 })?
3268 };
3269 let subnet = if let Some(root) = roots.roots.get(&configured.name) {
3270 root.subnet.clone()
3271 } else if let Some(catalog) = &catalog {
3272 catalog
3273 .catalog
3274 .resolve_canister_route(principal)
3275 .map(|route| route.subnet.to_text())
3276 .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?
3277 } else {
3278 configured.subnet.clone()
3279 };
3280 authorities.insert(
3281 configured.name.clone(),
3282 RootManagementCanisterObservation {
3283 live,
3284 name: configured.name.clone(),
3285 subnet,
3286 },
3287 );
3288 }
3289 Ok(Some(authorities))
3290 }
3291
3292 fn reinstall_inventory(
3293 &mut self,
3294 source_operation_id: &str,
3295 state: &FleetEnsureStateRecord,
3296 ) -> Result<Option<super::FleetReinstallObservation>, Self::Error> {
3297 let authorities = self.reinstall_authorities(state)?.ok_or_else(|| {
3298 IcpEnsurePlatformError::RootManagement("missing infrastructure".to_string())
3299 })?;
3300 let terminal = self.terminal_inventory(source_operation_id, state)?;
3301 let mut observation = self.observe(source_operation_id, state)?;
3302 for (name, observed) in &authorities {
3303 observation
3304 .canisters
3305 .insert(name.clone(), Some(observed.live.clone()));
3306 }
3307 let candid = self.root_protocol_candid()?;
3308 let candid_by_root = observation
3309 .estate_funding_domains
3310 .keys()
3311 .map(|name| (name.clone(), candid.clone()))
3312 .collect();
3313 let assets = self.inspect_reinstall_assets(
3314 state,
3315 &authorities,
3316 &mut observation,
3317 &candid_by_root,
3318 false,
3319 )?;
3320 let seen = assets
3321 .iter()
3322 .map(|asset| asset.principal.clone())
3323 .collect::<BTreeSet<_>>();
3324 let infrastructure = authorities
3325 .values()
3326 .map(|a| a.live.principal.as_str())
3327 .collect::<BTreeSet<_>>();
3328 if terminal
3329 .controlled_cycles_by_principal
3330 .keys()
3331 .any(|pid| !infrastructure.contains(pid.as_str()) && !seen.contains(pid))
3332 {
3333 return Err(IcpEnsurePlatformError::RootManagement(
3334 "terminal inventory escapes the reviewed reset estate".to_string(),
3335 ));
3336 }
3337 Ok(Some(super::FleetReinstallObservation {
3338 authorities,
3339 assets,
3340 observation,
3341 }))
3342 }
3343
3344 #[expect(
3345 clippy::too_many_lines,
3346 reason = "one source observation binds old protocol receipts, full pagination and physical management inspection"
3347 )]
3348 fn activation_reset_inventory(
3349 &mut self,
3350 source: &crate::fleet_ensure::model::FleetActivationSourceRecord,
3351 state: &FleetEnsureStateRecord,
3352 ) -> Result<Option<super::FleetActivationResetObservation>, Self::Error> {
3353 if source.operator != self.desired.operator
3354 || source.cycles_ledger != self.desired.cycles_ledger
3355 {
3356 return Err(pool_configuration_error(
3357 "source operator or Cycles Ledger differs from the requested reset".to_string(),
3358 ));
3359 }
3360 let authorities = self
3361 .reinstall_authorities(state)?
3362 .ok_or_else(|| pool_configuration_error("missing source infrastructure".to_string()))?;
3363 let mut observation = FleetObservation {
3364 additional_controlled_cycles: BTreeMap::new(),
3365 canisters: authorities
3366 .iter()
3367 .map(|(name, observed)| (name.clone(), Some(observed.live.clone())))
3368 .collect(),
3369 estate_funding_domains: BTreeMap::new(),
3370 ledger_fee_cycles: self
3371 .desired
3372 .ledger_fee_cycles
3373 .parse::<Cycles>()
3374 .map_err(|_| pool_configuration_error("invalid source fee bound".to_string()))?
3375 .to_u128(),
3376 operator_cycles: 0,
3377 protocol_ready: BTreeMap::new(),
3378 };
3379 let mut roots = Vec::new();
3380 let mut candid_by_root = BTreeMap::new();
3381 for configured in &self.desired.canisters {
3382 if configured.kind != DesiredCanisterKind::Root {
3383 continue;
3384 }
3385 let live = &authorities
3386 .get(&configured.name)
3387 .ok_or_else(|| pool_configuration_error("missing source Root".to_string()))?
3388 .live;
3389 let root = parse_principal("source Root", &live.principal)?;
3390 let evidence = current_protocol::observe_inactive_activation(
3391 &self.icp,
3392 &self.root,
3393 source,
3394 &configured.name,
3395 root,
3396 )?;
3397 let candid = source
3398 .registry_preparations
3399 .iter()
3400 .find_map(|action| match action {
3401 EnsureAction::FleetProtocol {
3402 principal, candid, ..
3403 } if principal == &live.principal => Some(self.root.join(candid)),
3404 _ => None,
3405 })
3406 .ok_or_else(|| {
3407 pool_configuration_error("missing source Root read contract".to_string())
3408 })?;
3409 let mut inventory = EstatePoolInventoryAccumulator::default();
3410 let mut cursor = None;
3411 loop {
3412 let page = self.query_estate_pool_page(&candid, root, cursor)?;
3413 if !inactive_source_pool_has_full_inventory(&page, &evidence) {
3414 return Err(pool_configuration_error(
3415 "source pool has unsettled work or spare creation capacity".to_string(),
3416 ));
3417 }
3418 let next = inventory.observe_page(&configured.name, page)?;
3419 if next.is_none() {
3420 break;
3421 }
3422 if next == cursor {
3423 return Err(pool_configuration_error(
3424 "source pool cursor did not advance".to_string(),
3425 ));
3426 }
3427 cursor = next;
3428 }
3429 observation.estate_funding_domains.insert(
3430 configured.name.clone(),
3431 EstateFundingDomainObservation {
3432 balance_cycles: Some(self.cycles_ledger_balance(&live.principal)?),
3433 cycles_ledger: self.desired.cycles_ledger.clone(),
3434 pool: Some(inventory.finish(&configured.name)?),
3435 root_principal: Some(live.principal.clone()),
3436 },
3437 );
3438 candid_by_root.insert(configured.name.clone(), candid);
3439 roots.push(evidence);
3440 }
3441 if roots.is_empty() || roots.len() != source.registry_preparations.len() {
3442 return Err(pool_configuration_error(
3443 "source Root inventory is incomplete".to_string(),
3444 ));
3445 }
3446 let assets = self.inspect_reinstall_assets(
3447 state,
3448 &authorities,
3449 &mut observation,
3450 &candid_by_root,
3451 false,
3452 )?;
3453 Ok(Some(super::FleetActivationResetObservation {
3454 inventory: super::FleetReinstallObservation {
3455 authorities,
3456 assets,
3457 observation,
3458 },
3459 roots,
3460 }))
3461 }
3462
3463 fn activation_reset_inventory_after_reset(
3464 &mut self,
3465 intent: &crate::fleet_ensure::model::FleetReinstallRecord,
3466 state: &FleetEnsureStateRecord,
3467 ) -> Result<Option<super::FleetReinstallObservation>, Self::Error> {
3468 let authorities = self
3469 .reinstall_authorities(state)?
3470 .ok_or_else(|| pool_configuration_error("missing reset infrastructure".to_string()))?;
3471 let mut observation = FleetObservation {
3472 canisters: authorities
3473 .iter()
3474 .map(|(name, entry)| (name.clone(), Some(entry.live.clone())))
3475 .collect(),
3476 additional_controlled_cycles: BTreeMap::new(),
3477 estate_funding_domains: BTreeMap::new(),
3478 ledger_fee_cycles: self
3479 .desired
3480 .ledger_fee_cycles
3481 .parse::<Cycles>()
3482 .map_err(|_| pool_configuration_error("invalid reset Ledger fee".to_string()))?
3483 .to_u128(),
3484 operator_cycles: 0,
3485 protocol_ready: BTreeMap::new(),
3486 };
3487 let candid = self.root_protocol_candid()?;
3488 let mut candid_by_root = BTreeMap::new();
3489 for configured in &self.desired.canisters {
3490 if configured.kind != DesiredCanisterKind::Root {
3491 continue;
3492 }
3493 let binding = authorities
3494 .get(&configured.name)
3495 .ok_or_else(|| pool_configuration_error("missing reset Root".to_string()))?;
3496 let root = parse_principal("reset Root", &binding.live.principal)?;
3497 let mut inventory = EstatePoolInventoryAccumulator::default();
3498 let mut cursor = None;
3499 loop {
3500 let page = self.query_estate_pool_page(&candid, root, cursor)?;
3501 let next = inventory.observe_page(&configured.name, page)?;
3502 if next.is_none() {
3503 break;
3504 }
3505 if next == cursor {
3506 return Err(pool_configuration_error(
3507 "reset inventory cursor did not advance".to_string(),
3508 ));
3509 }
3510 cursor = next;
3511 }
3512 let pool = inventory.finish(&configured.name)?;
3513 let expected = intent
3514 .assets
3515 .iter()
3516 .filter(|asset| asset.root == configured.name)
3517 .map(|asset| asset.principal.as_str())
3518 .collect::<BTreeSet<_>>();
3519 let observed = pool
3520 .assets
3521 .iter()
3522 .map(|asset| asset.principal.as_str())
3523 .collect::<BTreeSet<_>>();
3524 if observed != expected || pool.pending_creation.is_some() {
3525 return Err(pool_configuration_error(
3526 "reset changed the complete physical estate".to_string(),
3527 ));
3528 }
3529 observation.estate_funding_domains.insert(
3530 configured.name.clone(),
3531 EstateFundingDomainObservation {
3532 balance_cycles: Some(self.cycles_ledger_balance(&binding.live.principal)?),
3533 cycles_ledger: self.desired.cycles_ledger.clone(),
3534 pool: Some(pool),
3535 root_principal: Some(binding.live.principal.clone()),
3536 },
3537 );
3538 candid_by_root.insert(configured.name.clone(), candid.clone());
3539 }
3540 let assets = self.inspect_reinstall_assets(
3541 state,
3542 &authorities,
3543 &mut observation,
3544 &candid_by_root,
3545 true,
3546 )?;
3547 Ok(Some(super::FleetReinstallObservation {
3548 authorities,
3549 assets,
3550 observation,
3551 }))
3552 }
3553
3554 fn observe_root_management(
3555 &mut self,
3556 state: &FleetEnsureStateRecord,
3557 reviewed_targets: &BTreeSet<String>,
3558 ) -> Result<Option<RootManagementObservation>, Self::Error> {
3559 self.timed_observation(FleetObservationStage::RootManagement, |platform| {
3560 let configured_roots = platform
3561 .desired
3562 .canisters
3563 .iter()
3564 .filter(|configured| {
3565 configured.kind == DesiredCanisterKind::Root
3566 && configured.presence
3567 == crate::fleet_ensure::model::DesiredPresence::Present
3568 })
3569 .collect::<Vec<_>>();
3570 if configured_roots.is_empty() {
3571 return Ok(None);
3572 }
3573 platform.require_operator()?;
3574 let mut observed_roots = Vec::new();
3575 for configured in configured_roots {
3576 let Some(principal) = platform.current_principal(state, &configured.name) else {
3577 if is_unallocated_fresh_root(&platform.desired, configured, reviewed_targets) {
3578 continue;
3579 }
3580 return Err(IcpEnsurePlatformError::RootManagement(format!(
3581 "configured Root {} has no exact Principal",
3582 configured.name
3583 )));
3584 };
3585 let live = platform.status_optional(principal)?.ok_or_else(|| {
3586 IcpEnsurePlatformError::RootManagement(format!(
3587 "configured Root {} is unavailable",
3588 configured.name
3589 ))
3590 })?;
3591 observed_roots.push((configured, live));
3592 }
3593 let all_current =
3594 platform.management_roots_are_current(&observed_roots, reviewed_targets)?;
3595 if reviewed_targets.is_empty() && all_current {
3596 return Ok(None);
3597 }
3598 let network =
3599 resolve_icp_build_network_from_root(&platform.root, &platform.desired.environment)
3600 .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?;
3601 let catalog = if network == BuildNetwork::Ic {
3602 let now = SystemTime::now()
3603 .duration_since(UNIX_EPOCH)
3604 .map_err(|_| {
3605 IcpEnsurePlatformError::RootManagement(
3606 "system clock is before the Unix epoch".to_string(),
3607 )
3608 })?
3609 .as_secs();
3610 Some(
3611 load_cached_mainnet_subnet_catalog(&platform.root, now)
3612 .map_err(IcpEnsurePlatformError::SubnetCatalog)?,
3613 )
3614 } else {
3615 None
3616 };
3617 let mut roots = BTreeMap::new();
3618 for (configured, live) in observed_roots {
3619 let principal = live.principal.clone();
3620 let subnet = catalog.as_ref().map_or_else(
3621 || Ok(configured.subnet.clone()),
3622 |catalog| {
3623 catalog
3624 .catalog
3625 .resolve_canister_route(&principal)
3626 .map(|route| route.subnet.to_text())
3627 .map_err(|error| {
3628 IcpEnsurePlatformError::RootManagement(error.to_string())
3629 })
3630 },
3631 )?;
3632 let name = configured.name.clone();
3633 if roots
3634 .insert(
3635 name.clone(),
3636 RootManagementCanisterObservation { live, name, subnet },
3637 )
3638 .is_some()
3639 {
3640 return Err(IcpEnsurePlatformError::RootManagement(
3641 "configured Root names are not unique".to_string(),
3642 ));
3643 }
3644 }
3645 let operator_cycles = platform
3646 .icp
3647 .identity_cycles_balance()
3648 .map_err(|error| IcpEnsurePlatformError::LedgerWithdraw(error.to_string()))?;
3649 Ok(Some(RootManagementObservation {
3650 operator_cycles,
3651 roots,
3652 }))
3653 })
3654 }
3655
3656 fn observe_operator_funding(
3657 &mut self,
3658 ) -> Result<Option<crate::fleet_ensure::view::OperatorFundingObservation>, Self::Error> {
3659 self.require_operator()?;
3660 let ledger_fee_cycles = ledger_fee_cycles(self.icp.canister_query_candid(
3661 &self.desired.cycles_ledger,
3662 "icrc1_fee",
3663 &(),
3664 None,
3665 )?)?;
3666 Ok(Some(
3667 crate::fleet_ensure::view::OperatorFundingObservation {
3668 cycles_ledger: self.desired.cycles_ledger.clone(),
3669 ledger_fee_cycles,
3670 operator_cycles: self.cycles_ledger_balance(&self.desired.operator)?,
3671 },
3672 ))
3673 }
3674
3675 fn observe_native_funding(
3676 &mut self,
3677 root: &str,
3678 state: &FleetEnsureStateRecord,
3679 ) -> Result<Option<crate::fleet_ensure::model::NativeFundingObservation>, Self::Error> {
3680 self.require_operator()?;
3681 let configured = self
3682 .desired
3683 .canisters
3684 .iter()
3685 .find(|configured| {
3686 configured.name == root
3687 && configured.kind == DesiredCanisterKind::Root
3688 && configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
3689 })
3690 .ok_or_else(|| {
3691 IcpEnsurePlatformError::RootManagement(
3692 "native funding requires a configured Root".into(),
3693 )
3694 })?;
3695 let principal = self
3696 .current_principal(state, &configured.name)
3697 .ok_or_else(|| {
3698 IcpEnsurePlatformError::RootManagement(
3699 "native funding requires an allocated Root".into(),
3700 )
3701 })?;
3702 let live = self.status_optional(principal)?.ok_or_else(|| {
3703 IcpEnsurePlatformError::RootManagement("native funding Root is unavailable".into())
3704 })?;
3705 if let Some(wasm) = &configured.wasm {
3706 let expected = artifact_hash(&resolve_path(&self.root, wasm))?;
3707 if live.module_sha256.as_deref() != Some(&expected) {
3708 return Err(IcpEnsurePlatformError::RootManagement(
3709 "native funding Root module differs from the reviewed artifact".into(),
3710 ));
3711 }
3712 }
3713 let fee = ledger_fee_cycles(self.icp.canister_query_candid(
3714 &self.desired.cycles_ledger,
3715 "icrc1_fee",
3716 &(),
3717 None,
3718 )?)?;
3719 let operator_cycles = self.cycles_ledger_balance(&self.desired.operator)?;
3720 Ok(Some(crate::fleet_ensure::model::NativeFundingObservation {
3721 cycles_ledger: self.desired.cycles_ledger.clone(),
3722 ledger_fee_cycles: fee,
3723 live,
3724 operator_cycles,
3725 }))
3726 }
3727
3728 fn observe(
3729 &mut self,
3730 operation_id: &str,
3731 state: &FleetEnsureStateRecord,
3732 ) -> Result<FleetObservation, Self::Error> {
3733 self.with_observation_snapshot(|platform| {
3734 platform.observe_fleet_snapshot(operation_id, state)
3735 })
3736 }
3737
3738 fn protocol_actions(
3739 &mut self,
3740 operation_id: &str,
3741 state: &FleetEnsureStateRecord,
3742 ) -> Result<Vec<EnsureAction>, Self::Error> {
3743 self.timed_observation(FleetObservationStage::ProtocolActions, |platform| {
3744 if platform.desired.protocol.is_none() {
3745 return Ok(Vec::new());
3746 }
3747 current_protocol::validate_component_pool_capacity(&platform.root, &platform.desired)?;
3748 if !platform.current_protocol_owners_are_ready(state)? {
3749 return Ok(Vec::new());
3750 }
3751 let reconciliation = current_protocol::compile_pool_reconciliation(
3752 &platform.root,
3753 &platform.desired,
3754 state,
3755 &platform.estate_observations,
3756 )?;
3757 if !reconciliation.is_empty() {
3758 return Ok(reconciliation);
3759 }
3760 current_protocol::compile(
3761 &platform.icp,
3762 &platform.root,
3763 &platform.desired,
3764 operation_id,
3765 state,
3766 )
3767 .map_err(Into::into)
3768 })
3769 }
3770
3771 fn fresh_protocol_actions(
3772 &mut self,
3773 operation_id: &str,
3774 state: &FleetEnsureStateRecord,
3775 ) -> Result<Vec<EnsureAction>, Self::Error> {
3776 current_protocol::compile_fresh_protocol(&self.root, &self.desired, state, operation_id)
3777 .map_err(Into::into)
3778 }
3779
3780 fn terminal_inventory(
3781 &mut self,
3782 operation_id: &str,
3783 state: &FleetEnsureStateRecord,
3784 ) -> Result<TerminalFleetInventory, Self::Error> {
3785 self.timed_observation(FleetObservationStage::TerminalInventory, |platform| {
3786 if platform.desired.protocol.is_none() {
3787 if state.active_registry.is_some() {
3788 return Err(current_protocol::CurrentProtocolError::Configuration(
3789 "managed terminal Fleet cannot drop its typed protocol intent".to_string(),
3790 )
3791 .into());
3792 }
3793 return Ok(TerminalFleetInventory::default());
3794 }
3795 super::current_inventory::terminal_inventory(
3796 &platform.icp,
3797 &platform.root,
3798 &platform.desired,
3799 operation_id,
3800 state,
3801 )
3802 .map_err(Into::into)
3803 })
3804 }
3805
3806 #[expect(
3807 clippy::too_many_lines,
3808 reason = "the platform keeps every effect's exact live terminal predicate in one exhaustive match"
3809 )]
3810 fn observe_effect(
3811 &mut self,
3812 operation_id: &str,
3813 action: &EnsureAction,
3814 record: &EffectRecord,
3815 state: &FleetEnsureStateRecord,
3816 ) -> Result<EffectObservation, Self::Error> {
3817 if matches!(action, EnsureAction::SealAuthority { .. }) {
3818 return super::authority_seal::observe(&self.icp, &self.root, operation_id, action)
3819 .map_err(Into::into);
3820 }
3821 let mut retry = EffectRetry::None;
3822 let mut post_cycles = None;
3823 let (applied, progress_identity) = match action {
3824 EnsureAction::SealAuthority { .. } => {
3825 unreachable!("authority seals use their typed observer")
3826 }
3827 EnsureAction::Create {
3828 name,
3829 requested_initial_cycles,
3830 ..
3831 } => {
3832 let (live_cycles, deferred) =
3833 if record.created_principal.is_some() && record.receipt.is_some() {
3834 self.created_canister_cycles(
3835 name,
3836 record
3837 .created_principal
3838 .as_deref()
3839 .ok_or(IcpEnsurePlatformError::LedgerCreatePending)?,
3840 state,
3841 )?
3842 } else {
3843 (None, false)
3844 };
3845 post_cycles = live_cycles;
3846 let maximum_observation_burn_cycles =
3847 super::maximum_creation_observation_burn(&self.desired).ok_or(
3848 IcpEnsurePlatformError::Arithmetic("Create execution and observation burn"),
3849 )?;
3850 let applied = create_balance_is_terminal(
3851 live_cycles,
3852 *requested_initial_cycles,
3853 maximum_observation_burn_cycles,
3854 );
3855 if live_cycles.is_some() && !applied {
3856 retry = EffectRetry::ReplanRequiredAfterCreateBalanceDrift;
3857 } else if deferred {
3858 retry = EffectRetry::DeferUntilControllerObservation;
3859 }
3860 (
3861 applied,
3862 format!(
3863 "created:{:?}:actual:{live_cycles:?}:requested:{requested_initial_cycles}",
3864 record.created_principal,
3865 ),
3866 )
3867 }
3868 EnsureAction::Delete { principal, .. } => {
3869 let live = self.status_optional(Self::action_principal(state, principal)?)?;
3870 (live.is_none(), format!("delete:{live:?}"))
3871 }
3872 EnsureAction::Fund {
3873 amount,
3874 expected_post_cycles,
3875 funding_deficit_cycles,
3876 funding_margin_cycles,
3877 ..
3878 } => {
3879 let live_cycles = self.action_cycles(action, state)?;
3880 post_cycles = live_cycles;
3881 let observation = NativeFundingObservation {
3882 amount: *amount,
3883 expected_post_cycles: *expected_post_cycles,
3884 funding_deficit_cycles: *funding_deficit_cycles,
3885 funding_margin_cycles: *funding_margin_cycles,
3886 live_cycles,
3887 pre_cycles: record.pre_cycles,
3888 };
3889 (
3890 native_funding_completion(observation, record.receipt.is_some())?,
3891 format!(
3892 "native-topup:ledger-withdraw:{}:actual:{live_cycles:?}:expected:{expected_post_cycles}:margin:{funding_margin_cycles}",
3893 record.receipt.as_deref().unwrap_or("pending"),
3894 ),
3895 )
3896 }
3897 EnsureAction::FundEstate {
3898 amount,
3899 expected_post_cycles,
3900 ledger_fee_cycles,
3901 principal,
3902 ..
3903 } => {
3904 let source_cycles = self
3905 .icp
3906 .identity_cycles_balance()
3907 .map_err(|error| IcpEnsurePlatformError::LedgerTransfer(error.to_string()))?;
3908 let target = Self::action_principal(state, principal)?;
3909 let destination_cycles = self.cycles_ledger_balance(target)?;
3910 post_cycles = Some(source_cycles);
3911 (
3912 record.receipt.is_some()
3913 && estate_funding_applied(EstateFundingObservation {
3914 amount: *amount,
3915 destination_after: destination_cycles,
3916 destination_before: record.destination_pre_cycles,
3917 expected_destination_after: *expected_post_cycles,
3918 ledger_fee_cycles: *ledger_fee_cycles,
3919 source_after: source_cycles,
3920 source_before: record.pre_cycles,
3921 }),
3922 format!(
3923 "estate-funding:ledger-transfer:{}:source:{source_cycles}:destination:{destination_cycles}:expected:{expected_post_cycles}",
3924 record.receipt.as_deref().unwrap_or("pending"),
3925 ),
3926 )
3927 }
3928 EnsureAction::Install {
3929 mode,
3930 reinstall_witness,
3931 principal,
3932 wasm_sha256,
3933 ..
3934 } => {
3935 let live =
3936 self.install_status_optional(Self::action_principal(state, principal)?)?;
3937 let applied = if let Some(witness) = reinstall_witness {
3938 let current = self.reinstall_authorities(state)?;
3939 let authority = current
3940 .as_ref()
3941 .and_then(|a| a.get(&witness.authority.name))
3942 .and_then(super::reinstall::authority_binding);
3943 let history_authority = authority
3944 .as_ref()
3945 .and_then(|current| {
3946 crate::fleet_ensure::policy::reinstall::history_authority(
3947 witness,
3948 current,
3949 principal,
3950 wasm_sha256,
3951 &record.state,
3952 )
3953 })
3954 .ok_or_else(|| IcpEnsurePlatformError::ReinstallHistoryConflict {
3955 canister: witness.authority.principal.clone(),
3956 })?;
3957 let live = live.as_ref().ok_or_else(|| {
3958 IcpEnsurePlatformError::ReinstallHistoryConflict {
3959 canister: principal.clone(),
3960 }
3961 })?;
3962 let before = record.pre_canister_version.ok_or_else(|| {
3963 IcpEnsurePlatformError::ReinstallHistoryConflict {
3964 canister: principal.clone(),
3965 }
3966 })?;
3967 let history = super::install_history::observe(
3968 &self.icp,
3969 &self.root,
3970 &history_authority,
3971 parse_principal("operator", &self.desired.operator)?,
3972 before,
3973 live,
3974 )?;
3975 match super::install_history::reconcile(
3976 history,
3977 wasm_sha256,
3978 &witness.prior_module_sha256,
3979 before,
3980 live,
3981 ) {
3982 super::install_history::HistoryEffect::Applied => true,
3983 super::install_history::HistoryEffect::NotApplied => false,
3984 super::install_history::HistoryEffect::Conflict => {
3985 return Err(IcpEnsurePlatformError::ReinstallHistoryConflict {
3986 canister: live.principal.clone(),
3987 });
3988 }
3989 }
3990 } else {
3991 live.as_ref().is_some_and(|live| {
3992 install_effect_applied(
3993 *mode,
3994 wasm_sha256,
3995 live.module_sha256.as_deref(),
3996 record.pre_canister_version,
3997 live.canister_version,
3998 )
3999 })
4000 };
4001 let progress_identity = live.as_ref().map_or_else(
4002 || "install:missing".to_string(),
4003 |live| {
4004 format!(
4005 "install:{:?}:version:{:?}",
4006 live.module_sha256, live.canister_version
4007 )
4008 },
4009 );
4010 (applied, progress_identity)
4011 }
4012 EnsureAction::FleetProtocol {
4013 action: current_action,
4014 ..
4015 } => {
4016 let mut observation = match current_protocol::observe(&self.icp, &self.root, action)
4017 {
4018 Ok(observation) => observation,
4019 Err(error)
4020 if matches!(
4021 current_action.as_ref(),
4022 crate::fleet_ensure::model::CurrentFleetProtocolAction::AdoptStore { .. }
4023 ) && recoverable_current_protocol_error(&error) =>
4024 {
4025 EffectObservation {
4026 provisioning_progress: None,
4027 provisioning_failure: None,
4028 applied: false,
4029 estate_funding_required: None,
4030 post_cycles: None,
4031 progress_identity: "store-adoption:protected-status-unavailable"
4032 .to_string(),
4033 retry: EffectRetry::None,
4034 }
4035 }
4036 Err(error) => return Err(error.into()),
4037 };
4038 self.refresh_estate_funding_observation(&mut observation, state)?;
4039 return Ok(observation);
4040 }
4041 EnsureAction::Protocol { .. } => {
4042 let observation = protocol::observe(
4043 &self.icp,
4044 &self.root,
4045 operation_id,
4046 &self.protocol_principals(state),
4047 &resolved_protocol_action(action, state)?,
4048 )?;
4049 (observation.applied, observation.progress_identity)
4050 }
4051 EnsureAction::SetControllers {
4052 controller_canisters,
4053 controllers,
4054 name,
4055 principal,
4056 ..
4057 } => {
4058 let expected =
4059 self.resolved_controllers(state, controllers, controller_canisters)?;
4060 let principal = Self::action_principal(state, principal)?;
4061 let configured = self
4062 .desired
4063 .canisters
4064 .iter()
4065 .find(|configured| configured.name == *name);
4066 let mut observed_controllers = if let Some(configured) =
4067 configured.filter(|configured| configured.kind == DesiredCanisterKind::Pool)
4068 {
4069 self.inspect_root_owned_canister(configured, principal, state)?
4070 .map(|response| {
4071 response
4072 .settings
4073 .controllers
4074 .into_iter()
4075 .map(|controller| controller.to_text())
4076 .collect::<Vec<_>>()
4077 })
4078 } else {
4079 self.status_optional(principal)?
4080 .map(|live| live.controllers)
4081 };
4082 if let Some(controllers) = &mut observed_controllers {
4083 controllers.sort();
4084 controllers.dedup();
4085 }
4086 (
4087 observed_controllers.as_ref() == Some(&expected),
4088 format!("controllers:{observed_controllers:?}"),
4089 )
4090 }
4091 EnsureAction::Start { principal, .. } => {
4092 let live = self.status_optional(Self::action_principal(state, principal)?)?;
4093 (
4094 live.as_ref()
4095 .is_some_and(|live| live.status == CanisterRuntimeStatus::Running),
4096 format!("start:{:?}", live.map(|live| live.status)),
4097 )
4098 }
4099 EnsureAction::Stop { principal, .. } => {
4100 let live = self.status_optional(Self::action_principal(state, principal)?)?;
4101 (
4102 live.as_ref()
4103 .is_some_and(|live| live.status == CanisterRuntimeStatus::Stopped),
4104 format!("stop:{:?}", live.map(|live| live.status)),
4105 )
4106 }
4107 EnsureAction::Transfer {
4108 amount,
4109 maximum_execution_burn_cycles,
4110 name,
4111 principal,
4112 destination,
4113 ..
4114 } => {
4115 let source = self
4116 .status_optional(Self::action_principal(state, principal)?)?
4117 .map(|live| live.cycles)
4118 .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
4119 canister: name.clone(),
4120 side: "live source",
4121 })?;
4122 let destination = self
4123 .status_optional(self.current_principal(state, destination).ok_or_else(
4124 || IcpEnsurePlatformError::UnresolvedCreated(destination.clone()),
4125 )?)?
4126 .map(|live| live.cycles)
4127 .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
4128 canister: name.clone(),
4129 side: "live treasury",
4130 })?;
4131 let source_pre = record.pre_cycles.ok_or_else(|| {
4132 IcpEnsurePlatformError::MissingTransferBalance {
4133 canister: name.clone(),
4134 side: "retained source",
4135 }
4136 })?;
4137 let destination_pre = record.destination_pre_cycles.ok_or_else(|| {
4138 IcpEnsurePlatformError::MissingTransferBalance {
4139 canister: name.clone(),
4140 side: "retained treasury",
4141 }
4142 })?;
4143 let reconciliation = reconcile_retirement_transfer(RetirementTransferBalances {
4144 destination_after: destination,
4145 destination_before: destination_pre,
4146 maximum_execution_burn: *maximum_execution_burn_cycles,
4147 source_after: source,
4148 source_before: source_pre,
4149 transfer_amount: *amount,
4150 });
4151 let (applied, source_debit, destination_credit) = match reconciliation {
4152 Ok(RetirementTransferReconciliation::Pending) => (false, 0, 0),
4153 Ok(RetirementTransferReconciliation::Conserved {
4154 destination_credit,
4155 source_debit,
4156 }) => (true, source_debit, destination_credit),
4157 Err(RetirementTransferInvariantError::ArithmeticOverflow) => {
4158 return Err(IcpEnsurePlatformError::Arithmetic(
4159 "retirement transfer debit",
4160 ));
4161 }
4162 Err(RetirementTransferInvariantError::BalanceMovedInInvalidDirection) => {
4163 return Err(IcpEnsurePlatformError::InvalidTransferBalance {
4164 canister: name.clone(),
4165 });
4166 }
4167 Err(RetirementTransferInvariantError::Unbalanced {
4168 destination_credit,
4169 maximum_source_debit,
4170 source_debit,
4171 }) => {
4172 return Err(IcpEnsurePlatformError::UnbalancedTransfer {
4173 canister: name.clone(),
4174 destination_credit,
4175 expected: *amount,
4176 maximum_source_debit,
4177 source_debit,
4178 });
4179 }
4180 };
4181 (
4182 applied,
4183 format!("transfer:{source_debit}:{destination_credit}"),
4184 )
4185 }
4186 };
4187 Ok(EffectObservation {
4188 provisioning_progress: None,
4189 provisioning_failure: None,
4190 applied,
4191 estate_funding_required: None,
4192 post_cycles,
4193 progress_identity,
4194 retry,
4195 })
4196 }
4197
4198 #[expect(
4199 clippy::too_many_lines,
4200 reason = "the platform keeps every approved single-step effect in one exhaustive match"
4201 )]
4202 fn apply(
4203 &mut self,
4204 operation_id: &str,
4205 action: &EnsureAction,
4206 _record: &EffectRecord,
4207 state: &FleetEnsureStateRecord,
4208 ) -> Result<EffectOutcome, Self::Error> {
4209 self.require_operator()?;
4210 match action {
4211 EnsureAction::SealAuthority { .. } => {
4212 super::authority_seal::apply(&self.icp, &self.root, operation_id, action)
4213 .map_err(Into::into)
4214 }
4215 EnsureAction::Create {
4216 controller_canisters,
4217 controllers,
4218 created_at_time,
4219 ledger,
4220 requested_initial_cycles,
4221 subnet,
4222 ..
4223 } => self.apply_create(
4224 CreateCanisterAuthority {
4225 controller_canisters,
4226 controllers,
4227 created_at_time: *created_at_time,
4228 ledger,
4229 requested_initial_cycles: *requested_initial_cycles,
4230 subnet,
4231 },
4232 state,
4233 ),
4234 EnsureAction::Delete {
4235 maximum_remaining_cycles,
4236 principal,
4237 ..
4238 } => {
4239 let principal = Self::action_principal(state, principal)?;
4240 if let Some(live) = self.status_optional(principal)? {
4241 if live.cycles > *maximum_remaining_cycles {
4242 return Err(IcpEnsurePlatformError::MaterialCycles {
4243 actual: live.cycles,
4244 canister: principal.to_string(),
4245 maximum: *maximum_remaining_cycles,
4246 });
4247 }
4248 if live.status != CanisterRuntimeStatus::Stopped {
4249 return Err(IcpEnsurePlatformError::NotStopped {
4250 canister: principal.to_string(),
4251 });
4252 }
4253 self.icp.delete_canister_without_cycle_recovery(principal)?;
4254 }
4255 Ok(empty_outcome())
4256 }
4257 EnsureAction::Fund {
4258 amount,
4259 created_at_time,
4260 ledger,
4261 principal,
4262 ..
4263 } => self.apply_fund(
4264 *amount,
4265 *created_at_time,
4266 ledger,
4267 Self::action_principal(state, principal)?,
4268 ),
4269 EnsureAction::FundEstate {
4270 amount,
4271 created_at_time,
4272 ledger,
4273 ledger_fee_cycles,
4274 principal,
4275 ..
4276 } => self.apply_estate_fund(
4277 *amount,
4278 *created_at_time,
4279 ledger,
4280 Self::action_principal(state, principal)?,
4281 *ledger_fee_cycles,
4282 ),
4283 EnsureAction::Install {
4284 canic_init,
4285 init_arg,
4286 init_arg_sha256,
4287 init_candid,
4288 init_candid_sha256,
4289 mode,
4290 principal,
4291 wasm,
4292 wasm_sha256,
4293 ..
4294 } => self.apply_install(
4295 operation_id,
4296 &self.protocol_principals(state),
4297 canic_init.as_ref(),
4298 init_arg.as_deref(),
4299 init_arg_sha256.as_deref(),
4300 init_candid.as_deref(),
4301 init_candid_sha256.as_deref(),
4302 *mode,
4303 Self::action_principal(state, principal)?,
4304 wasm,
4305 wasm_sha256,
4306 ),
4307 EnsureAction::FleetProtocol { .. } => {
4308 current_protocol::apply(&self.icp, &self.root, action).map_err(Into::into)
4309 }
4310 EnsureAction::Protocol { .. } => {
4311 let action = resolved_protocol_action(action, state)?;
4312 protocol::apply(
4313 &self.icp,
4314 &self.root,
4315 operation_id,
4316 &self.protocol_principals(state),
4317 &action,
4318 )?;
4319 Ok(EffectOutcome {
4320 created_principal: None,
4321 post_cycles: None,
4322 receipt: Some(operation_id.to_string()),
4323 })
4324 }
4325 EnsureAction::SetControllers {
4326 controller_canisters,
4327 controllers,
4328 principal,
4329 ..
4330 } => self.apply_controllers(
4331 Self::action_principal(state, principal)?,
4332 &self.resolved_controllers(state, controllers, controller_canisters)?,
4333 ),
4334 EnsureAction::Start { principal, .. } => {
4335 self.icp
4336 .start_canister(Self::action_principal(state, principal)?)?;
4337 Ok(empty_outcome())
4338 }
4339 EnsureAction::Stop { principal, .. } => {
4340 self.icp
4341 .stop_canister(Self::action_principal(state, principal)?)?;
4342 Ok(empty_outcome())
4343 }
4344 EnsureAction::Transfer {
4345 amount,
4346 candid,
4347 candid_sha256,
4348 destination,
4349 method,
4350 principal,
4351 ..
4352 } => self.apply_transfer(
4353 *amount,
4354 candid,
4355 candid_sha256,
4356 self.current_principal(state, destination).ok_or_else(|| {
4357 IcpEnsurePlatformError::UnresolvedCreated(destination.clone())
4358 })?,
4359 method,
4360 operation_id,
4361 Self::action_principal(state, principal)?,
4362 ),
4363 }
4364 }
4365
4366 fn action_cycles(
4367 &mut self,
4368 action: &EnsureAction,
4369 state: &FleetEnsureStateRecord,
4370 ) -> Result<Option<u128>, Self::Error> {
4371 if matches!(action, EnsureAction::FundEstate { .. }) {
4372 return self
4373 .icp
4374 .identity_cycles_balance()
4375 .map(Some)
4376 .map_err(|error| IcpEnsurePlatformError::LedgerTransfer(error.to_string()));
4377 }
4378 if let EnsureAction::Fund {
4379 pool_funding: Some(authority),
4380 name,
4381 principal,
4382 ..
4383 } = action
4384 {
4385 let principal = Self::action_principal(state, principal)?;
4386 let module = self.pool_funding_module(name, authority, principal)?;
4387 return self
4388 .inspect_pool_balance(name, &authority.root, principal, module)
4389 .map(Some);
4390 }
4391 let (name, principal) = match action {
4392 EnsureAction::Create { .. } => return Ok(None),
4393 EnsureAction::SealAuthority {
4394 name, principal, ..
4395 }
4396 | EnsureAction::Delete {
4397 name, principal, ..
4398 }
4399 | EnsureAction::FleetProtocol {
4400 name, principal, ..
4401 }
4402 | EnsureAction::Fund {
4403 name, principal, ..
4404 }
4405 | EnsureAction::FundEstate {
4406 name, principal, ..
4407 }
4408 | EnsureAction::Install {
4409 name, principal, ..
4410 }
4411 | EnsureAction::Protocol {
4412 name, principal, ..
4413 }
4414 | EnsureAction::SetControllers {
4415 name, principal, ..
4416 }
4417 | EnsureAction::Start {
4418 name, principal, ..
4419 }
4420 | EnsureAction::Stop {
4421 name, principal, ..
4422 }
4423 | EnsureAction::Transfer {
4424 name, principal, ..
4425 } => (name, Self::action_principal(state, principal)?),
4426 };
4427 let observed = self
4428 .desired
4429 .canisters
4430 .iter()
4431 .find(|configured| configured.name == *name)
4432 .map_or_else(
4433 || self.status_optional(principal),
4434 |configured| self.observe_configured_canister(configured, principal, state),
4435 )?;
4436 Ok(observed.map(|live| live.cycles))
4437 }
4438
4439 fn action_destination_cycles(
4440 &mut self,
4441 action: &EnsureAction,
4442 state: &FleetEnsureStateRecord,
4443 ) -> Result<Option<u128>, Self::Error> {
4444 if let EnsureAction::FundEstate { principal, .. } = action {
4445 let owner = Self::action_principal(state, principal)?;
4446 return self.cycles_ledger_balance(owner).map(Some);
4447 }
4448 let EnsureAction::Transfer { destination, .. } = action else {
4449 return Ok(None);
4450 };
4451 let destination = self
4452 .current_principal(state, destination)
4453 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(destination.clone()))?;
4454 Ok(self.status_optional(destination)?.map(|live| live.cycles))
4455 }
4456
4457 fn action_canister_version(
4458 &mut self,
4459 action: &EnsureAction,
4460 state: &FleetEnsureStateRecord,
4461 ) -> Result<Option<u64>, Self::Error> {
4462 let EnsureAction::Install { principal, .. } = action else {
4463 return Ok(None);
4464 };
4465 let principal = Self::action_principal(state, principal)?;
4466 let version = self
4467 .install_status_optional(principal)?
4468 .and_then(|live| live.canister_version)
4469 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
4470 canister: principal.to_string(),
4471 field: "canister_version",
4472 })?;
4473 Ok(Some(version))
4474 }
4475}
4476
4477fn exact_install_canister_status(
4478 icp: &IcpCli,
4479 canister: &str,
4480 projected_canister_version: Option<u64>,
4481 projected_module_sha256: Option<String>,
4482) -> Result<ExactInstallCanisterStatus, IcpEnsurePlatformError> {
4483 exact_install_canister_status_with(
4484 canister,
4485 projected_canister_version,
4486 projected_module_sha256,
4487 |canister_id| {
4488 icp.management_canister_status_candid::<_, ManagementCanisterStatusResponse>(
4489 canister_id,
4490 &ManagementCanisterStatusRequest { canister_id },
4491 )
4492 },
4493 )
4494}
4495
4496fn exact_install_canister_status_with(
4497 canister: &str,
4498 projected_canister_version: Option<u64>,
4499 projected_module_sha256: Option<String>,
4500 management_status: impl FnOnce(
4501 Principal,
4502 )
4503 -> Result<ManagementCanisterStatusResponse, IcpManagementCallError>,
4504) -> Result<ExactInstallCanisterStatus, IcpEnsurePlatformError> {
4505 if let Some(canister_version) = projected_canister_version {
4506 return Ok(ExactInstallCanisterStatus {
4507 canister_version,
4508 module_sha256: projected_module_sha256,
4509 });
4510 }
4511 let canister_id = parse_principal("install target", canister)?;
4512 let response = management_status(canister_id).map_err(|source| {
4513 IcpEnsurePlatformError::InstallVersionProofUnavailable {
4514 canister: canister.to_string(),
4515 source: Box::new(source),
4516 }
4517 })?;
4518 Ok(ExactInstallCanisterStatus {
4519 canister_version: response.version,
4520 module_sha256: response
4521 .module_hash
4522 .map(|hash| canic_core::cdk::utils::hash::hex_bytes(&hash)),
4523 })
4524}
4525
4526fn exact_retained_root_owned_topology<'a>(
4527 state: &'a FleetEnsureStateRecord,
4528 name: &str,
4529 kind: DesiredCanisterKind,
4530 parent: &str,
4531 principal: &str,
4532 root: &str,
4533) -> Option<&'a crate::fleet_ensure::model::FleetEnsureTopologyRecord> {
4534 let child_identity_matches = retained_state_principal_matches(state, name, principal);
4535 let root_identity_matches = retained_state_principal_matches(state, parent, root);
4536 let topology = state
4537 .topology
4538 .get(name)
4539 .filter(|topology| topology.kind == kind && topology.parent.as_deref() == Some(parent));
4540 (child_identity_matches && root_identity_matches)
4541 .then_some(topology)
4542 .flatten()
4543}
4544
4545fn retained_state_principal_matches(
4546 state: &FleetEnsureStateRecord,
4547 name: &str,
4548 principal: &str,
4549) -> bool {
4550 let pending = state.pending_principals.get(name).map(String::as_str);
4551 let terminal = state.principals.get(name).map(String::as_str);
4552 let retained = pending.is_some() || terminal.is_some();
4553 retained
4554 && pending.is_none_or(|retained| retained == principal)
4555 && terminal.is_none_or(|retained| retained == principal)
4556}
4557
4558fn completed_reinstall_continuity(
4559 pre_canister_version: u64,
4560 principal_matches: bool,
4561 topology_matches: bool,
4562 live_module_sha256: Option<&str>,
4563 desired_module_sha256: &str,
4564 live_canister_version: Option<u64>,
4565) -> Result<(), &'static str> {
4566 if !principal_matches {
4567 return Err("Principal");
4568 }
4569 if !topology_matches {
4570 return Err("Root/parent/kind topology");
4571 }
4572 if live_module_sha256 != Some(desired_module_sha256) {
4573 return Err("module SHA-256");
4574 }
4575 if live_canister_version.is_some_and(|version| version <= pre_canister_version) {
4576 return Err("canister version");
4577 }
4578 Ok(())
4579}
4580
4581fn recoverable_root_status_error(error: &CanisterProtocolError) -> bool {
4582 error.is_rejected_with(canic_core::diagnostics::codes::STATE_CONFLICT)
4583 || error.is_rejected_with(canic_core::diagnostics::codes::STATE_UNAVAILABLE)
4584}
4585
4586fn recoverable_current_protocol_error(error: ¤t_protocol::CurrentProtocolError) -> bool {
4587 matches!(
4588 error,
4589 current_protocol::CurrentProtocolError::Transport(source)
4590 if recoverable_root_status_error(source)
4591 )
4592}
4593
4594pub fn install_effect_applied(
4595 mode: InstallMode,
4596 expected_hash: &str,
4597 live_hash: Option<&str>,
4598 pre_canister_version: Option<u64>,
4599 live_canister_version: Option<u64>,
4600) -> bool {
4601 if live_hash != Some(expected_hash) {
4602 return false;
4603 }
4604 match mode {
4605 InstallMode::Install => true,
4606 InstallMode::Reinstall => pre_canister_version
4607 .zip(live_canister_version)
4608 .is_some_and(|(before, after)| after > before),
4609 }
4610}
4611
4612#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4614pub struct NativeFundingObservation {
4615 pub amount: u128,
4616 pub expected_post_cycles: u128,
4617 pub funding_deficit_cycles: u128,
4618 pub funding_margin_cycles: u128,
4619 pub live_cycles: Option<u128>,
4620 pub pre_cycles: Option<u128>,
4621}
4622
4623pub const fn native_funding_applied(observation: NativeFundingObservation) -> bool {
4625 let Some(pre_cycles) = observation.pre_cycles else {
4626 return false;
4627 };
4628 let Some(live_cycles) = observation.live_cycles else {
4629 return false;
4630 };
4631 let Some(maximum_live_cycles) = pre_cycles.checked_add(observation.amount) else {
4632 return false;
4633 };
4634 let Some(reviewed_pre_cycles) = observation
4635 .expected_post_cycles
4636 .checked_sub(observation.amount)
4637 else {
4638 return false;
4639 };
4640 let Some(minimum_live_cycles) = observation
4641 .expected_post_cycles
4642 .checked_sub(observation.funding_margin_cycles)
4643 else {
4644 return false;
4645 };
4646 let Some(minimum_from_deficit) =
4647 reviewed_pre_cycles.checked_add(observation.funding_deficit_cycles)
4648 else {
4649 return false;
4650 };
4651 let reviewed_amount_matches =
4652 observation.funding_deficit_cycles > 0 && minimum_live_cycles == minimum_from_deficit;
4653 let observation_is_bounded = pre_cycles <= reviewed_pre_cycles
4654 && live_cycles >= minimum_live_cycles
4655 && live_cycles <= maximum_live_cycles;
4656 reviewed_amount_matches && observation_is_bounded
4657}
4658
4659fn native_funding_completion(
4660 observation: NativeFundingObservation,
4661 has_receipt: bool,
4662) -> Result<bool, IcpEnsurePlatformError> {
4663 if !has_receipt || observation.live_cycles.is_none() {
4664 return Ok(false);
4665 }
4666 if !native_funding_applied(observation) {
4667 return Err(IcpEnsurePlatformError::NativeFundingBalanceDrift {
4668 observation: Box::new(observation),
4669 });
4670 }
4671 Ok(true)
4672}
4673
4674#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4676pub struct EstateFundingObservation {
4677 pub amount: u128,
4678 pub destination_after: u128,
4679 pub destination_before: Option<u128>,
4680 pub expected_destination_after: u128,
4681 pub ledger_fee_cycles: u128,
4682 pub source_after: u128,
4683 pub source_before: Option<u128>,
4684}
4685
4686#[must_use]
4688pub const fn estate_funding_applied(observation: EstateFundingObservation) -> bool {
4689 let Some(source_before) = observation.source_before else {
4690 return false;
4691 };
4692 let Some(destination_before) = observation.destination_before else {
4693 return false;
4694 };
4695 let Some(expected_source_debit) = observation
4696 .amount
4697 .checked_add(observation.ledger_fee_cycles)
4698 else {
4699 return false;
4700 };
4701 let Some(source_debit) = source_before.checked_sub(observation.source_after) else {
4702 return false;
4703 };
4704 let Some(destination_credit) = observation
4705 .destination_after
4706 .checked_sub(destination_before)
4707 else {
4708 return false;
4709 };
4710 source_debit == expected_source_debit
4711 && destination_credit == observation.amount
4712 && observation.destination_after == observation.expected_destination_after
4713}
4714
4715#[derive(Clone, Copy, Debug, Eq, PartialEq)]
4717enum InspectedModule {
4718 Any,
4719 Empty,
4720}
4721
4722fn pool_funding_inspected_module(
4723 canister: &str,
4724 reviewed: EstatePoolAssetLifecycle,
4725 observed: &CanisterPoolAssetStatus,
4726) -> Result<InspectedModule, IcpEnsurePlatformError> {
4727 match (reviewed, observed) {
4728 (EstatePoolAssetLifecycle::Ready, CanisterPoolAssetStatus::Ready) => {
4729 Ok(InspectedModule::Empty)
4730 }
4731 (EstatePoolAssetLifecycle::PendingReset, CanisterPoolAssetStatus::PendingReset)
4732 | (EstatePoolAssetLifecycle::Failed, CanisterPoolAssetStatus::Failed { .. }) => {
4733 Ok(InspectedModule::Any)
4734 }
4735 _ => Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
4736 canister: canister.to_string(),
4737 field: "reviewed pool lifecycle",
4738 }),
4739 }
4740}
4741
4742fn validate_pending_fresh_pool_inspection(
4743 canister: &str,
4744 root: &str,
4745 operator: &str,
4746 controllers: &[Principal],
4747 module_hash: Option<&[u8]>,
4748 cycles: &Nat,
4749) -> Result<u128, IcpEnsurePlatformError> {
4750 let mut actual = controllers
4751 .iter()
4752 .map(Principal::to_text)
4753 .collect::<Vec<_>>();
4754 actual.sort();
4755 let mut temporary = vec![root.to_string(), operator.to_string()];
4756 temporary.sort();
4757 if actual != [root] && actual != temporary {
4758 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
4759 canister: canister.to_string(),
4760 field: "reviewed fresh pool controllers",
4761 });
4762 }
4763 validate_inspected_cycles(canister, InspectedModule::Empty, module_hash, cycles)
4766}
4767
4768fn validate_root_controlled_inspection(
4769 canister: &str,
4770 module: InspectedModule,
4771 root: &str,
4772 controllers: &[Principal],
4773 module_hash: Option<&[u8]>,
4774 cycles: &Nat,
4775) -> Result<u128, IcpEnsurePlatformError> {
4776 if controllers.len() != 1 || controllers[0].to_text() != root {
4777 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
4778 canister: canister.to_string(),
4779 field: "Root-only controllers",
4780 });
4781 }
4782 validate_inspected_cycles(canister, module, module_hash, cycles)
4783}
4784
4785fn validate_inspected_cycles(
4786 canister: &str,
4787 module: InspectedModule,
4788 module_hash: Option<&[u8]>,
4789 cycles: &Nat,
4790) -> Result<u128, IcpEnsurePlatformError> {
4791 if matches!(module, InspectedModule::Empty) && module_hash.is_some() {
4792 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
4793 canister: canister.to_string(),
4794 field: "module-free pool asset",
4795 });
4796 }
4797 u128::try_from(cycles.0.clone()).map_err(|_| IcpEnsurePlatformError::InvalidStatusCycles {
4798 canister: canister.to_string(),
4799 value: cycles.to_string(),
4800 })
4801}
4802
4803fn created_canister_outcome(
4804 canister_id: Principal,
4805 receipt: String,
4806 requested_initial_cycles: u128,
4807) -> EffectOutcome {
4808 EffectOutcome {
4809 created_principal: Some(canister_id.to_text()),
4810 post_cycles: Some(requested_initial_cycles),
4811 receipt: Some(receipt),
4812 }
4813}
4814
4815fn create_canister_response_outcome(
4816 response: Result<CreateCanisterSuccess, CreateCanisterError>,
4817 requested_initial_cycles: u128,
4818) -> Result<EffectOutcome, IcpEnsurePlatformError> {
4819 match response {
4820 Ok(success) => Ok(created_canister_outcome(
4821 success.canister_id,
4822 success.block_id.to_string(),
4823 requested_initial_cycles,
4824 )),
4825 Err(CreateCanisterError::Duplicate {
4826 duplicate_of,
4827 canister_id: Some(canister_id),
4828 }) => Ok(created_canister_outcome(
4829 canister_id,
4830 duplicate_of.to_string(),
4831 requested_initial_cycles,
4832 )),
4833 Err(CreateCanisterError::Duplicate {
4834 canister_id: None, ..
4835 }) => Err(IcpEnsurePlatformError::LedgerCreatePending),
4836 Err(error) => Err(IcpEnsurePlatformError::LedgerCreate(render_create_error(
4837 error,
4838 ))),
4839 }
4840}
4841
4842fn ledger_fee_cycles(value: Nat) -> Result<u128, IcpEnsurePlatformError> {
4843 let rendered = value.to_string();
4844 u128::try_from(value.0).map_err(|_| IcpEnsurePlatformError::InvalidLedgerFee(rendered))
4845}
4846
4847const fn empty_outcome() -> EffectOutcome {
4848 EffectOutcome {
4849 created_principal: None,
4850 post_cycles: None,
4851 receipt: None,
4852 }
4853}
4854
4855fn parse_status_cycles(value: &str) -> Option<u128> {
4856 value.replace('_', "").trim().parse().ok()
4857}
4858
4859fn normalize_hash(value: &str) -> String {
4860 value
4861 .strip_prefix("0x")
4862 .unwrap_or(value)
4863 .to_ascii_lowercase()
4864}
4865
4866fn parse_principal(field: &'static str, value: &str) -> Result<Principal, IcpEnsurePlatformError> {
4867 Principal::from_text(value).map_err(|_| IcpEnsurePlatformError::InvalidPrincipal {
4868 field,
4869 value: value.to_string(),
4870 })
4871}
4872
4873fn reject_public_status_projection(
4874 report: &IcpCanisterStatusReport,
4875 principal: &str,
4876) -> Result<(), IcpEnsurePlatformError> {
4877 if report.status.is_none()
4878 && report.settings.is_none()
4879 && report.cycles.is_none()
4880 && report.public_controllers.is_some()
4881 {
4882 return Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable {
4883 canister: principal.to_string(),
4884 });
4885 }
4886 Ok(())
4887}
4888
4889fn resolve_path(root: &Path, path: &str) -> PathBuf {
4890 let path = Path::new(path);
4891 if path.is_absolute() {
4892 path.to_path_buf()
4893 } else {
4894 root.join(path)
4895 }
4896}
4897
4898fn verify_artifact(
4899 path: &Path,
4900 expected: &str,
4901 kind: &'static str,
4902) -> Result<(), IcpEnsurePlatformError> {
4903 let bytes = std::fs::read(path)
4904 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
4905 let actual = canic_core::cdk::utils::hash::sha256_hex(&bytes);
4906 if actual != expected {
4907 return Err(IcpEnsurePlatformError::ArtifactDigestMismatch {
4908 actual,
4909 expected: expected.to_string(),
4910 kind,
4911 });
4912 }
4913 Ok(())
4914}
4915
4916fn artifact_hash(path: &Path) -> Result<String, IcpEnsurePlatformError> {
4917 let bytes = std::fs::read(path)
4918 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
4919 Ok(canic_core::cdk::utils::hash::sha256_hex(&bytes))
4920}
4921
4922fn resolved_protocol_action(
4923 action: &EnsureAction,
4924 state: &FleetEnsureStateRecord,
4925) -> Result<EnsureAction, IcpEnsurePlatformError> {
4926 let mut resolved = action.clone();
4927 if let EnsureAction::Protocol { principal, .. } = &mut resolved {
4928 *principal = IcpEnsurePlatform::action_principal(state, principal)?.to_string();
4929 return Ok(resolved);
4930 }
4931 Err(IcpEnsurePlatformError::Protocol(
4932 protocol::ProtocolEffectError::WrongAction,
4933 ))
4934}
4935
4936fn render_create_error(error: CreateCanisterError) -> String {
4937 match error {
4938 CreateCanisterError::CreatedInFuture { ledger_time } => {
4939 format!("created in future of ledger time {ledger_time}")
4940 }
4941 CreateCanisterError::Duplicate { duplicate_of, .. } => {
4942 format!("duplicate block {duplicate_of}")
4943 }
4944 CreateCanisterError::FailedToCreate {
4945 error,
4946 fee_block,
4947 refund_block,
4948 } => format!("failed: {error}; fee_block={fee_block:?}; refund_block={refund_block:?}"),
4949 CreateCanisterError::GenericError {
4950 error_code,
4951 message,
4952 } => format!("error {error_code}: {message}"),
4953 CreateCanisterError::InsufficientFunds { balance } => {
4954 format!("insufficient funds: balance={balance}")
4955 }
4956 CreateCanisterError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
4957 CreateCanisterError::TooOld => "request is too old".to_string(),
4958 }
4959}
4960
4961fn render_withdraw_error(error: WithdrawError) -> String {
4962 match error {
4963 WithdrawError::BadFee { expected_fee } => format!("bad fee; expected {expected_fee}"),
4964 WithdrawError::CreatedInFuture { ledger_time } => {
4965 format!("created in future of ledger time {ledger_time}")
4966 }
4967 WithdrawError::Duplicate { duplicate_of } => format!("duplicate block {duplicate_of}"),
4968 WithdrawError::FailedToWithdraw {
4969 fee_block,
4970 rejection_reason,
4971 ..
4972 } => format!("withdraw failed: {rejection_reason}; fee_block={fee_block:?}"),
4973 WithdrawError::GenericError {
4974 error_code,
4975 message,
4976 } => format!("error {error_code}: {message}"),
4977 WithdrawError::InsufficientFunds { balance } => {
4978 format!("insufficient funds: balance={balance}")
4979 }
4980 WithdrawError::InvalidReceiver { receiver } => format!("invalid receiver {receiver}"),
4981 WithdrawError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
4982 WithdrawError::TooOld => "request is too old".to_string(),
4983 }
4984}
4985
4986fn render_ledger_transfer_error(error: CyclesLedgerTransferError) -> String {
4987 match error {
4988 CyclesLedgerTransferError::BadBurn { min_burn_amount } => {
4989 format!("bad burn; minimum {min_burn_amount}")
4990 }
4991 CyclesLedgerTransferError::BadFee { expected_fee } => {
4992 format!("bad fee; expected {expected_fee}")
4993 }
4994 CyclesLedgerTransferError::CreatedInFuture { ledger_time } => {
4995 format!("created in future of ledger time {ledger_time}")
4996 }
4997 CyclesLedgerTransferError::Duplicate { duplicate_of } => {
4998 format!("duplicate block {duplicate_of}")
4999 }
5000 CyclesLedgerTransferError::GenericError {
5001 error_code,
5002 message,
5003 } => format!("error {error_code}: {message}"),
5004 CyclesLedgerTransferError::InsufficientFunds { balance } => {
5005 format!("insufficient funds: balance={balance}")
5006 }
5007 CyclesLedgerTransferError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
5008 CyclesLedgerTransferError::TooOld => "request is too old".to_string(),
5009 }
5010}
5011
5012#[expect(
5013 dead_code,
5014 reason = "Candid variant is retained for exact response decoding"
5015)]
5016const fn rejection_code_name(code: RejectionCode) -> &'static str {
5017 match code {
5018 RejectionCode::CanisterError => "canister_error",
5019 RejectionCode::CanisterReject => "canister_reject",
5020 RejectionCode::DestinationInvalid => "destination_invalid",
5021 RejectionCode::NoError => "no_error",
5022 RejectionCode::SysFatal => "sys_fatal",
5023 RejectionCode::SysTransient => "sys_transient",
5024 RejectionCode::Unknown => "unknown",
5025 }
5026}
5027
5028#[cfg(test)]
5029mod tests {
5030 use super::*;
5031 use canic_core::dto::pool::CanisterPoolAssetStatus;
5032
5033 #[test]
5034 fn identity_binding_keeps_operator_fence_before_effects() {
5035 let mut fixture = ProtocolOwnersFixture::new();
5036 std::fs::write(
5037 fixture.root.join("icp"),
5038 r#"#!/bin/sh
5039if [ "$1" = --version ]; then echo 'icp 1.5.0'; exit 0; fi
5040while [ "$1" = --project-root-override ] || [ "$1" = --identity-password-file ]; do shift 2; done
5041if [ "$1 $2" = 'identity default' ]; then echo selected; exit 0; fi
5042if [ "$1 $2" = 'identity principal' ]; then cat principal; exit 0; fi
5043echo effect >> effects
5044"#,
5045 )
5046 .unwrap();
5047 std::fs::write(fixture.root.join("principal"), "operator").unwrap();
5048 fixture.platform.require_operator().unwrap();
5049 std::fs::write(fixture.root.join("principal"), "changed-principal").unwrap();
5050 let action = EnsureAction::Start {
5051 name: "root".into(),
5052 principal: "root".into(),
5053 };
5054 let receipt = EffectRecord {
5055 maintenance_attempts: 0,
5056 publication_attempts: 0,
5057 action_sha256: crate::fleet_ensure::ops::action_sha256(&action),
5058 created_principal: None,
5059 destination_post_cycles: None,
5060 destination_pre_cycles: None,
5061 post_cycles: None,
5062 pre_cycles: None,
5063 pre_canister_version: None,
5064 progress_identity: None,
5065 receipt: None,
5066 state: crate::fleet_ensure::model::EffectState::Issued,
5067 };
5068 assert!(matches!(
5069 fixture
5070 .platform
5071 .apply("operation", &action, &receipt, &fixture.state),
5072 Err(IcpEnsurePlatformError::OperatorMismatch { .. })
5073 ));
5074 assert!(!fixture.root.join("effects").exists());
5075 std::fs::remove_dir_all(fixture.root).unwrap();
5076 }
5077
5078 #[test]
5079 fn inactive_source_inventory_rejects_spare_capacity_pending_work_and_omissions() {
5080 let root = crate::fleet_ensure::model::RootActivationResetRecord {
5081 root: "root".to_string(),
5082 activation_operation_id: [1; 32],
5083 inventory_hash: [2; 32],
5084 provisioning_receipt_hash: [3; 32],
5085 component_count: 1,
5086 managed_descendants: 2,
5087 };
5088 let page = inactive_source_pool_page();
5089 assert!(inactive_source_pool_has_full_inventory(&page, &root));
5090 let mut complete = EstatePoolInventoryAccumulator::default();
5091 assert_eq!(
5092 complete.observe_page("root", page.clone()).expect("page"),
5093 None
5094 );
5095 assert_eq!(complete.finish("root").expect("complete").assets.len(), 4);
5096
5097 let mut spare = page.clone();
5098 spare.config.maximum_size += 1;
5099 let mut insufficient_reserve = page.clone();
5100 insufficient_reserve.config.minimum_size = 2;
5101 let mut pending = page.clone();
5102 pending.pending_reset = 1;
5103 let mut wrong_workload = page.clone();
5104 wrong_workload.workload -= 1;
5105 wrong_workload.ready += 1;
5106 let mut overflow = page.clone();
5107 overflow.workload = u32::MAX;
5108 for changed in [
5109 spare,
5110 insufficient_reserve,
5111 pending,
5112 wrong_workload,
5113 overflow,
5114 ] {
5115 assert!(!inactive_source_pool_has_full_inventory(&changed, &root));
5116 }
5117
5118 let mut omitted = page;
5119 omitted.entries.pop();
5120 let mut inventory = EstatePoolInventoryAccumulator::default();
5121 inventory
5122 .observe_page("root", omitted)
5123 .expect("bounded page");
5124 assert!(matches!(
5125 inventory.finish("root"),
5126 Err(IcpEnsurePlatformError::CurrentProtocol(
5127 current_protocol::CurrentProtocolError::Configuration(_)
5128 ))
5129 ));
5130 }
5131
5132 fn inactive_source_pool_page() -> CanisterPoolResponse {
5133 let claim = canic_core::dto::pool::CanisterPoolClaim {
5134 component: canic_core::ids::ComponentInstanceId::from_generated_bytes([4; 32]),
5135 operation_id: [5; 32],
5136 };
5137 let entries = (0..5)
5138 .map(|index| CanisterPoolAsset {
5139 canister_id: Principal::from_slice(&[index]),
5140 creation_receipt: None,
5141 cycles: Cycles::new(100),
5142 origin: if index == 0 {
5143 CanisterPoolAssetOrigin::InfrastructureStore
5144 } else {
5145 CanisterPoolAssetOrigin::Imported
5146 },
5147 status: match index {
5148 0 => CanisterPoolAssetStatus::Store,
5149 1 => CanisterPoolAssetStatus::Ready,
5150 _ => CanisterPoolAssetStatus::Workload {
5151 claim: claim.clone(),
5152 },
5153 },
5154 added_at_ns: 1,
5155 updated_at_ns: 1,
5156 })
5157 .collect();
5158 CanisterPoolResponse {
5159 config: canic_core::ids::FleetSubnetCanisterPoolConfig {
5160 minimum_size: 1,
5161 maximum_size: 4,
5162 canister_cycles: Cycles::new(100),
5163 creation_execution_margin: Cycles::new(1),
5164 },
5165 tracked: 5,
5166 store: 1,
5167 store_deletion_pending: 0,
5168 pooled: 1,
5169 workload: 3,
5170 surplus: 0,
5171 ready: 1,
5172 pending_reset: 0,
5173 claimed: 0,
5174 recycling: 0,
5175 handing_off: 0,
5176 failed: 0,
5177 completed_handoffs: 0,
5178 pending_creation: None,
5179 pending_handoff: None,
5180 entries,
5181 next_start_after: None,
5182 }
5183 }
5184
5185 #[cfg(unix)]
5186 #[test]
5187 fn pool_inspection_preflight_stops_before_update_and_requeries_on_retry() {
5188 let mut fixture = PoolInspectionFixture::new();
5189 let root = fixture.root_id;
5190 let target = fixture.target;
5191 let path = fixture.owners.root.clone();
5192 fixture.inspection_hook(&format!(
5193 "touch '{}'",
5194 path.join("inspection-attempted").display()
5195 ));
5196 PoolInspectionFixture::reserve_response(&path, root, target, 99);
5197 fixture
5198 .owners
5199 .platform
5200 .with_observation_snapshot(|platform| {
5201 let error = platform
5202 .inspect_pool_balance(
5203 "pool",
5204 &root.to_text(),
5205 &target.to_text(),
5206 InspectedModule::Any,
5207 )
5208 .unwrap_err();
5209 let IcpEnsurePlatformError::CurrentProtocol(
5210 current_protocol::CurrentProtocolError::Transport(
5211 crate::CanisterProtocolError::InspectionPreflightReserve(evidence),
5212 ),
5213 ) = error
5214 else {
5215 panic!("preflight reserve shortfall");
5216 };
5217 assert_eq!(evidence.available_liquid_cycles, 99);
5218 assert!(!path.join("inspection-attempted").exists());
5219 PoolInspectionFixture::reserve_response(&path, root, target, 100);
5220 assert_eq!(
5221 platform.inspect_pool_balance(
5222 "pool",
5223 &root.to_text(),
5224 &target.to_text(),
5225 InspectedModule::Any,
5226 )?,
5227 1000
5228 );
5229 assert!(path.join("inspection-attempted").exists());
5230 Ok(())
5231 })
5232 .unwrap();
5233 std::fs::remove_dir_all(path).unwrap();
5234 }
5235
5236 #[cfg(unix)]
5237 #[test]
5238 fn pool_inspection_retains_protected_reserve_evidence() {
5239 let mut fixture = PoolInspectionFixture::new();
5240 let root = fixture.root_id;
5241 let target = fixture.target;
5242 let path = fixture.owners.root.clone();
5243 let evidence = canic_core::dto::canister::CanisterInspectionReserveResponse {
5244 caller: root,
5245 canister_id: target,
5246 native_cycles: 1000,
5247 available_liquid_cycles: 50,
5248 required_liquid_cycles: 100,
5249 };
5250 let bytes = candid::encode_one(Ok::<_, canic_core::dto::error::Error>(
5251 RootInspectionResponse::InspectionReserveRequired(evidence.clone()),
5252 ))
5253 .unwrap();
5254 std::fs::write(
5255 path.join("inspection.json"),
5256 serde_json::json!({"response_bytes": hex_bytes(bytes)}).to_string(),
5257 )
5258 .unwrap();
5259 fixture
5260 .owners
5261 .platform
5262 .with_observation_snapshot(|platform| {
5263 let error = platform
5264 .inspect_pool_balance(
5265 "pool",
5266 &root.to_text(),
5267 &target.to_text(),
5268 InspectedModule::Any,
5269 )
5270 .unwrap_err();
5271 let IcpEnsurePlatformError::CurrentProtocol(
5272 current_protocol::CurrentProtocolError::Transport(
5273 crate::CanisterProtocolError::InspectionReserve(actual),
5274 ),
5275 ) = error
5276 else {
5277 panic!("typed reserve failure");
5278 };
5279 assert_eq!(*actual, evidence);
5280 PoolInspectionFixture::response(&path, root, 1000);
5281 assert_eq!(
5282 platform.inspect_pool_balance(
5283 "pool",
5284 &root.to_text(),
5285 &target.to_text(),
5286 InspectedModule::Any
5287 )?,
5288 1000
5289 );
5290 Ok(())
5291 })
5292 .unwrap();
5293 std::fs::remove_dir_all(path).unwrap();
5294 }
5295
5296 #[test]
5297 fn pool_funding_binds_reset_lifecycle_without_weakening_ready_inspection() {
5298 let failed = CanisterPoolAssetStatus::Failed {
5299 reason: "reset stopped".to_string(),
5300 };
5301 for (lifecycle, status, expected) in [
5302 (
5303 EstatePoolAssetLifecycle::Ready,
5304 CanisterPoolAssetStatus::Ready,
5305 InspectedModule::Empty,
5306 ),
5307 (
5308 EstatePoolAssetLifecycle::PendingReset,
5309 CanisterPoolAssetStatus::PendingReset,
5310 InspectedModule::Any,
5311 ),
5312 (
5313 EstatePoolAssetLifecycle::Failed,
5314 failed,
5315 InspectedModule::Any,
5316 ),
5317 ] {
5318 let module = pool_funding_inspected_module("pool", lifecycle, &status).unwrap();
5319 assert_eq!(module, expected);
5320 let installed =
5321 validate_inspected_cycles("pool", module, Some(&[1; 32]), &Nat::from(100_u8));
5322 assert_eq!(installed.is_ok(), module == InspectedModule::Any);
5323 for wrong in [
5324 EstatePoolAssetLifecycle::Ready,
5325 EstatePoolAssetLifecycle::PendingReset,
5326 EstatePoolAssetLifecycle::Failed,
5327 EstatePoolAssetLifecycle::Workload,
5328 ] {
5329 if wrong != lifecycle {
5330 assert!(matches!(
5331 pool_funding_inspected_module("pool", wrong, &status),
5332 Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { .. })
5333 ));
5334 }
5335 }
5336 }
5337 }
5338
5339 #[cfg(unix)]
5340 #[test]
5341 fn funding_pause_uses_fresh_ledger_balance_without_completing_protocol_work() {
5342 let mut fixture = ProtocolOwnersFixture::new();
5343 let root = Principal::from_slice(&[1]);
5344 let ledger = Principal::from_slice(&[2]);
5345 fixture.platform.desired.cycles_ledger = ledger.to_text();
5346 fixture.platform.desired.canisters[1].principal = Some(root.to_text());
5347 std::fs::write(
5348 fixture.root.join("icp"),
5349 format!(
5350 "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.5.0'; exit 0; fi\ncat '{}'/balance.json\n",
5351 fixture.root.display(),
5352 ),
5353 )
5354 .unwrap();
5355 let pause = canic_core::dto::component_provisioning::RootEstateFundingRequired {
5356 available: Cycles::new(0),
5357 attempt_count: 0,
5358 creation_amount: Cycles::new(100),
5359 cycles_ledger: ledger,
5360 execution_margin: Cycles::new(10),
5361 last_attempt_at_ns: None,
5362 ledger_fee: Cycles::new(1),
5363 management_creation_fee: Cycles::new(20),
5364 operation_id: [1; 32],
5365 readiness_floor: Cycles::new(70),
5366 required: Cycles::new(101),
5367 retry_at_ns: 60,
5368 root,
5369 shortfall: Cycles::new(101),
5370 };
5371 for available in [0_u128, 40, 101, 404] {
5372 std::fs::write(
5373 fixture.root.join("balance.json"),
5374 serde_json::json!({
5375 "response_bytes": hex_bytes(candid::encode_one(Nat::from(available)).unwrap()),
5376 })
5377 .to_string(),
5378 )
5379 .unwrap();
5380 let mut observed = EffectObservation {
5381 provisioning_progress: None,
5382 provisioning_failure: None,
5383 applied: false,
5384 estate_funding_required: Some(pause.clone()),
5385 post_cycles: None,
5386 progress_identity: "pending-creation".to_string(),
5387 retry: EffectRetry::ContinuePoolMaintenance,
5388 };
5389 fixture
5390 .platform
5391 .refresh_estate_funding_observation(&mut observed, &fixture.state)
5392 .unwrap();
5393 assert!(!observed.applied);
5394 assert_eq!(observed.retry, EffectRetry::ContinuePoolMaintenance);
5395 assert_eq!(observed.progress_identity, "pending-creation");
5396 let expected = (available < 101).then(|| {
5397 let mut expected = pause.clone();
5398 expected.available = Cycles::new(available);
5399 expected.shortfall = Cycles::new(101 - available);
5400 expected
5401 });
5402 assert_eq!(observed.estate_funding_required, expected);
5403 }
5404 for (root, ledger) in [
5405 (Principal::anonymous(), ledger),
5406 (root, Principal::anonymous()),
5407 ] {
5408 let mut changed = pause.clone();
5409 changed.root = root;
5410 changed.cycles_ledger = ledger;
5411 let mut observed = EffectObservation {
5412 provisioning_progress: None,
5413 provisioning_failure: None,
5414 applied: false,
5415 estate_funding_required: Some(changed),
5416 post_cycles: None,
5417 progress_identity: String::new(),
5418 retry: EffectRetry::None,
5419 };
5420 assert!(matches!(
5421 fixture
5422 .platform
5423 .refresh_estate_funding_observation(&mut observed, &fixture.state),
5424 Err(IcpEnsurePlatformError::CurrentProtocol(
5425 current_protocol::CurrentProtocolError::ResponseMismatch
5426 ))
5427 ));
5428 }
5429 std::fs::remove_dir_all(&fixture.root).unwrap();
5430 }
5431
5432 #[test]
5433 fn completed_withdrawal_outside_bounds_fails_without_endless_observation() {
5434 let observation = NativeFundingObservation {
5435 amount: 350,
5436 expected_post_cycles: 800,
5437 funding_deficit_cycles: 150,
5438 funding_margin_cycles: 200,
5439 live_cycles: Some(599),
5440 pre_cycles: Some(449),
5441 };
5442 assert!(!native_funding_completion(observation, false).unwrap());
5443 assert!(matches!(
5444 native_funding_completion(observation, true),
5445 Err(IcpEnsurePlatformError::NativeFundingBalanceDrift {
5446 observation: actual,
5447 }) if *actual == observation
5448 ));
5449 assert!(
5450 native_funding_completion(
5451 NativeFundingObservation {
5452 live_cycles: Some(600),
5453 ..observation
5454 },
5455 true
5456 )
5457 .unwrap()
5458 );
5459 }
5460
5461 #[cfg(unix)]
5462 struct ProtocolOwnersFixture {
5463 root: PathBuf,
5464 platform: IcpEnsurePlatform,
5465 state: FleetEnsureStateRecord,
5466 }
5467
5468 #[cfg(unix)]
5469 impl ProtocolOwnersFixture {
5470 fn new() -> Self {
5471 use std::{fs, os::unix::fs::PermissionsExt};
5472
5473 let root = crate::test_support::temp_dir("canic-protocol-owner-observations");
5474 fs::create_dir_all(&root).unwrap();
5475 fs::write(root.join("owner.wasm"), b"current-owner-module").unwrap();
5476 let executable = root.join("icp");
5477 fs::write(&executable, format!(
5478 "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.5.0'; exit 0; fi\nwhile [ \"$#\" -gt 0 ] && [ \"$1\" != status ]; do shift; done\nshift\nprintf '%s\\n' \"$1\" >> '{}'/calls\ncat '{}'/\"$1\".json\n",
5479 root.display(), root.display(),
5480 )).unwrap();
5481 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
5482 let canisters = ["coordinator", "root", "store"].map(|name| {
5483 serde_json::json!({
5484 "controller_canisters": [], "controllers": [], "drain": null,
5485 "initial_cycles": "1T", "init_arg": null, "init_candid": null,
5486 "kind": name, "minimum_cycles": "1T", "name": name,
5487 "parent": null, "presence": "present", "principal": name,
5488 "replace": false, "subnet": "subnet", "wasm": "owner.wasm",
5489 })
5490 });
5491 let desired = serde_json::from_value(serde_json::json!({
5492 "bootstrap": null, "canisters": canisters, "cycles_ledger": "ledger",
5493 "environment": "local", "fleet": "owners", "ledger_fee_cycles": "100M",
5494 "management_creation_fee_cycles": "500B", "material_cycle_threshold": "1B",
5495 "maximum_observation_burn_cycles": "1B", "maximum_stalled_observations": 4,
5496 "maximum_update_burn_cycles": "1B", "operator": "operator", "protocol": null,
5497 "schema_version": 1, "treasury": "treasury",
5498 }))
5499 .unwrap();
5500 let platform = IcpEnsurePlatform::new(desired, executable.to_str().unwrap(), &root);
5501 let paths = crate::fleet_ensure::ops::EnsurePaths::under(&root, "local", "owners");
5502 let state = crate::fleet_ensure::ops::read_state(&paths, "owners").unwrap();
5503 let fixture = Self {
5504 root,
5505 platform,
5506 state,
5507 };
5508 for name in ["coordinator", "root", "store"] {
5509 fixture.status(name, "Running", true);
5510 }
5511 fixture
5512 }
5513
5514 fn status(&self, name: &str, status: &str, current_module: bool) {
5515 let module = if current_module {
5516 artifact_hash(&self.root.join("owner.wasm")).unwrap()
5517 } else {
5518 "ff".repeat(32)
5519 };
5520 std::fs::write(
5521 self.root.join(format!("{name}.json")),
5522 serde_json::json!({
5523 "id": name, "status": status, "settings": { "controllers": [] },
5524 "module_hash": module, "cycles": "1000000000000",
5525 })
5526 .to_string(),
5527 )
5528 .unwrap();
5529 }
5530 }
5531
5532 #[cfg(unix)]
5533 struct PoolInspectionFixture {
5534 owners: ProtocolOwnersFixture,
5535 root_id: Principal,
5536 target: Principal,
5537 }
5538
5539 #[cfg(unix)]
5540 impl PoolInspectionFixture {
5541 fn new() -> Self {
5542 let mut owners = ProtocolOwnersFixture::new();
5543 let root_id = Principal::from_slice(&[2]);
5544 let operator = Principal::from_slice(&[9]);
5545 owners.platform.desired.operator = operator.to_text();
5546 owners.platform.desired.protocol =
5547 Some(crate::fleet_ensure::model::DesiredFleetProtocol {
5548 app_config: "unused.toml".to_string(),
5549 component_group_placements: Vec::new(),
5550 root_candid: "root.did".to_string(),
5551 coordinator_candid: "unused.did".to_string(),
5552 store_candid: "unused.did".to_string(),
5553 });
5554 std::fs::write(owners.root.join("root.did"),
5555 "service : { canic_observability : (variant { InspectionReserve : record { canister_id : principal } }) -> () query }"
5556 ).unwrap();
5557 std::fs::write(owners.root.join("operator"), operator.to_text()).unwrap();
5558 for id in [root_id, Principal::from_slice(&[3])] {
5559 std::fs::write(
5560 owners.root.join(format!("{id}.json")),
5561 serde_json::json!({
5562 "id": id.to_text(), "status": "Running", "settings": {"controllers": []},
5563 "module_hash": null, "cycles": "10000",
5564 })
5565 .to_string(),
5566 )
5567 .unwrap();
5568 }
5569 std::fs::write(owners.root.join("icp"), format!(
5570 "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.5.0'; exit 0; fi\nwhile [ \"$#\" -gt 0 ] && [ \"$1\" != canister ] && [ \"$1\" != identity ]; do shift; done\ncase \"$1\" in\nidentity) cat '{}'/operator;;\ncanister) shift; case \"$1\" in\nstatus) shift; cat '{}'/\"$1\".json;;\ncall) if [ -e '{}'/fail ]; then exit 1; fi; cat '{}'/inspection.json;;\n*) exit 1;;\nesac;;\n*) exit 1;;\nesac\n",
5571 owners.root.display(), owners.root.display(), owners.root.display(), owners.root.display(),
5572 )).unwrap();
5573 let script = std::fs::read_to_string(owners.root.join("icp")).unwrap();
5574 let reserve_route = format!(
5575 r#"call) if [ "$3" = canic_observability ]; then
5576for expected in '{}'/reserve-"$2"-*.bin; do
5577 if cmp -s "$5" "$expected"; then cat "${{expected%.bin}}.json"; exit; fi
5578done
5579exit 1
5580fi
5581if"#,
5582 owners.root.display()
5583 );
5584 std::fs::write(
5585 owners.root.join("icp"),
5586 script.replace("call) if", &reserve_route),
5587 )
5588 .unwrap();
5589 Self::response(&owners.root, root_id, 1_000);
5590 for (caller, target) in [
5591 (root_id, Principal::from_slice(&[4])),
5592 (root_id, Principal::from_slice(&[5])),
5593 (Principal::from_slice(&[3]), Principal::from_slice(&[4])),
5594 ] {
5595 Self::reserve_response(&owners.root, caller, target, 1_000);
5596 }
5597 Self {
5598 owners,
5599 root_id,
5600 target: Principal::from_slice(&[4]),
5601 }
5602 }
5603
5604 fn reserve_response(path: &Path, caller: Principal, target: Principal, liquid: u128) {
5605 use crate::canister_protocol::inspection::{
5606 InspectionReserveRequest, InspectionReserveResponse,
5607 };
5608 let base = path.join(format!("reserve-{caller}-{target}"));
5609 std::fs::write(
5610 base.with_extension("bin"),
5611 candid::encode_one(InspectionReserveRequest::InspectionReserve(
5612 CanisterInspectionRequest {
5613 canister_id: target,
5614 },
5615 ))
5616 .unwrap(),
5617 )
5618 .unwrap();
5619 let response = Ok::<_, canic_core::dto::error::Error>(
5620 InspectionReserveResponse::InspectionReserve(
5621 canic_core::dto::canister::CanisterInspectionReserveResponse {
5622 caller,
5623 canister_id: target,
5624 native_cycles: 1000,
5625 available_liquid_cycles: liquid,
5626 required_liquid_cycles: 100,
5627 },
5628 ),
5629 );
5630 std::fs::write(
5631 base.with_extension("json"),
5632 serde_json::json!({
5633 "response_bytes": hex_bytes(candid::encode_one(response).unwrap()),
5634 })
5635 .to_string(),
5636 )
5637 .unwrap();
5638 }
5639
5640 fn response(path: &Path, controller: Principal, cycles: u128) {
5641 let response = Ok::<_, canic_core::dto::error::Error>(
5642 RootInspectionResponse::InspectCanister(RootInspectionStatus {
5643 status: canic_core::dto::canister::CanisterStatusType::Running,
5644 settings: ManagementCanisterObservationSettings {
5645 controllers: vec![controller],
5646 },
5647 module_hash: Some(vec![1; 32]),
5648 cycles: Nat::from(cycles),
5649 }),
5650 );
5651 std::fs::write(
5652 path.join("inspection.json"),
5653 serde_json::json!({
5654 "response_bytes": hex_bytes(candid::encode_one(response).unwrap()),
5655 })
5656 .to_string(),
5657 )
5658 .unwrap();
5659 }
5660 }
5661
5662 #[cfg(unix)]
5664 impl IcpEnsurePlatform {
5665 fn inspect_pending_pool_balance(
5666 &self,
5667 configured: &crate::fleet_ensure::model::DesiredCanister,
5668 principal: &str,
5669 state: &FleetEnsureStateRecord,
5670 ) -> Result<u128, IcpEnsurePlatformError> {
5671 let inspection = self.prepare_pending_pool_inspection(configured, principal, state)?;
5672 let response = self.read_pool_inspection(inspection.authority)?;
5673 self.complete_pool_inspection(inspection, response)
5674 }
5675 }
5676
5677 #[cfg(unix)]
5678 impl PoolInspectionFixture {
5679 fn fresh(pool_count: u32) -> Self {
5680 let mut fixture = Self::new();
5681 let owners = &mut fixture.owners;
5682 let operator = Principal::from_text(&owners.platform.desired.operator).unwrap();
5683 let mut root = owners.platform.desired.canisters[1].clone();
5684 root.principal = Some(fixture.root_id.to_text());
5685 root.controllers = vec![operator.to_text()];
5686 owners.platform.desired.canisters = vec![root.clone()];
5687 let config = canic_core::bootstrap::parse_config_model(
5688 "[app]\nname = 'inspection'\n[roles.root]\nkind = 'root'\n",
5689 )
5690 .unwrap();
5691 owners.platform.desired.bootstrap = Some(crate::fleet_ensure::model::DesiredFleetBootstrap {
5692 admission_identity_origin: None,
5693 admission: canic_core::shared_support::fleet_admission_policy::compile_fleet_admission_policy_template(vec![operator], Vec::new()).unwrap(),
5694 app: canic_core::ids::AppId::from("inspection"),
5695 canonical_network_id: canic_core::ids::CanonicalNetworkId::ic_mainnet(),
5696 component_deployment_configuration: config.compile_component_deployment_configuration().unwrap(),
5697 coordinator: "coordinator".into(),
5698 coordinator_subnet: canic_core::ids::SubnetId::from_principal(fixture.root_id),
5699 fleet_id: canic_core::ids::FleetId::from_generated_bytes([5; 32]),
5700 fresh_estate: true,
5701 release_build_id: canic_core::ids::ReleaseBuildId::from_nonce(canic_core::ids::ReleaseBuildNonce::from_random_bytes([7; 32])),
5702 root_funding: None,
5703 roots: Vec::new(),
5704 });
5705 std::fs::write(
5706 owners.root.join(format!("{}.json", fixture.root_id)),
5707 serde_json::json!({
5708 "id": fixture.root_id.to_text(), "status": "Running",
5709 "settings": {"controllers": [operator.to_text()]},
5710 "module_hash": artifact_hash(&owners.root.join("owner.wasm")).unwrap(),
5711 "cycles": "10000",
5712 })
5713 .to_string(),
5714 )
5715 .unwrap();
5716 let mut entries = Vec::new();
5717 for index in 0..pool_count {
5718 let target = Principal::from_slice(&[u8::try_from(index + 10).unwrap()]);
5719 Self::reserve_response(&owners.root, fixture.root_id, target, 1_000);
5720 let mut configured = root.clone();
5721 configured.name = format!("pool-{index:03}");
5722 configured.kind = DesiredCanisterKind::Pool;
5723 configured.principal = None;
5724 configured.parent = Some("root".into());
5725 owners
5726 .state
5727 .pending_principals
5728 .insert(configured.name.clone(), target.to_text());
5729 owners.platform.desired.canisters.push(configured);
5730 entries.push(CanisterPoolAsset {
5731 canister_id: target,
5732 creation_receipt: None,
5733 cycles: Cycles::new(0),
5734 origin: CanisterPoolAssetOrigin::Created,
5735 status: CanisterPoolAssetStatus::PendingReset,
5736 added_at_ns: 1,
5737 updated_at_ns: 1,
5738 });
5739 }
5740 fixture.target = entries[0].canister_id;
5741 Self::fresh_pool_page(&owners.root, entries);
5742 let script = std::fs::read_to_string(owners.root.join("icp")).unwrap();
5743 let script = script.replace(
5744 "call) if",
5745 &format!(
5746 "call) case \"$*\" in *canic_root_status*) cat '{}'/pool.json; exit;; esac; if",
5747 owners.root.display()
5748 ),
5749 );
5750 std::fs::write(owners.root.join("icp"), script).unwrap();
5751 Self::fresh_response(&owners.root, vec![fixture.root_id], None, 1_000);
5752 fixture
5753 }
5754
5755 fn fresh_pool_page(path: &Path, entries: Vec<CanisterPoolAsset>) {
5756 let pool_count = u32::try_from(entries.len()).unwrap();
5757 let page = CanisterPoolResponse {
5758 config: canic_core::ids::FleetSubnetCanisterPoolConfig {
5759 minimum_size: 1,
5760 maximum_size: 128,
5761 canister_cycles: Cycles::new(1_000),
5762 creation_execution_margin: Cycles::new(100),
5763 },
5764 tracked: pool_count,
5765 store: 0,
5766 store_deletion_pending: 0,
5767 pooled: pool_count,
5768 workload: 0,
5769 surplus: 0,
5770 ready: 0,
5771 pending_reset: pool_count,
5772 claimed: 0,
5773 recycling: 0,
5774 handing_off: 0,
5775 failed: 0,
5776 completed_handoffs: 0,
5777 pending_creation: None,
5778 pending_handoff: None,
5779 entries,
5780 next_start_after: None,
5781 };
5782 let response = Ok::<_, canic_core::dto::error::Error>(RootPoolStatusResponse::Pool(
5783 Box::new(page),
5784 ));
5785 std::fs::write(
5786 path.join("pool.json"),
5787 serde_json::json!({
5788 "response_bytes": hex_bytes(candid::encode_one(response).unwrap()),
5789 })
5790 .to_string(),
5791 )
5792 .unwrap();
5793 }
5794
5795 fn fresh_response(
5796 path: &Path,
5797 controllers: Vec<Principal>,
5798 module_hash: Option<Vec<u8>>,
5799 cycles: u128,
5800 ) {
5801 let response = Ok::<_, canic_core::dto::error::Error>(
5802 RootInspectionResponse::InspectCanister(RootInspectionStatus {
5803 status: canic_core::dto::canister::CanisterStatusType::Running,
5804 settings: ManagementCanisterObservationSettings { controllers },
5805 module_hash,
5806 cycles: Nat::from(cycles),
5807 }),
5808 );
5809 std::fs::write(
5810 path.join("inspection.json"),
5811 serde_json::json!({
5812 "response_bytes": hex_bytes(candid::encode_one(response).unwrap()),
5813 })
5814 .to_string(),
5815 )
5816 .unwrap();
5817 }
5818 }
5819
5820 #[cfg(unix)]
5821 impl PoolInspectionFixture {
5822 fn retained_observation(&self) -> FleetObservation {
5823 let assets = self
5824 .owners
5825 .state
5826 .pending_principals
5827 .values()
5828 .map(|principal| EstatePoolAssetObservation {
5829 creation_receipt: None,
5830 cycles: 500,
5831 lifecycle: EstatePoolAssetLifecycle::Workload,
5832 origin: EstatePoolAssetOrigin::Created,
5833 principal: principal.clone(),
5834 })
5835 .collect::<Vec<_>>();
5836 FleetObservation {
5837 additional_controlled_cycles: assets
5838 .iter()
5839 .map(|asset| (asset.principal.clone(), asset.cycles))
5840 .collect(),
5841 canisters: BTreeMap::new(),
5842 estate_funding_domains: BTreeMap::from([(
5843 "root".into(),
5844 EstateFundingDomainObservation {
5845 balance_cycles: Some(0),
5846 cycles_ledger: self.owners.platform.desired.cycles_ledger.clone(),
5847 pool: Some(EstatePoolInventoryObservation {
5848 assets,
5849 maximum_size: 128,
5850 minimum_size: 1,
5851 pending_creation: None,
5852 readiness_floor_cycles: 1_000,
5853 creation_execution_margin_cycles: 100,
5854 }),
5855 root_principal: Some(self.root_id.to_text()),
5856 },
5857 )]),
5858 ledger_fee_cycles: 100,
5859 operator_cycles: 1_000,
5860 protocol_ready: BTreeMap::new(),
5861 }
5862 }
5863
5864 fn inspect_retained(
5865 &self,
5866 observation: &mut FleetObservation,
5867 allow_pending: bool,
5868 ) -> Result<
5869 Vec<crate::fleet_ensure::model::FleetReinstallAssetRecord>,
5870 IcpEnsurePlatformError,
5871 > {
5872 let authorities = BTreeMap::from([(
5873 "root".into(),
5874 RootManagementCanisterObservation {
5875 live: LiveCanister {
5876 canister_version: None,
5877 controllers: vec![self.owners.platform.desired.operator.clone()],
5878 cycles: 10_000,
5879 module_sha256: Some("01".repeat(32)),
5880 principal: self.root_id.to_text(),
5881 reinstall_required: false,
5882 root_owned_lifecycle: None,
5883 status: CanisterRuntimeStatus::Running,
5884 },
5885 name: "root".into(),
5886 subnet: "subnet".into(),
5887 },
5888 )]);
5889 let candid = BTreeMap::from([(
5890 "root".into(),
5891 self.owners.platform.root_protocol_candid().unwrap(),
5892 )]);
5893 self.owners.platform.inspect_reinstall_assets(
5894 &self.owners.state,
5895 &authorities,
5896 observation,
5897 &candid,
5898 allow_pending,
5899 )
5900 }
5901
5902 fn inspection_hook(&self, hook: &str) {
5903 let path = &self.owners.root;
5904 let script = std::fs::read_to_string(path.join("icp")).unwrap();
5905 let response = format!("cat '{}'/inspection.json", path.display());
5906 assert!(script.contains(&response));
5907 std::fs::write(
5908 path.join("icp"),
5909 script.replace(&response, &format!("{hook}\n{response}")),
5910 )
5911 .unwrap();
5912 }
5913
5914 fn route_inspection_targets(&self) {
5915 let path = &self.owners.root;
5916 for configured in self
5917 .owners
5918 .platform
5919 .desired
5920 .canisters
5921 .iter()
5922 .filter(|canister| canister.kind == DesiredCanisterKind::Pool)
5923 {
5924 let target = &self.owners.state.pending_principals[&configured.name];
5925 let request = RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
5926 canister_id: Principal::from_text(target).unwrap(),
5927 });
5928 std::fs::write(
5929 path.join(format!("request-{}.bin", configured.name)),
5930 candid::encode_one(request).unwrap(),
5931 )
5932 .unwrap();
5933 }
5934 self.inspection_hook(&format!(
5935 r#"
5936cd '{}'
5937request_name=
5938for expected in request-*.bin; do
5939 if cmp -s "$5" "$expected"; then
5940 request_name=${{expected#request-}}
5941 request_name=${{request_name%.bin}}
5942 break
5943 fi
5944done
5945[ -n "$request_name" ] || exit 1
5946printf 'start:%s\n' "$request_name" >> events
5947if [ -f "$request_name.fail" ]; then
5948 printf 'finish:%s\n' "$request_name" >> events
5949 exit 1
5950fi
5951if [ -f "$request_name.response" ]; then
5952 attempts=0
5953 while ! grep -q '^finish:pool-001$' events; do
5954 attempts=$((attempts + 1))
5955 [ "$attempts" -lt 500 ] || exit 1
5956 sleep 0.01
5957 done
5958 printf 'finish:%s\n' "$request_name" >> events
5959 cat "$request_name.response"
5960 exit
5961fi
5962printf 'finish:%s\n' "$request_name" >> events
5963"#,
5964 path.display()
5965 ));
5966 }
5967 }
5968
5969 #[cfg(unix)]
5970 #[test]
5971 fn reinstall_inspections_overlap_and_retain_exact_inventory() {
5972 let bound = super::super::bounded_observations::MAX_IN_FLIGHT;
5973 let count = bound * 2 + 1;
5974 let fixture = PoolInspectionFixture::fresh(u32::try_from(count).unwrap());
5975 let path = &fixture.owners.root;
5976 fixture.inspection_hook(&format!(
5977 r#"
5978cd '{}'
5979printf 'start\n' >> events
5980started=$(grep -c '^start$' events)
5981goal=$(( (started + {bound} - 1) / {bound} * {bound} ))
5982[ "$goal" -le {count} ] || goal={count}
5983attempts=0
5984while [ "$(grep -c '^start$' events)" -lt "$goal" ]; do
5985 attempts=$((attempts + 1))
5986 [ "$attempts" -lt 500 ] || exit 1
5987 sleep 0.01
5988done
5989printf 'finish\n' >> events
5990"#,
5991 path.display()
5992 ));
5993 let mut observation = fixture.retained_observation();
5994 let assets = fixture.inspect_retained(&mut observation, false).unwrap();
5995 assert_eq!(assets.len(), count);
5996 assert!(
5997 assets
5998 .windows(2)
5999 .all(|pair| pair[0].principal < pair[1].principal)
6000 );
6001 assert!(
6002 assets
6003 .iter()
6004 .all(|asset| asset.controllers == [fixture.root_id.to_text()]
6005 && asset.module_sha256.is_none())
6006 );
6007 assert_eq!(observation.canisters.len(), count);
6008 assert!(observation.additional_controlled_cycles.is_empty());
6009 assert!(
6010 observation
6011 .canisters
6012 .values()
6013 .all(|live| live.as_ref().unwrap().cycles == 1_000)
6014 );
6015 let (mut active, mut maximum) = (0, 0);
6016 for event in std::fs::read_to_string(path.join("events"))
6017 .unwrap()
6018 .lines()
6019 {
6020 match event {
6021 "start" => {
6022 active += 1;
6023 maximum = maximum.max(active);
6024 }
6025 "finish" => {
6026 assert!(active > 0);
6027 active -= 1;
6028 }
6029 _ => panic!("unknown inspection event"),
6030 }
6031 assert!(active <= bound);
6032 }
6033 assert_eq!((active, maximum), (0, bound));
6034 assert_eq!(
6035 fixture.owners.platform.icp.remote_call_count(),
6036 2 * u64::try_from(count).unwrap()
6037 );
6038 std::fs::remove_dir_all(path).unwrap();
6039 }
6040
6041 #[cfg(unix)]
6042 #[test]
6043 fn reinstall_inspections_drain_failure_in_order_and_retry_fresh() {
6044 let fixture = PoolInspectionFixture::fresh(5);
6045 let path = &fixture.owners.root;
6046 PoolInspectionFixture::fresh_response(
6047 path,
6048 vec![fixture.root_id, Principal::anonymous()],
6049 None,
6050 1_000,
6051 );
6052 std::fs::copy(path.join("inspection.json"), path.join("pool-000.response")).unwrap();
6053 PoolInspectionFixture::fresh_response(path, vec![fixture.root_id], None, 2_000);
6054 std::fs::write(path.join("pool-001.fail"), []).unwrap();
6055 fixture.route_inspection_targets();
6056 let mut observation = fixture.retained_observation();
6057 let before = observation.clone();
6058 assert!(
6059 matches!(fixture.inspect_retained(&mut observation, false), Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { field: "exact Root controllers", canister }) if canister == fixture.target.to_text())
6060 );
6061 assert_eq!(observation, before);
6062 let events = std::fs::read_to_string(path.join("events")).unwrap();
6063 for index in 0..4 {
6064 assert!(events.contains(&format!("finish:pool-{index:03}")));
6065 }
6066 assert!(!events.contains("pool-004"));
6067 assert!(events.find("finish:pool-001").unwrap() < events.find("finish:pool-000").unwrap());
6068 assert_eq!(fixture.owners.platform.icp.remote_call_count(), 8);
6069 std::fs::remove_file(path.join("pool-000.response")).unwrap();
6070 std::fs::remove_file(path.join("pool-001.fail")).unwrap();
6071 for cycles in [2_000, 3_000] {
6072 PoolInspectionFixture::fresh_response(
6073 path,
6074 vec![fixture.root_id],
6075 Some(vec![1; 32]),
6076 cycles,
6077 );
6078 let assets = fixture.inspect_retained(&mut observation, false).unwrap();
6079 assert_eq!(assets.len(), 5);
6080 assert!(
6081 assets
6082 .iter()
6083 .all(|asset| asset.module_sha256.as_deref() == Some(&"01".repeat(32)))
6084 );
6085 assert!(
6086 observation
6087 .canisters
6088 .values()
6089 .all(|live| live.as_ref().unwrap().cycles == cycles)
6090 );
6091 }
6092 assert_eq!(fixture.owners.platform.icp.remote_call_count(), 28);
6093 std::fs::remove_dir_all(path).unwrap();
6094 }
6095
6096 #[cfg(unix)]
6097 #[test]
6098 fn reinstall_inspections_reject_duplicates_and_preserve_lifecycle_admission() {
6099 let fixture = PoolInspectionFixture::fresh(5);
6100 let mut observation = fixture.retained_observation();
6101 let pool = observation
6102 .estate_funding_domains
6103 .get_mut("root")
6104 .unwrap()
6105 .pool
6106 .as_mut()
6107 .unwrap();
6108 pool.assets[4].principal = pool.assets[0].principal.clone();
6109 assert!(matches!(
6110 fixture.inspect_retained(&mut observation, false),
6111 Err(IcpEnsurePlatformError::RootManagement(_))
6112 ));
6113 assert_eq!(fixture.owners.platform.icp.remote_call_count(), 8);
6114 for lifecycle in [
6115 EstatePoolAssetLifecycle::PendingReset,
6116 EstatePoolAssetLifecycle::Failed,
6117 EstatePoolAssetLifecycle::Claimed,
6118 ] {
6119 let mut observation = fixture.retained_observation();
6120 let pool = observation
6121 .estate_funding_domains
6122 .get_mut("root")
6123 .unwrap()
6124 .pool
6125 .as_mut()
6126 .unwrap();
6127 pool.assets.truncate(1);
6128 pool.assets[0].lifecycle = lifecycle;
6129 let before = fixture.owners.platform.icp.remote_call_count();
6130 assert!(matches!(
6131 fixture.inspect_retained(&mut observation, false),
6132 Err(IcpEnsurePlatformError::RootManagement(_))
6133 ));
6134 assert_eq!(fixture.owners.platform.icp.remote_call_count(), before);
6135 let result = fixture.inspect_retained(&mut observation, true);
6136 if lifecycle == EstatePoolAssetLifecycle::Claimed {
6137 assert!(matches!(
6138 result,
6139 Err(IcpEnsurePlatformError::RootManagement(_))
6140 ));
6141 assert_eq!(fixture.owners.platform.icp.remote_call_count(), before);
6142 } else {
6143 assert_eq!(result.unwrap().len(), 1);
6144 assert_eq!(fixture.owners.platform.icp.remote_call_count(), before + 2);
6145 }
6146 }
6147 std::fs::remove_dir_all(&fixture.owners.root).unwrap();
6148 }
6149
6150 #[cfg(unix)]
6151 #[test]
6152 fn reinstall_inspections_require_fresh_reserve_even_with_cached_evidence() {
6153 let fixture = PoolInspectionFixture::fresh(1);
6154 let platform = &fixture.owners.platform;
6155 *platform.observation_snapshot.borrow_mut() = Some(FleetObservationSnapshot::default());
6156 platform.retain_pool_inspection(
6157 PoolInspectionAuthority {
6158 root: fixture.root_id,
6159 target: fixture.target,
6160 },
6161 RootInspectionStatus {
6162 status: canic_core::dto::canister::CanisterStatusType::Running,
6163 settings: ManagementCanisterObservationSettings {
6164 controllers: vec![fixture.root_id],
6165 },
6166 module_hash: None,
6167 cycles: Nat::from(9_999_u64),
6168 },
6169 );
6170 let mut observation = fixture.retained_observation();
6171 PoolInspectionFixture::reserve_response(
6172 &fixture.owners.root,
6173 fixture.root_id,
6174 fixture.target,
6175 0,
6176 );
6177 assert!(matches!(fixture.inspect_retained(&mut observation, false),
6178 Err(IcpEnsurePlatformError::CurrentProtocol(current_protocol::CurrentProtocolError::Transport(
6179 CanisterProtocolError::InspectionPreflightReserve(evidence)
6180 ))) if evidence.canister_id == fixture.target && evidence.caller == fixture.root_id
6181 ));
6182 assert_eq!(platform.icp.remote_call_count(), 1);
6183 PoolInspectionFixture::reserve_response(
6184 &fixture.owners.root,
6185 fixture.root_id,
6186 fixture.target,
6187 1_000,
6188 );
6189 fixture.inspect_retained(&mut observation, false).unwrap();
6190 assert_eq!(
6191 observation.canisters["pool-000"].as_ref().unwrap().cycles,
6192 1_000
6193 );
6194 assert_eq!(platform.icp.remote_call_count(), 3);
6195 std::fs::remove_dir_all(&fixture.owners.root).unwrap();
6196 }
6197
6198 #[cfg(unix)]
6199 #[test]
6200 fn pool_balances_overlap_only_pending_and_failed_assets_within_the_bound() {
6201 let bound = super::super::bounded_observations::MAX_IN_FLIGHT;
6202 let count = bound * 2 + 1;
6203 let mut fixture = PoolInspectionFixture::fresh(u32::try_from(count + 1).unwrap());
6204 let path = &fixture.owners.root;
6205 fixture.inspection_hook(&format!(
6206 r#"
6207cd '{}'
6208printf 'start\n' >> events
6209started=$(grep -c '^start$' events)
6210goal=$(( (started + {bound} - 1) / {bound} * {bound} ))
6211[ "$goal" -le {count} ] || goal={count}
6212attempts=0
6213while [ "$(grep -c '^start$' events)" -lt "$goal" ]; do
6214 attempts=$((attempts + 1))
6215 [ "$attempts" -lt 500 ] || exit 1
6216 sleep 0.01
6217done
6218printf 'finish\n' >> events
6219"#,
6220 path.display()
6221 ));
6222 let mut observed = fixture.retained_observation();
6223 let assets = &mut observed
6224 .estate_funding_domains
6225 .get_mut("root")
6226 .unwrap()
6227 .pool
6228 .as_mut()
6229 .unwrap()
6230 .assets;
6231 for (index, asset) in assets.iter_mut().take(count).enumerate() {
6232 asset.lifecycle = if index % 2 == 0 {
6233 EstatePoolAssetLifecycle::Failed
6234 } else {
6235 EstatePoolAssetLifecycle::PendingReset
6236 };
6237 }
6238 fixture
6239 .owners
6240 .platform
6241 .with_observation_snapshot(|platform| {
6242 platform.refresh_pool_balances(&fixture.root_id.to_text(), assets)
6243 })
6244 .unwrap();
6245 assert!(assets[..count].iter().all(|asset| asset.cycles == 1_000));
6246 assert_eq!(assets[count].cycles, 500);
6247 let mut active = 0;
6248 let mut maximum = 0;
6249 for event in std::fs::read_to_string(path.join("events"))
6250 .unwrap()
6251 .lines()
6252 {
6253 match event {
6254 "start" => {
6255 active += 1;
6256 maximum = maximum.max(active);
6257 }
6258 "finish" => {
6259 assert!(active > 0);
6260 active -= 1;
6261 }
6262 _ => panic!("unknown inspection event"),
6263 }
6264 assert!(active <= bound);
6265 }
6266 assert_eq!((active, maximum), (0, bound));
6267 assert_eq!(
6268 fixture.owners.platform.icp.remote_call_count(),
6269 2 * u64::try_from(count).unwrap() + 1
6270 );
6271 std::fs::remove_dir_all(path).unwrap();
6272 }
6273
6274 #[cfg(unix)]
6275 #[test]
6276 fn pool_balances_drain_failed_batch_preserve_inventory_and_retry_fresh() {
6277 let mut fixture = PoolInspectionFixture::fresh(5);
6278 let path = &fixture.owners.root;
6279 PoolInspectionFixture::fresh_response(path, vec![Principal::from_slice(&[99])], None, 900);
6280 std::fs::copy(path.join("inspection.json"), path.join("pool-000.response")).unwrap();
6281 PoolInspectionFixture::fresh_response(path, vec![fixture.root_id], None, 1_000);
6282 std::fs::write(path.join("pool-001.fail"), []).unwrap();
6283 fixture.route_inspection_targets();
6284 let mut observed = fixture.retained_observation();
6285 let assets = &mut observed
6286 .estate_funding_domains
6287 .get_mut("root")
6288 .unwrap()
6289 .pool
6290 .as_mut()
6291 .unwrap()
6292 .assets;
6293 for asset in assets.iter_mut() {
6294 asset.lifecycle = EstatePoolAssetLifecycle::Failed;
6295 }
6296 let unchanged = assets.clone();
6297 let failed = fixture
6298 .owners
6299 .platform
6300 .with_observation_snapshot(|platform| {
6301 platform.refresh_pool_balances(&fixture.root_id.to_text(), assets)
6302 });
6303 assert!(
6304 matches!(failed, Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { canister, field: "Root-only controllers" }) if canister == unchanged[0].principal)
6305 );
6306 assert_eq!(*assets, unchanged);
6307 let events = std::fs::read_to_string(path.join("events")).unwrap();
6308 for index in 0..4 {
6309 assert!(events.contains(&format!("finish:pool-{index:03}")));
6310 }
6311 assert!(!events.contains("pool-004"));
6312 assert!(events.find("finish:pool-001").unwrap() < events.find("finish:pool-000").unwrap());
6313 assert_eq!(fixture.owners.platform.icp.remote_call_count(), 9);
6314 std::fs::remove_file(path.join("pool-000.response")).unwrap();
6315 std::fs::remove_file(path.join("pool-001.fail")).unwrap();
6316 PoolInspectionFixture::fresh_response(path, vec![fixture.root_id], None, 2_000);
6317 fixture
6318 .owners
6319 .platform
6320 .with_observation_snapshot(|platform| {
6321 platform.refresh_pool_balances(&fixture.root_id.to_text(), assets)
6322 })
6323 .unwrap();
6324 assert!(assets.iter().all(|asset| asset.cycles == 2_000));
6325 assert_eq!(fixture.owners.platform.icp.remote_call_count(), 20);
6326 assert!(
6327 fixture
6328 .owners
6329 .platform
6330 .observation_snapshot
6331 .borrow()
6332 .is_none()
6333 );
6334 std::fs::remove_dir_all(path).unwrap();
6335 }
6336
6337 #[cfg(unix)]
6338 #[test]
6339 fn configured_pool_inspections_overlap_within_the_bound_and_drain_partial_batches() {
6340 let bound = crate::fleet_ensure::ops::bounded_observations::MAX_IN_FLIGHT;
6341 let pool_count = bound * 2 + 1;
6342 let mut fixture = PoolInspectionFixture::fresh(u32::try_from(pool_count).unwrap());
6343 let path = &fixture.owners.root;
6344 fixture.inspection_hook(&format!(
6345 r#"
6346cd '{}'
6347printf 'start\n' >> events
6348started=$(grep -c '^start$' events)
6349goal=$(( (started + {bound} - 1) / {bound} * {bound} ))
6350[ "$goal" -le {pool_count} ] || goal={pool_count}
6351attempts=0
6352while [ "$(grep -c '^start$' events)" -lt "$goal" ]; do
6353 attempts=$((attempts + 1))
6354 [ "$attempts" -lt 500 ] || exit 1
6355 sleep 0.01
6356done
6357printf 'finish\n' >> events
6358"#,
6359 path.display()
6360 ));
6361 let observed = fixture
6362 .owners
6363 .platform
6364 .with_observation_snapshot(|platform| {
6365 platform.observe_configured_canisters(&fixture.owners.state)
6366 })
6367 .unwrap();
6368 assert_eq!(
6369 observed.len(),
6370 fixture.owners.platform.desired.canisters.len()
6371 );
6372 let events = std::fs::read_to_string(path.join("events")).unwrap();
6373 let mut active = 0;
6374 let mut maximum = 0;
6375 for event in events.lines() {
6376 match event {
6377 "start" => {
6378 active += 1;
6379 maximum = maximum.max(active);
6380 }
6381 "finish" => {
6382 assert!(active > 0);
6383 active -= 1;
6384 }
6385 _ => panic!("unknown fixture event"),
6386 }
6387 assert!(active <= bound);
6388 }
6389 assert_eq!(active, 0);
6390 assert_eq!(maximum, bound);
6391 assert_eq!(
6392 fixture.owners.platform.icp.remote_call_count(),
6393 2 * u64::try_from(pool_count).unwrap() + 2
6394 );
6395 assert!(
6396 fixture
6397 .owners
6398 .platform
6399 .observation_snapshot
6400 .borrow()
6401 .is_none()
6402 );
6403 std::fs::remove_dir_all(path).unwrap();
6404 }
6405
6406 #[cfg(unix)]
6407 #[test]
6408 fn pool_batch_keeps_consumer_error_before_later_transport_error_and_retries_fresh() {
6409 let mut fixture = PoolInspectionFixture::fresh(5);
6410 let path = &fixture.owners.root;
6411 PoolInspectionFixture::fresh_response(
6412 path,
6413 vec![fixture.root_id],
6414 Some(vec![1; 32]),
6415 1_000,
6416 );
6417 std::fs::copy(path.join("inspection.json"), path.join("pool-000.response")).unwrap();
6418 PoolInspectionFixture::fresh_response(path, vec![fixture.root_id], None, 1_000);
6419 std::fs::write(path.join("pool-001.fail"), []).unwrap();
6420 fixture.route_inspection_targets();
6421 let failed = fixture
6422 .owners
6423 .platform
6424 .with_observation_snapshot(|platform| {
6425 platform.observe_configured_canisters(&fixture.owners.state)
6426 });
6427 assert!(
6428 matches!(failed, Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { canister, field: "module-free pool asset" }) if canister == "pool-000")
6429 );
6430 let events = std::fs::read_to_string(path.join("events")).unwrap();
6431 for index in 0..4 {
6432 assert!(events.contains(&format!("finish:pool-{index:03}")));
6433 }
6434 assert!(!events.contains("pool-004"));
6435 assert!(events.find("finish:pool-001").unwrap() < events.find("finish:pool-000").unwrap());
6436 assert_eq!(fixture.owners.platform.icp.remote_call_count(), 10);
6437 assert!(
6438 fixture
6439 .owners
6440 .platform
6441 .observation_snapshot
6442 .borrow()
6443 .is_none()
6444 );
6445 std::fs::remove_file(path.join("pool-000.response")).unwrap();
6446 std::fs::remove_file(path.join("pool-001.fail")).unwrap();
6447 let observed = fixture
6448 .owners
6449 .platform
6450 .with_observation_snapshot(|platform| {
6451 platform.observe_configured_canisters(&fixture.owners.state)
6452 })
6453 .unwrap();
6454 assert_eq!(observed.len(), 6);
6455 assert_eq!(fixture.owners.platform.icp.remote_call_count(), 22);
6456 std::fs::remove_dir_all(path).unwrap();
6457 }
6458
6459 #[cfg(unix)]
6460 #[test]
6461 fn pool_batch_preserves_preparation_and_transport_error_precedence() {
6462 for invalid_index in [1, 2] {
6463 let mut fixture = PoolInspectionFixture::fresh(5);
6464 let path = &fixture.owners.root;
6465 fixture.owners.platform.desired.canisters[invalid_index].parent =
6467 Some("missing-root".into());
6468 fixture.route_inspection_targets();
6469 for index in 0..4 {
6470 std::fs::write(path.join(format!("pool-{index:03}.fail")), []).unwrap();
6471 }
6472 let failed = fixture
6473 .owners
6474 .platform
6475 .with_observation_snapshot(|platform| {
6476 platform.observe_configured_canisters(&fixture.owners.state)
6477 });
6478 if invalid_index == 1 {
6479 assert!(matches!(
6480 failed,
6481 Err(IcpEnsurePlatformError::CurrentProtocol(
6482 current_protocol::CurrentProtocolError::Configuration(_)
6483 ))
6484 ));
6485 } else {
6486 assert!(matches!(
6487 failed,
6488 Err(IcpEnsurePlatformError::CurrentProtocol(
6489 current_protocol::CurrentProtocolError::Transport(_)
6490 ))
6491 ));
6492 }
6493 let events = std::fs::read_to_string(path.join("events")).unwrap();
6494 assert_eq!(
6495 events
6496 .lines()
6497 .filter(|line| line.starts_with("finish:"))
6498 .count(),
6499 3
6500 );
6501 assert!(!events.contains(&format!("pool-{:03}", invalid_index - 1)));
6502 assert!(!events.contains("pool-004"));
6503 assert_eq!(fixture.owners.platform.icp.remote_call_count(), 8);
6504 assert!(
6505 fixture
6506 .owners
6507 .platform
6508 .observation_snapshot
6509 .borrow()
6510 .is_none()
6511 );
6512 std::fs::remove_dir_all(path).unwrap();
6513 }
6514 }
6515
6516 #[cfg(unix)]
6517 #[test]
6518 fn pool_batch_benchmark_matches_serial_observation_with_bounded_reads() {
6519 for pool_count in [1, 14, 27] {
6520 let mut serial_observed = None;
6521 for concurrent in [false, true] {
6522 let mut fixture = PoolInspectionFixture::fresh(pool_count);
6523 fixture.inspection_hook("sleep 0.02");
6524 let observed = fixture.owners.platform.with_observation_snapshot(|platform| {
6525 let started = std::time::Instant::now();
6526 let observed = if concurrent {
6527 platform.observe_configured_canisters(&fixture.owners.state)?
6528 } else {
6529 platform.desired.canisters.iter().map(|configured| {
6530 let principal = platform.current_principal(&fixture.owners.state, &configured.name).unwrap();
6531 platform.observe_configured_canister(configured, principal, &fixture.owners.state).map(|live| (configured.name.clone(), live))
6532 }).collect::<Result<BTreeMap<_, _>, _>>()?
6533 };
6534 let configured_ms = started.elapsed().as_millis();
6535 let configured_calls = platform.icp.remote_call_count();
6536 for live in observed.values().filter_map(Option::as_ref).filter(|live| live.principal != fixture.root_id.to_text()) {
6537 assert_eq!(platform.inspect_pool_balance("pool", &fixture.root_id.to_text(), &live.principal, InspectedModule::Any)?, live.cycles);
6538 }
6539 assert_eq!(platform.icp.remote_call_count(), configured_calls);
6540 assert_eq!(configured_calls, 2 * u64::from(pool_count) + 2);
6541 println!("pool_batch_benchmark assets={pool_count} concurrent={concurrent} configured_ms={configured_ms} configured_calls={configured_calls} extra_balance_calls=0 latency_ms=20");
6542 Ok(observed)
6543 }).unwrap();
6544 if let Some(serial) = &serial_observed {
6545 assert_eq!(&observed, serial);
6546 } else {
6547 serial_observed = Some(observed);
6548 }
6549 std::fs::remove_dir_all(&fixture.owners.root).unwrap();
6550 }
6551 }
6552 }
6553
6554 #[cfg(unix)]
6555 #[test]
6556 fn fresh_pool_observation_shares_inspections_with_balance_review() {
6557 let mut redundant_calls = 0;
6558 for pool_count in [1, 14, 27] {
6559 let mut fixture = PoolInspectionFixture::fresh(pool_count);
6560 let root = fixture.root_id.to_text();
6561 let path = &fixture.owners.root;
6562 fixture.owners.platform.with_observation_snapshot(|platform| {
6563 let started = std::time::Instant::now();
6564 let observed = platform.observe_configured_canisters(&fixture.owners.state)?;
6565 let configured_elapsed = started.elapsed();
6566 let configured_calls = platform.icp.remote_call_count();
6567 assert_eq!(observed.len(), usize::try_from(pool_count).unwrap() + 1);
6568 assert_eq!(configured_calls, 2 * u64::from(pool_count) + 2);
6569 let started = std::time::Instant::now();
6570 for live in observed.values().filter_map(Option::as_ref).filter(|live| live.principal != root) {
6571 assert_eq!(live.cycles, 1_000);
6572 assert_eq!(platform.inspect_pool_balance("pool", &root, &live.principal, InspectedModule::Any)?, 1_000);
6573 }
6574 let balance_calls = platform.icp.remote_call_count() - configured_calls;
6575 println!("fresh_pool_observation assets={pool_count} configured_ms={} configured_calls={configured_calls} balance_ms={} balance_calls={balance_calls}", configured_elapsed.as_millis(), started.elapsed().as_millis());
6576 redundant_calls += balance_calls;
6577 Ok(())
6578 }).unwrap();
6579 PoolInspectionFixture::fresh_response(path, vec![fixture.root_id], None, 900);
6580 let configured = &fixture.owners.platform.desired.canisters[1];
6581 assert_eq!(
6582 fixture
6583 .owners
6584 .platform
6585 .inspect_pending_pool_balance(
6586 configured,
6587 &fixture.target.to_text(),
6588 &fixture.owners.state
6589 )
6590 .unwrap(),
6591 900
6592 );
6593 std::fs::remove_dir_all(path).unwrap();
6594 }
6595 assert_eq!(redundant_calls, 0);
6596 }
6597
6598 #[cfg(unix)]
6599 #[test]
6600 fn fresh_pool_inspection_preserves_consumer_authority_and_failed_scope_expiry() {
6601 let mut fixture = PoolInspectionFixture::fresh(1);
6602 let root = fixture.root_id.to_text();
6603 let target = fixture.target.to_text();
6604 let operator = Principal::from_text(&fixture.owners.platform.desired.operator).unwrap();
6605 let configured = fixture.owners.platform.desired.canisters[1].clone();
6606 let path = &fixture.owners.root;
6607 PoolInspectionFixture::fresh_response(path, vec![fixture.root_id, operator], None, 1_000);
6608 let failed = fixture
6609 .owners
6610 .platform
6611 .with_observation_snapshot(|platform| {
6612 assert_eq!(
6613 platform.inspect_pending_pool_balance(
6614 &configured,
6615 &target,
6616 &fixture.owners.state
6617 )?,
6618 1_000
6619 );
6620 let calls = platform.icp.remote_call_count();
6621 assert!(matches!(
6622 platform.inspect_pool_balance("pool", &root, &target, InspectedModule::Any),
6623 Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
6624 field: "Root-only controllers",
6625 ..
6626 })
6627 ));
6628 assert_eq!(platform.icp.remote_call_count(), calls);
6629 std::fs::write(path.join("operator"), Principal::from_slice(&[8]).to_text())
6630 .unwrap();
6631 assert!(matches!(
6632 platform.inspect_pending_pool_balance(
6633 &configured,
6634 &target,
6635 &fixture.owners.state
6636 ),
6637 Err(IcpEnsurePlatformError::OperatorMismatch { .. })
6638 ));
6639 Err::<(), _>(IcpEnsurePlatformError::Arithmetic(
6640 "injected observation failure",
6641 ))
6642 });
6643 assert!(matches!(failed, Err(IcpEnsurePlatformError::Arithmetic(_))));
6644 assert!(
6645 fixture
6646 .owners
6647 .platform
6648 .observation_snapshot
6649 .borrow()
6650 .is_none()
6651 );
6652 std::fs::write(path.join("operator"), operator.to_text()).unwrap();
6653 PoolInspectionFixture::fresh_response(path, vec![fixture.root_id], Some(vec![1; 32]), 900);
6654 fixture
6655 .owners
6656 .platform
6657 .with_observation_snapshot(|platform| {
6658 assert_eq!(
6659 platform.inspect_pool_balance("pool", &root, &target, InspectedModule::Any)?,
6660 900
6661 );
6662 assert!(matches!(
6663 platform.inspect_pending_pool_balance(
6664 &configured,
6665 &target,
6666 &fixture.owners.state
6667 ),
6668 Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
6669 field: "module-free pool asset",
6670 ..
6671 })
6672 ));
6673 Ok(())
6674 })
6675 .unwrap();
6676 std::fs::remove_dir_all(path).unwrap();
6677 }
6678
6679 #[cfg(unix)]
6680 #[test]
6681 fn fresh_pool_inspection_retries_failures_and_rechecks_root_authority_on_cache_hits() {
6682 let mut fixture = PoolInspectionFixture::fresh(1);
6683 let target = fixture.target.to_text();
6684 let root = fixture.root_id.to_text();
6685 let configured = fixture.owners.platform.desired.canisters[1].clone();
6686 let path = &fixture.owners.root;
6687 fixture
6688 .owners
6689 .platform
6690 .with_observation_snapshot(|platform| {
6691 let inspect = |platform: &IcpEnsurePlatform| {
6692 platform.inspect_pending_pool_balance(
6693 &configured,
6694 &target,
6695 &fixture.owners.state,
6696 )
6697 };
6698 std::fs::write(path.join("fail"), []).unwrap();
6699 assert!(matches!(
6700 inspect(platform),
6701 Err(IcpEnsurePlatformError::CurrentProtocol(
6702 current_protocol::CurrentProtocolError::Transport(_)
6703 ))
6704 ));
6705 std::fs::remove_file(path.join("fail")).unwrap();
6706 PoolInspectionFixture::fresh_response(
6707 path,
6708 vec![Principal::from_slice(&[8])],
6709 None,
6710 900,
6711 );
6712 assert!(matches!(
6713 inspect(platform),
6714 Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
6715 field: "reviewed fresh pool controllers",
6716 ..
6717 })
6718 ));
6719 PoolInspectionFixture::fresh_response(path, vec![fixture.root_id], None, 800);
6720 assert_eq!(inspect(platform)?, 800);
6721 assert_eq!(platform.icp.remote_call_count(), 7);
6722 assert_eq!(
6725 platform.inspect_pool_balance("pool", &root, &target, InspectedModule::Any)?,
6726 800
6727 );
6728 std::fs::write(path.join("owner.wasm"), b"changed-root-artifact").unwrap();
6729 assert!(matches!(
6730 inspect(platform),
6731 Err(
6732 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
6733 field: "Root module SHA-256",
6734 ..
6735 }
6736 )
6737 ));
6738 std::fs::write(path.join("owner.wasm"), b"current-owner-module").unwrap();
6739 platform.desired.canisters[0].controllers =
6740 vec![Principal::from_slice(&[8]).to_text()];
6741 assert!(matches!(
6742 inspect(platform),
6743 Err(
6744 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
6745 field: "Root controllers",
6746 ..
6747 }
6748 )
6749 ));
6750 Ok(())
6751 })
6752 .unwrap();
6753 std::fs::remove_dir_all(path).unwrap();
6754 }
6755
6756 #[cfg(unix)]
6757 #[test]
6758 fn pool_inspection_honors_bound_source_contract_without_bypassing_query_failures() {
6759 let fixture = PoolInspectionFixture::new();
6760 let root = fixture.root_id.to_text();
6761 let target = fixture.target.to_text();
6762 let path = &fixture.owners.root;
6763 let platform = &fixture.owners.platform;
6764 let candid_path = path.join("root.did");
6765 let current = std::fs::read_to_string(&candid_path).unwrap();
6766 std::fs::write(
6767 &candid_path,
6768 "service : { canic_observability : (variant { CycleBalance }) -> () query }",
6769 )
6770 .unwrap();
6771 let inspect =
6772 || platform.inspect_pool_balance("pool", &root, &target, InspectedModule::Any);
6773 assert_eq!(inspect().unwrap(), 1000);
6774 assert_eq!(platform.icp.remote_call_count(), 2);
6776 std::fs::write(&candid_path, ¤t).unwrap();
6777 PoolInspectionFixture::reserve_response(path, fixture.root_id, fixture.target, 99);
6778 let before = platform.icp.remote_call_count();
6779 assert!(matches!(
6780 inspect(),
6781 Err(IcpEnsurePlatformError::CurrentProtocol(
6782 current_protocol::CurrentProtocolError::Transport(
6783 CanisterProtocolError::InspectionPreflightReserve(_)
6784 )
6785 ))
6786 ));
6787 assert_eq!(platform.icp.remote_call_count() - before, 2);
6788 std::fs::write(
6790 path.join(format!(
6791 "reserve-{}-{}.json",
6792 fixture.root_id, fixture.target
6793 )),
6794 "invalid response",
6795 )
6796 .unwrap();
6797 let before = platform.icp.remote_call_count();
6798 assert!(matches!(
6799 inspect(),
6800 Err(IcpEnsurePlatformError::CurrentProtocol(
6801 current_protocol::CurrentProtocolError::Transport(
6802 CanisterProtocolError::Response { .. }
6803 )
6804 ))
6805 ));
6806 assert_eq!(platform.icp.remote_call_count() - before, 2);
6807 std::fs::write(&candid_path, "invalid Candid").unwrap();
6808 let before = platform.icp.remote_call_count();
6809 assert!(matches!(
6810 inspect(),
6811 Err(IcpEnsurePlatformError::CurrentProtocol(
6812 current_protocol::CurrentProtocolError::Transport(
6813 CanisterProtocolError::InspectionContract { .. }
6814 )
6815 ))
6816 ));
6817 assert_eq!(platform.icp.remote_call_count() - before, 1);
6818 std::fs::write(&candid_path, current).unwrap();
6819 PoolInspectionFixture::reserve_response(path, fixture.root_id, fixture.target, 1000);
6820 let before = platform.icp.remote_call_count();
6821 assert_eq!(inspect().unwrap(), 1000);
6822 assert_eq!(platform.icp.remote_call_count() - before, 3);
6823 std::fs::remove_dir_all(path).unwrap();
6824 }
6825
6826 #[cfg(unix)]
6827 #[test]
6828 fn pool_inspection_snapshot_expires_after_success_and_failure() {
6829 let mut fixture = PoolInspectionFixture::new();
6830 let root = fixture.root_id.to_text();
6831 let target = fixture.target.to_text();
6832 let path = &fixture.owners.root;
6833 let inspect = |platform: &IcpEnsurePlatform| {
6834 platform.inspect_pool_balance("pool", &root, &target, InspectedModule::Any)
6835 };
6836 fixture
6837 .owners
6838 .platform
6839 .with_observation_snapshot(|platform| {
6840 assert_eq!(inspect(platform)?, 1_000);
6841 PoolInspectionFixture::response(path, fixture.root_id, 900);
6842 assert_eq!(inspect(platform)?, 1_000);
6843 assert_eq!(platform.icp.remote_call_count(), 3);
6845 Ok(())
6846 })
6847 .unwrap();
6848 assert!(
6849 fixture
6850 .owners
6851 .platform
6852 .observation_snapshot
6853 .borrow()
6854 .is_none()
6855 );
6856 assert_eq!(inspect(&fixture.owners.platform).unwrap(), 900);
6857 let failed: Result<(), IcpEnsurePlatformError> = fixture
6858 .owners
6859 .platform
6860 .with_observation_snapshot(|platform| {
6861 assert_eq!(inspect(platform)?, 900);
6862 PoolInspectionFixture::response(path, fixture.root_id, 800);
6863 Err(IcpEnsurePlatformError::Arithmetic(
6864 "injected observation failure",
6865 ))
6866 });
6867 assert!(matches!(failed, Err(IcpEnsurePlatformError::Arithmetic(_))));
6868 assert!(
6869 fixture
6870 .owners
6871 .platform
6872 .observation_snapshot
6873 .borrow()
6874 .is_none()
6875 );
6876 assert_eq!(inspect(&fixture.owners.platform).unwrap(), 800);
6877 fixture
6878 .owners
6879 .platform
6880 .with_observation_snapshot(|platform| {
6881 assert_eq!(inspect(platform)?, 800);
6882 Ok(())
6883 })
6884 .unwrap();
6885 std::fs::remove_dir_all(path).unwrap();
6886 }
6887
6888 #[cfg(unix)]
6889 #[test]
6890 fn pool_inspection_snapshot_binds_root_target_module_and_operator() {
6891 let mut fixture = PoolInspectionFixture::new();
6892 let root = fixture.root_id.to_text();
6893 let target = fixture.target.to_text();
6894 let other_root = Principal::from_slice(&[3]);
6895 let other_target = Principal::from_slice(&[5]).to_text();
6896 let path = &fixture.owners.root;
6897 fixture
6898 .owners
6899 .platform
6900 .with_observation_snapshot(|platform| {
6901 assert_eq!(
6902 platform.inspect_pool_balance("pool", &root, &target, InspectedModule::Any)?,
6903 1_000
6904 );
6905 assert!(matches!(
6906 platform.inspect_pool_balance("pool", &root, &target, InspectedModule::Empty),
6907 Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
6908 field: "module-free pool asset",
6909 ..
6910 })
6911 ));
6912 PoolInspectionFixture::response(path, fixture.root_id, 900);
6913 assert_eq!(
6914 platform.inspect_pool_balance(
6915 "other asset",
6916 &root,
6917 &other_target,
6918 InspectedModule::Any
6919 )?,
6920 900
6921 );
6922 PoolInspectionFixture::response(path, other_root, 800);
6923 assert_eq!(
6924 platform.inspect_pool_balance(
6925 "other Root",
6926 &other_root.to_text(),
6927 &target,
6928 InspectedModule::Any
6929 )?,
6930 800
6931 );
6932 assert_eq!(
6933 platform.inspect_pool_balance("pool", &root, &target, InspectedModule::Any)?,
6934 1_000
6935 );
6936 std::fs::write(path.join("operator"), other_root.to_text()).unwrap();
6938 assert!(matches!(
6939 platform.inspect_pool_balance("pool", &root, &target, InspectedModule::Any),
6940 Err(IcpEnsurePlatformError::OperatorMismatch { .. })
6941 ));
6942 Ok(())
6943 })
6944 .unwrap();
6945 std::fs::remove_dir_all(path).unwrap();
6946 }
6947
6948 #[cfg(unix)]
6949 #[test]
6950 fn pool_inspection_snapshot_does_not_retain_transport_or_authority_failures() {
6951 let mut fixture = PoolInspectionFixture::new();
6952 let root = fixture.root_id.to_text();
6953 let target = fixture.target.to_text();
6954 let path = &fixture.owners.root;
6955 fixture
6956 .owners
6957 .platform
6958 .with_observation_snapshot(|platform| {
6959 std::fs::write(path.join("fail"), []).unwrap();
6960 assert!(matches!(
6961 platform.inspect_pool_balance("pool", &root, &target, InspectedModule::Any),
6962 Err(IcpEnsurePlatformError::CurrentProtocol(
6963 current_protocol::CurrentProtocolError::Transport(_)
6964 ))
6965 ));
6966 std::fs::remove_file(path.join("fail")).unwrap();
6967 PoolInspectionFixture::response(path, Principal::from_slice(&[3]), 900);
6968 assert!(matches!(
6969 platform.inspect_pool_balance("pool", &root, &target, InspectedModule::Any),
6970 Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
6971 field: "Root-only controllers",
6972 ..
6973 })
6974 ));
6975 PoolInspectionFixture::response(path, fixture.root_id, 800);
6976 assert_eq!(
6977 platform.inspect_pool_balance("pool", &root, &target, InspectedModule::Any)?,
6978 800
6979 );
6980 assert_eq!(platform.icp.remote_call_count(), 7);
6981 Ok(())
6982 })
6983 .unwrap();
6984 std::fs::remove_dir_all(path).unwrap();
6985 }
6986
6987 #[cfg(unix)]
6988 #[test]
6989 #[expect(
6990 clippy::too_many_lines,
6991 reason = "one measured transport fixture keeps equivalent retained inventories and call accounting together"
6992 )]
6993 fn configured_observation_benchmark_retains_snapshot_call_bound() {
6994 use std::{
6995 fs,
6996 os::unix::fs::PermissionsExt,
6997 sync::{Arc, Mutex},
6998 };
6999 for pool_count in [0_u32, 8, 24, 96] {
7000 let mut fixture = ProtocolOwnersFixture::new();
7001 let root_pid = Principal::from_slice(&[2]);
7002 for (index, canister) in fixture.platform.desired.canisters.iter_mut().enumerate() {
7003 let pid = Principal::from_slice(&[u8::try_from(index + 1).unwrap()]).to_text();
7004 let mut status: serde_json::Value = serde_json::from_slice(
7005 &fs::read(fixture.root.join(format!("{}.json", canister.name))).unwrap(),
7006 )
7007 .unwrap();
7008 status["id"] = serde_json::Value::String(pid.clone());
7009 fs::write(fixture.root.join(format!("{pid}.json")), status.to_string()).unwrap();
7010 canister.principal = Some(pid);
7011 }
7012 fixture.platform.desired.protocol =
7013 Some(crate::fleet_ensure::model::DesiredFleetProtocol {
7014 app_config: "unused.toml".to_string(),
7015 component_group_placements: Vec::new(),
7016 root_candid: "root.did".to_string(),
7017 coordinator_candid: "unused.did".to_string(),
7018 store_candid: "unused.did".to_string(),
7019 });
7020 fs::write(fixture.root.join("root.did"), "service : {}").unwrap();
7021 let mut entries = Vec::new();
7022 for index in 0..pool_count {
7023 let pid = Principal::from_slice(&[u8::try_from(index + 10).unwrap()]);
7024 let mut configured = fixture.platform.desired.canisters[0].clone();
7025 configured.name = format!("pool-{index:03}");
7026 configured.kind = DesiredCanisterKind::Pool;
7027 configured.principal = Some(pid.to_text());
7028 configured.parent = Some("root".to_string());
7029 fixture.platform.desired.canisters.push(configured);
7030 entries.push(CanisterPoolAsset {
7031 canister_id: pid,
7032 creation_receipt: None,
7033 cycles: Cycles::new(1_000_000_000_000),
7034 origin: CanisterPoolAssetOrigin::Imported,
7035 status: CanisterPoolAssetStatus::Ready,
7036 added_at_ns: 1,
7037 updated_at_ns: 1,
7038 });
7039 }
7040 let page = CanisterPoolResponse {
7041 config: canic_core::ids::FleetSubnetCanisterPoolConfig {
7042 minimum_size: 5,
7043 maximum_size: 128,
7044 canister_cycles: Cycles::new(1_000_000_000_000),
7045 creation_execution_margin: Cycles::new(1_000_000_000),
7046 },
7047 tracked: pool_count,
7048 store: 0,
7049 store_deletion_pending: 0,
7050 pooled: pool_count,
7051 workload: 0,
7052 surplus: 0,
7053 ready: pool_count,
7054 pending_reset: 0,
7055 claimed: 0,
7056 recycling: 0,
7057 handing_off: 0,
7058 failed: 0,
7059 completed_handoffs: 0,
7060 pending_creation: None,
7061 pending_handoff: None,
7062 entries,
7063 next_start_after: None,
7064 };
7065 let response = Ok::<_, canic_core::dto::error::Error>(RootPoolStatusResponse::Pool(
7066 Box::new(page),
7067 ));
7068 fs::write(fixture.root.join("pool.json"), serde_json::json!({"response_bytes": hex_bytes(candid::encode_one(response).unwrap())}).to_string()).unwrap();
7069 let executable = fixture.root.join("icp");
7070 fs::write(&executable, format!(
7071 "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.5.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",
7072 fixture.root.display(), fixture.root.display())).unwrap();
7073 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755)).unwrap();
7074 let timings = Arc::new(Mutex::new(Vec::new()));
7075 let sink = Arc::clone(&timings);
7076 fixture.platform = fixture
7077 .platform
7078 .with_observation_handler(move |timing| sink.lock().unwrap().push(timing));
7079 let observed = fixture
7080 .platform
7081 .with_observation_snapshot(|platform| {
7082 platform
7083 .timed_observation(FleetObservationStage::ConfiguredCanisters, |platform| {
7084 platform.observe_configured_canisters(&fixture.state)
7085 })
7086 })
7087 .unwrap();
7088 assert_eq!(observed.len(), usize::try_from(pool_count).unwrap() + 3);
7089 assert!(observed.values().all(Option::is_some));
7090 assert_eq!(
7091 observed["root"].as_ref().unwrap().principal,
7092 root_pid.to_text()
7093 );
7094 let timing = timings.lock().unwrap().pop().unwrap();
7095 assert_eq!(timing.remote_call_attempts, 3 + u64::from(pool_count > 0));
7096 assert!(timing.succeeded);
7097 println!(
7098 "configured_observation pool_assets={pool_count} elapsed_ms={} calls={}",
7099 timing.elapsed_millis, timing.remote_call_attempts
7100 );
7101 fs::remove_dir_all(fixture.root).unwrap();
7102 }
7103 }
7104
7105 #[cfg(unix)]
7106 #[test]
7107 fn configured_observation_failure_drains_batch_and_keeps_first_error() {
7108 use std::{
7109 fs,
7110 sync::{Arc, Mutex},
7111 };
7112 let mut fixture = ProtocolOwnersFixture::new();
7113 let timings = Arc::new(Mutex::new(Vec::new()));
7114 let sink = Arc::clone(&timings);
7115 fixture.platform = fixture
7116 .platform
7117 .with_observation_handler(move |timing| sink.lock().unwrap().push(timing));
7118 let mut first: serde_json::Value =
7119 serde_json::from_slice(&fs::read(fixture.root.join("coordinator.json")).unwrap())
7120 .unwrap();
7121 first["id"] = serde_json::json!("different-principal");
7122 fs::write(fixture.root.join("coordinator.json"), first.to_string()).unwrap();
7123 fs::remove_file(fixture.root.join("root.json")).unwrap();
7124 let failure = fixture.platform.with_observation_snapshot(|platform| {
7125 platform.timed_observation(FleetObservationStage::ConfiguredCanisters, |platform| {
7126 platform.observe_configured_canisters(&fixture.state)
7127 })
7128 });
7129 assert!(
7130 matches!(failure, Err(IcpEnsurePlatformError::StatusIdentityMismatch { expected, .. }) if expected == "coordinator")
7131 );
7132 let timing = timings.lock().unwrap().pop().unwrap();
7133 assert!(!timing.succeeded);
7134 assert_eq!(timing.remote_call_attempts, 3);
7135 assert!(fixture.platform.observation_snapshot.borrow().is_none());
7136 fixture.status("coordinator", "Running", true);
7137 fixture.status("root", "Running", true);
7138 assert_eq!(
7139 fixture
7140 .platform
7141 .with_observation_snapshot(
7142 |platform| platform.observe_configured_canisters(&fixture.state)
7143 )
7144 .unwrap()
7145 .len(),
7146 3
7147 );
7148 assert_eq!(fixture.platform.icp.remote_call_count(), 6);
7149 fs::remove_dir_all(fixture.root).unwrap();
7150 }
7151
7152 #[cfg(unix)]
7153 #[test]
7154 fn reinstall_authority_pass_reads_statuses_and_refreshes_after_failure() {
7155 let mut fixture = ProtocolOwnersFixture::new();
7156 let path = &fixture.root;
7157 std::fs::write(path.join("icp"), r#"#!/bin/sh
7158if [ "$1" = '--version' ]; then echo 'icp 1.5.0'; exit 0; fi
7159while [ "$#" -gt 0 ] && [ "$1" != canister ] && [ "$1" != identity ] && [ "$1" != cycles ]; do shift; done
7160case "$1" in
7161identity) echo operator;;
7162cycles) echo '{"balance":"1000000000000 cycles"}';;
7163canister) shift; [ "$1" = status ] || exit 1; shift; printf '%s\n' "$1" >> calls; cat "$1.json";;
7164*) exit 1;;
7165esac
7166"#).unwrap();
7167 for name in ["root", "coordinator", "store"] {
7168 let file = path.join(format!("{name}.json"));
7169 let mut status: serde_json::Value =
7170 serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
7171 status["version"] = serde_json::json!(7);
7172 std::fs::write(file, serde_json::to_vec(&status).unwrap()).unwrap();
7173 }
7174 let first = fixture
7175 .platform
7176 .reinstall_authorities(&fixture.state)
7177 .unwrap()
7178 .unwrap();
7179 assert_eq!(first.len(), 3);
7180 assert!(
7181 first
7182 .values()
7183 .all(|value| value.live.canister_version == Some(7))
7184 );
7185 let calls = std::fs::read_to_string(path.join("calls")).unwrap();
7186 eprintln!(
7187 "reinstall authority status calls: {calls:?}; remote calls: {}",
7188 fixture.platform.icp.remote_call_count()
7189 );
7190 assert_eq!(calls, "root\ncoordinator\nstore\n");
7191 assert_eq!(fixture.platform.icp.remote_call_count(), 4);
7192 let file = path.join("root.json");
7193 let mut status: serde_json::Value =
7194 serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
7195 status["version"] = serde_json::json!(8);
7196 status["status"] = serde_json::json!("Stopped");
7197 status["module_hash"] = serde_json::json!("ab".repeat(32));
7198 status["settings"]["controllers"] = serde_json::json!(["changed-controller"]);
7199 std::fs::write(&file, serde_json::to_vec(&status).unwrap()).unwrap();
7200 std::fs::rename(path.join("store.json"), path.join("store.saved")).unwrap();
7201 assert!(
7202 fixture
7203 .platform
7204 .reinstall_authorities(&fixture.state)
7205 .is_err()
7206 );
7207 std::fs::rename(path.join("store.saved"), path.join("store.json")).unwrap();
7208 let after = fixture
7209 .platform
7210 .reinstall_authorities(&fixture.state)
7211 .unwrap()
7212 .unwrap();
7213 let root = &after["root"].live;
7214 assert_eq!(root.canister_version, Some(8));
7215 assert_eq!(root.status, CanisterRuntimeStatus::Stopped);
7216 assert_eq!(
7217 root.module_sha256.as_deref(),
7218 Some("ab".repeat(32).as_str())
7219 );
7220 assert_eq!(root.controllers, vec!["changed-controller"]);
7221 std::fs::remove_dir_all(path).unwrap();
7222 }
7223
7224 #[cfg(unix)]
7225 #[test]
7226 fn protocol_owner_observations_read_each_owner_once_and_refresh_between_calls() {
7227 let fixture = ProtocolOwnersFixture::new();
7228 assert!(
7229 fixture
7230 .platform
7231 .current_protocol_owners_are_ready(&fixture.state)
7232 .unwrap()
7233 );
7234 assert_eq!(
7235 std::fs::read_to_string(fixture.root.join("calls")).unwrap(),
7236 "coordinator\nroot\nstore\n"
7237 );
7238 fixture.status("store", "Stopping", true);
7239 assert!(
7240 !fixture
7241 .platform
7242 .current_protocol_owners_are_ready(&fixture.state)
7243 .unwrap()
7244 );
7245 fixture.status("store", "Stopped", true);
7246 assert!(
7247 !fixture
7248 .platform
7249 .current_protocol_owners_are_ready(&fixture.state)
7250 .unwrap()
7251 );
7252 fixture.status("store", "Running", false);
7253 assert!(
7254 !fixture
7255 .platform
7256 .current_protocol_owners_are_ready(&fixture.state)
7257 .unwrap()
7258 );
7259 fixture.status("store", "Running", true);
7260 assert!(
7261 fixture
7262 .platform
7263 .current_protocol_owners_are_ready(&fixture.state)
7264 .unwrap()
7265 );
7266 assert_eq!(
7267 std::fs::read_to_string(fixture.root.join("calls"))
7268 .unwrap()
7269 .lines()
7270 .count(),
7271 15
7272 );
7273 std::fs::remove_dir_all(fixture.root).unwrap();
7274 }
7275
7276 #[cfg(unix)]
7277 #[test]
7278 fn protocol_owner_observations_preserve_missing_owner_and_transport_failures() {
7279 let mut fixture = ProtocolOwnersFixture::new();
7280 fixture.platform.desired.canisters[0].principal = None;
7281 assert!(
7282 !fixture
7283 .platform
7284 .current_protocol_owners_are_ready(&fixture.state)
7285 .unwrap()
7286 );
7287 std::fs::remove_file(fixture.root.join("store.json")).unwrap();
7288 assert!(matches!(
7289 fixture
7290 .platform
7291 .current_protocol_owners_are_ready(&fixture.state),
7292 Err(IcpEnsurePlatformError::Icp(_))
7293 ));
7294 fixture.platform.desired.canisters[0].principal = Some("coordinator".to_string());
7295 fixture.status("coordinator", "Running", false);
7296 assert!(matches!(
7297 fixture
7298 .platform
7299 .current_protocol_owners_are_ready(&fixture.state),
7300 Err(IcpEnsurePlatformError::Icp(_))
7301 ));
7302 std::fs::remove_dir_all(fixture.root).unwrap();
7303 }
7304
7305 #[cfg(unix)]
7306 #[test]
7307 #[expect(
7308 clippy::too_many_lines,
7309 reason = "one transport sequence proves snapshot reuse and expiry across success, failure and effects"
7310 )]
7311 fn observation_snapshot_expires_before_effects_and_after_failed_observation() {
7312 use std::{fs, os::unix::fs::PermissionsExt};
7313
7314 let root = crate::test_support::temp_dir("canic-observation-snapshot");
7315 fs::create_dir_all(&root).expect("create observation fixture");
7316 let executable = root.join("icp");
7317 let commands = root.join("commands.log");
7318 let response = root.join("status.json");
7319 let canister = "rrkah-fqaaa-aaaaa-aaaaq-cai";
7320 let operator = "rdmx6-jaaaa-aaaaa-aaadq-cai";
7321 let status = |cycles: u128| {
7322 serde_json::json!({
7323 "id": canister,
7324 "status": "Running",
7325 "settings": { "controllers": [operator] },
7326 "module_hash": null,
7327 "cycles": cycles.to_string(),
7328 })
7329 .to_string()
7330 };
7331 fs::write(&response, status(1_000)).expect("write initial live balance");
7332 fs::write(&executable, format!(
7333 "#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo 'icp 1.5.0'; exit 0; fi\nprintf '%s\\n' \"$*\" >> '{}'\ncat '{}'\n",
7334 commands.display(), response.display(),
7335 )).expect("write observation transport");
7336 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))
7337 .expect("make observation transport executable");
7338 let desired = DesiredFleet {
7339 bootstrap: None,
7340 canisters: Vec::new(),
7341 cycles_ledger: "um5iw-rqaaa-aaaaq-qaaba-cai".to_string(),
7342 environment: "local".to_string(),
7343 fleet: "observation-snapshot".to_string(),
7344 ledger_fee_cycles: "100M".to_string(),
7345 management_creation_fee_cycles: "500B".to_string(),
7346 material_cycle_threshold: "1B".to_string(),
7347 maximum_observation_burn_cycles: "1B".to_string(),
7348 maximum_stalled_observations: 4,
7349 maximum_update_burn_cycles: "1B".to_string(),
7350 operator: operator.to_string(),
7351 protocol: None,
7352 protocol_steps: Vec::new(),
7353 schema_version: 1,
7354 treasury: "treasury".to_string(),
7355 };
7356 let mut platform =
7357 IcpEnsurePlatform::new(desired, executable.to_str().expect("transport path"), &root);
7358 let failed: Result<(), IcpEnsurePlatformError> =
7359 platform.with_observation_snapshot(|platform| {
7360 assert_eq!(
7361 platform
7362 .status_optional(canister)?
7363 .expect("live canister")
7364 .cycles,
7365 1_000
7366 );
7367 fs::write(&response, status(900)).expect("advance simulated live balance");
7368 assert_eq!(
7369 platform
7370 .status_optional(canister)?
7371 .expect("same snapshot")
7372 .cycles,
7373 1_000
7374 );
7375 Err(IcpEnsurePlatformError::Arithmetic(
7376 "injected observation failure",
7377 ))
7378 });
7379 assert!(matches!(failed, Err(IcpEnsurePlatformError::Arithmetic(_))));
7380 assert_eq!(
7381 fs::read_to_string(&commands)
7382 .expect("first observation calls")
7383 .lines()
7384 .count(),
7385 1
7386 );
7387 assert_eq!(
7388 platform
7389 .status_optional(canister)
7390 .expect("fresh action read")
7391 .expect("live canister")
7392 .cycles,
7393 900
7394 );
7395 platform
7396 .with_observation_snapshot(|platform| {
7397 assert_eq!(
7398 platform
7399 .status_optional(canister)?
7400 .expect("next observation")
7401 .cycles,
7402 900
7403 );
7404 fs::write(&response, status(800)).expect("advance live balance again");
7405 assert_eq!(
7406 platform
7407 .status_optional(canister)?
7408 .expect("next snapshot")
7409 .cycles,
7410 900
7411 );
7412 Ok(())
7413 })
7414 .expect("next observation succeeds");
7415 assert_eq!(
7416 platform
7417 .status_optional(canister)
7418 .expect("post-observation action read")
7419 .expect("live canister")
7420 .cycles,
7421 800
7422 );
7423 assert_eq!(
7424 fs::read_to_string(&commands)
7425 .expect("all observation calls")
7426 .lines()
7427 .count(),
7428 4
7429 );
7430 fs::remove_dir_all(root).expect("remove observation fixture");
7431 }
7432
7433 #[test]
7434 fn protocol_observation_delay_uses_bounded_exponential_backoff() {
7435 let production_cap = MAXIMUM_PROTOCOL_OBSERVATION_DELAY;
7436 assert_eq!(
7437 protocol_observation_delay(0, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
7438 Duration::from_millis(250)
7439 );
7440 assert_eq!(
7441 protocol_observation_delay(1, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
7442 Duration::from_millis(250)
7443 );
7444 assert_eq!(
7445 protocol_observation_delay(2, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
7446 Duration::from_millis(500)
7447 );
7448 assert_eq!(
7449 protocol_observation_delay(5, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
7450 Duration::from_secs(4)
7451 );
7452 assert_eq!(
7453 protocol_observation_delay(6, INITIAL_PROTOCOL_OBSERVATION_DELAY, production_cap),
7454 Duration::from_secs(5)
7455 );
7456 assert_eq!(
7457 protocol_observation_delay(
7458 u32::MAX,
7459 INITIAL_PROTOCOL_OBSERVATION_DELAY,
7460 production_cap
7461 ),
7462 Duration::from_secs(5)
7463 );
7464 assert_eq!(
7465 protocol_observation_delay(u32::MAX, Duration::from_secs(1), Duration::from_secs(1)),
7466 Duration::from_secs(1)
7467 );
7468 }
7469
7470 #[cfg(unix)]
7471 #[test]
7472 fn icp_1_5_public_non_controller_status_is_typed_unavailable_evidence() {
7473 use std::{fs, os::unix::fs::PermissionsExt};
7474
7475 let canister = "rrkah-fqaaa-aaaaa-aaaaq-cai";
7476 let root = crate::test_support::temp_dir("canic-public-status-projection");
7477 fs::create_dir_all(&root).expect("create public status fixture");
7478 let executable = root.join("icp");
7479 let commands = root.join("commands.log");
7480 let script = format!(
7481 "#!/bin/sh\n\
7482 if [ \"$1\" = \"--version\" ]; then echo 'icp 1.5.0'; exit 0; fi\n\
7483 printf '%s\\n' \"$*\" >> '{}'\n\
7484 printf '%s\\n' '{{\"id\":\"{canister}\",\"controllers\":[\"rdmx6-jaaaa-aaaaa-aaadq-cai\"],\"module_hash\":null}}'\n",
7485 commands.display(),
7486 );
7487 fs::write(&executable, script).expect("write fake ICP 1.5.0");
7488 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))
7489 .expect("make fake ICP executable");
7490 let desired = DesiredFleet {
7491 bootstrap: None,
7492 canisters: Vec::new(),
7493 cycles_ledger: "um5iw-rqaaa-aaaaq-qaaba-cai".to_string(),
7494 environment: "local".to_string(),
7495 fleet: "public-status".to_string(),
7496 ledger_fee_cycles: "100M".to_string(),
7497 management_creation_fee_cycles: "500B".to_string(),
7498 material_cycle_threshold: "1B".to_string(),
7499 maximum_observation_burn_cycles: "1B".to_string(),
7500 maximum_stalled_observations: 4,
7501 maximum_update_burn_cycles: "1B".to_string(),
7502 operator: "rdmx6-jaaaa-aaaaa-aaadq-cai".to_string(),
7503 protocol: None,
7504 protocol_steps: Vec::new(),
7505 schema_version: 1,
7506 treasury: "treasury".to_string(),
7507 };
7508 let platform =
7509 IcpEnsurePlatform::new(desired, executable.to_string_lossy().as_ref(), &root);
7510
7511 assert!(matches!(
7512 platform.status_optional(canister),
7513 Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable {
7514 canister: unavailable,
7515 }) if unavailable == canister
7516 ));
7517 assert!(
7518 fs::read_to_string(commands)
7519 .expect("read fake ICP commands")
7520 .contains("canister status rrkah-fqaaa-aaaaa-aaaaq-cai --json")
7521 );
7522 }
7523
7524 #[cfg(unix)]
7525 #[test]
7526 fn versionless_icp_status_uses_exact_typed_management_version() {
7527 #[derive(CandidType)]
7528 struct CanonicalManagementCanisterStatusFixture {
7529 version: u64,
7530 module_hash: Option<Vec<u8>>,
7531 }
7532
7533 use std::{fs, os::unix::fs::PermissionsExt};
7534
7535 let root = crate::test_support::temp_dir("canic-install-version-fallback");
7536 fs::create_dir_all(&root).expect("create version fallback fixture");
7537 let executable = root.join("icp");
7538 let commands = root.join("commands.log");
7539 let canister = "rrkah-fqaaa-aaaaa-aaaaq-cai";
7540 let status_json = format!(
7541 r#"{{"id":"{canister}","name":"coordinator","status":"Running","settings":{{"controllers":["rdmx6-jaaaa-aaaaa-aaadq-cai"]}},"module_hash":"0x{}","memory_size":"1","cycles":"1000000000000","query_stats":{{"num_calls_total":"0","num_instructions_total":"0","request_payload_bytes_total":"0","response_payload_bytes_total":"0"}}}}"#,
7542 "11".repeat(32),
7543 );
7544 let response_bytes = candid::encode_one(CanonicalManagementCanisterStatusFixture {
7545 version: 42,
7546 module_hash: Some(vec![0x22; 32]),
7547 })
7548 .expect("encode independently modelled management status");
7549 let script = format!(
7550 "#!/bin/sh\n\
7551 if [ \"$1\" = \"--version\" ]; then printf '%s\\n' 'icp 1.5.0'; exit 0; fi\n\
7552 printf '%s\\n' \"$*\" >> '{}'\n\
7553 case \"$*\" in\n\
7554 *\"canister status {canister}\"*) printf '%s\\n' '{}' ;;\n\
7555 *) printf '%s\\n' 'unexpected fake ICP command' >&2; exit 23 ;;\n\
7556 esac\n",
7557 commands.display(),
7558 status_json,
7559 );
7560 fs::write(&executable, script).expect("write fake ICP 1.5.0");
7561 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))
7562 .expect("make fake ICP executable");
7563 let icp = IcpCli::new(executable.to_string_lossy(), Some("ic".to_string()));
7564
7565 let projected = icp
7566 .canister_status_report(canister)
7567 .expect("read ICP 1.5.0 status projection");
7568 assert_eq!(projected.canister_version, None);
7569 let exact = exact_install_canister_status_with(
7570 canister,
7571 projected.canister_version,
7572 projected.module_hash.map(|hash| normalize_hash(&hash)),
7573 |effective_canister_id| {
7574 assert_eq!(
7575 effective_canister_id,
7576 canister.parse().expect("target Principal")
7577 );
7578 candid::decode_one(&response_bytes).map_err(IcpManagementCallError::CandidResponse)
7579 },
7580 )
7581 .expect("obtain exact typed management status");
7582 assert_eq!(exact.canister_version, 42);
7583 assert_eq!(exact.module_sha256, Some("22".repeat(32)));
7584 let commands = fs::read_to_string(&commands).expect("read fake ICP commands");
7585 assert!(commands.contains("canister status rrkah-fqaaa-aaaaa-aaaaq-cai"));
7586 assert!(!commands.contains("canister call aaaaa-aa canister_status"));
7587
7588 fs::write(
7589 &executable,
7590 "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'icp 1.5.0'; exit 0; fi\necho '{\"not_response_bytes\":true}'\n",
7591 )
7592 .expect("replace unavailable management fixture");
7593 let error = exact_install_canister_status_with(canister, None, None, |_| {
7594 Err(IcpManagementCallError::MissingEnvironment)
7595 })
7596 .expect_err("missing projected and typed version must fail closed");
7597 assert!(matches!(
7598 error,
7599 IcpEnsurePlatformError::InstallVersionProofUnavailable {
7600 canister: observed_canister,
7601 source,
7602 } if observed_canister == canister
7603 && matches!(source.as_ref(), IcpManagementCallError::MissingEnvironment)
7604 ));
7605
7606 fs::remove_dir_all(root).expect("remove version fallback fixture");
7607 }
7608
7609 #[test]
7610 fn root_owned_observation_classifies_bootstrap_and_workload_lifecycle() {
7611 assert_eq!(
7612 root_owned_lifecycle(DesiredCanisterKind::Store, &CanisterPoolAssetStatus::Store),
7613 Some(RootOwnedCanisterLifecycle::Store)
7614 );
7615 assert_eq!(
7616 root_owned_lifecycle(DesiredCanisterKind::Pool, &CanisterPoolAssetStatus::Ready),
7617 Some(RootOwnedCanisterLifecycle::Idle)
7618 );
7619 assert_eq!(
7620 root_owned_lifecycle(
7621 DesiredCanisterKind::Pool,
7622 &CanisterPoolAssetStatus::Claimed {
7623 claim: canic_core::dto::pool::CanisterPoolClaim {
7624 component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
7625 [1; 32]
7626 ),
7627 operation_id: [2; 32],
7628 },
7629 }
7630 ),
7631 Some(RootOwnedCanisterLifecycle::Claimed)
7632 );
7633 assert_eq!(
7634 root_owned_lifecycle(
7635 DesiredCanisterKind::Pool,
7636 &CanisterPoolAssetStatus::Workload {
7637 claim: canic_core::dto::pool::CanisterPoolClaim {
7638 component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
7639 [1; 32]
7640 ),
7641 operation_id: [2; 32],
7642 },
7643 }
7644 ),
7645 Some(RootOwnedCanisterLifecycle::Workload)
7646 );
7647 assert_eq!(
7648 root_owned_lifecycle(
7649 DesiredCanisterKind::Pool,
7650 &CanisterPoolAssetStatus::HandingOff {
7651 recipient: Principal::anonymous(),
7652 }
7653 ),
7654 None
7655 );
7656 assert_eq!(
7657 root_owned_lifecycle(
7658 DesiredCanisterKind::Store,
7659 &CanisterPoolAssetStatus::PendingReset
7660 ),
7661 None
7662 );
7663 }
7664
7665 #[cfg(unix)]
7666 #[test]
7667 #[expect(
7668 clippy::too_many_lines,
7669 reason = "one platform-to-policy matrix proves lifecycle projection and exclusive funding authority"
7670 )]
7671 fn configured_pool_observation_preserves_one_funding_owner() {
7672 use crate::fleet_ensure::{model::DesiredFleetArtifacts, policy::compile_plan};
7673
7674 let mut fixture = PoolInspectionFixture::fresh(1);
7675 fixture.target = Principal::from_slice(&[10]);
7676 PoolInspectionFixture::reserve_response(
7677 &fixture.owners.root,
7678 fixture.root_id,
7679 fixture.target,
7680 1_000,
7681 );
7682 let root_id = fixture.root_id.to_text();
7683 let target = fixture.target.to_text();
7684 let mut desired = fixture.owners.platform.desired.clone();
7685 desired.cycles_ledger = Principal::from_slice(&[8]).to_text();
7686 desired.treasury = "root".into();
7687 desired.ledger_fee_cycles = "5".into();
7688 desired.management_creation_fee_cycles = "500".into();
7689 desired.material_cycle_threshold = "1".into();
7690 desired.maximum_observation_burn_cycles = "10".into();
7691 desired.maximum_update_burn_cycles = "20".into();
7692 let mut root = desired.canisters[0].clone();
7693 root.principal = Some(root_id.clone());
7694 root.subnet = root_id.clone();
7695 root.controllers = vec![desired.operator.clone()];
7696 root.wasm = None;
7697 root.initial_cycles = "1000".into();
7698 root.minimum_cycles = "1000".into();
7699 let mut configured = root.clone();
7700 configured.name = "pool".into();
7701 configured.kind = DesiredCanisterKind::Pool;
7702 configured.principal = Some(target.clone());
7703 configured.controllers = vec![root_id.clone()];
7704 configured.parent = Some(root.name.clone());
7705 let mut coordinator = root.clone();
7706 coordinator.name = "coordinator".into();
7707 coordinator.kind = DesiredCanisterKind::Coordinator;
7708 coordinator.principal = Some(Principal::from_slice(&[20]).to_text());
7709 root.parent = Some(coordinator.name.clone());
7710 desired.canisters = vec![coordinator.clone(), root, configured.clone()];
7711 let bootstrap = desired.bootstrap.as_mut().unwrap();
7712 bootstrap.fresh_estate = false;
7713 bootstrap.roots = vec![crate::fleet_ensure::model::DesiredFleetBootstrapRoot {
7714 canister_pool_imports: vec![target.clone()],
7715 component_admissions: Vec::new(),
7716 component_topology_digest: canic_core::ids::ComponentTopologyDigest::from_bytes(
7717 [1; 32],
7718 ),
7719 funding: crate::test_support::fleet_subnet_root_funding_authority(),
7720 limits: canic_core::ids::FleetSubnetRootLimits {
7721 maximum_component_instances: 1,
7722 maximum_registry_bytes: 1,
7723 maximum_wasm_store_bytes: 1,
7724 canister_pool: canic_core::ids::FleetSubnetCanisterPoolConfig {
7725 minimum_size: 0,
7726 maximum_size: 1,
7727 canister_cycles: Cycles::new(1_000),
7728 creation_execution_margin: Cycles::new(100),
7729 },
7730 cycles_funding: canic_core::ids::CyclesFundingBudget {
7731 window_secs: 1,
7732 maximum_cycles: Cycles::new(1),
7733 },
7734 maximum_group_placements: 1,
7735 },
7736 placement_subnet: canic_core::ids::SubnetId::from_principal(fixture.root_id),
7737 root: "root".into(),
7738 store: "store".into(),
7739 }];
7740 let claim = canic_core::dto::pool::CanisterPoolClaim {
7741 component: canic_core::ids::ComponentInstanceId::from_generated_bytes([1; 32]),
7742 operation_id: [2; 32],
7743 };
7744 for status in [
7745 CanisterPoolAssetStatus::PendingReset,
7746 CanisterPoolAssetStatus::Failed {
7747 reason: "reset interrupted".into(),
7748 },
7749 CanisterPoolAssetStatus::Ready,
7750 CanisterPoolAssetStatus::Claimed {
7751 claim: claim.clone(),
7752 },
7753 CanisterPoolAssetStatus::Workload { claim },
7754 ] {
7755 for cycles in [900, 1_050, 1_100] {
7756 let lifecycle = estate_pool_lifecycle(&status).unwrap();
7757 let retained = matches!(
7758 lifecycle,
7759 EstatePoolAssetLifecycle::PendingReset | EstatePoolAssetLifecycle::Failed
7760 );
7761 let live = IcpEnsurePlatform::observed_root_owned_asset(
7762 &configured,
7763 &target,
7764 &root_id,
7765 CanisterPoolAsset {
7766 canister_id: fixture.target,
7767 creation_receipt: None,
7768 cycles: Cycles::new(cycles),
7769 origin: CanisterPoolAssetOrigin::Imported,
7770 status: status.clone(),
7771 added_at_ns: 1,
7772 updated_at_ns: 1,
7773 },
7774 )
7775 .unwrap();
7776 let mut observation = FleetObservation {
7777 additional_controlled_cycles: BTreeMap::new(),
7778 canisters: BTreeMap::from([
7779 ("pool".into(), live),
7780 (
7781 "root".into(),
7782 Some(LiveCanister {
7783 canister_version: None,
7784 controllers: vec![desired.operator.clone()],
7785 cycles: 10_000,
7786 module_sha256: None,
7787 principal: root_id.clone(),
7788 reinstall_required: false,
7789 root_owned_lifecycle: None,
7790 status: CanisterRuntimeStatus::Running,
7791 }),
7792 ),
7793 ]),
7794 estate_funding_domains: BTreeMap::from([(
7795 "root".into(),
7796 EstateFundingDomainObservation {
7797 balance_cycles: Some(0),
7798 cycles_ledger: desired.cycles_ledger.clone(),
7799 root_principal: Some(root_id.clone()),
7800 pool: Some(EstatePoolInventoryObservation {
7801 assets: vec![EstatePoolAssetObservation {
7802 creation_receipt: None,
7803 cycles,
7804 lifecycle,
7805 origin: EstatePoolAssetOrigin::Imported,
7806 principal: target.clone(),
7807 }],
7808 maximum_size: 1,
7809 minimum_size: 0,
7810 pending_creation: None,
7811 readiness_floor_cycles: 1_000,
7812 creation_execution_margin_cycles: 100,
7813 }),
7814 },
7815 )]),
7816 ledger_fee_cycles: 5,
7817 operator_cycles: 10_000,
7818 protocol_ready: BTreeMap::new(),
7819 };
7820 let mut coordinator_live = observation.canisters["root"].clone().unwrap();
7821 coordinator_live.principal = coordinator.principal.clone().unwrap();
7822 observation
7823 .canisters
7824 .insert(coordinator.name.clone(), Some(coordinator_live));
7825 let actions = current_protocol::compile_pool_reconciliation(
7826 &fixture.owners.root,
7827 &desired,
7828 &fixture.owners.state,
7829 &observation.estate_funding_domains,
7830 )
7831 .unwrap();
7832 assert_eq!(actions.len(), usize::from(retained));
7833 let plan = compile_plan(
7834 &desired,
7835 &DesiredFleetArtifacts::default(),
7836 &actions,
7837 &"11".repeat(32),
7838 &desired.fleet,
7839 &observation,
7840 1,
7841 &"22".repeat(32),
7842 None,
7843 )
7844 .expect("configured pool and reconciliation must share one funding owner");
7845 let pool_plan = plan
7846 .canisters
7847 .iter()
7848 .find(|entry| entry.name == "pool")
7849 .unwrap();
7850 let funds = pool_plan
7851 .actions
7852 .iter()
7853 .filter(|action| matches!(action, EnsureAction::Fund { .. }))
7854 .collect::<Vec<_>>();
7855 let should_fund = if retained {
7856 cycles < 1_100
7857 } else {
7858 cycles < 1_000 && lifecycle == EstatePoolAssetLifecycle::Ready
7859 };
7860 assert_eq!(funds.len(), usize::from(should_fund));
7861 if let Some(EnsureAction::Fund {
7862 amount,
7863 pool_funding,
7864 expected_post_cycles,
7865 ..
7866 }) = funds.first()
7867 {
7868 let authority = pool_funding.as_ref().unwrap();
7869 assert_eq!(
7870 (&authority.root, authority.lifecycle),
7871 (&root_id, lifecycle)
7872 );
7873 if retained {
7874 assert_eq!((*amount, *expected_post_cycles), (1_130 - cycles, 1_130));
7875 }
7876 }
7877 if retained && cycles == 1_100 {
7878 observation
7879 .canisters
7880 .get_mut(&coordinator.name)
7881 .unwrap()
7882 .as_mut()
7883 .unwrap()
7884 .status = CanisterRuntimeStatus::Stopped;
7885 let prerequisite = compile_plan(
7886 &desired,
7887 &DesiredFleetArtifacts::default(),
7888 &[],
7889 &"11".repeat(32),
7890 &desired.fleet,
7891 &observation,
7892 1,
7893 &"22".repeat(32),
7894 None,
7895 )
7896 .expect("live reset evidence must not block Coordinator readiness");
7897 assert!(prerequisite.canisters.iter().flat_map(|entry| &entry.actions)
7898 .any(|action| matches!(action, EnsureAction::Start { name, .. } if name == &coordinator.name)));
7899 }
7900 }
7901 }
7902 std::fs::remove_dir_all(&fixture.owners.root).unwrap();
7903 }
7904
7905 #[test]
7906 fn estate_pool_inventory_reconciles_every_lifecycle_and_declared_total() {
7907 let claim = canic_core::dto::pool::CanisterPoolClaim {
7908 component: canic_core::ids::ComponentInstanceId::from_generated_bytes([1; 32]),
7909 operation_id: [2; 32],
7910 };
7911 let statuses = [
7912 CanisterPoolAssetStatus::Store,
7913 CanisterPoolAssetStatus::StoreDeletionPending {
7914 operation_id: [3; 32],
7915 },
7916 CanisterPoolAssetStatus::PendingReset,
7917 CanisterPoolAssetStatus::Ready,
7918 CanisterPoolAssetStatus::Claimed {
7919 claim: claim.clone(),
7920 },
7921 CanisterPoolAssetStatus::Workload {
7922 claim: claim.clone(),
7923 },
7924 CanisterPoolAssetStatus::Recycling {
7925 claim,
7926 reset: canic_core::dto::pool::CanisterPoolRecycleReset::Pending,
7927 },
7928 CanisterPoolAssetStatus::HandingOff {
7929 recipient: Principal::anonymous(),
7930 },
7931 CanisterPoolAssetStatus::Failed {
7932 reason: "retained failure".to_string(),
7933 },
7934 ];
7935 let mut observed = EstatePoolLifecycleCounts::default();
7936 for status in &statuses {
7937 observed.observe(status).expect("bounded lifecycle count");
7938 }
7939 let authority = EstatePoolPageAuthority {
7940 completed_handoffs: 0,
7941 counts: observed,
7942 pending_handoff: None,
7943 pooled: 4,
7944 surplus: 1,
7945 };
7946
7947 assert!(authority.matches_complete_inventory(observed, 3));
7948 let mut incomplete = observed;
7949 incomplete.failed -= 1;
7950 incomplete.tracked -= 1;
7951 assert!(!authority.matches_complete_inventory(incomplete, 3));
7952
7953 assert_eq!(estate_pool_lifecycle(&statuses[0]), None);
7954 assert_eq!(estate_pool_lifecycle(&statuses[1]), None);
7955 assert_eq!(
7956 statuses[2..]
7957 .iter()
7958 .map(estate_pool_lifecycle)
7959 .collect::<Vec<_>>(),
7960 vec![
7961 Some(EstatePoolAssetLifecycle::PendingReset),
7962 Some(EstatePoolAssetLifecycle::Ready),
7963 Some(EstatePoolAssetLifecycle::Claimed),
7964 Some(EstatePoolAssetLifecycle::Workload),
7965 Some(EstatePoolAssetLifecycle::Recycling),
7966 Some(EstatePoolAssetLifecycle::HandingOff),
7967 Some(EstatePoolAssetLifecycle::Failed),
7968 ]
7969 );
7970 }
7971
7972 #[test]
7973 fn estate_funding_requires_exact_source_debit_and_destination_credit() {
7974 let exact = EstateFundingObservation {
7975 amount: 60,
7976 destination_after: 100,
7977 destination_before: Some(40),
7978 expected_destination_after: 100,
7979 ledger_fee_cycles: 5,
7980 source_after: 135,
7981 source_before: Some(200),
7982 };
7983 assert!(estate_funding_applied(exact));
7984
7985 for drifted in [
7986 EstateFundingObservation {
7987 source_after: 136,
7988 ..exact
7989 },
7990 EstateFundingObservation {
7991 destination_after: 99,
7992 ..exact
7993 },
7994 EstateFundingObservation {
7995 source_before: None,
7996 ..exact
7997 },
7998 EstateFundingObservation {
7999 destination_before: None,
8000 ..exact
8001 },
8002 ] {
8003 assert!(!estate_funding_applied(drifted));
8004 }
8005 }
8006
8007 #[test]
8008 #[expect(
8009 clippy::too_many_lines,
8010 reason = "the binding regression mutates each authority field independently"
8011 )]
8012 fn retained_root_owned_topology_requires_exact_child_root_and_parent_binding() {
8013 let mut state = FleetEnsureStateRecord {
8014 active_registry: None,
8015 completed_reinstall_action_sha256: BTreeMap::new(),
8016 completed_reinstall_operation_id: None,
8017 completed_reinstalls: BTreeMap::new(),
8018 fleet: "fleet".to_string(),
8019 pending_principals: BTreeMap::new(),
8020 principals: BTreeMap::from([
8021 ("root".to_string(), "root-principal".to_string()),
8022 ("store".to_string(), "store-principal".to_string()),
8023 ]),
8024 retained_cycles_by_principal: BTreeMap::new(),
8025 schema_version: crate::fleet_ensure::model::FLEET_ENSURE_SCHEMA_VERSION,
8026 topology: BTreeMap::from([(
8027 "store".to_string(),
8028 crate::fleet_ensure::model::FleetEnsureTopologyRecord {
8029 kind: DesiredCanisterKind::Store,
8030 module_hash: Some("11".repeat(32)),
8031 parent: Some("root".to_string()),
8032 protocol_binding: None,
8033 role: None,
8034 },
8035 )]),
8036 };
8037
8038 assert!(
8039 exact_retained_root_owned_topology(
8040 &state,
8041 "store",
8042 DesiredCanisterKind::Store,
8043 "root",
8044 "store-principal",
8045 "root-principal",
8046 )
8047 .is_some()
8048 );
8049 assert!(
8050 exact_retained_root_owned_topology(
8051 &state,
8052 "store",
8053 DesiredCanisterKind::Store,
8054 "root",
8055 "foreign-store",
8056 "root-principal",
8057 )
8058 .is_none()
8059 );
8060 state
8061 .principals
8062 .insert("store".to_string(), "store-principal".to_string());
8063 state
8064 .principals
8065 .insert("root".to_string(), "foreign-root".to_string());
8066 assert!(
8067 exact_retained_root_owned_topology(
8068 &state,
8069 "store",
8070 DesiredCanisterKind::Store,
8071 "root",
8072 "store-principal",
8073 "root-principal",
8074 )
8075 .is_none()
8076 );
8077 state
8078 .principals
8079 .insert("root".to_string(), "root-principal".to_string());
8080 state
8081 .topology
8082 .get_mut("store")
8083 .expect("Store topology")
8084 .kind = DesiredCanisterKind::Pool;
8085 assert!(
8086 exact_retained_root_owned_topology(
8087 &state,
8088 "store",
8089 DesiredCanisterKind::Store,
8090 "root",
8091 "store-principal",
8092 "root-principal",
8093 )
8094 .is_none()
8095 );
8096 state
8097 .topology
8098 .get_mut("store")
8099 .expect("Store topology")
8100 .kind = DesiredCanisterKind::Store;
8101 state
8102 .topology
8103 .get_mut("store")
8104 .expect("Store topology")
8105 .parent = Some("foreign-root".to_string());
8106 assert!(
8107 exact_retained_root_owned_topology(
8108 &state,
8109 "store",
8110 DesiredCanisterKind::Store,
8111 "root",
8112 "store-principal",
8113 "root-principal",
8114 )
8115 .is_none()
8116 );
8117 state
8118 .topology
8119 .get_mut("store")
8120 .expect("Store topology")
8121 .parent = Some("root".to_string());
8122 state.principals.clear();
8123 state.pending_principals = BTreeMap::from([
8124 ("root".to_string(), "root-principal".to_string()),
8125 ("store".to_string(), "store-principal".to_string()),
8126 ]);
8127 assert!(
8128 exact_retained_root_owned_topology(
8129 &state,
8130 "store",
8131 DesiredCanisterKind::Store,
8132 "root",
8133 "store-principal",
8134 "root-principal",
8135 )
8136 .is_some()
8137 );
8138 state
8139 .principals
8140 .insert("store".to_string(), "foreign-store".to_string());
8141 assert!(
8142 exact_retained_root_owned_topology(
8143 &state,
8144 "store",
8145 DesiredCanisterKind::Store,
8146 "root",
8147 "store-principal",
8148 "root-principal",
8149 )
8150 .is_none()
8151 );
8152 }
8153
8154 #[test]
8155 fn completed_reinstall_requires_exact_continuity_when_ordinary_status_has_no_version() {
8156 let desired = "11".repeat(32);
8157 assert_eq!(
8158 completed_reinstall_continuity(7, true, true, Some(&desired), &desired, None,),
8159 Ok(())
8160 );
8161 assert_eq!(
8162 completed_reinstall_continuity(7, true, true, Some(&desired), &desired, Some(8),),
8163 Ok(())
8164 );
8165 for (rejected, field) in [
8166 (
8167 completed_reinstall_continuity(7, false, true, Some(&desired), &desired, None),
8168 "Principal",
8169 ),
8170 (
8171 completed_reinstall_continuity(7, true, false, Some(&desired), &desired, None),
8172 "Root/parent/kind topology",
8173 ),
8174 (
8175 completed_reinstall_continuity(
8176 7,
8177 true,
8178 true,
8179 Some(&"22".repeat(32)),
8180 &desired,
8181 None,
8182 ),
8183 "module SHA-256",
8184 ),
8185 (
8186 completed_reinstall_continuity(7, true, true, Some(&desired), &desired, Some(7)),
8187 "canister version",
8188 ),
8189 ] {
8190 assert_eq!(rejected, Err(field));
8191 }
8192 }
8193
8194 #[test]
8195 fn fresh_fleet_create_responses_retain_the_exact_requested_balance() {
8196 let canister = Principal::from_slice(&[9; 29]);
8197 let canister_text = canister.to_text();
8198 for (response, receipt) in [
8199 (
8200 Ok(CreateCanisterSuccess {
8201 block_id: Nat::from(41_u8),
8202 canister_id: canister,
8203 }),
8204 "41",
8205 ),
8206 (
8207 Err(CreateCanisterError::Duplicate {
8208 duplicate_of: Nat::from(41_u8),
8209 canister_id: Some(canister),
8210 }),
8211 "41",
8212 ),
8213 ] {
8214 let outcome = create_canister_response_outcome(response, 5_000)
8215 .expect("successful or duplicate-with-Principal Create response");
8216 assert_eq!(
8217 outcome.created_principal.as_deref(),
8218 Some(canister_text.as_str())
8219 );
8220 assert_eq!(outcome.post_cycles, Some(5_000));
8221 assert_eq!(outcome.receipt.as_deref(), Some(receipt));
8222 }
8223 }
8224
8225 #[test]
8226 fn root_funding_inspection_requires_exact_controller_and_pool_shape() {
8227 let root = Principal::from_slice(&[7; 29]);
8228 let root_text = root.to_text();
8229 let cycles = Nat::from(2_898_749_313_788_u128);
8230 assert_eq!(
8231 validate_root_controlled_inspection(
8232 "pool-0",
8233 InspectedModule::Empty,
8234 &root_text,
8235 &[root],
8236 None,
8237 &cycles,
8238 )
8239 .expect("exact Root-authorized pool inspection"),
8240 2_898_749_313_788,
8241 );
8242
8243 assert_eq!(
8244 validate_root_controlled_inspection(
8245 "pending-reset",
8246 InspectedModule::Any,
8247 &root_text,
8248 &[root],
8249 Some(&[1]),
8250 &cycles,
8251 )
8252 .expect("installed controlled asset remains part of conservation"),
8253 2_898_749_313_788,
8254 );
8255 let foreign = Principal::from_slice(&[8; 29]);
8256 for rejected in [
8257 validate_root_controlled_inspection(
8258 "pending-reset",
8259 InspectedModule::Any,
8260 &root_text,
8261 &[foreign],
8262 Some(&[1]),
8263 &cycles,
8264 ),
8265 validate_root_controlled_inspection(
8266 "pool-0",
8267 InspectedModule::Empty,
8268 &root_text,
8269 &[foreign],
8270 None,
8271 &cycles,
8272 ),
8273 validate_root_controlled_inspection(
8274 "pool-0",
8275 InspectedModule::Empty,
8276 &root_text,
8277 &[root],
8278 Some(&[1]),
8279 &cycles,
8280 ),
8281 ] {
8282 assert!(matches!(
8283 rejected,
8284 Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { .. })
8285 ));
8286 }
8287 }
8288
8289 #[test]
8290 fn pending_fresh_pool_observation_accepts_only_its_reviewed_controller_transition() {
8291 let root = Principal::from_slice(&[7; 29]);
8292 let operator = Principal::from_slice(&[8; 29]);
8293 let foreign = Principal::from_slice(&[9; 29]);
8294 for controllers in [vec![root, operator], vec![root]] {
8295 assert_eq!(
8296 validate_pending_fresh_pool_inspection(
8297 "pool",
8298 &root.to_text(),
8299 &operator.to_text(),
8300 &controllers,
8301 None,
8302 &Nat::from(17_u8),
8303 )
8304 .expect("issued fresh controller transition"),
8305 17,
8306 );
8307 }
8308 for controllers in [
8309 vec![root, foreign],
8310 vec![operator],
8311 vec![root, operator, foreign],
8312 vec![root, root],
8313 ] {
8314 assert!(matches!(
8315 validate_pending_fresh_pool_inspection(
8316 "pool",
8317 &root.to_text(),
8318 &operator.to_text(),
8319 &controllers,
8320 None,
8321 &Nat::from(17_u8),
8322 ),
8323 Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { .. })
8324 ));
8325 }
8326 }
8327}