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