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