1use crate::{
8 canister_protocol::{CanisterProtocolError, call_with_candid, query_with_candid},
9 fleet_ensure::{
10 model::{
11 CanisterRuntimeStatus, DesiredCanisterKind, DesiredFleet, EffectRecord, EnsureAction,
12 FleetEnsureStateRecord, FleetObservation, InstallMode, LiveCanister,
13 RetirementTransferBalances, RetirementTransferInvariantError,
14 RetirementTransferReconciliation, RootManagementCanisterObservation,
15 RootManagementObservation, RootOwnedCanisterLifecycle, create_balance_is_terminal,
16 reconcile_retirement_transfer,
17 },
18 ops::{
19 EffectObservation, EffectOutcome, EffectRetry, EnsurePaths, EnsurePlatform,
20 EnsureStateError, TerminalFleetInventory, canic_init, current_protocol,
21 predecessor_root_status, protocol, read_root_start_authority, root_owned_lifecycle,
22 verify_root_start_release_authority,
23 },
24 },
25 icp::{
26 IcpCandidCallError, IcpCanisterStatusReport, IcpCli, IcpCommandError, IcpDiagnostic,
27 IcpManagementCallError, run_status,
28 },
29 icp_config::resolve_icp_build_network_from_root,
30 subnet_catalog::load_mainnet_subnet_catalog,
31};
32use candid::{CandidType, Nat, Principal};
33use canic_core::{
34 cdk::types::Cycles,
35 dto::canister::{CanisterInspectionRequest, CanisterStatusResponse},
36 dto::pool::{CanisterPoolAsset, CanisterPoolResponse, CanisterPoolStatusRequest},
37 ids::BuildNetwork,
38 protocol as canic_protocol,
39};
40use serde::Deserialize;
41use std::{
42 cell::RefCell,
43 collections::{BTreeMap, BTreeSet},
44 path::{Path, PathBuf},
45 time::{SystemTime, UNIX_EPOCH},
46};
47use thiserror::Error as ThisError;
48
49#[derive(CandidType)]
50struct ManagementCanisterStatusRequest {
51 canister_id: Principal,
52}
53
54#[derive(CandidType, Deserialize)]
55struct ManagementCanisterStatusResponse {
56 version: u64,
57 module_hash: Option<Vec<u8>>,
58}
59
60#[derive(Debug, Eq, PartialEq)]
61struct ExactInstallCanisterStatus {
62 canister_version: u64,
63 module_sha256: Option<String>,
64}
65
66#[derive(Debug, Eq, PartialEq)]
67struct RetainedStoreControlBinding {
68 action_root: String,
69 retained_root: Option<String>,
70 retained_store: Option<String>,
71 root_kind: Option<DesiredCanisterKind>,
72 root_parent: Option<String>,
73 store_kind: Option<DesiredCanisterKind>,
74 store_parent: Option<String>,
75}
76
77#[derive(Debug, Eq, PartialEq)]
78struct RetainedStoreControlLiveBinding {
79 root_controllers: Vec<String>,
80 root_module_sha256: Option<String>,
81 store_controllers: Vec<String>,
82 store_module_sha256: Option<String>,
83}
84
85fn retained_store_control_binding(
86 action_root: &str,
87 state: &FleetEnsureStateRecord,
88 root_name: &str,
89 store_name: &str,
90) -> RetainedStoreControlBinding {
91 let root = state.topology.get(root_name);
92 let store = state.topology.get(store_name);
93 RetainedStoreControlBinding {
94 action_root: action_root.to_string(),
95 retained_root: state.principals.get(root_name).cloned(),
96 retained_store: state.principals.get(store_name).cloned(),
97 root_kind: root.map(|topology| topology.kind),
98 root_parent: root.and_then(|topology| topology.parent.clone()),
99 store_kind: store.map(|topology| topology.kind),
100 store_parent: store.and_then(|topology| topology.parent.clone()),
101 }
102}
103
104fn retained_store_control_live_binding(
105 root: LiveCanister,
106 store: LiveCanister,
107) -> RetainedStoreControlLiveBinding {
108 RetainedStoreControlLiveBinding {
109 root_controllers: root.controllers,
110 root_module_sha256: root.module_sha256,
111 store_controllers: store.controllers,
112 store_module_sha256: store.module_sha256,
113 }
114}
115
116fn retained_predecessor_module_matches(
117 retained_module_sha256: Option<&str>,
118 observed_module_sha256: Option<&str>,
119 reviewed_successor_sha256: &str,
120) -> bool {
121 let Some(observed_module_sha256) = observed_module_sha256 else {
122 return false;
123 };
124 observed_module_sha256 != reviewed_successor_sha256
125 && retained_module_sha256.is_none_or(|retained| retained == observed_module_sha256)
126}
127
128fn retained_store_control_live_is_exact(
129 observed: &RetainedStoreControlLiveBinding,
130 root_controllers: &[String],
131 store_controllers: &[String],
132 retained_root_module_sha256: Option<&str>,
133 retained_store_module_sha256: Option<&str>,
134 root_successor_sha256: &str,
135 store_successor_sha256: &str,
136) -> bool {
137 let controllers_match = observed.root_controllers == root_controllers
138 && observed.store_controllers == store_controllers;
139 let root_module_matches = retained_predecessor_module_matches(
140 retained_root_module_sha256,
141 observed.root_module_sha256.as_deref(),
142 root_successor_sha256,
143 );
144 let store_module_matches = retained_predecessor_module_matches(
145 retained_store_module_sha256,
146 observed.store_module_sha256.as_deref(),
147 store_successor_sha256,
148 );
149 controllers_match && root_module_matches && store_module_matches
150}
151
152fn desired_root_by_principal<'a>(
153 desired: &'a DesiredFleet,
154 state: &FleetEnsureStateRecord,
155 principal: &str,
156) -> Option<&'a crate::fleet_ensure::model::DesiredCanister> {
157 desired.canisters.iter().find(|canister| {
158 canister.kind == DesiredCanisterKind::Root
159 && state.principals.get(&canister.name).map(String::as_str) == Some(principal)
160 })
161}
162
163fn is_unallocated_fresh_root(
164 desired: &DesiredFleet,
165 configured: &crate::fleet_ensure::model::DesiredCanister,
166 reviewed_targets: &BTreeSet<String>,
167) -> bool {
168 reviewed_targets.is_empty()
169 && configured.principal.is_none()
170 && desired.bootstrap.as_ref().is_some_and(|bootstrap| {
171 bootstrap.fresh_estate
172 && bootstrap
173 .roots
174 .iter()
175 .any(|root| root.root == configured.name)
176 })
177}
178
179#[derive(CandidType)]
182struct DrainRequest {
183 operation_id: String,
184 destination: Principal,
185 cycles: Nat,
186}
187
188#[derive(CandidType, Deserialize)]
189enum DrainResponse {
190 Accepted { transferred_cycles: Nat },
191 Replayed { transferred_cycles: Nat },
192}
193
194#[derive(CandidType)]
195enum RootPoolStatusRequest {
196 Pool(CanisterPoolStatusRequest),
197}
198
199#[derive(CandidType, Deserialize)]
200enum RootPoolStatusResponse {
201 Pool(Box<CanisterPoolResponse>),
202}
203
204#[derive(CandidType)]
205enum RootInspectionCommand {
206 InspectCanister(CanisterInspectionRequest),
207}
208
209#[derive(CandidType, Deserialize)]
210enum RootInspectionResponse {
211 InspectCanister(CanisterStatusResponse),
212}
213
214#[derive(CandidType)]
215enum ManagedCanisterStatusRequest {
216 CycleBalance,
217}
218
219#[derive(CandidType, Deserialize)]
220enum ManagedCanisterStatusResponse {
221 CycleBalance(canic_core::dto::role::CycleBalanceStatusResponse),
222}
223
224#[derive(CandidType)]
225struct CreateCanisterArgs {
226 amount: Nat,
227 created_at_time: Option<u64>,
228 creation_args: Option<CmcCreateCanisterArgs>,
229 from_subaccount: Option<[u8; 32]>,
230}
231
232#[derive(CandidType)]
233struct CmcCreateCanisterArgs {
234 settings: Option<CanisterSettings>,
235 subnet_selection: Option<SubnetSelection>,
236}
237
238#[derive(CandidType)]
239struct CanisterSettings {
240 compute_allocation: Option<Nat>,
241 controllers: Option<Vec<Principal>>,
242 freezing_threshold: Option<Nat>,
243 memory_allocation: Option<Nat>,
244 reserved_cycles_limit: Option<Nat>,
245}
246
247struct CreateCanisterAuthority<'a> {
248 controller_canisters: &'a [String],
249 controllers: &'a [String],
250 created_at_time: u64,
251 ledger: &'a str,
252 requested_initial_cycles: u128,
253 subnet: &'a str,
254}
255
256#[derive(CandidType)]
257enum SubnetSelection {
258 Subnet { subnet: Principal },
259}
260
261#[derive(CandidType, Deserialize)]
262struct CreateCanisterSuccess {
263 block_id: Nat,
264 canister_id: Principal,
265}
266
267#[derive(CandidType, Deserialize)]
268enum CreateCanisterError {
269 CreatedInFuture {
270 ledger_time: u64,
271 },
272 Duplicate {
273 duplicate_of: Nat,
274 canister_id: Option<Principal>,
275 },
276 FailedToCreate {
277 error: String,
278 fee_block: Option<Nat>,
279 refund_block: Option<Nat>,
280 },
281 GenericError {
282 error_code: Nat,
283 message: String,
284 },
285 InsufficientFunds {
286 balance: Nat,
287 },
288 TemporarilyUnavailable,
289 TooOld,
290}
291
292#[derive(CandidType)]
293struct WithdrawArgs {
294 amount: Nat,
295 created_at_time: Option<u64>,
296 from_subaccount: Option<[u8; 32]>,
297 to: Principal,
298}
299
300#[derive(CandidType, Deserialize)]
301enum WithdrawError {
302 BadFee {
303 expected_fee: Nat,
304 },
305 CreatedInFuture {
306 ledger_time: u64,
307 },
308 Duplicate {
309 duplicate_of: Nat,
310 },
311 FailedToWithdraw {
312 fee_block: Option<Nat>,
313 rejection_code: RejectionCode,
314 rejection_reason: String,
315 },
316 GenericError {
317 error_code: Nat,
318 message: String,
319 },
320 InsufficientFunds {
321 balance: Nat,
322 },
323 InvalidReceiver {
324 receiver: Principal,
325 },
326 TemporarilyUnavailable,
327 TooOld,
328}
329
330#[derive(CandidType, Deserialize)]
331enum RejectionCode {
332 CanisterError,
333 CanisterReject,
334 DestinationInvalid,
335 NoError,
336 SysFatal,
337 SysTransient,
338 Unknown,
339}
340
341#[derive(Debug, ThisError)]
344pub enum IcpEnsurePlatformError {
345 #[error("cycle arithmetic overflow while executing {0}")]
346 Arithmetic(&'static str),
347
348 #[error("configured Candid file is not a regular file: {}", .0.display())]
349 CandidUnavailable(PathBuf),
350
351 #[error("configured effect artifact is unavailable: {}", .0.display())]
352 ArtifactUnavailable(PathBuf),
353
354 #[error("configured {kind} changed after plan review: expected {expected}, observed {actual}")]
355 ArtifactDigestMismatch {
356 actual: String,
357 expected: String,
358 kind: &'static str,
359 },
360
361 #[error("configured Principal is invalid for {field}: {value}")]
362 InvalidPrincipal { field: &'static str, value: String },
363
364 #[error("Cycles Ledger returned an unsupported fee value: {0}")]
365 InvalidLedgerFee(String),
366
367 #[error("active ICP identity is {actual}, but reviewed Fleet operator is {expected}")]
368 OperatorMismatch { actual: String, expected: String },
369
370 #[error("management status for configured canister {expected} returned identity {actual}")]
371 StatusIdentityMismatch { actual: String, expected: String },
372
373 #[error("retained Root management observation failed: {0}")]
374 RootManagement(String),
375
376 #[error("ICP status has invalid cycle balance for {canister}: {value}")]
377 InvalidStatusCycles { canister: String, value: String },
378
379 #[error("ICP status omitted exact {field} required for controlled canister {canister}")]
380 IncompleteCanisterStatus {
381 canister: String,
382 field: &'static str,
383 },
384
385 #[error(
386 "ICP returned only public non-controller status for {canister}; exact runtime and cycle evidence is unavailable"
387 )]
388 PublicCanisterStatusUnavailable { canister: String },
389
390 #[error(
391 "Root-owned observation for {canister} conflicts with reviewed {field}; no paid effect was repeated"
392 )]
393 RootOwnedObservationAuthorityConflict {
394 canister: String,
395 field: &'static str,
396 },
397
398 #[error(
399 "ICP CLI status JSON omitted canister_version for {canister}, and the exact typed \
400 effective-ID-routed management-canister status call failed: {source}; no install was \
401 issued. Restore the selected controller identity and management-status access, then \
402 resume the same reviewed plan"
403 )]
404 InstallVersionProofUnavailable {
405 canister: String,
406 #[source]
407 source: Box<IcpManagementCallError>,
408 },
409
410 #[error(
411 "completed reinstall proof for {canister} conflicts with current {field}; no install was authorized"
412 )]
413 CompletedReinstallAuthorityConflict {
414 canister: String,
415 field: &'static str,
416 },
417
418 #[error(transparent)]
419 Candid(#[from] IcpCandidCallError),
420
421 #[error(transparent)]
422 Icp(#[from] IcpCommandError),
423
424 #[error("Cycles Ledger create_canister failed: {0}")]
425 LedgerCreate(String),
426
427 #[error("Cycles Ledger duplicate does not yet identify its created canister")]
428 LedgerCreatePending,
429
430 #[error("Cycles Ledger withdraw failed: {0}")]
431 LedgerWithdraw(String),
432
433 #[error(
434 "Root-authorized funding inspection for {canister} conflicts with reviewed {field}; no Ledger withdrawal was repeated"
435 )]
436 FundingInspectionAuthorityConflict {
437 canister: String,
438 field: &'static str,
439 },
440
441 #[error(
442 "canister {canister} retains {actual} cycles above deletion limit {maximum}; left untouched"
443 )]
444 MaterialCycles {
445 actual: u128,
446 canister: String,
447 maximum: u128,
448 },
449
450 #[error("canister {canister} is not stopped; deletion was not attempted")]
451 NotStopped { canister: String },
452
453 #[error("effect references unresolved created canister {0}")]
454 UnresolvedCreated(String),
455
456 #[error("configured retirement endpoint transferred {actual} cycles, expected {expected}")]
457 WrongTransferAmount { actual: Nat, expected: u128 },
458
459 #[error(
460 "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"
461 )]
462 UnbalancedTransfer {
463 canister: String,
464 destination_credit: u128,
465 expected: u128,
466 maximum_source_debit: u128,
467 source_debit: u128,
468 },
469
470 #[error("retirement transfer for {canister} is missing its durable {side} balance")]
471 MissingTransferBalance {
472 canister: String,
473 side: &'static str,
474 },
475
476 #[error("retirement transfer balance for {canister} moved in an invalid direction")]
477 InvalidTransferBalance { canister: String },
478
479 #[error(transparent)]
480 CurrentProtocol(#[from] current_protocol::CurrentProtocolError),
481
482 #[error(transparent)]
483 CanicInit(#[from] canic_init::CanicInitError),
484
485 #[error(transparent)]
486 Protocol(#[from] protocol::ProtocolEffectError),
487
488 #[error("retained Root status authority is invalid: {0}")]
489 RetainedRootStatusAuthority(#[source] Box<EnsureStateError>),
490
491 #[error("predecessor Root status is invalid: {0}")]
492 PredecessorRootStatus(#[source] Box<predecessor_root_status::PredecessorRootStatusError>),
493}
494
495pub struct IcpEnsurePlatform {
497 desired: DesiredFleet,
498 icp: IcpCli,
499 recovery_reinstalls: RefCell<BTreeSet<String>>,
500 root: PathBuf,
501}
502
503#[derive(Clone, Copy)]
504enum RetainedRootOwnedObservationMode {
505 DeferredUntilRootStart,
506 ReinstallRecovery,
507}
508
509impl IcpEnsurePlatform {
510 #[must_use]
511 pub fn new(desired: DesiredFleet, icp_executable: &str, root: &Path) -> Self {
512 let icp = IcpCli::new(icp_executable, Some(desired.environment.clone()))
513 .with_cwd(root.to_path_buf());
514 Self {
515 desired,
516 icp,
517 recovery_reinstalls: RefCell::new(BTreeSet::new()),
518 root: root.to_path_buf(),
519 }
520 }
521
522 fn require_operator(&self) -> Result<(), IcpEnsurePlatformError> {
523 let actual = self.icp.identity_principal_text()?;
524 if actual != self.desired.operator {
525 return Err(IcpEnsurePlatformError::OperatorMismatch {
526 actual,
527 expected: self.desired.operator.clone(),
528 });
529 }
530 Ok(())
531 }
532
533 fn current_principal<'a>(
534 &'a self,
535 state: &'a FleetEnsureStateRecord,
536 name: &str,
537 ) -> Option<&'a str> {
538 state
539 .pending_principals
540 .get(name)
541 .or_else(|| state.principals.get(name))
542 .map(String::as_str)
543 .or_else(|| {
544 self.desired
545 .canisters
546 .iter()
547 .find(|configured| configured.name == name)
548 .and_then(|configured| configured.principal.as_deref())
549 })
550 }
551
552 fn protocol_principals(&self, state: &FleetEnsureStateRecord) -> BTreeMap<String, String> {
553 self.desired
554 .canisters
555 .iter()
556 .filter_map(|configured| {
557 state
558 .pending_principals
559 .get(&configured.name)
560 .or_else(|| state.principals.get(&configured.name))
561 .or(configured.principal.as_ref())
562 .map(|principal| (configured.name.clone(), principal.clone()))
563 })
564 .collect()
565 }
566
567 fn resolved_controllers(
568 &self,
569 state: &FleetEnsureStateRecord,
570 controllers: &[String],
571 controller_canisters: &[String],
572 ) -> Result<Vec<String>, IcpEnsurePlatformError> {
573 let mut resolved = controllers.to_vec();
574 for name in controller_canisters {
575 resolved.push(
576 self.current_principal(state, name)
577 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.clone()))?
578 .to_string(),
579 );
580 }
581 resolved.sort();
582 resolved.dedup();
583 Ok(resolved)
584 }
585
586 fn retained_store_control_replan_is_exact(
587 &self,
588 operation_id: &str,
589 action: &EnsureAction,
590 state: &FleetEnsureStateRecord,
591 ) -> Result<bool, IcpEnsurePlatformError> {
592 let EnsureAction::FleetProtocol {
593 action: current_action,
594 name,
595 principal,
596 ..
597 } = action
598 else {
599 return Ok(false);
600 };
601 let crate::fleet_ensure::model::CurrentFleetProtocolAction::AdoptStore { request } =
602 current_action.as_ref()
603 else {
604 return Ok(false);
605 };
606 let requested_root = request.authority.fleet_subnet_root.to_text();
607 let Some(root) = desired_root_by_principal(&self.desired, state, &requested_root) else {
608 return Ok(false);
609 };
610 let root_name = root.name.as_str();
611 if name != &format!("root-store-control:{root_name}") {
612 return Ok(false);
613 }
614 if !current_protocol::retained_store_control_request_is_exact(
615 &self.root,
616 &self.desired,
617 operation_id,
618 state,
619 root_name,
620 request,
621 )? {
622 return Ok(false);
623 }
624 let principals = self.protocol_principals(state);
625 let Some((_name, expected)) =
626 canic_init::compile_root_authorities(&self.root, &self.desired, &principals)?
627 .into_iter()
628 .find(|(name, _authority)| name == root_name)
629 else {
630 return Ok(false);
631 };
632 let root_principal = expected.binding.fleet_subnet_root.to_text();
633 let store_principal = expected.wasm_store_authority.wasm_store.to_text();
634 let root_topology = state.topology.get(root_name);
635 let store = self.desired.canisters.iter().find(|canister| {
636 canister.kind == DesiredCanisterKind::Store
637 && canister.parent.as_deref() == Some(root_name)
638 });
639 let Some(store) = store else {
640 return Ok(false);
641 };
642 let store_topology = state.topology.get(&store.name);
643 let observed_binding =
644 retained_store_control_binding(principal, state, root_name, &store.name);
645 let expected_binding = RetainedStoreControlBinding {
646 action_root: root_principal.clone(),
647 retained_root: Some(root_principal.clone()),
648 retained_store: Some(store_principal.clone()),
649 root_kind: Some(DesiredCanisterKind::Root),
650 root_parent: root.parent.clone(),
651 store_kind: Some(DesiredCanisterKind::Store),
652 store_parent: Some(root_name.to_string()),
653 };
654 if observed_binding != expected_binding {
655 return Ok(false);
656 }
657
658 let Some(root_live) = self.install_status_optional(&root_principal)? else {
659 return Ok(false);
660 };
661 let Some(store_live) = self.install_status_optional(&store_principal)? else {
662 return Ok(false);
663 };
664 let Some(root_wasm) = root.wasm.as_ref() else {
665 return Ok(false);
666 };
667 let Some(store_wasm) = store.wasm.as_ref() else {
668 return Ok(false);
669 };
670 let root_successor = artifact_hash(&resolve_path(&self.root, root_wasm))?;
671 let store_successor = artifact_hash(&resolve_path(&self.root, store_wasm))?;
672 let root_controllers =
673 self.resolved_controllers(state, &root.controllers, &root.controller_canisters)?;
674 let store_controllers =
675 self.resolved_controllers(state, &store.controllers, &store.controller_canisters)?;
676 let observed_live = retained_store_control_live_binding(root_live, store_live);
677 Ok(retained_store_control_live_is_exact(
678 &observed_live,
679 &root_controllers,
680 &store_controllers,
681 root_topology.and_then(|topology| topology.module_hash.as_deref()),
682 store_topology.and_then(|topology| topology.module_hash.as_deref()),
683 &root_successor,
684 &store_successor,
685 ))
686 }
687
688 fn observed_protocol_action(
689 &self,
690 step: &crate::fleet_ensure::model::DesiredProtocolStep,
691 principal: String,
692 ) -> Result<EnsureAction, IcpEnsurePlatformError> {
693 Ok(EnsureAction::Protocol {
694 candid: step.candid.clone(),
695 candid_sha256: artifact_hash(&resolve_path(&self.root, &step.candid))?,
696 command_args: step.command_args.clone(),
697 command_args_sha256: artifact_hash(&resolve_path(&self.root, &step.command_args))?,
698 command_method: step.command_method.clone(),
699 expected_status: step.expected_status.clone(),
700 expected_status_sha256: artifact_hash(&resolve_path(
701 &self.root,
702 &step.expected_status,
703 ))?,
704 maximum_execution_burn_cycles: step
705 .maximum_execution_burn_cycles
706 .parse()
707 .map_err(|_| IcpEnsurePlatformError::Arithmetic("protocol burn"))?,
708 name: step.name.clone(),
709 principal,
710 status_args: step.status_args.clone(),
711 status_args_sha256: artifact_hash(&resolve_path(&self.root, &step.status_args))?,
712 status_method: step.status_method.clone(),
713 })
714 }
715
716 fn action_principal<'a>(
717 state: &'a FleetEnsureStateRecord,
718 principal: &'a str,
719 ) -> Result<&'a str, IcpEnsurePlatformError> {
720 if let Some(name) = principal.strip_prefix("created:") {
721 return state
722 .pending_principals
723 .get(name)
724 .map(String::as_str)
725 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.to_string()));
726 }
727 Ok(principal)
728 }
729
730 fn status_optional(
731 &self,
732 principal: &str,
733 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
734 let report = match self.icp.canister_status_report(principal) {
735 Ok(report) => report,
736 Err(error)
737 if matches!(
738 error.diagnostic(),
739 Some(IcpDiagnostic::CanisterNotFound { .. })
740 ) =>
741 {
742 return Ok(None);
743 }
744 Err(error) => return Err(error.into()),
745 };
746 if report.id != principal {
747 return Err(IcpEnsurePlatformError::StatusIdentityMismatch {
748 actual: report.id,
749 expected: principal.to_string(),
750 });
751 }
752 reject_public_status_projection(&report, principal)?;
753 let status_text =
754 report
755 .status
756 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
757 canister: principal.to_string(),
758 field: "status",
759 })?;
760 let cycles_text =
761 report
762 .cycles
763 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
764 canister: principal.to_string(),
765 field: "cycles",
766 })?;
767 let cycles = parse_status_cycles(&cycles_text).ok_or_else(|| {
768 IcpEnsurePlatformError::InvalidStatusCycles {
769 canister: principal.to_string(),
770 value: cycles_text,
771 }
772 })?;
773 let mut controllers = report
774 .settings
775 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
776 canister: principal.to_string(),
777 field: "controllers",
778 })?
779 .controllers;
780 controllers.sort();
781 let status = match status_text.to_ascii_lowercase().as_str() {
782 "stopped" => CanisterRuntimeStatus::Stopped,
783 "stopping" => CanisterRuntimeStatus::Stopping,
784 _ => CanisterRuntimeStatus::Running,
785 };
786 Ok(Some(LiveCanister {
787 canister_version: report.canister_version,
788 controllers,
789 cycles,
790 module_sha256: report.module_hash.map(|hash| normalize_hash(&hash)),
791 principal: principal.to_string(),
792 reinstall_required: false,
793 root_owned_lifecycle: None,
794 status,
795 }))
796 }
797
798 fn inspect_root_owned_canister(
799 &self,
800 configured: &crate::fleet_ensure::model::DesiredCanister,
801 principal: &str,
802 state: &FleetEnsureStateRecord,
803 ) -> Result<Option<CanisterStatusResponse>, IcpEnsurePlatformError> {
804 let parent = configured.parent.as_deref().ok_or_else(|| {
805 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
806 canister: configured.name.clone(),
807 field: "Root parent",
808 }
809 })?;
810 let root = self.current_principal(state, parent).ok_or_else(|| {
811 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
812 canister: configured.name.clone(),
813 field: "Root Principal",
814 }
815 })?;
816 let root_configured = self
817 .desired
818 .canisters
819 .iter()
820 .find(|candidate| {
821 candidate.name == parent && candidate.kind == DesiredCanisterKind::Root
822 })
823 .ok_or_else(
824 || IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
825 canister: configured.name.clone(),
826 field: "Root desired authority",
827 },
828 )?;
829 let root_wasm = root_configured.wasm.as_deref().ok_or_else(|| {
830 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
831 canister: configured.name.clone(),
832 field: "Root artifact",
833 }
834 })?;
835 let expected_root_module = artifact_hash(&resolve_path(&self.root, root_wasm))?;
836 let root_live = self.status_optional(root)?.ok_or_else(|| {
837 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
838 canister: configured.name.clone(),
839 field: "live Root",
840 }
841 })?;
842 let expected_root_controllers = self.resolved_controllers(
843 state,
844 &root_configured.controllers,
845 &root_configured.controller_canisters,
846 )?;
847 if root_live.controllers != expected_root_controllers {
848 return Err(
849 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
850 canister: configured.name.clone(),
851 field: "Root controllers",
852 },
853 );
854 }
855 let Some(root_module) = root_live.module_sha256.as_deref() else {
856 return Ok(None);
857 };
858 if root_module != expected_root_module {
859 return Err(
860 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
861 canister: configured.name.clone(),
862 field: "Root module SHA-256",
863 },
864 );
865 }
866 if root_live.status != CanisterRuntimeStatus::Running {
867 return Ok(None);
868 }
869 self.require_operator()?;
870 let response: RootInspectionResponse = call_with_candid(
871 &self.icp,
872 &self.root_protocol_candid()?,
873 parse_principal("Fleet Subnet Root", root)?,
874 canic_protocol::CANIC_ROOT_COMMAND,
875 &RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
876 canister_id: parse_principal("Root-owned canister", principal)?,
877 }),
878 )
879 .map_err(current_protocol::CurrentProtocolError::from)?;
880 let RootInspectionResponse::InspectCanister(response) = response;
881 Ok(Some(response))
882 }
883
884 fn created_canister_cycles(
885 &self,
886 name: &str,
887 principal: &str,
888 state: &FleetEnsureStateRecord,
889 ) -> Result<(Option<u128>, bool), IcpEnsurePlatformError> {
890 match self.status_optional(principal) {
891 Ok(live) => return Ok((live.map(|live| live.cycles), false)),
892 Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable { .. }) => {}
893 Err(error) => return Err(error),
894 }
895 let configured = self
896 .desired
897 .canisters
898 .iter()
899 .find(|configured| configured.name == name)
900 .ok_or_else(
901 || IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
902 canister: name.to_string(),
903 field: "desired canister",
904 },
905 )?;
906 if configured.kind != DesiredCanisterKind::Pool {
907 return Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable {
908 canister: principal.to_string(),
909 });
910 }
911 let Some(response) = self.inspect_root_owned_canister(configured, principal, state)? else {
912 return Ok((None, true));
913 };
914 let parent = configured.parent.as_deref().ok_or_else(|| {
915 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
916 canister: name.to_string(),
917 field: "Root parent",
918 }
919 })?;
920 let root = self.current_principal(state, parent).ok_or_else(|| {
921 IcpEnsurePlatformError::RootOwnedObservationAuthorityConflict {
922 canister: name.to_string(),
923 field: "Root Principal",
924 }
925 })?;
926 let cycles = validate_root_funding_inspection(
927 name,
928 configured.kind,
929 root,
930 &response.settings.controllers,
931 response.module_hash.as_deref(),
932 &response.cycles,
933 )?;
934 Ok((Some(cycles), false))
935 }
936
937 fn install_status_optional(
938 &self,
939 principal: &str,
940 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
941 let Some(mut live) = self.status_optional(principal)? else {
942 return Ok(None);
943 };
944 let exact = exact_install_canister_status(
945 &self.icp,
946 principal,
947 live.canister_version,
948 live.module_sha256.clone(),
949 )?;
950 live.canister_version = Some(exact.canister_version);
951 live.module_sha256 = exact.module_sha256;
952 Ok(Some(live))
953 }
954
955 fn has_stopped_retained_protocol_owner(
956 &self,
957 state: &FleetEnsureStateRecord,
958 ) -> Result<bool, IcpEnsurePlatformError> {
959 for configured in self.desired.canisters.iter().filter(|configured| {
960 configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
961 && matches!(
962 configured.kind,
963 DesiredCanisterKind::Coordinator
964 | DesiredCanisterKind::Root
965 | DesiredCanisterKind::Store
966 )
967 }) {
968 let Some(principal) = self.current_principal(state, &configured.name) else {
969 continue;
970 };
971 let Some(live) = self.status_optional(principal)? else {
972 continue;
973 };
974 if live.status != CanisterRuntimeStatus::Running {
975 return Ok(true);
976 }
977 }
978 Ok(false)
979 }
980
981 fn observe_configured_canister(
982 &self,
983 configured: &crate::fleet_ensure::model::DesiredCanister,
984 principal: &str,
985 state: &FleetEnsureStateRecord,
986 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
987 if configured.kind == DesiredCanisterKind::Pool {
988 return self.observe_root_owned_canister(configured, principal, state);
989 }
990 match self.status_optional(principal) {
991 Ok(observed) => Ok(observed),
992 Err(error)
993 if matches!(
994 configured.kind,
995 DesiredCanisterKind::Store | DesiredCanisterKind::Pool
996 ) && !matches!(
997 error,
998 IcpEnsurePlatformError::Icp(ref source)
999 if matches!(
1000 source.diagnostic(),
1001 Some(IcpDiagnostic::CanisterNotFound { .. })
1002 )
1003 ) =>
1004 {
1005 self.observe_root_owned_canister(configured, principal, state)
1006 }
1007 Err(error) => Err(error),
1008 }
1009 }
1010
1011 fn observe_root_owned_canister(
1012 &self,
1013 configured: &crate::fleet_ensure::model::DesiredCanister,
1014 principal: &str,
1015 state: &FleetEnsureStateRecord,
1016 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1017 let candid = self.root_protocol_candid()?;
1018 let parent = configured.parent.as_deref().ok_or_else(|| {
1019 current_protocol::CurrentProtocolError::Configuration(format!(
1020 "Root-owned canister {} has no Root parent",
1021 configured.name
1022 ))
1023 })?;
1024 let root = self.current_principal(state, parent).ok_or_else(|| {
1025 current_protocol::CurrentProtocolError::Configuration(format!(
1026 "Root-owned canister {} has no resolved Root",
1027 configured.name
1028 ))
1029 })?;
1030 let retained_observation = |mode| {
1031 self.retained_root_owned_observation(configured, principal, parent, root, state, mode)
1032 };
1033 if self.required_root_status(&configured.name, root)? == CanisterRuntimeStatus::Stopped {
1034 return retained_observation(RetainedRootOwnedObservationMode::DeferredUntilRootStart);
1035 }
1036 let predecessor_status = self.predecessor_root_status_authorized(parent, root)?;
1037 if predecessor_status {
1038 self.record_recovery_reinstalls(parent);
1039 }
1040 let target = parse_principal("Root-owned canister", principal)?;
1041 let mut start_after = None;
1042 loop {
1043 let page = if predecessor_status {
1044 predecessor_root_status::query_pool(
1045 &self.icp,
1046 parse_principal("Fleet Subnet Root", root)?,
1047 start_after,
1048 256,
1049 )
1050 .map_err(|error| IcpEnsurePlatformError::PredecessorRootStatus(Box::new(error)))?
1051 } else {
1052 let response: Result<RootPoolStatusResponse, CanisterProtocolError> =
1053 query_with_candid(
1054 &self.icp,
1055 &candid,
1056 parse_principal("Fleet Subnet Root", root)?,
1057 canic_protocol::CANIC_STATUS,
1058 &RootPoolStatusRequest::Pool(CanisterPoolStatusRequest {
1059 start_after,
1060 limit: 256,
1061 }),
1062 );
1063 let response = match response {
1064 Ok(response) => response,
1065 Err(error) if recoverable_root_status_error(&error) => {
1066 return retained_observation(
1067 RetainedRootOwnedObservationMode::ReinstallRecovery,
1068 );
1069 }
1070 Err(error) => {
1071 return Err(current_protocol::CurrentProtocolError::from(error).into());
1072 }
1073 };
1074 let RootPoolStatusResponse::Pool(page) = response;
1075 *page
1076 };
1077 if let Some(asset) = page
1078 .entries
1079 .into_iter()
1080 .find(|asset| asset.canister_id == target)
1081 {
1082 if matches!(
1083 asset.status,
1084 canic_core::dto::pool::CanisterPoolAssetStatus::PendingReset
1085 ) && asset.cycles.to_u128() == 0
1086 {
1087 return retained_observation(
1088 RetainedRootOwnedObservationMode::ReinstallRecovery,
1089 );
1090 }
1091 return Self::observed_root_owned_asset(configured, principal, root, asset);
1092 }
1093 let next = page.next_start_after;
1094 if next.is_none() {
1095 return Ok(None);
1096 }
1097 if next == start_after {
1098 return Err(
1099 current_protocol::CurrentProtocolError::Configuration(format!(
1100 "Root {parent} pool cursor did not advance"
1101 ))
1102 .into(),
1103 );
1104 }
1105 start_after = next;
1106 }
1107 }
1108
1109 fn inspect_root_owned_funding_balance(
1110 &self,
1111 configured: &crate::fleet_ensure::model::DesiredCanister,
1112 principal: &str,
1113 state: &FleetEnsureStateRecord,
1114 ) -> Result<u128, IcpEnsurePlatformError> {
1115 let parent = configured.parent.as_deref().ok_or_else(|| {
1116 IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1117 canister: configured.name.clone(),
1118 field: "Root parent",
1119 }
1120 })?;
1121 let root = self.current_principal(state, parent).ok_or_else(|| {
1122 IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1123 canister: configured.name.clone(),
1124 field: "Root Principal",
1125 }
1126 })?;
1127 if self.required_root_status(&configured.name, root)? != CanisterRuntimeStatus::Running {
1128 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
1129 canister: configured.name.clone(),
1130 field: "running Root",
1131 });
1132 }
1133 self.require_operator()?;
1134 let target = parse_principal("Root-owned funding target", principal)?;
1135 let response: RootInspectionResponse = call_with_candid(
1136 &self.icp,
1137 &self.root_protocol_candid()?,
1138 parse_principal("Fleet Subnet Root", root)?,
1139 canic_protocol::CANIC_ROOT_COMMAND,
1140 &RootInspectionCommand::InspectCanister(CanisterInspectionRequest {
1141 canister_id: target,
1142 }),
1143 )
1144 .map_err(current_protocol::CurrentProtocolError::from)?;
1145 let RootInspectionResponse::InspectCanister(response) = response;
1146 validate_root_funding_inspection(
1147 &configured.name,
1148 configured.kind,
1149 root,
1150 &response.settings.controllers,
1151 response.module_hash.as_deref(),
1152 &response.cycles,
1153 )
1154 }
1155
1156 fn observed_root_owned_asset(
1157 configured: &crate::fleet_ensure::model::DesiredCanister,
1158 principal: &str,
1159 root: &str,
1160 asset: CanisterPoolAsset,
1161 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1162 let Some(root_owned_lifecycle) = root_owned_lifecycle(configured.kind, &asset.status)
1163 else {
1164 return Err(
1165 current_protocol::CurrentProtocolError::Configuration(format!(
1166 "Root-owned canister {} has a live pool role incompatible with desired {:?}",
1167 configured.name, configured.kind
1168 ))
1169 .into(),
1170 );
1171 };
1172 let status = match root_owned_lifecycle {
1173 RootOwnedCanisterLifecycle::Store | RootOwnedCanisterLifecycle::Workload => {
1174 CanisterRuntimeStatus::Running
1175 }
1176 RootOwnedCanisterLifecycle::Claimed
1177 | RootOwnedCanisterLifecycle::Idle
1178 | RootOwnedCanisterLifecycle::Retained => CanisterRuntimeStatus::Stopped,
1179 };
1180 Ok(Some(LiveCanister {
1181 canister_version: None,
1182 controllers: vec![root.to_string()],
1183 cycles: asset.cycles.to_u128(),
1184 module_sha256: None,
1185 principal: principal.to_string(),
1186 reinstall_required: false,
1187 root_owned_lifecycle: Some(root_owned_lifecycle),
1188 status,
1189 }))
1190 }
1191
1192 fn predecessor_root_status_authorized(
1193 &self,
1194 root_name: &str,
1195 root: &str,
1196 ) -> Result<bool, IcpEnsurePlatformError> {
1197 let paths = EnsurePaths::under(&self.root, &self.desired.environment, &self.desired.fleet);
1198 let Some(authority) = read_root_start_authority(&paths).map_err(|error| {
1199 IcpEnsurePlatformError::RetainedRootStatusAuthority(Box::new(error))
1200 })?
1201 else {
1202 return Ok(false);
1203 };
1204 let Some(binding) = authority
1205 .roots
1206 .iter()
1207 .find(|binding| binding.principal == root)
1208 else {
1209 return Ok(false);
1210 };
1211 let live = self.status_optional(root)?.ok_or_else(|| {
1212 current_protocol::CurrentProtocolError::Configuration(format!(
1213 "retained predecessor Root {root_name} is unavailable"
1214 ))
1215 })?;
1216 if live.module_sha256.as_deref() != Some(binding.predecessor_module_sha256.as_str()) {
1217 return Ok(false);
1218 }
1219 let bootstrap = self.desired.bootstrap.as_ref().ok_or_else(|| {
1220 current_protocol::CurrentProtocolError::Configuration(
1221 "predecessor Root status requires current Fleet bootstrap authority".to_string(),
1222 )
1223 })?;
1224 let configured = self
1225 .desired
1226 .canisters
1227 .iter()
1228 .find(|configured| {
1229 configured.name == root_name && configured.kind == DesiredCanisterKind::Root
1230 })
1231 .ok_or_else(|| {
1232 current_protocol::CurrentProtocolError::Configuration(format!(
1233 "predecessor Root {root_name} is absent from desired topology"
1234 ))
1235 })?;
1236 let successor_wasm = configured.wasm.as_deref().ok_or_else(|| {
1237 current_protocol::CurrentProtocolError::Configuration(format!(
1238 "predecessor Root {root_name} has no desired successor artifact"
1239 ))
1240 })?;
1241 let successor_module = artifact_hash(&resolve_path(&self.root, successor_wasm))?;
1242 let identity_matches = authority.environment == self.desired.environment
1243 && authority.fleet == self.desired.fleet
1244 && authority.fleet_id == bootstrap.fleet_id
1245 && authority.release_build_id == bootstrap.release_build_id
1246 && authority.successor_module_sha256 == successor_module
1247 && binding.name == configured.name
1248 && configured.principal.as_deref() == Some(root)
1249 && binding.subnet == configured.subnet
1250 && binding.controllers == configured.controllers
1251 && live.controllers == configured.controllers;
1252 if !identity_matches {
1253 return Err(
1254 current_protocol::CurrentProtocolError::Configuration(format!(
1255 "predecessor Root {root_name} conflicts with sealed successor authority"
1256 ))
1257 .into(),
1258 );
1259 }
1260 verify_root_start_release_authority(&self.root, &authority).map_err(|error| {
1261 IcpEnsurePlatformError::RetainedRootStatusAuthority(Box::new(error))
1262 })?;
1263 Ok(true)
1264 }
1265
1266 fn root_protocol_candid(&self) -> Result<PathBuf, IcpEnsurePlatformError> {
1267 let protocol = self.desired.protocol.as_ref().ok_or_else(|| {
1268 current_protocol::CurrentProtocolError::Configuration(
1269 "Root-owned observation requires typed Fleet protocol".to_string(),
1270 )
1271 })?;
1272 Ok(resolve_path(&self.root, &protocol.root_candid))
1273 }
1274
1275 fn required_root_status(
1276 &self,
1277 configured_name: &str,
1278 root: &str,
1279 ) -> Result<CanisterRuntimeStatus, IcpEnsurePlatformError> {
1280 self.status_optional(root)?
1281 .map(|live| live.status)
1282 .ok_or_else(|| {
1283 current_protocol::CurrentProtocolError::Configuration(format!(
1284 "Root-owned canister {configured_name} has no live Root"
1285 ))
1286 .into()
1287 })
1288 }
1289
1290 fn retained_root_owned_observation(
1291 &self,
1292 configured: &crate::fleet_ensure::model::DesiredCanister,
1293 principal: &str,
1294 parent: &str,
1295 root: &str,
1296 state: &FleetEnsureStateRecord,
1297 mode: RetainedRootOwnedObservationMode,
1298 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
1299 let Some(retained_topology) = exact_retained_root_owned_topology(
1300 state,
1301 &configured.name,
1302 configured.kind,
1303 parent,
1304 principal,
1305 root,
1306 ) else {
1307 return Err(
1308 current_protocol::CurrentProtocolError::Configuration(format!(
1309 "Root-owned canister {} has no exact retained topology authority",
1310 configured.name
1311 ))
1312 .into(),
1313 );
1314 };
1315 let cycles = self
1316 .public_cycle_balance(principal)
1317 .or_else(|| state.retained_cycles_by_principal.get(principal).copied())
1318 .ok_or_else(|| {
1319 current_protocol::CurrentProtocolError::Configuration(format!(
1320 "Root-owned canister {} has no current or retained exact native balance",
1321 configured.name
1322 ))
1323 })?;
1324 if !matches!(
1325 configured.kind,
1326 DesiredCanisterKind::Pool | DesiredCanisterKind::Store
1327 ) || configured.presence != crate::fleet_ensure::model::DesiredPresence::Present
1328 || configured.replace
1329 || configured.drain.is_some()
1330 || (configured.kind == DesiredCanisterKind::Pool && configured.wasm.is_some())
1331 || (configured.kind == DesiredCanisterKind::Store && configured.wasm.is_none())
1332 {
1333 return Err(
1334 current_protocol::CurrentProtocolError::Configuration(format!(
1335 "Root-owned recovery evidence for {} cannot authorize a mutation",
1336 configured.name
1337 ))
1338 .into(),
1339 );
1340 }
1341 let root_config = self
1342 .desired
1343 .canisters
1344 .iter()
1345 .find(|canister| canister.name == parent && canister.kind == DesiredCanisterKind::Root)
1346 .ok_or_else(|| {
1347 current_protocol::CurrentProtocolError::Configuration(format!(
1348 "Root-owned canister {} has no exact configured Root",
1349 configured.name
1350 ))
1351 })?;
1352 let live_root = self.status_optional(root)?.ok_or_else(|| {
1353 current_protocol::CurrentProtocolError::Configuration(format!(
1354 "Root-owned canister {} has no live Root",
1355 configured.name
1356 ))
1357 })?;
1358 let mut expected_controllers = root_config.controllers.clone();
1359 expected_controllers.sort();
1360 if live_root.controllers != expected_controllers {
1361 return Err(
1362 current_protocol::CurrentProtocolError::Configuration(format!(
1363 "Root-owned canister {} Root controllers drifted",
1364 configured.name
1365 ))
1366 .into(),
1367 );
1368 }
1369 if matches!(mode, RetainedRootOwnedObservationMode::ReinstallRecovery) {
1370 self.record_recovery_reinstalls(parent);
1371 }
1372 Ok(Some(LiveCanister {
1373 canister_version: None,
1374 controllers: vec![root.to_string()],
1375 cycles,
1376 module_sha256: retained_topology.module_hash.clone(),
1377 principal: principal.to_string(),
1378 reinstall_required: false,
1379 root_owned_lifecycle: Some(RootOwnedCanisterLifecycle::Retained),
1380 status: if configured.kind == DesiredCanisterKind::Store {
1381 CanisterRuntimeStatus::Running
1382 } else {
1383 CanisterRuntimeStatus::Stopped
1384 },
1385 }))
1386 }
1387
1388 fn record_recovery_reinstalls(&self, root_name: &str) {
1389 let mut recovery = self.recovery_reinstalls.borrow_mut();
1390 recovery.insert(root_name.to_string());
1391 recovery.extend(
1392 self.desired
1393 .canisters
1394 .iter()
1395 .filter(|canister| {
1396 canister.kind == DesiredCanisterKind::Coordinator
1397 || (canister.kind == DesiredCanisterKind::Store
1398 && canister.parent.as_deref() == Some(root_name))
1399 })
1400 .map(|canister| canister.name.clone()),
1401 );
1402 }
1403
1404 fn completed_reinstall_is_current(
1405 &self,
1406 state: &FleetEnsureStateRecord,
1407 name: &str,
1408 live: &LiveCanister,
1409 ) -> Result<bool, IcpEnsurePlatformError> {
1410 if state.completed_reinstall_operation_id.is_none()
1411 || !state.completed_reinstall_action_sha256.contains_key(name)
1412 {
1413 return Ok(false);
1414 }
1415 let Some(pre_canister_version) = state.completed_reinstalls.get(name) else {
1416 return Ok(false);
1417 };
1418 let Some(configured) = self
1419 .desired
1420 .canisters
1421 .iter()
1422 .find(|configured| configured.name == name)
1423 else {
1424 return Ok(false);
1425 };
1426 let Some(wasm) = configured.wasm.as_deref() else {
1427 return Ok(false);
1428 };
1429 let principal_matches = state
1430 .principals
1431 .get(name)
1432 .is_some_and(|principal| principal == &live.principal);
1433 let retained_topology = state.topology.get(name);
1434 let topology_matches = retained_topology.is_some_and(|topology| {
1435 topology.kind == configured.kind && topology.parent == configured.parent
1436 });
1437 let desired_module_sha256 = artifact_hash(&resolve_path(&self.root, wasm))?;
1438 let root_owned_store_module = (live.module_sha256.is_none()
1439 && live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Store))
1440 .then(|| {
1441 configured
1442 .parent
1443 .as_deref()
1444 .and_then(|parent| state.principals.get(parent))
1445 .filter(|root| live.controllers.as_slice() == [root.as_str()])
1446 .and(retained_topology)
1447 .and_then(|topology| topology.module_hash.as_deref())
1448 })
1449 .flatten();
1450 completed_reinstall_continuity(
1451 *pre_canister_version,
1452 principal_matches,
1453 topology_matches,
1454 live.module_sha256.as_deref().or(root_owned_store_module),
1455 &desired_module_sha256,
1456 live.canister_version,
1457 )
1458 .map_err(
1459 |field| IcpEnsurePlatformError::CompletedReinstallAuthorityConflict {
1460 canister: name.to_string(),
1461 field,
1462 },
1463 )?;
1464 Ok(true)
1465 }
1466
1467 fn public_cycle_balance(&self, principal: &str) -> Option<u128> {
1468 let response: Result<ManagedCanisterStatusResponse, canic_core::dto::error::Error> = self
1469 .icp
1470 .canister_query_candid(
1471 principal,
1472 canic_protocol::CANIC_STATUS,
1473 &ManagedCanisterStatusRequest::CycleBalance,
1474 None,
1475 )
1476 .ok()?;
1477 let ManagedCanisterStatusResponse::CycleBalance(balance) = response.ok()?;
1478 Some(balance.cycles)
1479 }
1480
1481 fn apply_create(
1482 &self,
1483 authority: CreateCanisterAuthority<'_>,
1484 state: &FleetEnsureStateRecord,
1485 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
1486 let creation_fee = self
1487 .desired
1488 .management_creation_fee_cycles
1489 .parse::<Cycles>()
1490 .map(|cycles| cycles.to_u128())
1491 .map_err(|_| IcpEnsurePlatformError::Arithmetic("management creation fee"))?;
1492 let amount = authority
1493 .requested_initial_cycles
1494 .checked_add(creation_fee)
1495 .ok_or(IcpEnsurePlatformError::Arithmetic("creation amount"))?;
1496 let controllers = self
1497 .resolved_controllers(state, authority.controllers, authority.controller_canisters)?
1498 .iter()
1499 .map(|value| parse_principal("controller", value))
1500 .collect::<Result<Vec<_>, _>>()?;
1501 let request = CreateCanisterArgs {
1502 amount: Nat::from(amount),
1503 created_at_time: Some(authority.created_at_time),
1504 creation_args: Some(CmcCreateCanisterArgs {
1505 settings: Some(CanisterSettings {
1506 compute_allocation: None,
1507 controllers: Some(controllers),
1508 freezing_threshold: None,
1509 memory_allocation: None,
1510 reserved_cycles_limit: None,
1511 }),
1512 subnet_selection: Some(SubnetSelection::Subnet {
1513 subnet: parse_principal("subnet", authority.subnet)?,
1514 }),
1515 }),
1516 from_subaccount: None,
1517 };
1518 let response: Result<CreateCanisterSuccess, CreateCanisterError> = self
1519 .icp
1520 .canister_call_candid(authority.ledger, "create_canister", &request, None)?;
1521 create_canister_response_outcome(response, authority.requested_initial_cycles)
1522 }
1523
1524 fn apply_fund(
1525 &self,
1526 amount: u128,
1527 created_at_time: u64,
1528 ledger: &str,
1529 principal: &str,
1530 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
1531 let request = WithdrawArgs {
1532 amount: Nat::from(amount),
1533 created_at_time: Some(created_at_time),
1534 from_subaccount: None,
1535 to: parse_principal("funding target", principal)?,
1536 };
1537 let response: Result<Nat, WithdrawError> = self
1538 .icp
1539 .canister_call_candid(ledger, "withdraw", &request, None)?;
1540 match response {
1541 Ok(block) => Ok(EffectOutcome {
1542 created_principal: None,
1543 post_cycles: None,
1544 receipt: Some(block.to_string()),
1545 }),
1546 Err(WithdrawError::Duplicate { duplicate_of }) => Ok(EffectOutcome {
1547 created_principal: None,
1548 post_cycles: None,
1549 receipt: Some(duplicate_of.to_string()),
1550 }),
1551 Err(error) => Err(IcpEnsurePlatformError::LedgerWithdraw(
1552 render_withdraw_error(error),
1553 )),
1554 }
1555 }
1556
1557 #[expect(
1558 clippy::too_many_arguments,
1559 reason = "the install boundary verifies one complete immutable action tuple"
1560 )]
1561 fn apply_install(
1562 &self,
1563 operation_id: &str,
1564 principals: &BTreeMap<String, String>,
1565 canic_init: Option<&crate::fleet_ensure::model::DesiredCanisterInit>,
1566 init_arg: Option<&str>,
1567 init_arg_sha256: Option<&str>,
1568 init_candid: Option<&str>,
1569 init_candid_sha256: Option<&str>,
1570 mode: InstallMode,
1571 principal: &str,
1572 wasm: &str,
1573 wasm_sha256: &str,
1574 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
1575 verify_artifact(&resolve_path(&self.root, wasm), wasm_sha256, "Wasm")?;
1576 let mut command = self.icp.canister_command();
1577 command.args(["install", principal, "--mode"]);
1578 command.arg(match mode {
1579 InstallMode::Install => "install",
1580 InstallMode::Reinstall => "reinstall",
1581 });
1582 command.args(["--yes", "--wasm"]);
1583 command.arg(resolve_path(&self.root, wasm));
1584 let generated_init = if let Some(canic_init) = canic_init {
1585 let path = canic_init::write_arguments(canic_init::CanicInitRequest {
1586 desired: &self.desired,
1587 init: canic_init,
1588 operation_id,
1589 principals,
1590 root: &self.root,
1591 wasm,
1592 wasm_sha256,
1593 })?;
1594 command.arg("--args-file");
1595 command.arg(&path);
1596 command.args(["--args-format", "bin"]);
1597 Some(path)
1598 } else if let Some(init_arg) = init_arg {
1599 let expected =
1600 init_arg_sha256.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
1601 actual: "missing".to_string(),
1602 expected: "reviewed init argument identity".to_string(),
1603 kind: "init argument",
1604 })?;
1605 let init_candid =
1606 init_candid.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
1607 actual: "missing".to_string(),
1608 expected: "reviewed init Candid".to_string(),
1609 kind: "init Candid",
1610 })?;
1611 let init_candid_sha256 = init_candid_sha256.ok_or_else(|| {
1612 IcpEnsurePlatformError::ArtifactDigestMismatch {
1613 actual: "missing".to_string(),
1614 expected: "reviewed init Candid identity".to_string(),
1615 kind: "init Candid",
1616 }
1617 })?;
1618 let path = protocol::write_init_arguments(
1619 &self.root,
1620 operation_id,
1621 principals,
1622 init_candid,
1623 init_candid_sha256,
1624 init_arg,
1625 expected,
1626 )?;
1627 command.arg("--args-file");
1628 command.arg(&path);
1629 command.args(["--args-format", "bin"]);
1630 Some(path)
1631 } else {
1632 command.args(["--args", "()"]);
1633 None
1634 };
1635 self.icp.add_target_args(&mut command);
1636 let result = run_status(&mut command).map_err(IcpEnsurePlatformError::from);
1637 if let Some(path) = generated_init {
1638 std::fs::remove_file(&path)
1639 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path))?;
1640 }
1641 result?;
1642 Ok(empty_outcome())
1643 }
1644
1645 fn apply_controllers(
1646 &self,
1647 principal: &str,
1648 controllers: &[String],
1649 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
1650 let mut command = self.icp.canister_command();
1651 command.args([
1652 "settings",
1653 "update",
1654 principal,
1655 "--force",
1656 "--remove-all-controllers",
1657 ]);
1658 for controller in controllers {
1659 parse_principal("controller", controller)?;
1660 command.args(["--add-controller", controller]);
1661 }
1662 self.icp.add_target_args(&mut command);
1663 run_status(&mut command)?;
1664 Ok(empty_outcome())
1665 }
1666
1667 #[expect(
1668 clippy::too_many_arguments,
1669 reason = "one typed transfer effect carries the complete reviewed authority tuple"
1670 )]
1671 fn apply_transfer(
1672 &self,
1673 amount: u128,
1674 candid: &str,
1675 candid_sha256: &str,
1676 destination: &str,
1677 method: &str,
1678 operation_id: &str,
1679 principal: &str,
1680 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
1681 let candid = resolve_path(&self.root, candid);
1682 if !candid.is_file() {
1683 return Err(IcpEnsurePlatformError::CandidUnavailable(candid));
1684 }
1685 verify_artifact(&candid, candid_sha256, "drain Candid")?;
1686 let response: DrainResponse = self.icp.canister_call_candid(
1687 principal,
1688 method,
1689 &DrainRequest {
1690 operation_id: operation_id.to_string(),
1691 destination: parse_principal("treasury", destination)?,
1692 cycles: Nat::from(amount),
1693 },
1694 Some(&candid),
1695 )?;
1696 let transferred = match response {
1697 DrainResponse::Accepted { transferred_cycles }
1698 | DrainResponse::Replayed { transferred_cycles } => transferred_cycles,
1699 };
1700 if transferred != amount {
1701 return Err(IcpEnsurePlatformError::WrongTransferAmount {
1702 actual: transferred,
1703 expected: amount,
1704 });
1705 }
1706 Ok(EffectOutcome {
1707 created_principal: None,
1708 post_cycles: None,
1709 receipt: Some(operation_id.to_string()),
1710 })
1711 }
1712}
1713
1714impl EnsurePlatform for IcpEnsurePlatform {
1715 type Error = IcpEnsurePlatformError;
1716
1717 fn bind_reviewed_desired(&mut self, desired: &DesiredFleet) -> Result<(), Self::Error> {
1718 self.desired = desired.clone();
1719 Ok(())
1720 }
1721
1722 fn observe_root_management(
1723 &mut self,
1724 state: &FleetEnsureStateRecord,
1725 reviewed_targets: &BTreeSet<String>,
1726 ) -> Result<Option<RootManagementObservation>, Self::Error> {
1727 let configured_roots = self
1728 .desired
1729 .canisters
1730 .iter()
1731 .filter(|configured| {
1732 configured.kind == DesiredCanisterKind::Root
1733 && configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
1734 })
1735 .collect::<Vec<_>>();
1736 if configured_roots.is_empty() {
1737 return Ok(None);
1738 }
1739 self.require_operator()?;
1740 let mut observed_roots = Vec::new();
1741 for configured in configured_roots {
1742 let Some(principal) = self.current_principal(state, &configured.name) else {
1743 if is_unallocated_fresh_root(&self.desired, configured, reviewed_targets) {
1744 continue;
1745 }
1746 return Err(IcpEnsurePlatformError::RootManagement(format!(
1747 "configured Root {} has no exact Principal",
1748 configured.name
1749 )));
1750 };
1751 let live = self.status_optional(principal)?.ok_or_else(|| {
1752 IcpEnsurePlatformError::RootManagement(format!(
1753 "configured Root {} is unavailable",
1754 configured.name
1755 ))
1756 })?;
1757 observed_roots.push((configured, live));
1758 }
1759 if reviewed_targets.is_empty()
1760 && observed_roots
1761 .iter()
1762 .all(|(_, live)| live.status == CanisterRuntimeStatus::Running)
1763 {
1764 return Ok(None);
1765 }
1766 let network = resolve_icp_build_network_from_root(&self.root, &self.desired.environment)
1767 .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?;
1768 let catalog = if network == BuildNetwork::Ic {
1769 let now = SystemTime::now()
1770 .duration_since(UNIX_EPOCH)
1771 .map_err(|_| {
1772 IcpEnsurePlatformError::RootManagement(
1773 "system clock is before the Unix epoch".to_string(),
1774 )
1775 })?
1776 .as_secs();
1777 Some(
1778 load_mainnet_subnet_catalog(&self.root, now)
1779 .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))?,
1780 )
1781 } else {
1782 None
1783 };
1784 let mut roots = BTreeMap::new();
1785 for (configured, live) in observed_roots {
1786 let principal = live.principal.clone();
1787 let subnet = catalog.as_ref().map_or_else(
1788 || Ok(configured.subnet.clone()),
1789 |catalog| {
1790 catalog
1791 .catalog
1792 .resolve_canister_route(&principal)
1793 .map(|route| route.subnet.to_text())
1794 .map_err(|error| IcpEnsurePlatformError::RootManagement(error.to_string()))
1795 },
1796 )?;
1797 let name = configured.name.clone();
1798 if roots
1799 .insert(
1800 name.clone(),
1801 RootManagementCanisterObservation { live, name, subnet },
1802 )
1803 .is_some()
1804 {
1805 return Err(IcpEnsurePlatformError::RootManagement(
1806 "configured Root names are not unique".to_string(),
1807 ));
1808 }
1809 }
1810 let operator_cycles = self
1811 .icp
1812 .identity_cycles_balance()
1813 .map_err(|error| IcpEnsurePlatformError::LedgerWithdraw(error.to_string()))?;
1814 Ok(Some(RootManagementObservation {
1815 operator_cycles,
1816 roots,
1817 }))
1818 }
1819
1820 fn observe(
1821 &mut self,
1822 operation_id: &str,
1823 state: &FleetEnsureStateRecord,
1824 ) -> Result<FleetObservation, Self::Error> {
1825 self.require_operator()?;
1826 self.recovery_reinstalls.borrow_mut().clear();
1827 let mut canisters = BTreeMap::new();
1828 for configured in &self.desired.canisters {
1829 let observed = self
1830 .current_principal(state, &configured.name)
1831 .map(|principal| self.observe_configured_canister(configured, principal, state))
1832 .transpose()?
1833 .flatten();
1834 canisters.insert(configured.name.clone(), observed);
1835 }
1836 let mut completed_reinstalls = BTreeSet::new();
1837 for name in self.recovery_reinstalls.borrow().iter() {
1838 let Some(live) = canisters.get_mut(name).and_then(Option::as_mut) else {
1839 continue;
1840 };
1841 if self.completed_reinstall_is_current(state, name, live)? {
1842 if live.module_sha256.is_none()
1843 && live.root_owned_lifecycle == Some(RootOwnedCanisterLifecycle::Store)
1844 {
1845 live.module_sha256 = state
1846 .topology
1847 .get(name)
1848 .and_then(|topology| topology.module_hash.clone());
1849 }
1850 completed_reinstalls.insert(name.clone());
1851 }
1852 }
1853 self.recovery_reinstalls
1854 .borrow_mut()
1855 .retain(|name| !completed_reinstalls.contains(name));
1856 for name in self.recovery_reinstalls.borrow().iter() {
1857 if let Some(live) = canisters.get_mut(name).and_then(Option::as_mut) {
1858 live.reinstall_required = true;
1859 }
1860 }
1861 let principals = self.protocol_principals(state);
1862 let protocol_ready = self
1863 .desired
1864 .protocol_steps
1865 .iter()
1866 .map(|step| {
1867 let Some(principal) = principals.get(&step.canister) else {
1868 return Ok((step.name.clone(), false));
1869 };
1870 let live = canisters.get(&step.canister).and_then(Option::as_ref);
1871 let Some(live) = live else {
1872 return Ok((step.name.clone(), false));
1873 };
1874 let configured = self
1875 .desired
1876 .canisters
1877 .iter()
1878 .find(|configured| configured.name == step.canister)
1879 .expect("protocol target was validated against desired canisters");
1880 if let Some(wasm) = &configured.wasm {
1881 let desired_hash = artifact_hash(&resolve_path(&self.root, wasm))?;
1882 if live.module_sha256.as_deref() != Some(desired_hash.as_str()) {
1883 return Ok((step.name.clone(), false));
1884 }
1885 }
1886 let action = self.observed_protocol_action(step, principal.clone())?;
1887 protocol::observe(&self.icp, &self.root, operation_id, &principals, &action)
1888 .map(|observation| (step.name.clone(), observation.applied))
1889 .map_err(IcpEnsurePlatformError::from)
1890 })
1891 .collect::<Result<BTreeMap<_, _>, _>>()?;
1892 Ok(FleetObservation {
1893 additional_controlled_cycles: BTreeMap::new(),
1894 canisters,
1895 ledger_fee_cycles: ledger_fee_cycles(self.icp.canister_query_candid(
1896 &self.desired.cycles_ledger,
1897 "icrc1_fee",
1898 &(),
1899 None,
1900 )?)?,
1901 operator_cycles: self
1902 .icp
1903 .identity_cycles_balance()
1904 .map_err(|error| IcpEnsurePlatformError::LedgerWithdraw(error.to_string()))?,
1905 protocol_ready,
1906 })
1907 }
1908
1909 fn protocol_actions(
1910 &mut self,
1911 operation_id: &str,
1912 state: &FleetEnsureStateRecord,
1913 ) -> Result<Vec<EnsureAction>, Self::Error> {
1914 if self.desired.protocol.is_none() {
1915 return Ok(Vec::new());
1916 }
1917 current_protocol::validate_component_pool_capacity(&self.root, &self.desired)?;
1918 if self.has_stopped_retained_protocol_owner(state)? {
1919 return Ok(Vec::new());
1920 }
1921 if !self.recovery_reinstalls.borrow().is_empty() {
1922 return Ok(Vec::new());
1923 }
1924 let coordinator = self
1925 .desired
1926 .canisters
1927 .iter()
1928 .find(|configured| {
1929 configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
1930 && configured.kind
1931 == crate::fleet_ensure::model::DesiredCanisterKind::Coordinator
1932 })
1933 .expect("typed topology validation requires one Coordinator");
1934 let Some(principal) = self.current_principal(state, &coordinator.name) else {
1935 return Ok(Vec::new());
1936 };
1937 let Some(live) = self.status_optional(principal)? else {
1938 return Ok(Vec::new());
1939 };
1940 if let Some(wasm) = &coordinator.wasm {
1941 let expected = artifact_hash(&resolve_path(&self.root, wasm))?;
1942 if live.module_sha256.as_deref() != Some(expected.as_str()) {
1943 return Ok(Vec::new());
1944 }
1945 }
1946 current_protocol::compile(&self.icp, &self.root, &self.desired, operation_id, state)
1947 .map_err(Into::into)
1948 }
1949
1950 fn terminal_inventory(
1951 &mut self,
1952 operation_id: &str,
1953 state: &FleetEnsureStateRecord,
1954 ) -> Result<TerminalFleetInventory, Self::Error> {
1955 if self.desired.protocol.is_none() {
1956 if state.active_registry.is_some() {
1957 return Err(current_protocol::CurrentProtocolError::Configuration(
1958 "managed terminal Fleet cannot drop its typed protocol intent".to_string(),
1959 )
1960 .into());
1961 }
1962 return Ok(TerminalFleetInventory::default());
1963 }
1964 super::current_inventory::terminal_inventory(
1965 &self.icp,
1966 &self.root,
1967 &self.desired,
1968 operation_id,
1969 state,
1970 )
1971 .map_err(Into::into)
1972 }
1973
1974 #[expect(
1975 clippy::too_many_lines,
1976 reason = "the platform keeps every effect's exact live terminal predicate in one exhaustive match"
1977 )]
1978 fn observe_effect(
1979 &mut self,
1980 operation_id: &str,
1981 action: &EnsureAction,
1982 record: &EffectRecord,
1983 state: &FleetEnsureStateRecord,
1984 ) -> Result<EffectObservation, Self::Error> {
1985 let mut retry = EffectRetry::None;
1986 let mut post_cycles = None;
1987 let (applied, progress_identity) = match action {
1988 EnsureAction::Create {
1989 name,
1990 requested_initial_cycles,
1991 ..
1992 } => {
1993 let (live_cycles, deferred) =
1994 if record.created_principal.is_some() && record.receipt.is_some() {
1995 self.created_canister_cycles(
1996 name,
1997 record
1998 .created_principal
1999 .as_deref()
2000 .ok_or(IcpEnsurePlatformError::LedgerCreatePending)?,
2001 state,
2002 )?
2003 } else {
2004 (None, false)
2005 };
2006 post_cycles = live_cycles;
2007 let maximum_observation_burn_cycles = self
2008 .desired
2009 .maximum_observation_burn_cycles
2010 .parse::<Cycles>()
2011 .map(|cycles| cycles.to_u128())
2012 .map_err(|_| IcpEnsurePlatformError::Arithmetic("observation burn"))?;
2013 let applied = create_balance_is_terminal(
2014 live_cycles,
2015 *requested_initial_cycles,
2016 maximum_observation_burn_cycles,
2017 );
2018 if live_cycles.is_some() && !applied {
2019 retry = EffectRetry::ReplanRequiredAfterCreateBalanceDrift;
2020 } else if deferred {
2021 retry = EffectRetry::DeferUntilControllerObservation;
2022 }
2023 (
2024 applied,
2025 format!(
2026 "created:{:?}:actual:{live_cycles:?}:requested:{requested_initial_cycles}",
2027 record.created_principal,
2028 ),
2029 )
2030 }
2031 EnsureAction::Delete { principal, .. } => {
2032 let live = self.status_optional(Self::action_principal(state, principal)?)?;
2033 (live.is_none(), format!("delete:{live:?}"))
2034 }
2035 EnsureAction::Fund {
2036 amount,
2037 expected_post_cycles,
2038 funding_deficit_cycles,
2039 funding_margin_cycles,
2040 name,
2041 principal,
2042 ..
2043 } => {
2044 let live_cycles = if record.receipt.is_some() {
2045 let configured = self
2046 .desired
2047 .canisters
2048 .iter()
2049 .find(|configured| configured.name == *name)
2050 .ok_or_else(|| {
2051 current_protocol::CurrentProtocolError::Configuration(format!(
2052 "funding target {name} is absent from desired topology"
2053 ))
2054 })?;
2055 if configured.kind == DesiredCanisterKind::Pool {
2056 Some(self.inspect_root_owned_funding_balance(
2057 configured,
2058 Self::action_principal(state, principal)?,
2059 state,
2060 )?)
2061 } else {
2062 self.action_cycles(action, state)?
2063 }
2064 } else {
2065 self.action_cycles(action, state)?
2066 };
2067 post_cycles = live_cycles;
2068 (
2069 record.receipt.is_some()
2070 && native_funding_applied(NativeFundingObservation {
2071 amount: *amount,
2072 expected_post_cycles: *expected_post_cycles,
2073 funding_deficit_cycles: *funding_deficit_cycles,
2074 funding_margin_cycles: *funding_margin_cycles,
2075 live_cycles,
2076 pre_cycles: record.pre_cycles,
2077 }),
2078 format!(
2079 "native-topup:ledger-withdraw:{}:actual:{live_cycles:?}:expected:{expected_post_cycles}:margin:{funding_margin_cycles}",
2080 record.receipt.as_deref().unwrap_or("pending"),
2081 ),
2082 )
2083 }
2084 EnsureAction::Install {
2085 mode,
2086 principal,
2087 wasm_sha256,
2088 ..
2089 } => {
2090 let live =
2091 self.install_status_optional(Self::action_principal(state, principal)?)?;
2092 let applied = live.as_ref().is_some_and(|live| {
2093 install_effect_applied(
2094 *mode,
2095 wasm_sha256,
2096 live.module_sha256.as_deref(),
2097 record.pre_canister_version,
2098 live.canister_version,
2099 )
2100 });
2101 let progress_identity = live.as_ref().map_or_else(
2102 || "install:missing".to_string(),
2103 |live| {
2104 format!(
2105 "install:{:?}:version:{:?}",
2106 live.module_sha256, live.canister_version
2107 )
2108 },
2109 );
2110 (applied, progress_identity)
2111 }
2112 EnsureAction::FleetProtocol {
2113 action: current_action,
2114 ..
2115 } => {
2116 let observation = match current_protocol::observe(&self.icp, &self.root, action) {
2117 Ok(observation) => observation,
2118 Err(error)
2119 if matches!(
2120 current_action.as_ref(),
2121 crate::fleet_ensure::model::CurrentFleetProtocolAction::AdoptStore { .. }
2122 ) && predecessor_store_control_rejection(&error) =>
2123 {
2124 if !self.retained_store_control_replan_is_exact(
2125 operation_id,
2126 action,
2127 state,
2128 )? {
2129 return Err(error.into());
2130 }
2131 EffectObservation {
2132 applied: false,
2133 post_cycles: None,
2134 progress_identity: "store-adoption:replan-required:diagnostic:132"
2135 .to_string(),
2136 retry: EffectRetry::ReplanRequiredAfterRejectedPrerequisite,
2137 }
2138 }
2139 Err(error)
2140 if matches!(
2141 current_action.as_ref(),
2142 crate::fleet_ensure::model::CurrentFleetProtocolAction::AdoptStore { .. }
2143 ) && recoverable_current_protocol_error(&error) =>
2144 {
2145 EffectObservation {
2146 applied: false,
2147 post_cycles: None,
2148 progress_identity: "store-adoption:protected-status-unavailable"
2149 .to_string(),
2150 retry: EffectRetry::None,
2151 }
2152 }
2153 Err(error) => return Err(error.into()),
2154 };
2155 retry = observation.retry;
2156 (observation.applied, observation.progress_identity)
2157 }
2158 EnsureAction::Protocol { .. } => {
2159 let observation = protocol::observe(
2160 &self.icp,
2161 &self.root,
2162 operation_id,
2163 &self.protocol_principals(state),
2164 &resolved_protocol_action(action, state)?,
2165 )?;
2166 (observation.applied, observation.progress_identity)
2167 }
2168 EnsureAction::SetControllers {
2169 controller_canisters,
2170 controllers,
2171 name,
2172 principal,
2173 ..
2174 } => {
2175 let expected =
2176 self.resolved_controllers(state, controllers, controller_canisters)?;
2177 let principal = Self::action_principal(state, principal)?;
2178 let configured = self
2179 .desired
2180 .canisters
2181 .iter()
2182 .find(|configured| configured.name == *name);
2183 let mut observed_controllers = if let Some(configured) =
2184 configured.filter(|configured| configured.kind == DesiredCanisterKind::Pool)
2185 {
2186 self.inspect_root_owned_canister(configured, principal, state)?
2187 .map(|response| {
2188 response
2189 .settings
2190 .controllers
2191 .into_iter()
2192 .map(|controller| controller.to_text())
2193 .collect::<Vec<_>>()
2194 })
2195 } else {
2196 self.status_optional(principal)?
2197 .map(|live| live.controllers)
2198 };
2199 if let Some(controllers) = &mut observed_controllers {
2200 controllers.sort();
2201 controllers.dedup();
2202 }
2203 (
2204 observed_controllers.as_ref() == Some(&expected),
2205 format!("controllers:{observed_controllers:?}"),
2206 )
2207 }
2208 EnsureAction::Start { principal, .. } => {
2209 let live = self.status_optional(Self::action_principal(state, principal)?)?;
2210 (
2211 live.as_ref()
2212 .is_some_and(|live| live.status == CanisterRuntimeStatus::Running),
2213 format!("start:{:?}", live.map(|live| live.status)),
2214 )
2215 }
2216 EnsureAction::Stop { principal, .. } => {
2217 let live = self.status_optional(Self::action_principal(state, principal)?)?;
2218 (
2219 live.as_ref()
2220 .is_some_and(|live| live.status == CanisterRuntimeStatus::Stopped),
2221 format!("stop:{:?}", live.map(|live| live.status)),
2222 )
2223 }
2224 EnsureAction::Transfer {
2225 amount,
2226 maximum_execution_burn_cycles,
2227 name,
2228 principal,
2229 destination,
2230 ..
2231 } => {
2232 let source = self
2233 .status_optional(Self::action_principal(state, principal)?)?
2234 .map(|live| live.cycles)
2235 .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
2236 canister: name.clone(),
2237 side: "live source",
2238 })?;
2239 let destination = self
2240 .status_optional(self.current_principal(state, destination).ok_or_else(
2241 || IcpEnsurePlatformError::UnresolvedCreated(destination.clone()),
2242 )?)?
2243 .map(|live| live.cycles)
2244 .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
2245 canister: name.clone(),
2246 side: "live treasury",
2247 })?;
2248 let source_pre = record.pre_cycles.ok_or_else(|| {
2249 IcpEnsurePlatformError::MissingTransferBalance {
2250 canister: name.clone(),
2251 side: "retained source",
2252 }
2253 })?;
2254 let destination_pre = record.destination_pre_cycles.ok_or_else(|| {
2255 IcpEnsurePlatformError::MissingTransferBalance {
2256 canister: name.clone(),
2257 side: "retained treasury",
2258 }
2259 })?;
2260 let reconciliation = reconcile_retirement_transfer(RetirementTransferBalances {
2261 destination_after: destination,
2262 destination_before: destination_pre,
2263 maximum_execution_burn: *maximum_execution_burn_cycles,
2264 source_after: source,
2265 source_before: source_pre,
2266 transfer_amount: *amount,
2267 });
2268 let (applied, source_debit, destination_credit) = match reconciliation {
2269 Ok(RetirementTransferReconciliation::Pending) => (false, 0, 0),
2270 Ok(RetirementTransferReconciliation::Conserved {
2271 destination_credit,
2272 source_debit,
2273 }) => (true, source_debit, destination_credit),
2274 Err(RetirementTransferInvariantError::ArithmeticOverflow) => {
2275 return Err(IcpEnsurePlatformError::Arithmetic(
2276 "retirement transfer debit",
2277 ));
2278 }
2279 Err(RetirementTransferInvariantError::BalanceMovedInInvalidDirection) => {
2280 return Err(IcpEnsurePlatformError::InvalidTransferBalance {
2281 canister: name.clone(),
2282 });
2283 }
2284 Err(RetirementTransferInvariantError::Unbalanced {
2285 destination_credit,
2286 maximum_source_debit,
2287 source_debit,
2288 }) => {
2289 return Err(IcpEnsurePlatformError::UnbalancedTransfer {
2290 canister: name.clone(),
2291 destination_credit,
2292 expected: *amount,
2293 maximum_source_debit,
2294 source_debit,
2295 });
2296 }
2297 };
2298 (
2299 applied,
2300 format!("transfer:{source_debit}:{destination_credit}"),
2301 )
2302 }
2303 };
2304 Ok(EffectObservation {
2305 applied,
2306 post_cycles,
2307 progress_identity,
2308 retry,
2309 })
2310 }
2311
2312 #[expect(
2313 clippy::too_many_lines,
2314 reason = "the platform keeps every approved single-step effect in one exhaustive match"
2315 )]
2316 fn apply(
2317 &mut self,
2318 operation_id: &str,
2319 action: &EnsureAction,
2320 _record: &EffectRecord,
2321 state: &FleetEnsureStateRecord,
2322 ) -> Result<EffectOutcome, Self::Error> {
2323 self.require_operator()?;
2324 match action {
2325 EnsureAction::Create {
2326 controller_canisters,
2327 controllers,
2328 created_at_time,
2329 ledger,
2330 requested_initial_cycles,
2331 subnet,
2332 ..
2333 } => self.apply_create(
2334 CreateCanisterAuthority {
2335 controller_canisters,
2336 controllers,
2337 created_at_time: *created_at_time,
2338 ledger,
2339 requested_initial_cycles: *requested_initial_cycles,
2340 subnet,
2341 },
2342 state,
2343 ),
2344 EnsureAction::Delete {
2345 maximum_remaining_cycles,
2346 principal,
2347 ..
2348 } => {
2349 let principal = Self::action_principal(state, principal)?;
2350 if let Some(live) = self.status_optional(principal)? {
2351 if live.cycles > *maximum_remaining_cycles {
2352 return Err(IcpEnsurePlatformError::MaterialCycles {
2353 actual: live.cycles,
2354 canister: principal.to_string(),
2355 maximum: *maximum_remaining_cycles,
2356 });
2357 }
2358 if live.status != CanisterRuntimeStatus::Stopped {
2359 return Err(IcpEnsurePlatformError::NotStopped {
2360 canister: principal.to_string(),
2361 });
2362 }
2363 self.icp.delete_canister_without_cycle_recovery(principal)?;
2364 }
2365 Ok(empty_outcome())
2366 }
2367 EnsureAction::Fund {
2368 amount,
2369 created_at_time,
2370 ledger,
2371 principal,
2372 ..
2373 } => self.apply_fund(
2374 *amount,
2375 *created_at_time,
2376 ledger,
2377 Self::action_principal(state, principal)?,
2378 ),
2379 EnsureAction::Install {
2380 canic_init,
2381 init_arg,
2382 init_arg_sha256,
2383 init_candid,
2384 init_candid_sha256,
2385 mode,
2386 principal,
2387 wasm,
2388 wasm_sha256,
2389 ..
2390 } => self.apply_install(
2391 operation_id,
2392 &self.protocol_principals(state),
2393 canic_init.as_ref(),
2394 init_arg.as_deref(),
2395 init_arg_sha256.as_deref(),
2396 init_candid.as_deref(),
2397 init_candid_sha256.as_deref(),
2398 *mode,
2399 Self::action_principal(state, principal)?,
2400 wasm,
2401 wasm_sha256,
2402 ),
2403 EnsureAction::FleetProtocol { .. } => {
2404 current_protocol::apply(&self.icp, &self.root, action).map_err(Into::into)
2405 }
2406 EnsureAction::Protocol { .. } => {
2407 let action = resolved_protocol_action(action, state)?;
2408 protocol::apply(
2409 &self.icp,
2410 &self.root,
2411 operation_id,
2412 &self.protocol_principals(state),
2413 &action,
2414 )?;
2415 Ok(EffectOutcome {
2416 created_principal: None,
2417 post_cycles: None,
2418 receipt: Some(operation_id.to_string()),
2419 })
2420 }
2421 EnsureAction::SetControllers {
2422 controller_canisters,
2423 controllers,
2424 principal,
2425 ..
2426 } => self.apply_controllers(
2427 Self::action_principal(state, principal)?,
2428 &self.resolved_controllers(state, controllers, controller_canisters)?,
2429 ),
2430 EnsureAction::Start { principal, .. } => {
2431 self.icp
2432 .start_canister(Self::action_principal(state, principal)?)?;
2433 Ok(empty_outcome())
2434 }
2435 EnsureAction::Stop { principal, .. } => {
2436 self.icp
2437 .stop_canister(Self::action_principal(state, principal)?)?;
2438 Ok(empty_outcome())
2439 }
2440 EnsureAction::Transfer {
2441 amount,
2442 candid,
2443 candid_sha256,
2444 destination,
2445 method,
2446 principal,
2447 ..
2448 } => self.apply_transfer(
2449 *amount,
2450 candid,
2451 candid_sha256,
2452 self.current_principal(state, destination).ok_or_else(|| {
2453 IcpEnsurePlatformError::UnresolvedCreated(destination.clone())
2454 })?,
2455 method,
2456 operation_id,
2457 Self::action_principal(state, principal)?,
2458 ),
2459 }
2460 }
2461
2462 fn action_cycles(
2463 &mut self,
2464 action: &EnsureAction,
2465 state: &FleetEnsureStateRecord,
2466 ) -> Result<Option<u128>, Self::Error> {
2467 let (name, principal) = match action {
2468 EnsureAction::Create { .. } => return Ok(None),
2469 EnsureAction::Delete {
2470 name, principal, ..
2471 }
2472 | EnsureAction::FleetProtocol {
2473 name, principal, ..
2474 }
2475 | EnsureAction::Fund {
2476 name, principal, ..
2477 }
2478 | EnsureAction::Install {
2479 name, principal, ..
2480 }
2481 | EnsureAction::Protocol {
2482 name, principal, ..
2483 }
2484 | EnsureAction::SetControllers {
2485 name, principal, ..
2486 }
2487 | EnsureAction::Start {
2488 name, principal, ..
2489 }
2490 | EnsureAction::Stop {
2491 name, principal, ..
2492 }
2493 | EnsureAction::Transfer {
2494 name, principal, ..
2495 } => (name, Self::action_principal(state, principal)?),
2496 };
2497 let observed = self
2498 .desired
2499 .canisters
2500 .iter()
2501 .find(|configured| configured.name == *name)
2502 .map_or_else(
2503 || self.status_optional(principal),
2504 |configured| self.observe_configured_canister(configured, principal, state),
2505 )?;
2506 Ok(observed.map(|live| live.cycles))
2507 }
2508
2509 fn action_destination_cycles(
2510 &mut self,
2511 action: &EnsureAction,
2512 state: &FleetEnsureStateRecord,
2513 ) -> Result<Option<u128>, Self::Error> {
2514 let EnsureAction::Transfer { destination, .. } = action else {
2515 return Ok(None);
2516 };
2517 let destination = self
2518 .current_principal(state, destination)
2519 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(destination.clone()))?;
2520 Ok(self.status_optional(destination)?.map(|live| live.cycles))
2521 }
2522
2523 fn action_canister_version(
2524 &mut self,
2525 action: &EnsureAction,
2526 state: &FleetEnsureStateRecord,
2527 ) -> Result<Option<u64>, Self::Error> {
2528 let EnsureAction::Install { principal, .. } = action else {
2529 return Ok(None);
2530 };
2531 let principal = Self::action_principal(state, principal)?;
2532 let version = self
2533 .install_status_optional(principal)?
2534 .and_then(|live| live.canister_version)
2535 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
2536 canister: principal.to_string(),
2537 field: "canister_version",
2538 })?;
2539 Ok(Some(version))
2540 }
2541}
2542
2543fn exact_install_canister_status(
2544 icp: &IcpCli,
2545 canister: &str,
2546 projected_canister_version: Option<u64>,
2547 projected_module_sha256: Option<String>,
2548) -> Result<ExactInstallCanisterStatus, IcpEnsurePlatformError> {
2549 exact_install_canister_status_with(
2550 canister,
2551 projected_canister_version,
2552 projected_module_sha256,
2553 |canister_id| {
2554 icp.management_canister_status_candid::<_, ManagementCanisterStatusResponse>(
2555 canister_id,
2556 &ManagementCanisterStatusRequest { canister_id },
2557 )
2558 },
2559 )
2560}
2561
2562fn exact_install_canister_status_with(
2563 canister: &str,
2564 projected_canister_version: Option<u64>,
2565 projected_module_sha256: Option<String>,
2566 management_status: impl FnOnce(
2567 Principal,
2568 )
2569 -> Result<ManagementCanisterStatusResponse, IcpManagementCallError>,
2570) -> Result<ExactInstallCanisterStatus, IcpEnsurePlatformError> {
2571 if let Some(canister_version) = projected_canister_version {
2572 return Ok(ExactInstallCanisterStatus {
2573 canister_version,
2574 module_sha256: projected_module_sha256,
2575 });
2576 }
2577 let canister_id = parse_principal("install target", canister)?;
2578 let response = management_status(canister_id).map_err(|source| {
2579 IcpEnsurePlatformError::InstallVersionProofUnavailable {
2580 canister: canister.to_string(),
2581 source: Box::new(source),
2582 }
2583 })?;
2584 Ok(ExactInstallCanisterStatus {
2585 canister_version: response.version,
2586 module_sha256: response
2587 .module_hash
2588 .map(|hash| canic_core::cdk::utils::hash::hex_bytes(&hash)),
2589 })
2590}
2591
2592fn exact_retained_root_owned_topology<'a>(
2593 state: &'a FleetEnsureStateRecord,
2594 name: &str,
2595 kind: DesiredCanisterKind,
2596 parent: &str,
2597 principal: &str,
2598 root: &str,
2599) -> Option<&'a crate::fleet_ensure::model::FleetEnsureTopologyRecord> {
2600 let child_identity_matches = retained_state_principal_matches(state, name, principal);
2601 let root_identity_matches = retained_state_principal_matches(state, parent, root);
2602 let topology = state
2603 .topology
2604 .get(name)
2605 .filter(|topology| topology.kind == kind && topology.parent.as_deref() == Some(parent));
2606 (child_identity_matches && root_identity_matches)
2607 .then_some(topology)
2608 .flatten()
2609}
2610
2611fn retained_state_principal_matches(
2612 state: &FleetEnsureStateRecord,
2613 name: &str,
2614 principal: &str,
2615) -> bool {
2616 let pending = state.pending_principals.get(name).map(String::as_str);
2617 let terminal = state.principals.get(name).map(String::as_str);
2618 let retained = pending.is_some() || terminal.is_some();
2619 retained
2620 && pending.is_none_or(|retained| retained == principal)
2621 && terminal.is_none_or(|retained| retained == principal)
2622}
2623
2624fn completed_reinstall_continuity(
2625 pre_canister_version: u64,
2626 principal_matches: bool,
2627 topology_matches: bool,
2628 live_module_sha256: Option<&str>,
2629 desired_module_sha256: &str,
2630 live_canister_version: Option<u64>,
2631) -> Result<(), &'static str> {
2632 if !principal_matches {
2633 return Err("Principal");
2634 }
2635 if !topology_matches {
2636 return Err("Root/parent/kind topology");
2637 }
2638 if live_module_sha256 != Some(desired_module_sha256) {
2639 return Err("module SHA-256");
2640 }
2641 if live_canister_version.is_some_and(|version| version <= pre_canister_version) {
2642 return Err("canister version");
2643 }
2644 Ok(())
2645}
2646
2647fn recoverable_root_status_error(error: &CanisterProtocolError) -> bool {
2648 error.is_rejected_with(canic_core::diagnostics::codes::STATE_CONFLICT)
2649 || error.is_rejected_with(canic_core::diagnostics::codes::STATE_UNAVAILABLE)
2650}
2651
2652fn recoverable_current_protocol_error(error: ¤t_protocol::CurrentProtocolError) -> bool {
2653 matches!(
2654 error,
2655 current_protocol::CurrentProtocolError::Transport(source)
2656 if recoverable_root_status_error(source)
2657 )
2658}
2659
2660fn predecessor_store_control_rejection(error: ¤t_protocol::CurrentProtocolError) -> bool {
2661 matches!(
2662 error,
2663 current_protocol::CurrentProtocolError::Transport(source)
2664 if source.is_rejected_with(canic_core::diagnostics::codes::STATE_CONFLICT)
2665 )
2666}
2667
2668pub fn install_effect_applied(
2669 mode: InstallMode,
2670 expected_hash: &str,
2671 live_hash: Option<&str>,
2672 pre_canister_version: Option<u64>,
2673 live_canister_version: Option<u64>,
2674) -> bool {
2675 if live_hash != Some(expected_hash) {
2676 return false;
2677 }
2678 match mode {
2679 InstallMode::Install => true,
2680 InstallMode::Reinstall => pre_canister_version
2681 .zip(live_canister_version)
2682 .is_some_and(|(before, after)| after > before),
2683 }
2684}
2685
2686#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2688pub struct NativeFundingObservation {
2689 pub amount: u128,
2690 pub expected_post_cycles: u128,
2691 pub funding_deficit_cycles: u128,
2692 pub funding_margin_cycles: u128,
2693 pub live_cycles: Option<u128>,
2694 pub pre_cycles: Option<u128>,
2695}
2696
2697pub const fn native_funding_applied(observation: NativeFundingObservation) -> bool {
2698 let Some(pre_cycles) = observation.pre_cycles else {
2699 return false;
2700 };
2701 let Some(live_cycles) = observation.live_cycles else {
2702 return false;
2703 };
2704 let Some(expected_from_amount) = pre_cycles.checked_add(observation.amount) else {
2705 return false;
2706 };
2707 let Some(minimum_live_cycles) = observation
2708 .expected_post_cycles
2709 .checked_sub(observation.funding_margin_cycles)
2710 else {
2711 return false;
2712 };
2713 let Some(minimum_from_deficit) = pre_cycles.checked_add(observation.funding_deficit_cycles)
2714 else {
2715 return false;
2716 };
2717 observation.funding_deficit_cycles > 0
2718 && expected_from_amount == observation.expected_post_cycles
2719 && minimum_live_cycles == minimum_from_deficit
2720 && minimum_live_cycles > pre_cycles
2721 && live_cycles >= minimum_live_cycles
2722}
2723
2724fn validate_root_funding_inspection(
2725 canister: &str,
2726 kind: DesiredCanisterKind,
2727 root: &str,
2728 controllers: &[Principal],
2729 module_hash: Option<&[u8]>,
2730 cycles: &Nat,
2731) -> Result<u128, IcpEnsurePlatformError> {
2732 if controllers.len() != 1 || controllers[0].to_text() != root {
2733 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
2734 canister: canister.to_string(),
2735 field: "Root-only controllers",
2736 });
2737 }
2738 if kind == DesiredCanisterKind::Pool && module_hash.is_some() {
2739 return Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict {
2740 canister: canister.to_string(),
2741 field: "module-free pool asset",
2742 });
2743 }
2744 u128::try_from(cycles.0.clone()).map_err(|_| IcpEnsurePlatformError::InvalidStatusCycles {
2745 canister: canister.to_string(),
2746 value: cycles.to_string(),
2747 })
2748}
2749
2750fn created_canister_outcome(
2751 canister_id: Principal,
2752 receipt: String,
2753 requested_initial_cycles: u128,
2754) -> EffectOutcome {
2755 EffectOutcome {
2756 created_principal: Some(canister_id.to_text()),
2757 post_cycles: Some(requested_initial_cycles),
2758 receipt: Some(receipt),
2759 }
2760}
2761
2762fn create_canister_response_outcome(
2763 response: Result<CreateCanisterSuccess, CreateCanisterError>,
2764 requested_initial_cycles: u128,
2765) -> Result<EffectOutcome, IcpEnsurePlatformError> {
2766 match response {
2767 Ok(success) => Ok(created_canister_outcome(
2768 success.canister_id,
2769 success.block_id.to_string(),
2770 requested_initial_cycles,
2771 )),
2772 Err(CreateCanisterError::Duplicate {
2773 duplicate_of,
2774 canister_id: Some(canister_id),
2775 }) => Ok(created_canister_outcome(
2776 canister_id,
2777 duplicate_of.to_string(),
2778 requested_initial_cycles,
2779 )),
2780 Err(CreateCanisterError::Duplicate {
2781 canister_id: None, ..
2782 }) => Err(IcpEnsurePlatformError::LedgerCreatePending),
2783 Err(error) => Err(IcpEnsurePlatformError::LedgerCreate(render_create_error(
2784 error,
2785 ))),
2786 }
2787}
2788
2789fn ledger_fee_cycles(value: Nat) -> Result<u128, IcpEnsurePlatformError> {
2790 let rendered = value.to_string();
2791 u128::try_from(value.0).map_err(|_| IcpEnsurePlatformError::InvalidLedgerFee(rendered))
2792}
2793
2794const fn empty_outcome() -> EffectOutcome {
2795 EffectOutcome {
2796 created_principal: None,
2797 post_cycles: None,
2798 receipt: None,
2799 }
2800}
2801
2802fn parse_status_cycles(value: &str) -> Option<u128> {
2803 value.replace('_', "").trim().parse().ok()
2804}
2805
2806fn normalize_hash(value: &str) -> String {
2807 value
2808 .strip_prefix("0x")
2809 .unwrap_or(value)
2810 .to_ascii_lowercase()
2811}
2812
2813fn parse_principal(field: &'static str, value: &str) -> Result<Principal, IcpEnsurePlatformError> {
2814 Principal::from_text(value).map_err(|_| IcpEnsurePlatformError::InvalidPrincipal {
2815 field,
2816 value: value.to_string(),
2817 })
2818}
2819
2820fn reject_public_status_projection(
2821 report: &IcpCanisterStatusReport,
2822 principal: &str,
2823) -> Result<(), IcpEnsurePlatformError> {
2824 if report.status.is_none()
2825 && report.settings.is_none()
2826 && report.cycles.is_none()
2827 && report.public_controllers.is_some()
2828 {
2829 return Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable {
2830 canister: principal.to_string(),
2831 });
2832 }
2833 Ok(())
2834}
2835
2836fn resolve_path(root: &Path, path: &str) -> PathBuf {
2837 let path = Path::new(path);
2838 if path.is_absolute() {
2839 path.to_path_buf()
2840 } else {
2841 root.join(path)
2842 }
2843}
2844
2845fn verify_artifact(
2846 path: &Path,
2847 expected: &str,
2848 kind: &'static str,
2849) -> Result<(), IcpEnsurePlatformError> {
2850 let bytes = std::fs::read(path)
2851 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
2852 let actual = canic_core::cdk::utils::hash::sha256_hex(&bytes);
2853 if actual != expected {
2854 return Err(IcpEnsurePlatformError::ArtifactDigestMismatch {
2855 actual,
2856 expected: expected.to_string(),
2857 kind,
2858 });
2859 }
2860 Ok(())
2861}
2862
2863fn artifact_hash(path: &Path) -> Result<String, IcpEnsurePlatformError> {
2864 let bytes = std::fs::read(path)
2865 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
2866 Ok(canic_core::cdk::utils::hash::sha256_hex(&bytes))
2867}
2868
2869fn resolved_protocol_action(
2870 action: &EnsureAction,
2871 state: &FleetEnsureStateRecord,
2872) -> Result<EnsureAction, IcpEnsurePlatformError> {
2873 let mut resolved = action.clone();
2874 if let EnsureAction::Protocol { principal, .. } = &mut resolved {
2875 *principal = IcpEnsurePlatform::action_principal(state, principal)?.to_string();
2876 return Ok(resolved);
2877 }
2878 Err(IcpEnsurePlatformError::Protocol(
2879 protocol::ProtocolEffectError::WrongAction,
2880 ))
2881}
2882
2883fn render_create_error(error: CreateCanisterError) -> String {
2884 match error {
2885 CreateCanisterError::CreatedInFuture { ledger_time } => {
2886 format!("created in future of ledger time {ledger_time}")
2887 }
2888 CreateCanisterError::Duplicate { duplicate_of, .. } => {
2889 format!("duplicate block {duplicate_of}")
2890 }
2891 CreateCanisterError::FailedToCreate {
2892 error,
2893 fee_block,
2894 refund_block,
2895 } => format!("failed: {error}; fee_block={fee_block:?}; refund_block={refund_block:?}"),
2896 CreateCanisterError::GenericError {
2897 error_code,
2898 message,
2899 } => format!("error {error_code}: {message}"),
2900 CreateCanisterError::InsufficientFunds { balance } => {
2901 format!("insufficient funds: balance={balance}")
2902 }
2903 CreateCanisterError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
2904 CreateCanisterError::TooOld => "request is too old".to_string(),
2905 }
2906}
2907
2908fn render_withdraw_error(error: WithdrawError) -> String {
2909 match error {
2910 WithdrawError::BadFee { expected_fee } => format!("bad fee; expected {expected_fee}"),
2911 WithdrawError::CreatedInFuture { ledger_time } => {
2912 format!("created in future of ledger time {ledger_time}")
2913 }
2914 WithdrawError::Duplicate { duplicate_of } => format!("duplicate block {duplicate_of}"),
2915 WithdrawError::FailedToWithdraw {
2916 fee_block,
2917 rejection_reason,
2918 ..
2919 } => format!("withdraw failed: {rejection_reason}; fee_block={fee_block:?}"),
2920 WithdrawError::GenericError {
2921 error_code,
2922 message,
2923 } => format!("error {error_code}: {message}"),
2924 WithdrawError::InsufficientFunds { balance } => {
2925 format!("insufficient funds: balance={balance}")
2926 }
2927 WithdrawError::InvalidReceiver { receiver } => format!("invalid receiver {receiver}"),
2928 WithdrawError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
2929 WithdrawError::TooOld => "request is too old".to_string(),
2930 }
2931}
2932
2933#[expect(
2934 dead_code,
2935 reason = "Candid variant is retained for exact response decoding"
2936)]
2937const fn rejection_code_name(code: RejectionCode) -> &'static str {
2938 match code {
2939 RejectionCode::CanisterError => "canister_error",
2940 RejectionCode::CanisterReject => "canister_reject",
2941 RejectionCode::DestinationInvalid => "destination_invalid",
2942 RejectionCode::NoError => "no_error",
2943 RejectionCode::SysFatal => "sys_fatal",
2944 RejectionCode::SysTransient => "sys_transient",
2945 RejectionCode::Unknown => "unknown",
2946 }
2947}
2948
2949#[cfg(test)]
2950mod tests {
2951 use super::*;
2952 use canic_core::dto::pool::CanisterPoolAssetStatus;
2953
2954 #[cfg(unix)]
2955 #[test]
2956 fn icp_1_3_public_non_controller_status_is_typed_unavailable_evidence() {
2957 use std::{fs, os::unix::fs::PermissionsExt};
2958
2959 let canister = "rrkah-fqaaa-aaaaa-aaaaq-cai";
2960 let root = crate::test_support::temp_dir("canic-public-status-projection");
2961 fs::create_dir_all(&root).expect("create public status fixture");
2962 let executable = root.join("icp");
2963 let commands = root.join("commands.log");
2964 let script = format!(
2965 "#!/bin/sh\n\
2966 if [ \"$1\" = \"--version\" ]; then echo 'icp 1.3.0'; exit 0; fi\n\
2967 printf '%s\\n' \"$*\" >> '{}'\n\
2968 printf '%s\\n' '{{\"id\":\"{canister}\",\"controllers\":[\"rdmx6-jaaaa-aaaaa-aaadq-cai\"],\"module_hash\":null}}'\n",
2969 commands.display(),
2970 );
2971 fs::write(&executable, script).expect("write fake ICP 1.3.0");
2972 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))
2973 .expect("make fake ICP executable");
2974 let desired = DesiredFleet {
2975 bootstrap: None,
2976 canisters: Vec::new(),
2977 cycles_ledger: "um5iw-rqaaa-aaaaq-qaaba-cai".to_string(),
2978 environment: "local".to_string(),
2979 fleet: "public-status".to_string(),
2980 ledger_fee_cycles: "100M".to_string(),
2981 management_creation_fee_cycles: "500B".to_string(),
2982 material_cycle_threshold: "1B".to_string(),
2983 maximum_observation_burn_cycles: "1B".to_string(),
2984 maximum_stalled_observations: 4,
2985 maximum_update_burn_cycles: "1B".to_string(),
2986 operator: "rdmx6-jaaaa-aaaaa-aaadq-cai".to_string(),
2987 protocol: None,
2988 protocol_steps: Vec::new(),
2989 schema_version: 1,
2990 treasury: "treasury".to_string(),
2991 };
2992 let platform =
2993 IcpEnsurePlatform::new(desired, executable.to_string_lossy().as_ref(), &root);
2994
2995 assert!(matches!(
2996 platform.status_optional(canister),
2997 Err(IcpEnsurePlatformError::PublicCanisterStatusUnavailable {
2998 canister: unavailable,
2999 }) if unavailable == canister
3000 ));
3001 assert!(
3002 fs::read_to_string(commands)
3003 .expect("read fake ICP commands")
3004 .contains("canister status rrkah-fqaaa-aaaaa-aaaaq-cai --json")
3005 );
3006 }
3007
3008 #[test]
3009 fn retained_predecessor_modules_are_observed_and_optionally_cross_checked() {
3010 let root_controllers = vec!["root-controller".to_string()];
3011 let store_controllers = vec!["root-controller".to_string(), "operator".to_string()];
3012 let observed = RetainedStoreControlLiveBinding {
3013 root_controllers: root_controllers.clone(),
3014 root_module_sha256: Some("root-predecessor".to_string()),
3015 store_controllers: store_controllers.clone(),
3016 store_module_sha256: Some("store-predecessor".to_string()),
3017 };
3018
3019 assert!(retained_store_control_live_is_exact(
3020 &observed,
3021 &root_controllers,
3022 &store_controllers,
3023 None,
3024 None,
3025 "root-successor",
3026 "store-successor",
3027 ));
3028 assert!(retained_store_control_live_is_exact(
3029 &observed,
3030 &root_controllers,
3031 &store_controllers,
3032 Some("root-predecessor"),
3033 Some("store-predecessor"),
3034 "root-successor",
3035 "store-successor",
3036 ));
3037 assert!(!retained_store_control_live_is_exact(
3038 &observed,
3039 &root_controllers,
3040 &store_controllers,
3041 Some("wrong-root-predecessor"),
3042 Some("store-predecessor"),
3043 "root-successor",
3044 "store-successor",
3045 ));
3046
3047 let missing_live_module = RetainedStoreControlLiveBinding {
3048 root_controllers: root_controllers.clone(),
3049 root_module_sha256: None,
3050 store_controllers: store_controllers.clone(),
3051 store_module_sha256: Some("store-predecessor".to_string()),
3052 };
3053 assert!(!retained_store_control_live_is_exact(
3054 &missing_live_module,
3055 &root_controllers,
3056 &store_controllers,
3057 None,
3058 None,
3059 "root-successor",
3060 "store-successor",
3061 ));
3062
3063 let mut successor_is_already_live = observed;
3064 successor_is_already_live.store_module_sha256 = Some("store-successor".to_string());
3065 assert!(!retained_store_control_live_is_exact(
3066 &successor_is_already_live,
3067 &root_controllers,
3068 &store_controllers,
3069 None,
3070 None,
3071 "root-successor",
3072 "store-successor",
3073 ));
3074 }
3075
3076 #[cfg(unix)]
3077 #[test]
3078 fn versionless_icp_status_uses_exact_typed_management_version() {
3079 #[derive(CandidType)]
3080 struct CanonicalManagementCanisterStatusFixture {
3081 version: u64,
3082 module_hash: Option<Vec<u8>>,
3083 }
3084
3085 use std::{fs, os::unix::fs::PermissionsExt};
3086
3087 let root = crate::test_support::temp_dir("canic-install-version-fallback");
3088 fs::create_dir_all(&root).expect("create version fallback fixture");
3089 let executable = root.join("icp");
3090 let commands = root.join("commands.log");
3091 let canister = "rrkah-fqaaa-aaaaa-aaaaq-cai";
3092 let status_json = format!(
3093 r#"{{"id":"{canister}","name":"coordinator","status":"Running","settings":{{"controllers":["rdmx6-jaaaa-aaaaa-aaadq-cai"]}},"module_hash":"0x{}","memory_size":"1","cycles":"1000000000000","query_stats":{{}}}}"#,
3094 "11".repeat(32),
3095 );
3096 let response_bytes = candid::encode_one(CanonicalManagementCanisterStatusFixture {
3097 version: 42,
3098 module_hash: Some(vec![0x22; 32]),
3099 })
3100 .expect("encode independently modelled management status");
3101 let script = format!(
3102 "#!/bin/sh\n\
3103 if [ \"$1\" = \"--version\" ]; then printf '%s\\n' 'icp 1.3.0'; exit 0; fi\n\
3104 printf '%s\\n' \"$*\" >> '{}'\n\
3105 case \"$*\" in\n\
3106 *\"canister status {canister}\"*) printf '%s\\n' '{}' ;;\n\
3107 *) printf '%s\\n' 'unexpected fake ICP command' >&2; exit 23 ;;\n\
3108 esac\n",
3109 commands.display(),
3110 status_json,
3111 );
3112 fs::write(&executable, script).expect("write fake ICP 1.3.0");
3113 fs::set_permissions(&executable, fs::Permissions::from_mode(0o755))
3114 .expect("make fake ICP executable");
3115 let icp = IcpCli::new(executable.to_string_lossy(), Some("ic".to_string()));
3116
3117 let projected = icp
3118 .canister_status_report(canister)
3119 .expect("read ICP 1.3.0 status projection");
3120 assert_eq!(projected.canister_version, None);
3121 let exact = exact_install_canister_status_with(
3122 canister,
3123 projected.canister_version,
3124 projected.module_hash.map(|hash| normalize_hash(&hash)),
3125 |effective_canister_id| {
3126 assert_eq!(
3127 effective_canister_id,
3128 canister.parse().expect("target Principal")
3129 );
3130 candid::decode_one(&response_bytes).map_err(IcpManagementCallError::CandidResponse)
3131 },
3132 )
3133 .expect("obtain exact typed management status");
3134 assert_eq!(exact.canister_version, 42);
3135 assert_eq!(exact.module_sha256, Some("22".repeat(32)));
3136 let commands = fs::read_to_string(&commands).expect("read fake ICP commands");
3137 assert!(commands.contains("canister status rrkah-fqaaa-aaaaa-aaaaq-cai"));
3138 assert!(!commands.contains("canister call aaaaa-aa canister_status"));
3139
3140 fs::write(
3141 &executable,
3142 "#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo 'icp 1.3.0'; exit 0; fi\necho '{\"not_response_bytes\":true}'\n",
3143 )
3144 .expect("replace unavailable management fixture");
3145 let error = exact_install_canister_status_with(canister, None, None, |_| {
3146 Err(IcpManagementCallError::MissingEnvironment)
3147 })
3148 .expect_err("missing projected and typed version must fail closed");
3149 assert!(matches!(
3150 error,
3151 IcpEnsurePlatformError::InstallVersionProofUnavailable { .. }
3152 ));
3153 assert!(error.to_string().contains("no install was issued"));
3154 assert!(error.to_string().contains("resume the same reviewed plan"));
3155
3156 fs::remove_dir_all(root).expect("remove version fallback fixture");
3157 }
3158
3159 #[test]
3160 fn root_owned_observation_classifies_bootstrap_and_workload_lifecycle() {
3161 assert_eq!(
3162 root_owned_lifecycle(DesiredCanisterKind::Store, &CanisterPoolAssetStatus::Store),
3163 Some(RootOwnedCanisterLifecycle::Store)
3164 );
3165 assert_eq!(
3166 root_owned_lifecycle(DesiredCanisterKind::Pool, &CanisterPoolAssetStatus::Ready),
3167 Some(RootOwnedCanisterLifecycle::Idle)
3168 );
3169 assert_eq!(
3170 root_owned_lifecycle(
3171 DesiredCanisterKind::Pool,
3172 &CanisterPoolAssetStatus::Claimed {
3173 claim: canic_core::dto::pool::CanisterPoolClaim {
3174 component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
3175 [1; 32]
3176 ),
3177 operation_id: [2; 32],
3178 },
3179 }
3180 ),
3181 Some(RootOwnedCanisterLifecycle::Claimed)
3182 );
3183 assert_eq!(
3184 root_owned_lifecycle(
3185 DesiredCanisterKind::Pool,
3186 &CanisterPoolAssetStatus::Workload {
3187 claim: canic_core::dto::pool::CanisterPoolClaim {
3188 component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
3189 [1; 32]
3190 ),
3191 operation_id: [2; 32],
3192 },
3193 }
3194 ),
3195 Some(RootOwnedCanisterLifecycle::Workload)
3196 );
3197 assert_eq!(
3198 root_owned_lifecycle(
3199 DesiredCanisterKind::Pool,
3200 &CanisterPoolAssetStatus::HandingOff {
3201 recipient: Principal::anonymous(),
3202 }
3203 ),
3204 None
3205 );
3206 assert_eq!(
3207 root_owned_lifecycle(
3208 DesiredCanisterKind::Store,
3209 &CanisterPoolAssetStatus::PendingReset
3210 ),
3211 None
3212 );
3213 }
3214
3215 #[test]
3216 #[expect(
3217 clippy::too_many_lines,
3218 reason = "the binding regression mutates each authority field independently"
3219 )]
3220 fn retained_root_owned_topology_requires_exact_child_root_and_parent_binding() {
3221 let mut state = FleetEnsureStateRecord {
3222 active_registry: None,
3223 completed_reinstall_action_sha256: BTreeMap::new(),
3224 completed_reinstall_operation_id: None,
3225 completed_reinstalls: BTreeMap::new(),
3226 fleet: "fleet".to_string(),
3227 pending_principals: BTreeMap::new(),
3228 principals: BTreeMap::from([
3229 ("root".to_string(), "root-principal".to_string()),
3230 ("store".to_string(), "store-principal".to_string()),
3231 ]),
3232 retained_cycles_by_principal: BTreeMap::new(),
3233 schema_version: crate::fleet_ensure::model::FLEET_ENSURE_SCHEMA_VERSION,
3234 topology: BTreeMap::from([(
3235 "store".to_string(),
3236 crate::fleet_ensure::model::FleetEnsureTopologyRecord {
3237 kind: DesiredCanisterKind::Store,
3238 module_hash: Some("11".repeat(32)),
3239 parent: Some("root".to_string()),
3240 protocol_binding: None,
3241 role: None,
3242 },
3243 )]),
3244 };
3245
3246 assert!(
3247 exact_retained_root_owned_topology(
3248 &state,
3249 "store",
3250 DesiredCanisterKind::Store,
3251 "root",
3252 "store-principal",
3253 "root-principal",
3254 )
3255 .is_some()
3256 );
3257 assert!(
3258 exact_retained_root_owned_topology(
3259 &state,
3260 "store",
3261 DesiredCanisterKind::Store,
3262 "root",
3263 "foreign-store",
3264 "root-principal",
3265 )
3266 .is_none()
3267 );
3268 state
3269 .principals
3270 .insert("store".to_string(), "store-principal".to_string());
3271 state
3272 .principals
3273 .insert("root".to_string(), "foreign-root".to_string());
3274 assert!(
3275 exact_retained_root_owned_topology(
3276 &state,
3277 "store",
3278 DesiredCanisterKind::Store,
3279 "root",
3280 "store-principal",
3281 "root-principal",
3282 )
3283 .is_none()
3284 );
3285 state
3286 .principals
3287 .insert("root".to_string(), "root-principal".to_string());
3288 state
3289 .topology
3290 .get_mut("store")
3291 .expect("Store topology")
3292 .kind = DesiredCanisterKind::Pool;
3293 assert!(
3294 exact_retained_root_owned_topology(
3295 &state,
3296 "store",
3297 DesiredCanisterKind::Store,
3298 "root",
3299 "store-principal",
3300 "root-principal",
3301 )
3302 .is_none()
3303 );
3304 state
3305 .topology
3306 .get_mut("store")
3307 .expect("Store topology")
3308 .kind = DesiredCanisterKind::Store;
3309 state
3310 .topology
3311 .get_mut("store")
3312 .expect("Store topology")
3313 .parent = Some("foreign-root".to_string());
3314 assert!(
3315 exact_retained_root_owned_topology(
3316 &state,
3317 "store",
3318 DesiredCanisterKind::Store,
3319 "root",
3320 "store-principal",
3321 "root-principal",
3322 )
3323 .is_none()
3324 );
3325 state
3326 .topology
3327 .get_mut("store")
3328 .expect("Store topology")
3329 .parent = Some("root".to_string());
3330 state.principals.clear();
3331 state.pending_principals = BTreeMap::from([
3332 ("root".to_string(), "root-principal".to_string()),
3333 ("store".to_string(), "store-principal".to_string()),
3334 ]);
3335 assert!(
3336 exact_retained_root_owned_topology(
3337 &state,
3338 "store",
3339 DesiredCanisterKind::Store,
3340 "root",
3341 "store-principal",
3342 "root-principal",
3343 )
3344 .is_some()
3345 );
3346 state
3347 .principals
3348 .insert("store".to_string(), "foreign-store".to_string());
3349 assert!(
3350 exact_retained_root_owned_topology(
3351 &state,
3352 "store",
3353 DesiredCanisterKind::Store,
3354 "root",
3355 "store-principal",
3356 "root-principal",
3357 )
3358 .is_none()
3359 );
3360 }
3361
3362 #[test]
3363 fn completed_reinstall_requires_exact_continuity_when_ordinary_status_has_no_version() {
3364 let desired = "11".repeat(32);
3365 assert_eq!(
3366 completed_reinstall_continuity(7, true, true, Some(&desired), &desired, None,),
3367 Ok(())
3368 );
3369 assert_eq!(
3370 completed_reinstall_continuity(7, true, true, Some(&desired), &desired, Some(8),),
3371 Ok(())
3372 );
3373 for (rejected, field) in [
3374 (
3375 completed_reinstall_continuity(7, false, true, Some(&desired), &desired, None),
3376 "Principal",
3377 ),
3378 (
3379 completed_reinstall_continuity(7, true, false, Some(&desired), &desired, None),
3380 "Root/parent/kind topology",
3381 ),
3382 (
3383 completed_reinstall_continuity(
3384 7,
3385 true,
3386 true,
3387 Some(&"22".repeat(32)),
3388 &desired,
3389 None,
3390 ),
3391 "module SHA-256",
3392 ),
3393 (
3394 completed_reinstall_continuity(7, true, true, Some(&desired), &desired, Some(7)),
3395 "canister version",
3396 ),
3397 ] {
3398 assert_eq!(rejected, Err(field));
3399 }
3400 }
3401
3402 #[test]
3403 fn toko_fresh_fleet_create_responses_retain_the_exact_requested_balance() {
3404 let canister = Principal::from_slice(&[9; 29]);
3405 let canister_text = canister.to_text();
3406 for (response, receipt) in [
3407 (
3408 Ok(CreateCanisterSuccess {
3409 block_id: Nat::from(41_u8),
3410 canister_id: canister,
3411 }),
3412 "41",
3413 ),
3414 (
3415 Err(CreateCanisterError::Duplicate {
3416 duplicate_of: Nat::from(41_u8),
3417 canister_id: Some(canister),
3418 }),
3419 "41",
3420 ),
3421 ] {
3422 let outcome = create_canister_response_outcome(response, 5_000)
3423 .expect("successful or duplicate-with-Principal Create response");
3424 assert_eq!(
3425 outcome.created_principal.as_deref(),
3426 Some(canister_text.as_str())
3427 );
3428 assert_eq!(outcome.post_cycles, Some(5_000));
3429 assert_eq!(outcome.receipt.as_deref(), Some(receipt));
3430 }
3431 }
3432
3433 #[test]
3434 fn root_funding_inspection_requires_exact_controller_and_pool_shape() {
3435 let root = Principal::from_slice(&[7; 29]);
3436 let root_text = root.to_text();
3437 let cycles = Nat::from(2_898_749_313_788_u128);
3438 assert_eq!(
3439 validate_root_funding_inspection(
3440 "pool-0",
3441 DesiredCanisterKind::Pool,
3442 &root_text,
3443 &[root],
3444 None,
3445 &cycles,
3446 )
3447 .expect("exact Root-authorized pool inspection"),
3448 2_898_749_313_788,
3449 );
3450
3451 let foreign = Principal::from_slice(&[8; 29]);
3452 for rejected in [
3453 validate_root_funding_inspection(
3454 "pool-0",
3455 DesiredCanisterKind::Pool,
3456 &root_text,
3457 &[foreign],
3458 None,
3459 &cycles,
3460 ),
3461 validate_root_funding_inspection(
3462 "pool-0",
3463 DesiredCanisterKind::Pool,
3464 &root_text,
3465 &[root],
3466 Some(&[1]),
3467 &cycles,
3468 ),
3469 ] {
3470 assert!(matches!(
3471 rejected,
3472 Err(IcpEnsurePlatformError::FundingInspectionAuthorityConflict { .. })
3473 ));
3474 }
3475 }
3476}