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