1use crate::{
8 canister_protocol::{CanisterProtocolError, query_with_candid},
9 fleet_ensure::{
10 model::{
11 CanisterRuntimeStatus, DesiredCanisterKind, DesiredFleet, EffectRecord, EnsureAction,
12 FleetEnsureStateRecord, FleetObservation, InstallMode, LiveCanister,
13 RetirementTransferBalances, RetirementTransferInvariantError,
14 RetirementTransferReconciliation, RootOwnedCanisterLifecycle,
15 reconcile_retirement_transfer,
16 },
17 ops::{
18 EffectObservation, EffectOutcome, EffectRetry, EnsurePlatform, TerminalFleetInventory,
19 canic_init, current_protocol, protocol, root_owned_lifecycle,
20 },
21 },
22 icp::{IcpCandidCallError, IcpCli, IcpCommandError, IcpDiagnostic, run_status},
23};
24use candid::{CandidType, Nat, Principal};
25use canic_core::{
26 cdk::types::Cycles,
27 dto::pool::{CanisterPoolResponse, CanisterPoolStatusRequest},
28 protocol as canic_protocol,
29};
30use serde::Deserialize;
31use std::{
32 cell::RefCell,
33 collections::{BTreeMap, BTreeSet},
34 path::{Path, PathBuf},
35};
36use thiserror::Error as ThisError;
37
38#[derive(CandidType)]
41struct DrainRequest {
42 operation_id: String,
43 destination: Principal,
44 cycles: Nat,
45}
46
47#[derive(CandidType, Deserialize)]
48enum DrainResponse {
49 Accepted { transferred_cycles: Nat },
50 Replayed { transferred_cycles: Nat },
51}
52
53#[derive(CandidType)]
54enum RootPoolStatusRequest {
55 Pool(CanisterPoolStatusRequest),
56}
57
58#[derive(CandidType, Deserialize)]
59enum RootPoolStatusResponse {
60 Pool(Box<CanisterPoolResponse>),
61}
62
63#[derive(CandidType)]
64enum ManagedCanisterStatusRequest {
65 CycleBalance,
66}
67
68#[derive(CandidType, Deserialize)]
69enum ManagedCanisterStatusResponse {
70 CycleBalance(canic_core::dto::role::CycleBalanceStatusResponse),
71}
72
73#[derive(CandidType)]
74struct CreateCanisterArgs {
75 amount: Nat,
76 created_at_time: Option<u64>,
77 creation_args: Option<CmcCreateCanisterArgs>,
78 from_subaccount: Option<[u8; 32]>,
79}
80
81#[derive(CandidType)]
82struct CmcCreateCanisterArgs {
83 settings: Option<CanisterSettings>,
84 subnet_selection: Option<SubnetSelection>,
85}
86
87#[derive(CandidType)]
88struct CanisterSettings {
89 compute_allocation: Option<Nat>,
90 controllers: Option<Vec<Principal>>,
91 freezing_threshold: Option<Nat>,
92 memory_allocation: Option<Nat>,
93 reserved_cycles_limit: Option<Nat>,
94}
95
96struct CreateCanisterAuthority<'a> {
97 controller_canisters: &'a [String],
98 controllers: &'a [String],
99 created_at_time: u64,
100 ledger: &'a str,
101 requested_initial_cycles: u128,
102 subnet: &'a str,
103}
104
105#[derive(CandidType)]
106enum SubnetSelection {
107 Subnet { subnet: Principal },
108}
109
110#[derive(CandidType, Deserialize)]
111struct CreateCanisterSuccess {
112 block_id: Nat,
113 canister_id: Principal,
114}
115
116#[derive(CandidType, Deserialize)]
117enum CreateCanisterError {
118 CreatedInFuture {
119 ledger_time: u64,
120 },
121 Duplicate {
122 duplicate_of: Nat,
123 canister_id: Option<Principal>,
124 },
125 FailedToCreate {
126 error: String,
127 fee_block: Option<Nat>,
128 refund_block: Option<Nat>,
129 },
130 GenericError {
131 error_code: Nat,
132 message: String,
133 },
134 InsufficientFunds {
135 balance: Nat,
136 },
137 TemporarilyUnavailable,
138 TooOld,
139}
140
141#[derive(CandidType)]
142struct WithdrawArgs {
143 amount: Nat,
144 created_at_time: Option<u64>,
145 from_subaccount: Option<[u8; 32]>,
146 to: Principal,
147}
148
149#[derive(CandidType, Deserialize)]
150enum WithdrawError {
151 BadFee {
152 expected_fee: Nat,
153 },
154 CreatedInFuture {
155 ledger_time: u64,
156 },
157 Duplicate {
158 duplicate_of: Nat,
159 },
160 FailedToWithdraw {
161 fee_block: Option<Nat>,
162 rejection_code: RejectionCode,
163 rejection_reason: String,
164 },
165 GenericError {
166 error_code: Nat,
167 message: String,
168 },
169 InsufficientFunds {
170 balance: Nat,
171 },
172 InvalidReceiver {
173 receiver: Principal,
174 },
175 TemporarilyUnavailable,
176 TooOld,
177}
178
179#[derive(CandidType, Deserialize)]
180enum RejectionCode {
181 CanisterError,
182 CanisterReject,
183 DestinationInvalid,
184 NoError,
185 SysFatal,
186 SysTransient,
187 Unknown,
188}
189
190#[derive(Debug, ThisError)]
193pub enum IcpEnsurePlatformError {
194 #[error("cycle arithmetic overflow while executing {0}")]
195 Arithmetic(&'static str),
196
197 #[error("configured Candid file is not a regular file: {}", .0.display())]
198 CandidUnavailable(PathBuf),
199
200 #[error("configured effect artifact is unavailable: {}", .0.display())]
201 ArtifactUnavailable(PathBuf),
202
203 #[error("configured {kind} changed after plan review: expected {expected}, observed {actual}")]
204 ArtifactDigestMismatch {
205 actual: String,
206 expected: String,
207 kind: &'static str,
208 },
209
210 #[error("configured Principal is invalid for {field}: {value}")]
211 InvalidPrincipal { field: &'static str, value: String },
212
213 #[error("Cycles Ledger returned an unsupported fee value: {0}")]
214 InvalidLedgerFee(String),
215
216 #[error("active ICP identity is {actual}, but reviewed Fleet operator is {expected}")]
217 OperatorMismatch { actual: String, expected: String },
218
219 #[error("ICP status has invalid cycle balance for {canister}: {value}")]
220 InvalidStatusCycles { canister: String, value: String },
221
222 #[error("ICP status omitted exact {field} required for controlled canister {canister}")]
223 IncompleteCanisterStatus {
224 canister: String,
225 field: &'static str,
226 },
227
228 #[error("ICP status omitted the canister version required to prove install on {canister}")]
229 MissingCanisterVersion { canister: String },
230
231 #[error(transparent)]
232 Candid(#[from] IcpCandidCallError),
233
234 #[error(transparent)]
235 Icp(#[from] IcpCommandError),
236
237 #[error("Cycles Ledger create_canister failed: {0}")]
238 LedgerCreate(String),
239
240 #[error("Cycles Ledger duplicate does not yet identify its created canister")]
241 LedgerCreatePending,
242
243 #[error("Cycles Ledger withdraw failed: {0}")]
244 LedgerWithdraw(String),
245
246 #[error(
247 "canister {canister} retains {actual} cycles above deletion limit {maximum}; left untouched"
248 )]
249 MaterialCycles {
250 actual: u128,
251 canister: String,
252 maximum: u128,
253 },
254
255 #[error("canister {canister} is not stopped; deletion was not attempted")]
256 NotStopped { canister: String },
257
258 #[error("effect references unresolved created canister {0}")]
259 UnresolvedCreated(String),
260
261 #[error("configured retirement endpoint transferred {actual} cycles, expected {expected}")]
262 WrongTransferAmount { actual: Nat, expected: u128 },
263
264 #[error(
265 "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"
266 )]
267 UnbalancedTransfer {
268 canister: String,
269 destination_credit: u128,
270 expected: u128,
271 maximum_source_debit: u128,
272 source_debit: u128,
273 },
274
275 #[error("retirement transfer for {canister} is missing its durable {side} balance")]
276 MissingTransferBalance {
277 canister: String,
278 side: &'static str,
279 },
280
281 #[error("retirement transfer balance for {canister} moved in an invalid direction")]
282 InvalidTransferBalance { canister: String },
283
284 #[error(transparent)]
285 CurrentProtocol(#[from] current_protocol::CurrentProtocolError),
286
287 #[error(transparent)]
288 CanicInit(#[from] canic_init::CanicInitError),
289
290 #[error(transparent)]
291 Protocol(#[from] protocol::ProtocolEffectError),
292}
293
294pub struct IcpEnsurePlatform {
296 desired: DesiredFleet,
297 icp: IcpCli,
298 recovery_reinstalls: RefCell<BTreeSet<String>>,
299 root: PathBuf,
300}
301
302#[derive(Clone, Copy)]
303enum RetainedRootOwnedObservationMode {
304 DeferredUntilRootStart,
305 ReinstallRecovery,
306}
307
308impl IcpEnsurePlatform {
309 #[must_use]
310 pub fn new(desired: DesiredFleet, icp_executable: &str, root: &Path) -> Self {
311 let icp = IcpCli::new(icp_executable, Some(desired.environment.clone()))
312 .with_cwd(root.to_path_buf());
313 Self {
314 desired,
315 icp,
316 recovery_reinstalls: RefCell::new(BTreeSet::new()),
317 root: root.to_path_buf(),
318 }
319 }
320
321 fn require_operator(&self) -> Result<(), IcpEnsurePlatformError> {
322 let actual = self.icp.identity_principal_text()?;
323 if actual != self.desired.operator {
324 return Err(IcpEnsurePlatformError::OperatorMismatch {
325 actual,
326 expected: self.desired.operator.clone(),
327 });
328 }
329 Ok(())
330 }
331
332 fn current_principal<'a>(
333 &'a self,
334 state: &'a FleetEnsureStateRecord,
335 name: &str,
336 ) -> Option<&'a str> {
337 state
338 .pending_principals
339 .get(name)
340 .or_else(|| state.principals.get(name))
341 .map(String::as_str)
342 .or_else(|| {
343 self.desired
344 .canisters
345 .iter()
346 .find(|configured| configured.name == name)
347 .and_then(|configured| configured.principal.as_deref())
348 })
349 }
350
351 fn protocol_principals(&self, state: &FleetEnsureStateRecord) -> BTreeMap<String, String> {
352 self.desired
353 .canisters
354 .iter()
355 .filter_map(|configured| {
356 state
357 .pending_principals
358 .get(&configured.name)
359 .or_else(|| state.principals.get(&configured.name))
360 .or(configured.principal.as_ref())
361 .map(|principal| (configured.name.clone(), principal.clone()))
362 })
363 .collect()
364 }
365
366 fn resolved_controllers(
367 &self,
368 state: &FleetEnsureStateRecord,
369 controllers: &[String],
370 controller_canisters: &[String],
371 ) -> Result<Vec<String>, IcpEnsurePlatformError> {
372 let mut resolved = controllers.to_vec();
373 for name in controller_canisters {
374 resolved.push(
375 self.current_principal(state, name)
376 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.clone()))?
377 .to_string(),
378 );
379 }
380 resolved.sort();
381 resolved.dedup();
382 Ok(resolved)
383 }
384
385 fn observed_protocol_action(
386 &self,
387 step: &crate::fleet_ensure::model::DesiredProtocolStep,
388 principal: String,
389 ) -> Result<EnsureAction, IcpEnsurePlatformError> {
390 Ok(EnsureAction::Protocol {
391 candid: step.candid.clone(),
392 candid_sha256: artifact_hash(&resolve_path(&self.root, &step.candid))?,
393 command_args: step.command_args.clone(),
394 command_args_sha256: artifact_hash(&resolve_path(&self.root, &step.command_args))?,
395 command_method: step.command_method.clone(),
396 expected_status: step.expected_status.clone(),
397 expected_status_sha256: artifact_hash(&resolve_path(
398 &self.root,
399 &step.expected_status,
400 ))?,
401 maximum_execution_burn_cycles: step
402 .maximum_execution_burn_cycles
403 .parse()
404 .map_err(|_| IcpEnsurePlatformError::Arithmetic("protocol burn"))?,
405 name: step.name.clone(),
406 principal,
407 status_args: step.status_args.clone(),
408 status_args_sha256: artifact_hash(&resolve_path(&self.root, &step.status_args))?,
409 status_method: step.status_method.clone(),
410 })
411 }
412
413 fn action_principal<'a>(
414 state: &'a FleetEnsureStateRecord,
415 principal: &'a str,
416 ) -> Result<&'a str, IcpEnsurePlatformError> {
417 if let Some(name) = principal.strip_prefix("created:") {
418 return state
419 .pending_principals
420 .get(name)
421 .map(String::as_str)
422 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.to_string()));
423 }
424 Ok(principal)
425 }
426
427 fn status_optional(
428 &self,
429 principal: &str,
430 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
431 let report = match self.icp.canister_status_report(principal) {
432 Ok(report) => report,
433 Err(error)
434 if matches!(
435 error.diagnostic(),
436 Some(IcpDiagnostic::CanisterNotFound { .. })
437 ) =>
438 {
439 return Ok(None);
440 }
441 Err(error) => return Err(error.into()),
442 };
443 let cycles_text =
444 report
445 .cycles
446 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
447 canister: principal.to_string(),
448 field: "cycles",
449 })?;
450 let cycles = parse_status_cycles(&cycles_text).ok_or_else(|| {
451 IcpEnsurePlatformError::InvalidStatusCycles {
452 canister: principal.to_string(),
453 value: cycles_text,
454 }
455 })?;
456 let mut controllers = report
457 .settings
458 .ok_or_else(|| IcpEnsurePlatformError::IncompleteCanisterStatus {
459 canister: principal.to_string(),
460 field: "controllers",
461 })?
462 .controllers;
463 controllers.sort();
464 let status = match report.status.to_ascii_lowercase().as_str() {
465 "stopped" => CanisterRuntimeStatus::Stopped,
466 "stopping" => CanisterRuntimeStatus::Stopping,
467 _ => CanisterRuntimeStatus::Running,
468 };
469 Ok(Some(LiveCanister {
470 canister_version: report.canister_version,
471 controllers,
472 cycles,
473 module_sha256: report.module_hash.map(|hash| normalize_hash(&hash)),
474 principal: principal.to_string(),
475 reinstall_required: false,
476 root_owned_lifecycle: None,
477 status,
478 }))
479 }
480
481 fn has_stopped_retained_protocol_owner(
482 &self,
483 state: &FleetEnsureStateRecord,
484 ) -> Result<bool, IcpEnsurePlatformError> {
485 for configured in self.desired.canisters.iter().filter(|configured| {
486 configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
487 && matches!(
488 configured.kind,
489 DesiredCanisterKind::Coordinator
490 | DesiredCanisterKind::Root
491 | DesiredCanisterKind::Store
492 )
493 }) {
494 let Some(principal) = self.current_principal(state, &configured.name) else {
495 continue;
496 };
497 let Some(live) = self.status_optional(principal)? else {
498 continue;
499 };
500 if live.status != CanisterRuntimeStatus::Running {
501 return Ok(true);
502 }
503 }
504 Ok(false)
505 }
506
507 fn observe_configured_canister(
508 &self,
509 configured: &crate::fleet_ensure::model::DesiredCanister,
510 principal: &str,
511 state: &FleetEnsureStateRecord,
512 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
513 if configured.kind == DesiredCanisterKind::Pool {
514 return self.observe_root_owned_canister(configured, principal, state);
515 }
516 match self.status_optional(principal) {
517 Ok(observed) => Ok(observed),
518 Err(error)
519 if matches!(
520 configured.kind,
521 DesiredCanisterKind::Store | DesiredCanisterKind::Pool
522 ) && !matches!(
523 error,
524 IcpEnsurePlatformError::Icp(ref source)
525 if matches!(
526 source.diagnostic(),
527 Some(IcpDiagnostic::CanisterNotFound { .. })
528 )
529 ) =>
530 {
531 self.observe_root_owned_canister(configured, principal, state)
532 }
533 Err(error) => Err(error),
534 }
535 }
536
537 fn observe_root_owned_canister(
538 &self,
539 configured: &crate::fleet_ensure::model::DesiredCanister,
540 principal: &str,
541 state: &FleetEnsureStateRecord,
542 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
543 let candid = self.root_protocol_candid()?;
544 let parent = configured.parent.as_deref().ok_or_else(|| {
545 current_protocol::CurrentProtocolError::Configuration(format!(
546 "Root-owned canister {} has no Root parent",
547 configured.name
548 ))
549 })?;
550 let root = self.current_principal(state, parent).ok_or_else(|| {
551 current_protocol::CurrentProtocolError::Configuration(format!(
552 "Root-owned canister {} has no resolved Root",
553 configured.name
554 ))
555 })?;
556 let retained_observation = |mode| {
557 self.retained_root_owned_observation(configured, principal, parent, root, state, mode)
558 };
559 if self.required_root_status(&configured.name, root)? == CanisterRuntimeStatus::Stopped {
560 return retained_observation(RetainedRootOwnedObservationMode::DeferredUntilRootStart);
561 }
562 let target = parse_principal("Root-owned canister", principal)?;
563 let mut start_after = None;
564 loop {
565 let response: Result<RootPoolStatusResponse, CanisterProtocolError> = query_with_candid(
566 &self.icp,
567 &candid,
568 parse_principal("Fleet Subnet Root", root)?,
569 canic_protocol::CANIC_STATUS,
570 &RootPoolStatusRequest::Pool(CanisterPoolStatusRequest {
571 start_after,
572 limit: 256,
573 }),
574 );
575 let response = match response {
576 Ok(response) => response,
577 Err(error) if recoverable_root_status_error(&error) => {
578 return retained_observation(
579 RetainedRootOwnedObservationMode::ReinstallRecovery,
580 );
581 }
582 Err(error) => {
583 return Err(current_protocol::CurrentProtocolError::from(error).into());
584 }
585 };
586 let RootPoolStatusResponse::Pool(page) = response;
587 if let Some(asset) = page
588 .entries
589 .into_iter()
590 .find(|asset| asset.canister_id == target)
591 {
592 if matches!(
593 asset.status,
594 canic_core::dto::pool::CanisterPoolAssetStatus::PendingReset
595 ) && asset.cycles.to_u128() == 0
596 {
597 return retained_observation(
598 RetainedRootOwnedObservationMode::ReinstallRecovery,
599 );
600 }
601 let Some(root_owned_lifecycle) =
602 root_owned_lifecycle(configured.kind, &asset.status)
603 else {
604 return Err(current_protocol::CurrentProtocolError::Configuration(format!(
605 "Root-owned canister {} has a live pool role incompatible with desired {:?}",
606 configured.name, configured.kind
607 ))
608 .into());
609 };
610 return Ok(Some(LiveCanister {
611 canister_version: None,
612 controllers: vec![root.to_string()],
613 cycles: asset.cycles.to_u128(),
614 module_sha256: None,
615 principal: principal.to_string(),
616 reinstall_required: false,
617 root_owned_lifecycle: Some(root_owned_lifecycle),
618 status: match root_owned_lifecycle {
619 RootOwnedCanisterLifecycle::Store
620 | RootOwnedCanisterLifecycle::Workload => CanisterRuntimeStatus::Running,
621 RootOwnedCanisterLifecycle::Claimed
622 | RootOwnedCanisterLifecycle::Idle
623 | RootOwnedCanisterLifecycle::Retained => CanisterRuntimeStatus::Stopped,
624 },
625 }));
626 }
627 let next = page.next_start_after;
628 if next.is_none() {
629 return Ok(None);
630 }
631 if next == start_after {
632 return Err(
633 current_protocol::CurrentProtocolError::Configuration(format!(
634 "Root {parent} pool cursor did not advance"
635 ))
636 .into(),
637 );
638 }
639 start_after = next;
640 }
641 }
642
643 fn root_protocol_candid(&self) -> Result<PathBuf, IcpEnsurePlatformError> {
644 let protocol = self.desired.protocol.as_ref().ok_or_else(|| {
645 current_protocol::CurrentProtocolError::Configuration(
646 "Root-owned observation requires typed Fleet protocol".to_string(),
647 )
648 })?;
649 Ok(resolve_path(&self.root, &protocol.root_candid))
650 }
651
652 fn required_root_status(
653 &self,
654 configured_name: &str,
655 root: &str,
656 ) -> Result<CanisterRuntimeStatus, IcpEnsurePlatformError> {
657 self.status_optional(root)?
658 .map(|live| live.status)
659 .ok_or_else(|| {
660 current_protocol::CurrentProtocolError::Configuration(format!(
661 "Root-owned canister {configured_name} has no live Root"
662 ))
663 .into()
664 })
665 }
666
667 fn retained_root_owned_observation(
668 &self,
669 configured: &crate::fleet_ensure::model::DesiredCanister,
670 principal: &str,
671 parent: &str,
672 root: &str,
673 state: &FleetEnsureStateRecord,
674 mode: RetainedRootOwnedObservationMode,
675 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
676 let Some(retained_topology) = exact_retained_root_owned_topology(
677 state,
678 &configured.name,
679 configured.kind,
680 parent,
681 principal,
682 root,
683 ) else {
684 return Err(
685 current_protocol::CurrentProtocolError::Configuration(format!(
686 "Root-owned canister {} has no exact retained topology authority",
687 configured.name
688 ))
689 .into(),
690 );
691 };
692 let cycles = self
693 .public_cycle_balance(principal)
694 .or_else(|| state.retained_cycles_by_principal.get(principal).copied())
695 .ok_or_else(|| {
696 current_protocol::CurrentProtocolError::Configuration(format!(
697 "Root-owned canister {} has no current or retained exact native balance",
698 configured.name
699 ))
700 })?;
701 if !matches!(
702 configured.kind,
703 DesiredCanisterKind::Pool | DesiredCanisterKind::Store
704 ) || configured.presence != crate::fleet_ensure::model::DesiredPresence::Present
705 || configured.replace
706 || configured.drain.is_some()
707 || (configured.kind == DesiredCanisterKind::Pool && configured.wasm.is_some())
708 || (configured.kind == DesiredCanisterKind::Store && configured.wasm.is_none())
709 {
710 return Err(
711 current_protocol::CurrentProtocolError::Configuration(format!(
712 "Root-owned recovery evidence for {} cannot authorize a mutation",
713 configured.name
714 ))
715 .into(),
716 );
717 }
718 let root_config = self
719 .desired
720 .canisters
721 .iter()
722 .find(|canister| canister.name == parent && canister.kind == DesiredCanisterKind::Root)
723 .ok_or_else(|| {
724 current_protocol::CurrentProtocolError::Configuration(format!(
725 "Root-owned canister {} has no exact configured Root",
726 configured.name
727 ))
728 })?;
729 let live_root = self.status_optional(root)?.ok_or_else(|| {
730 current_protocol::CurrentProtocolError::Configuration(format!(
731 "Root-owned canister {} has no live Root",
732 configured.name
733 ))
734 })?;
735 let mut expected_controllers = root_config.controllers.clone();
736 expected_controllers.sort();
737 if live_root.controllers != expected_controllers {
738 return Err(
739 current_protocol::CurrentProtocolError::Configuration(format!(
740 "Root-owned canister {} Root controllers drifted",
741 configured.name
742 ))
743 .into(),
744 );
745 }
746 if matches!(mode, RetainedRootOwnedObservationMode::ReinstallRecovery) {
747 self.record_recovery_reinstalls(parent);
748 }
749 Ok(Some(LiveCanister {
750 canister_version: None,
751 controllers: vec![root.to_string()],
752 cycles,
753 module_sha256: retained_topology.module_hash.clone(),
754 principal: principal.to_string(),
755 reinstall_required: false,
756 root_owned_lifecycle: Some(RootOwnedCanisterLifecycle::Retained),
757 status: if configured.kind == DesiredCanisterKind::Store {
758 CanisterRuntimeStatus::Running
759 } else {
760 CanisterRuntimeStatus::Stopped
761 },
762 }))
763 }
764
765 fn record_recovery_reinstalls(&self, root_name: &str) {
766 let mut recovery = self.recovery_reinstalls.borrow_mut();
767 recovery.insert(root_name.to_string());
768 recovery.extend(
769 self.desired
770 .canisters
771 .iter()
772 .filter(|canister| {
773 canister.kind == DesiredCanisterKind::Coordinator
774 || (canister.kind == DesiredCanisterKind::Store
775 && canister.parent.as_deref() == Some(root_name))
776 })
777 .map(|canister| canister.name.clone()),
778 );
779 }
780
781 fn public_cycle_balance(&self, principal: &str) -> Option<u128> {
782 let response: Result<ManagedCanisterStatusResponse, canic_core::dto::error::Error> = self
783 .icp
784 .canister_query_candid(
785 principal,
786 canic_protocol::CANIC_STATUS,
787 &ManagedCanisterStatusRequest::CycleBalance,
788 None,
789 )
790 .ok()?;
791 let ManagedCanisterStatusResponse::CycleBalance(balance) = response.ok()?;
792 Some(balance.cycles)
793 }
794
795 fn apply_create(
796 &self,
797 authority: CreateCanisterAuthority<'_>,
798 state: &FleetEnsureStateRecord,
799 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
800 let creation_fee = self
801 .desired
802 .management_creation_fee_cycles
803 .parse::<Cycles>()
804 .map(|cycles| cycles.to_u128())
805 .map_err(|_| IcpEnsurePlatformError::Arithmetic("management creation fee"))?;
806 let amount = authority
807 .requested_initial_cycles
808 .checked_add(creation_fee)
809 .ok_or(IcpEnsurePlatformError::Arithmetic("creation amount"))?;
810 let controllers = self
811 .resolved_controllers(state, authority.controllers, authority.controller_canisters)?
812 .iter()
813 .map(|value| parse_principal("controller", value))
814 .collect::<Result<Vec<_>, _>>()?;
815 let request = CreateCanisterArgs {
816 amount: Nat::from(amount),
817 created_at_time: Some(authority.created_at_time),
818 creation_args: Some(CmcCreateCanisterArgs {
819 settings: Some(CanisterSettings {
820 compute_allocation: None,
821 controllers: Some(controllers),
822 freezing_threshold: None,
823 memory_allocation: None,
824 reserved_cycles_limit: None,
825 }),
826 subnet_selection: Some(SubnetSelection::Subnet {
827 subnet: parse_principal("subnet", authority.subnet)?,
828 }),
829 }),
830 from_subaccount: None,
831 };
832 let response: Result<CreateCanisterSuccess, CreateCanisterError> = self
833 .icp
834 .canister_call_candid(authority.ledger, "create_canister", &request, None)?;
835 match response {
836 Ok(success) => Ok(EffectOutcome {
837 created_principal: Some(success.canister_id.to_text()),
838 post_cycles: None,
839 receipt: Some(success.block_id.to_string()),
840 }),
841 Err(CreateCanisterError::Duplicate {
842 duplicate_of,
843 canister_id: Some(canister_id),
844 }) => Ok(EffectOutcome {
845 created_principal: Some(canister_id.to_text()),
846 post_cycles: None,
847 receipt: Some(duplicate_of.to_string()),
848 }),
849 Err(CreateCanisterError::Duplicate {
850 canister_id: None, ..
851 }) => Err(IcpEnsurePlatformError::LedgerCreatePending),
852 Err(error) => Err(IcpEnsurePlatformError::LedgerCreate(render_create_error(
853 error,
854 ))),
855 }
856 }
857
858 fn apply_fund(
859 &self,
860 amount: u128,
861 created_at_time: u64,
862 ledger: &str,
863 principal: &str,
864 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
865 let request = WithdrawArgs {
866 amount: Nat::from(amount),
867 created_at_time: Some(created_at_time),
868 from_subaccount: None,
869 to: parse_principal("funding target", principal)?,
870 };
871 let response: Result<Nat, WithdrawError> = self
872 .icp
873 .canister_call_candid(ledger, "withdraw", &request, None)?;
874 match response {
875 Ok(block) => Ok(EffectOutcome {
876 created_principal: None,
877 post_cycles: None,
878 receipt: Some(block.to_string()),
879 }),
880 Err(WithdrawError::Duplicate { duplicate_of }) => Ok(EffectOutcome {
881 created_principal: None,
882 post_cycles: None,
883 receipt: Some(duplicate_of.to_string()),
884 }),
885 Err(error) => Err(IcpEnsurePlatformError::LedgerWithdraw(
886 render_withdraw_error(error),
887 )),
888 }
889 }
890
891 #[expect(
892 clippy::too_many_arguments,
893 reason = "the install boundary verifies one complete immutable action tuple"
894 )]
895 fn apply_install(
896 &self,
897 operation_id: &str,
898 principals: &BTreeMap<String, String>,
899 canic_init: Option<&crate::fleet_ensure::model::DesiredCanisterInit>,
900 init_arg: Option<&str>,
901 init_arg_sha256: Option<&str>,
902 init_candid: Option<&str>,
903 init_candid_sha256: Option<&str>,
904 mode: InstallMode,
905 principal: &str,
906 wasm: &str,
907 wasm_sha256: &str,
908 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
909 verify_artifact(&resolve_path(&self.root, wasm), wasm_sha256, "Wasm")?;
910 let mut command = self.icp.canister_command();
911 command.args(["install", principal, "--mode"]);
912 command.arg(match mode {
913 InstallMode::Install => "install",
914 InstallMode::Reinstall => "reinstall",
915 });
916 command.args(["--yes", "--wasm"]);
917 command.arg(resolve_path(&self.root, wasm));
918 let generated_init = if let Some(canic_init) = canic_init {
919 let path = canic_init::write_arguments(canic_init::CanicInitRequest {
920 desired: &self.desired,
921 init: canic_init,
922 operation_id,
923 principals,
924 root: &self.root,
925 wasm,
926 wasm_sha256,
927 })?;
928 command.arg("--args-file");
929 command.arg(&path);
930 command.args(["--args-format", "bin"]);
931 Some(path)
932 } else if let Some(init_arg) = init_arg {
933 let expected =
934 init_arg_sha256.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
935 actual: "missing".to_string(),
936 expected: "reviewed init argument identity".to_string(),
937 kind: "init argument",
938 })?;
939 let init_candid =
940 init_candid.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
941 actual: "missing".to_string(),
942 expected: "reviewed init Candid".to_string(),
943 kind: "init Candid",
944 })?;
945 let init_candid_sha256 = init_candid_sha256.ok_or_else(|| {
946 IcpEnsurePlatformError::ArtifactDigestMismatch {
947 actual: "missing".to_string(),
948 expected: "reviewed init Candid identity".to_string(),
949 kind: "init Candid",
950 }
951 })?;
952 let path = protocol::write_init_arguments(
953 &self.root,
954 operation_id,
955 principals,
956 init_candid,
957 init_candid_sha256,
958 init_arg,
959 expected,
960 )?;
961 command.arg("--args-file");
962 command.arg(&path);
963 command.args(["--args-format", "bin"]);
964 Some(path)
965 } else {
966 command.args(["--args", "()"]);
967 None
968 };
969 self.icp.add_target_args(&mut command);
970 let result = run_status(&mut command).map_err(IcpEnsurePlatformError::from);
971 if let Some(path) = generated_init {
972 std::fs::remove_file(&path)
973 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path))?;
974 }
975 result?;
976 Ok(empty_outcome())
977 }
978
979 fn apply_controllers(
980 &self,
981 principal: &str,
982 controllers: &[String],
983 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
984 let mut command = self.icp.canister_command();
985 command.args([
986 "settings",
987 "update",
988 principal,
989 "--force",
990 "--remove-all-controllers",
991 ]);
992 for controller in controllers {
993 parse_principal("controller", controller)?;
994 command.args(["--add-controller", controller]);
995 }
996 self.icp.add_target_args(&mut command);
997 run_status(&mut command)?;
998 Ok(empty_outcome())
999 }
1000
1001 #[expect(
1002 clippy::too_many_arguments,
1003 reason = "one typed transfer effect carries the complete reviewed authority tuple"
1004 )]
1005 fn apply_transfer(
1006 &self,
1007 amount: u128,
1008 candid: &str,
1009 candid_sha256: &str,
1010 destination: &str,
1011 method: &str,
1012 operation_id: &str,
1013 principal: &str,
1014 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
1015 let candid = resolve_path(&self.root, candid);
1016 if !candid.is_file() {
1017 return Err(IcpEnsurePlatformError::CandidUnavailable(candid));
1018 }
1019 verify_artifact(&candid, candid_sha256, "drain Candid")?;
1020 let response: DrainResponse = self.icp.canister_call_candid(
1021 principal,
1022 method,
1023 &DrainRequest {
1024 operation_id: operation_id.to_string(),
1025 destination: parse_principal("treasury", destination)?,
1026 cycles: Nat::from(amount),
1027 },
1028 Some(&candid),
1029 )?;
1030 let transferred = match response {
1031 DrainResponse::Accepted { transferred_cycles }
1032 | DrainResponse::Replayed { transferred_cycles } => transferred_cycles,
1033 };
1034 if transferred != amount {
1035 return Err(IcpEnsurePlatformError::WrongTransferAmount {
1036 actual: transferred,
1037 expected: amount,
1038 });
1039 }
1040 Ok(EffectOutcome {
1041 created_principal: None,
1042 post_cycles: None,
1043 receipt: Some(operation_id.to_string()),
1044 })
1045 }
1046}
1047
1048impl EnsurePlatform for IcpEnsurePlatform {
1049 type Error = IcpEnsurePlatformError;
1050
1051 fn bind_reviewed_desired(&mut self, desired: &DesiredFleet) -> Result<(), Self::Error> {
1052 self.desired = desired.clone();
1053 Ok(())
1054 }
1055
1056 fn observe(
1057 &mut self,
1058 operation_id: &str,
1059 state: &FleetEnsureStateRecord,
1060 ) -> Result<FleetObservation, Self::Error> {
1061 self.require_operator()?;
1062 self.recovery_reinstalls.borrow_mut().clear();
1063 let mut canisters = BTreeMap::new();
1064 for configured in &self.desired.canisters {
1065 let observed = self
1066 .current_principal(state, &configured.name)
1067 .map(|principal| self.observe_configured_canister(configured, principal, state))
1068 .transpose()?
1069 .flatten();
1070 canisters.insert(configured.name.clone(), observed);
1071 }
1072 self.recovery_reinstalls.borrow_mut().retain(|name| {
1073 let Some(live) = canisters.get(name).and_then(Option::as_ref) else {
1074 return true;
1075 };
1076 state
1077 .completed_reinstalls
1078 .get(name)
1079 .zip(live.canister_version)
1080 .is_none_or(|(before, after)| after <= *before)
1081 });
1082 for name in self.recovery_reinstalls.borrow().iter() {
1083 if let Some(live) = canisters.get_mut(name).and_then(Option::as_mut) {
1084 live.reinstall_required = true;
1085 }
1086 }
1087 let principals = self.protocol_principals(state);
1088 let protocol_ready = self
1089 .desired
1090 .protocol_steps
1091 .iter()
1092 .map(|step| {
1093 let Some(principal) = principals.get(&step.canister) else {
1094 return Ok((step.name.clone(), false));
1095 };
1096 let live = canisters.get(&step.canister).and_then(Option::as_ref);
1097 let Some(live) = live else {
1098 return Ok((step.name.clone(), false));
1099 };
1100 let configured = self
1101 .desired
1102 .canisters
1103 .iter()
1104 .find(|configured| configured.name == step.canister)
1105 .expect("protocol target was validated against desired canisters");
1106 if let Some(wasm) = &configured.wasm {
1107 let desired_hash = artifact_hash(&resolve_path(&self.root, wasm))?;
1108 if live.module_sha256.as_deref() != Some(desired_hash.as_str()) {
1109 return Ok((step.name.clone(), false));
1110 }
1111 }
1112 let action = self.observed_protocol_action(step, principal.clone())?;
1113 protocol::observe(&self.icp, &self.root, operation_id, &principals, &action)
1114 .map(|observation| (step.name.clone(), observation.applied))
1115 .map_err(IcpEnsurePlatformError::from)
1116 })
1117 .collect::<Result<BTreeMap<_, _>, _>>()?;
1118 Ok(FleetObservation {
1119 additional_controlled_cycles: BTreeMap::new(),
1120 canisters,
1121 ledger_fee_cycles: ledger_fee_cycles(self.icp.canister_query_candid(
1122 &self.desired.cycles_ledger,
1123 "icrc1_fee",
1124 &(),
1125 None,
1126 )?)?,
1127 operator_cycles: self
1128 .icp
1129 .identity_cycles_balance()
1130 .map_err(|error| IcpEnsurePlatformError::LedgerWithdraw(error.to_string()))?,
1131 protocol_ready,
1132 })
1133 }
1134
1135 fn protocol_actions(
1136 &mut self,
1137 operation_id: &str,
1138 state: &FleetEnsureStateRecord,
1139 ) -> Result<Vec<EnsureAction>, Self::Error> {
1140 if self.desired.protocol.is_none() {
1141 return Ok(Vec::new());
1142 }
1143 current_protocol::validate_component_pool_capacity(&self.root, &self.desired)?;
1144 if self.has_stopped_retained_protocol_owner(state)? {
1145 return Ok(Vec::new());
1146 }
1147 if !self.recovery_reinstalls.borrow().is_empty() {
1148 let recovery = self.recovery_reinstalls.borrow();
1149 let mut actions = current_protocol::compile_store_control_actions_unobserved(
1150 &self.root,
1151 &self.desired,
1152 operation_id,
1153 state,
1154 )?;
1155 actions.retain(|action| {
1156 action
1157 .name()
1158 .strip_prefix("root-store-control:")
1159 .is_some_and(|root| recovery.contains(root))
1160 });
1161 return Ok(actions);
1162 }
1163 let store_control = current_protocol::compile_store_control_actions(
1164 &self.icp,
1165 &self.root,
1166 &self.desired,
1167 operation_id,
1168 state,
1169 )?;
1170 if !store_control.is_empty() {
1171 return Ok(store_control);
1172 }
1173 let coordinator = self
1174 .desired
1175 .canisters
1176 .iter()
1177 .find(|configured| {
1178 configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
1179 && configured.kind
1180 == crate::fleet_ensure::model::DesiredCanisterKind::Coordinator
1181 })
1182 .expect("typed topology validation requires one Coordinator");
1183 let Some(principal) = self.current_principal(state, &coordinator.name) else {
1184 return Ok(store_control);
1185 };
1186 let Some(live) = self.status_optional(principal)? else {
1187 return Ok(store_control);
1188 };
1189 if let Some(wasm) = &coordinator.wasm {
1190 let expected = artifact_hash(&resolve_path(&self.root, wasm))?;
1191 if live.module_sha256.as_deref() != Some(expected.as_str()) {
1192 return Ok(store_control);
1193 }
1194 }
1195 current_protocol::compile(&self.icp, &self.root, &self.desired, operation_id, state)
1196 .map_err(Into::into)
1197 }
1198
1199 fn terminal_inventory(
1200 &mut self,
1201 operation_id: &str,
1202 state: &FleetEnsureStateRecord,
1203 ) -> Result<TerminalFleetInventory, Self::Error> {
1204 if self.desired.protocol.is_none() {
1205 if state.active_registry.is_some() {
1206 return Err(current_protocol::CurrentProtocolError::Configuration(
1207 "managed terminal Fleet cannot drop its typed protocol intent".to_string(),
1208 )
1209 .into());
1210 }
1211 return Ok(TerminalFleetInventory::default());
1212 }
1213 super::current_inventory::terminal_inventory(
1214 &self.icp,
1215 &self.root,
1216 &self.desired,
1217 operation_id,
1218 state,
1219 )
1220 .map_err(Into::into)
1221 }
1222
1223 #[expect(
1224 clippy::too_many_lines,
1225 reason = "the platform keeps every effect's exact live terminal predicate in one exhaustive match"
1226 )]
1227 fn observe_effect(
1228 &mut self,
1229 operation_id: &str,
1230 action: &EnsureAction,
1231 record: &EffectRecord,
1232 state: &FleetEnsureStateRecord,
1233 ) -> Result<EffectObservation, Self::Error> {
1234 let mut retry = EffectRetry::None;
1235 let (applied, progress_identity) = match action {
1236 EnsureAction::Create { .. } => (
1237 record.created_principal.is_some(),
1238 format!("created:{:?}", record.created_principal),
1239 ),
1240 EnsureAction::Delete { principal, .. } => {
1241 let live = self.status_optional(Self::action_principal(state, principal)?)?;
1242 (live.is_none(), format!("delete:{live:?}"))
1243 }
1244 EnsureAction::Fund {
1245 expected_post_cycles,
1246 ..
1247 } => {
1248 let live_cycles = self.action_cycles(action, state)?;
1249 (
1250 native_funding_applied(*expected_post_cycles, live_cycles),
1251 format!(
1252 "native-topup:ledger-withdraw:{}:actual:{live_cycles:?}:required:{expected_post_cycles}",
1253 record.receipt.as_deref().unwrap_or("pending")
1254 ),
1255 )
1256 }
1257 EnsureAction::Install {
1258 mode,
1259 principal,
1260 wasm_sha256,
1261 ..
1262 } => {
1263 let live = self.status_optional(Self::action_principal(state, principal)?)?;
1264 let applied = live.as_ref().is_some_and(|live| {
1265 install_effect_applied(
1266 *mode,
1267 wasm_sha256,
1268 live.module_sha256.as_deref(),
1269 record.pre_canister_version,
1270 live.canister_version,
1271 )
1272 });
1273 let progress_identity = live.as_ref().map_or_else(
1274 || "install:missing".to_string(),
1275 |live| {
1276 format!(
1277 "install:{:?}:version:{:?}",
1278 live.module_sha256, live.canister_version
1279 )
1280 },
1281 );
1282 (applied, progress_identity)
1283 }
1284 EnsureAction::FleetProtocol {
1285 action: current_action,
1286 ..
1287 } => {
1288 let observation = match current_protocol::observe(&self.icp, &self.root, action) {
1289 Ok(observation) => observation,
1290 Err(error)
1291 if matches!(
1292 current_action.as_ref(),
1293 crate::fleet_ensure::model::CurrentFleetProtocolAction::AdoptStore { .. }
1294 ) && recoverable_current_protocol_error(&error) =>
1295 {
1296 EffectObservation {
1297 applied: false,
1298 progress_identity: "store-adoption:protected-status-unavailable"
1299 .to_string(),
1300 retry: EffectRetry::None,
1301 }
1302 }
1303 Err(error) => return Err(error.into()),
1304 };
1305 retry = observation.retry;
1306 (observation.applied, observation.progress_identity)
1307 }
1308 EnsureAction::Protocol { .. } => {
1309 let observation = protocol::observe(
1310 &self.icp,
1311 &self.root,
1312 operation_id,
1313 &self.protocol_principals(state),
1314 &resolved_protocol_action(action, state)?,
1315 )?;
1316 (observation.applied, observation.progress_identity)
1317 }
1318 EnsureAction::SetControllers {
1319 controller_canisters,
1320 controllers,
1321 principal,
1322 ..
1323 } => {
1324 let expected =
1325 self.resolved_controllers(state, controllers, controller_canisters)?;
1326 let live = self.status_optional(Self::action_principal(state, principal)?)?;
1327 (
1328 live.as_ref()
1329 .is_some_and(|live| live.controllers == expected),
1330 format!("controllers:{:?}", live.map(|live| live.controllers)),
1331 )
1332 }
1333 EnsureAction::Start { principal, .. } => {
1334 let live = self.status_optional(Self::action_principal(state, principal)?)?;
1335 (
1336 live.as_ref()
1337 .is_some_and(|live| live.status == CanisterRuntimeStatus::Running),
1338 format!("start:{:?}", live.map(|live| live.status)),
1339 )
1340 }
1341 EnsureAction::Stop { principal, .. } => {
1342 let live = self.status_optional(Self::action_principal(state, principal)?)?;
1343 (
1344 live.as_ref()
1345 .is_some_and(|live| live.status == CanisterRuntimeStatus::Stopped),
1346 format!("stop:{:?}", live.map(|live| live.status)),
1347 )
1348 }
1349 EnsureAction::Transfer {
1350 amount,
1351 maximum_execution_burn_cycles,
1352 name,
1353 principal,
1354 destination,
1355 ..
1356 } => {
1357 let source = self
1358 .status_optional(Self::action_principal(state, principal)?)?
1359 .map(|live| live.cycles)
1360 .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
1361 canister: name.clone(),
1362 side: "live source",
1363 })?;
1364 let destination = self
1365 .status_optional(self.current_principal(state, destination).ok_or_else(
1366 || IcpEnsurePlatformError::UnresolvedCreated(destination.clone()),
1367 )?)?
1368 .map(|live| live.cycles)
1369 .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
1370 canister: name.clone(),
1371 side: "live treasury",
1372 })?;
1373 let source_pre = record.pre_cycles.ok_or_else(|| {
1374 IcpEnsurePlatformError::MissingTransferBalance {
1375 canister: name.clone(),
1376 side: "retained source",
1377 }
1378 })?;
1379 let destination_pre = record.destination_pre_cycles.ok_or_else(|| {
1380 IcpEnsurePlatformError::MissingTransferBalance {
1381 canister: name.clone(),
1382 side: "retained treasury",
1383 }
1384 })?;
1385 let reconciliation = reconcile_retirement_transfer(RetirementTransferBalances {
1386 destination_after: destination,
1387 destination_before: destination_pre,
1388 maximum_execution_burn: *maximum_execution_burn_cycles,
1389 source_after: source,
1390 source_before: source_pre,
1391 transfer_amount: *amount,
1392 });
1393 let (applied, source_debit, destination_credit) = match reconciliation {
1394 Ok(RetirementTransferReconciliation::Pending) => (false, 0, 0),
1395 Ok(RetirementTransferReconciliation::Conserved {
1396 destination_credit,
1397 source_debit,
1398 }) => (true, source_debit, destination_credit),
1399 Err(RetirementTransferInvariantError::ArithmeticOverflow) => {
1400 return Err(IcpEnsurePlatformError::Arithmetic(
1401 "retirement transfer debit",
1402 ));
1403 }
1404 Err(RetirementTransferInvariantError::BalanceMovedInInvalidDirection) => {
1405 return Err(IcpEnsurePlatformError::InvalidTransferBalance {
1406 canister: name.clone(),
1407 });
1408 }
1409 Err(RetirementTransferInvariantError::Unbalanced {
1410 destination_credit,
1411 maximum_source_debit,
1412 source_debit,
1413 }) => {
1414 return Err(IcpEnsurePlatformError::UnbalancedTransfer {
1415 canister: name.clone(),
1416 destination_credit,
1417 expected: *amount,
1418 maximum_source_debit,
1419 source_debit,
1420 });
1421 }
1422 };
1423 (
1424 applied,
1425 format!("transfer:{source_debit}:{destination_credit}"),
1426 )
1427 }
1428 };
1429 Ok(EffectObservation {
1430 applied,
1431 progress_identity,
1432 retry,
1433 })
1434 }
1435
1436 #[expect(
1437 clippy::too_many_lines,
1438 reason = "the platform keeps every approved single-step effect in one exhaustive match"
1439 )]
1440 fn apply(
1441 &mut self,
1442 operation_id: &str,
1443 action: &EnsureAction,
1444 _record: &EffectRecord,
1445 state: &FleetEnsureStateRecord,
1446 ) -> Result<EffectOutcome, Self::Error> {
1447 self.require_operator()?;
1448 match action {
1449 EnsureAction::Create {
1450 controller_canisters,
1451 controllers,
1452 created_at_time,
1453 ledger,
1454 requested_initial_cycles,
1455 subnet,
1456 ..
1457 } => self.apply_create(
1458 CreateCanisterAuthority {
1459 controller_canisters,
1460 controllers,
1461 created_at_time: *created_at_time,
1462 ledger,
1463 requested_initial_cycles: *requested_initial_cycles,
1464 subnet,
1465 },
1466 state,
1467 ),
1468 EnsureAction::Delete {
1469 maximum_remaining_cycles,
1470 principal,
1471 ..
1472 } => {
1473 let principal = Self::action_principal(state, principal)?;
1474 if let Some(live) = self.status_optional(principal)? {
1475 if live.cycles > *maximum_remaining_cycles {
1476 return Err(IcpEnsurePlatformError::MaterialCycles {
1477 actual: live.cycles,
1478 canister: principal.to_string(),
1479 maximum: *maximum_remaining_cycles,
1480 });
1481 }
1482 if live.status != CanisterRuntimeStatus::Stopped {
1483 return Err(IcpEnsurePlatformError::NotStopped {
1484 canister: principal.to_string(),
1485 });
1486 }
1487 self.icp.delete_canister_without_cycle_recovery(principal)?;
1488 }
1489 Ok(empty_outcome())
1490 }
1491 EnsureAction::Fund {
1492 amount,
1493 created_at_time,
1494 ledger,
1495 principal,
1496 ..
1497 } => self.apply_fund(
1498 *amount,
1499 *created_at_time,
1500 ledger,
1501 Self::action_principal(state, principal)?,
1502 ),
1503 EnsureAction::Install {
1504 canic_init,
1505 init_arg,
1506 init_arg_sha256,
1507 init_candid,
1508 init_candid_sha256,
1509 mode,
1510 principal,
1511 wasm,
1512 wasm_sha256,
1513 ..
1514 } => self.apply_install(
1515 operation_id,
1516 &self.protocol_principals(state),
1517 canic_init.as_ref(),
1518 init_arg.as_deref(),
1519 init_arg_sha256.as_deref(),
1520 init_candid.as_deref(),
1521 init_candid_sha256.as_deref(),
1522 *mode,
1523 Self::action_principal(state, principal)?,
1524 wasm,
1525 wasm_sha256,
1526 ),
1527 EnsureAction::FleetProtocol { .. } => {
1528 current_protocol::apply(&self.icp, &self.root, action).map_err(Into::into)
1529 }
1530 EnsureAction::Protocol { .. } => {
1531 let action = resolved_protocol_action(action, state)?;
1532 protocol::apply(
1533 &self.icp,
1534 &self.root,
1535 operation_id,
1536 &self.protocol_principals(state),
1537 &action,
1538 )?;
1539 Ok(EffectOutcome {
1540 created_principal: None,
1541 post_cycles: None,
1542 receipt: Some(operation_id.to_string()),
1543 })
1544 }
1545 EnsureAction::SetControllers {
1546 controller_canisters,
1547 controllers,
1548 principal,
1549 ..
1550 } => self.apply_controllers(
1551 Self::action_principal(state, principal)?,
1552 &self.resolved_controllers(state, controllers, controller_canisters)?,
1553 ),
1554 EnsureAction::Start { principal, .. } => {
1555 self.icp
1556 .start_canister(Self::action_principal(state, principal)?)?;
1557 Ok(empty_outcome())
1558 }
1559 EnsureAction::Stop { principal, .. } => {
1560 self.icp
1561 .stop_canister(Self::action_principal(state, principal)?)?;
1562 Ok(empty_outcome())
1563 }
1564 EnsureAction::Transfer {
1565 amount,
1566 candid,
1567 candid_sha256,
1568 destination,
1569 method,
1570 principal,
1571 ..
1572 } => self.apply_transfer(
1573 *amount,
1574 candid,
1575 candid_sha256,
1576 self.current_principal(state, destination).ok_or_else(|| {
1577 IcpEnsurePlatformError::UnresolvedCreated(destination.clone())
1578 })?,
1579 method,
1580 operation_id,
1581 Self::action_principal(state, principal)?,
1582 ),
1583 }
1584 }
1585
1586 fn action_cycles(
1587 &mut self,
1588 action: &EnsureAction,
1589 state: &FleetEnsureStateRecord,
1590 ) -> Result<Option<u128>, Self::Error> {
1591 let (name, principal) = match action {
1592 EnsureAction::Create { .. } => return Ok(None),
1593 EnsureAction::Delete {
1594 name, principal, ..
1595 }
1596 | EnsureAction::FleetProtocol {
1597 name, principal, ..
1598 }
1599 | EnsureAction::Fund {
1600 name, principal, ..
1601 }
1602 | EnsureAction::Install {
1603 name, principal, ..
1604 }
1605 | EnsureAction::Protocol {
1606 name, principal, ..
1607 }
1608 | EnsureAction::SetControllers {
1609 name, principal, ..
1610 }
1611 | EnsureAction::Start {
1612 name, principal, ..
1613 }
1614 | EnsureAction::Stop {
1615 name, principal, ..
1616 }
1617 | EnsureAction::Transfer {
1618 name, principal, ..
1619 } => (name, Self::action_principal(state, principal)?),
1620 };
1621 let observed = self
1622 .desired
1623 .canisters
1624 .iter()
1625 .find(|configured| configured.name == *name)
1626 .map_or_else(
1627 || self.status_optional(principal),
1628 |configured| self.observe_configured_canister(configured, principal, state),
1629 )?;
1630 Ok(observed.map(|live| live.cycles))
1631 }
1632
1633 fn action_destination_cycles(
1634 &mut self,
1635 action: &EnsureAction,
1636 state: &FleetEnsureStateRecord,
1637 ) -> Result<Option<u128>, Self::Error> {
1638 let EnsureAction::Transfer { destination, .. } = action else {
1639 return Ok(None);
1640 };
1641 let destination = self
1642 .current_principal(state, destination)
1643 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(destination.clone()))?;
1644 Ok(self.status_optional(destination)?.map(|live| live.cycles))
1645 }
1646
1647 fn action_canister_version(
1648 &mut self,
1649 action: &EnsureAction,
1650 state: &FleetEnsureStateRecord,
1651 ) -> Result<Option<u64>, Self::Error> {
1652 let EnsureAction::Install { principal, .. } = action else {
1653 return Ok(None);
1654 };
1655 let principal = Self::action_principal(state, principal)?;
1656 let version = self
1657 .icp
1658 .canister_status_report(principal)?
1659 .canister_version
1660 .ok_or_else(|| IcpEnsurePlatformError::MissingCanisterVersion {
1661 canister: principal.to_string(),
1662 })?;
1663 Ok(Some(version))
1664 }
1665}
1666
1667fn exact_retained_root_owned_topology<'a>(
1668 state: &'a FleetEnsureStateRecord,
1669 name: &str,
1670 kind: DesiredCanisterKind,
1671 parent: &str,
1672 principal: &str,
1673 root: &str,
1674) -> Option<&'a crate::fleet_ensure::model::FleetEnsureTopologyRecord> {
1675 let child_identity_matches = state
1676 .principals
1677 .get(name)
1678 .is_some_and(|retained| retained == principal);
1679 let root_identity_matches = state
1680 .principals
1681 .get(parent)
1682 .is_some_and(|retained| retained == root);
1683 let topology = state
1684 .topology
1685 .get(name)
1686 .filter(|topology| topology.kind == kind && topology.parent.as_deref() == Some(parent));
1687 (child_identity_matches && root_identity_matches)
1688 .then_some(topology)
1689 .flatten()
1690}
1691
1692fn recoverable_root_status_error(error: &CanisterProtocolError) -> bool {
1693 error.is_rejected_with(canic_core::diagnostics::codes::STATE_CONFLICT)
1694 || error.is_rejected_with(canic_core::diagnostics::codes::STATE_UNAVAILABLE)
1695}
1696
1697fn recoverable_current_protocol_error(error: ¤t_protocol::CurrentProtocolError) -> bool {
1698 matches!(
1699 error,
1700 current_protocol::CurrentProtocolError::Transport(source)
1701 if recoverable_root_status_error(source)
1702 )
1703}
1704
1705pub fn install_effect_applied(
1706 mode: InstallMode,
1707 expected_hash: &str,
1708 live_hash: Option<&str>,
1709 pre_canister_version: Option<u64>,
1710 live_canister_version: Option<u64>,
1711) -> bool {
1712 if live_hash != Some(expected_hash) {
1713 return false;
1714 }
1715 match mode {
1716 InstallMode::Install => true,
1717 InstallMode::Reinstall => pre_canister_version
1718 .zip(live_canister_version)
1719 .is_some_and(|(before, after)| after > before),
1720 }
1721}
1722
1723pub fn native_funding_applied(expected_post_cycles: u128, live_cycles: Option<u128>) -> bool {
1724 expected_post_cycles > 0 && live_cycles.is_some_and(|actual| actual >= expected_post_cycles)
1725}
1726
1727fn ledger_fee_cycles(value: Nat) -> Result<u128, IcpEnsurePlatformError> {
1728 let rendered = value.to_string();
1729 u128::try_from(value.0).map_err(|_| IcpEnsurePlatformError::InvalidLedgerFee(rendered))
1730}
1731
1732const fn empty_outcome() -> EffectOutcome {
1733 EffectOutcome {
1734 created_principal: None,
1735 post_cycles: None,
1736 receipt: None,
1737 }
1738}
1739
1740fn parse_status_cycles(value: &str) -> Option<u128> {
1741 value.replace('_', "").trim().parse().ok()
1742}
1743
1744fn normalize_hash(value: &str) -> String {
1745 value
1746 .strip_prefix("0x")
1747 .unwrap_or(value)
1748 .to_ascii_lowercase()
1749}
1750
1751fn parse_principal(field: &'static str, value: &str) -> Result<Principal, IcpEnsurePlatformError> {
1752 Principal::from_text(value).map_err(|_| IcpEnsurePlatformError::InvalidPrincipal {
1753 field,
1754 value: value.to_string(),
1755 })
1756}
1757
1758fn resolve_path(root: &Path, path: &str) -> PathBuf {
1759 let path = Path::new(path);
1760 if path.is_absolute() {
1761 path.to_path_buf()
1762 } else {
1763 root.join(path)
1764 }
1765}
1766
1767fn verify_artifact(
1768 path: &Path,
1769 expected: &str,
1770 kind: &'static str,
1771) -> Result<(), IcpEnsurePlatformError> {
1772 let bytes = std::fs::read(path)
1773 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
1774 let actual = canic_core::cdk::utils::hash::sha256_hex(&bytes);
1775 if actual != expected {
1776 return Err(IcpEnsurePlatformError::ArtifactDigestMismatch {
1777 actual,
1778 expected: expected.to_string(),
1779 kind,
1780 });
1781 }
1782 Ok(())
1783}
1784
1785fn artifact_hash(path: &Path) -> Result<String, IcpEnsurePlatformError> {
1786 let bytes = std::fs::read(path)
1787 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
1788 Ok(canic_core::cdk::utils::hash::sha256_hex(&bytes))
1789}
1790
1791fn resolved_protocol_action(
1792 action: &EnsureAction,
1793 state: &FleetEnsureStateRecord,
1794) -> Result<EnsureAction, IcpEnsurePlatformError> {
1795 let mut resolved = action.clone();
1796 if let EnsureAction::Protocol { principal, .. } = &mut resolved {
1797 *principal = IcpEnsurePlatform::action_principal(state, principal)?.to_string();
1798 return Ok(resolved);
1799 }
1800 Err(IcpEnsurePlatformError::Protocol(
1801 protocol::ProtocolEffectError::WrongAction,
1802 ))
1803}
1804
1805fn render_create_error(error: CreateCanisterError) -> String {
1806 match error {
1807 CreateCanisterError::CreatedInFuture { ledger_time } => {
1808 format!("created in future of ledger time {ledger_time}")
1809 }
1810 CreateCanisterError::Duplicate { duplicate_of, .. } => {
1811 format!("duplicate block {duplicate_of}")
1812 }
1813 CreateCanisterError::FailedToCreate {
1814 error,
1815 fee_block,
1816 refund_block,
1817 } => format!("failed: {error}; fee_block={fee_block:?}; refund_block={refund_block:?}"),
1818 CreateCanisterError::GenericError {
1819 error_code,
1820 message,
1821 } => format!("error {error_code}: {message}"),
1822 CreateCanisterError::InsufficientFunds { balance } => {
1823 format!("insufficient funds: balance={balance}")
1824 }
1825 CreateCanisterError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
1826 CreateCanisterError::TooOld => "request is too old".to_string(),
1827 }
1828}
1829
1830fn render_withdraw_error(error: WithdrawError) -> String {
1831 match error {
1832 WithdrawError::BadFee { expected_fee } => format!("bad fee; expected {expected_fee}"),
1833 WithdrawError::CreatedInFuture { ledger_time } => {
1834 format!("created in future of ledger time {ledger_time}")
1835 }
1836 WithdrawError::Duplicate { duplicate_of } => format!("duplicate block {duplicate_of}"),
1837 WithdrawError::FailedToWithdraw {
1838 fee_block,
1839 rejection_reason,
1840 ..
1841 } => format!("withdraw failed: {rejection_reason}; fee_block={fee_block:?}"),
1842 WithdrawError::GenericError {
1843 error_code,
1844 message,
1845 } => format!("error {error_code}: {message}"),
1846 WithdrawError::InsufficientFunds { balance } => {
1847 format!("insufficient funds: balance={balance}")
1848 }
1849 WithdrawError::InvalidReceiver { receiver } => format!("invalid receiver {receiver}"),
1850 WithdrawError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
1851 WithdrawError::TooOld => "request is too old".to_string(),
1852 }
1853}
1854
1855#[expect(
1856 dead_code,
1857 reason = "Candid variant is retained for exact response decoding"
1858)]
1859const fn rejection_code_name(code: RejectionCode) -> &'static str {
1860 match code {
1861 RejectionCode::CanisterError => "canister_error",
1862 RejectionCode::CanisterReject => "canister_reject",
1863 RejectionCode::DestinationInvalid => "destination_invalid",
1864 RejectionCode::NoError => "no_error",
1865 RejectionCode::SysFatal => "sys_fatal",
1866 RejectionCode::SysTransient => "sys_transient",
1867 RejectionCode::Unknown => "unknown",
1868 }
1869}
1870
1871#[cfg(test)]
1872mod tests {
1873 use super::*;
1874 use canic_core::dto::pool::CanisterPoolAssetStatus;
1875
1876 #[test]
1877 fn root_owned_observation_classifies_bootstrap_and_workload_lifecycle() {
1878 assert_eq!(
1879 root_owned_lifecycle(DesiredCanisterKind::Store, &CanisterPoolAssetStatus::Store),
1880 Some(RootOwnedCanisterLifecycle::Store)
1881 );
1882 assert_eq!(
1883 root_owned_lifecycle(DesiredCanisterKind::Pool, &CanisterPoolAssetStatus::Ready),
1884 Some(RootOwnedCanisterLifecycle::Idle)
1885 );
1886 assert_eq!(
1887 root_owned_lifecycle(
1888 DesiredCanisterKind::Pool,
1889 &CanisterPoolAssetStatus::Claimed {
1890 claim: canic_core::dto::pool::CanisterPoolClaim {
1891 component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
1892 [1; 32]
1893 ),
1894 operation_id: [2; 32],
1895 },
1896 }
1897 ),
1898 Some(RootOwnedCanisterLifecycle::Claimed)
1899 );
1900 assert_eq!(
1901 root_owned_lifecycle(
1902 DesiredCanisterKind::Pool,
1903 &CanisterPoolAssetStatus::Workload {
1904 claim: canic_core::dto::pool::CanisterPoolClaim {
1905 component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
1906 [1; 32]
1907 ),
1908 operation_id: [2; 32],
1909 },
1910 }
1911 ),
1912 Some(RootOwnedCanisterLifecycle::Workload)
1913 );
1914 assert_eq!(
1915 root_owned_lifecycle(
1916 DesiredCanisterKind::Pool,
1917 &CanisterPoolAssetStatus::HandingOff {
1918 recipient: Principal::anonymous(),
1919 }
1920 ),
1921 None
1922 );
1923 assert_eq!(
1924 root_owned_lifecycle(
1925 DesiredCanisterKind::Store,
1926 &CanisterPoolAssetStatus::PendingReset
1927 ),
1928 None
1929 );
1930 }
1931
1932 #[test]
1933 fn retained_root_owned_topology_requires_exact_child_root_and_parent_binding() {
1934 let mut state = FleetEnsureStateRecord {
1935 active_registry: None,
1936 completed_reinstalls: BTreeMap::new(),
1937 fleet: "fleet".to_string(),
1938 pending_principals: BTreeMap::new(),
1939 principals: BTreeMap::from([
1940 ("root".to_string(), "root-principal".to_string()),
1941 ("store".to_string(), "store-principal".to_string()),
1942 ]),
1943 retained_cycles_by_principal: BTreeMap::new(),
1944 schema_version: crate::fleet_ensure::model::FLEET_ENSURE_SCHEMA_VERSION,
1945 topology: BTreeMap::from([(
1946 "store".to_string(),
1947 crate::fleet_ensure::model::FleetEnsureTopologyRecord {
1948 kind: DesiredCanisterKind::Store,
1949 module_hash: Some("11".repeat(32)),
1950 parent: Some("root".to_string()),
1951 protocol_binding: None,
1952 role: None,
1953 },
1954 )]),
1955 };
1956
1957 assert!(
1958 exact_retained_root_owned_topology(
1959 &state,
1960 "store",
1961 DesiredCanisterKind::Store,
1962 "root",
1963 "store-principal",
1964 "root-principal",
1965 )
1966 .is_some()
1967 );
1968 assert!(
1969 exact_retained_root_owned_topology(
1970 &state,
1971 "store",
1972 DesiredCanisterKind::Store,
1973 "root",
1974 "foreign-store",
1975 "root-principal",
1976 )
1977 .is_none()
1978 );
1979 state
1980 .topology
1981 .get_mut("store")
1982 .expect("Store topology")
1983 .parent = Some("foreign-root".to_string());
1984 assert!(
1985 exact_retained_root_owned_topology(
1986 &state,
1987 "store",
1988 DesiredCanisterKind::Store,
1989 "root",
1990 "store-principal",
1991 "root-principal",
1992 )
1993 .is_none()
1994 );
1995 }
1996}