1use crate::{
8 canister_protocol::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, EnsurePlatform, TerminalFleetInventory, canic_init,
19 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 dto::pool::{CanisterPoolResponse, CanisterPoolStatusRequest},
27 protocol as canic_protocol,
28};
29use serde::Deserialize;
30use std::{
31 collections::BTreeMap,
32 path::{Path, PathBuf},
33};
34use thiserror::Error as ThisError;
35
36#[derive(CandidType)]
39struct DrainRequest {
40 operation_id: String,
41 destination: Principal,
42 cycles: Nat,
43}
44
45#[derive(CandidType, Deserialize)]
46enum DrainResponse {
47 Accepted { transferred_cycles: Nat },
48 Replayed { transferred_cycles: Nat },
49}
50
51#[derive(CandidType)]
52enum RootPoolStatusRequest {
53 Pool(CanisterPoolStatusRequest),
54}
55
56#[derive(CandidType, Deserialize)]
57enum RootPoolStatusResponse {
58 Pool(Box<CanisterPoolResponse>),
59}
60
61#[derive(CandidType)]
62struct CreateCanisterArgs {
63 amount: Nat,
64 created_at_time: Option<u64>,
65 creation_args: Option<CmcCreateCanisterArgs>,
66 from_subaccount: Option<[u8; 32]>,
67}
68
69#[derive(CandidType)]
70struct CmcCreateCanisterArgs {
71 settings: Option<CanisterSettings>,
72 subnet_selection: Option<SubnetSelection>,
73}
74
75#[derive(CandidType)]
76struct CanisterSettings {
77 compute_allocation: Option<Nat>,
78 controllers: Option<Vec<Principal>>,
79 freezing_threshold: Option<Nat>,
80 memory_allocation: Option<Nat>,
81 reserved_cycles_limit: Option<Nat>,
82}
83
84#[derive(CandidType)]
85enum SubnetSelection {
86 Subnet { subnet: Principal },
87}
88
89#[derive(CandidType, Deserialize)]
90struct CreateCanisterSuccess {
91 block_id: Nat,
92 canister_id: Principal,
93}
94
95#[derive(CandidType, Deserialize)]
96enum CreateCanisterError {
97 CreatedInFuture {
98 ledger_time: u64,
99 },
100 Duplicate {
101 duplicate_of: Nat,
102 canister_id: Option<Principal>,
103 },
104 FailedToCreate {
105 error: String,
106 fee_block: Option<Nat>,
107 refund_block: Option<Nat>,
108 },
109 GenericError {
110 error_code: Nat,
111 message: String,
112 },
113 InsufficientFunds {
114 balance: Nat,
115 },
116 TemporarilyUnavailable,
117 TooOld,
118}
119
120#[derive(CandidType)]
121struct WithdrawArgs {
122 amount: Nat,
123 created_at_time: Option<u64>,
124 from_subaccount: Option<[u8; 32]>,
125 to: Principal,
126}
127
128#[derive(CandidType, Deserialize)]
129enum WithdrawError {
130 BadFee {
131 expected_fee: Nat,
132 },
133 CreatedInFuture {
134 ledger_time: u64,
135 },
136 Duplicate {
137 duplicate_of: Nat,
138 },
139 FailedToWithdraw {
140 fee_block: Option<Nat>,
141 rejection_code: RejectionCode,
142 rejection_reason: String,
143 },
144 GenericError {
145 error_code: Nat,
146 message: String,
147 },
148 InsufficientFunds {
149 balance: Nat,
150 },
151 InvalidReceiver {
152 receiver: Principal,
153 },
154 TemporarilyUnavailable,
155 TooOld,
156}
157
158#[derive(CandidType, Deserialize)]
159enum RejectionCode {
160 CanisterError,
161 CanisterReject,
162 DestinationInvalid,
163 NoError,
164 SysFatal,
165 SysTransient,
166 Unknown,
167}
168
169#[derive(Debug, ThisError)]
172pub enum IcpEnsurePlatformError {
173 #[error("cycle arithmetic overflow while executing {0}")]
174 Arithmetic(&'static str),
175
176 #[error("configured Candid file is not a regular file: {}", .0.display())]
177 CandidUnavailable(PathBuf),
178
179 #[error("configured effect artifact is unavailable: {}", .0.display())]
180 ArtifactUnavailable(PathBuf),
181
182 #[error("configured {kind} changed after plan review: expected {expected}, observed {actual}")]
183 ArtifactDigestMismatch {
184 actual: String,
185 expected: String,
186 kind: &'static str,
187 },
188
189 #[error("configured Principal is invalid for {field}: {value}")]
190 InvalidPrincipal { field: &'static str, value: String },
191
192 #[error("Cycles Ledger returned an unsupported fee value: {0}")]
193 InvalidLedgerFee(String),
194
195 #[error("active ICP identity is {actual}, but reviewed Fleet operator is {expected}")]
196 OperatorMismatch { actual: String, expected: String },
197
198 #[error("ICP status has invalid cycle balance for {canister}: {value}")]
199 InvalidStatusCycles { canister: String, value: String },
200
201 #[error(transparent)]
202 Candid(#[from] IcpCandidCallError),
203
204 #[error(transparent)]
205 Icp(#[from] IcpCommandError),
206
207 #[error("Cycles Ledger create_canister failed: {0}")]
208 LedgerCreate(String),
209
210 #[error("Cycles Ledger duplicate does not yet identify its created canister")]
211 LedgerCreatePending,
212
213 #[error("Cycles Ledger withdraw failed: {0}")]
214 LedgerWithdraw(String),
215
216 #[error(
217 "canister {canister} retains {actual} cycles above deletion limit {maximum}; left untouched"
218 )]
219 MaterialCycles {
220 actual: u128,
221 canister: String,
222 maximum: u128,
223 },
224
225 #[error("canister {canister} is not stopped; deletion was not attempted")]
226 NotStopped { canister: String },
227
228 #[error("effect references unresolved created canister {0}")]
229 UnresolvedCreated(String),
230
231 #[error("configured retirement endpoint transferred {actual} cycles, expected {expected}")]
232 WrongTransferAmount { actual: Nat, expected: u128 },
233
234 #[error(
235 "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"
236 )]
237 UnbalancedTransfer {
238 canister: String,
239 destination_credit: u128,
240 expected: u128,
241 maximum_source_debit: u128,
242 source_debit: u128,
243 },
244
245 #[error("retirement transfer for {canister} is missing its durable {side} balance")]
246 MissingTransferBalance {
247 canister: String,
248 side: &'static str,
249 },
250
251 #[error("retirement transfer balance for {canister} moved in an invalid direction")]
252 InvalidTransferBalance { canister: String },
253
254 #[error(transparent)]
255 CurrentProtocol(#[from] current_protocol::CurrentProtocolError),
256
257 #[error(transparent)]
258 CanicInit(#[from] canic_init::CanicInitError),
259
260 #[error(transparent)]
261 Protocol(#[from] protocol::ProtocolEffectError),
262}
263
264pub struct IcpEnsurePlatform {
266 desired: DesiredFleet,
267 icp: IcpCli,
268 root: PathBuf,
269}
270
271impl IcpEnsurePlatform {
272 #[must_use]
273 pub fn new(desired: DesiredFleet, icp_executable: &str, root: &Path) -> Self {
274 let icp = IcpCli::new(icp_executable, Some(desired.environment.clone()))
275 .with_cwd(root.to_path_buf());
276 Self {
277 desired,
278 icp,
279 root: root.to_path_buf(),
280 }
281 }
282
283 fn require_operator(&self) -> Result<(), IcpEnsurePlatformError> {
284 let actual = self.icp.identity_principal_text()?;
285 if actual != self.desired.operator {
286 return Err(IcpEnsurePlatformError::OperatorMismatch {
287 actual,
288 expected: self.desired.operator.clone(),
289 });
290 }
291 Ok(())
292 }
293
294 fn current_principal<'a>(
295 &'a self,
296 state: &'a FleetEnsureStateRecord,
297 name: &str,
298 ) -> Option<&'a str> {
299 state
300 .pending_principals
301 .get(name)
302 .or_else(|| state.principals.get(name))
303 .map(String::as_str)
304 .or_else(|| {
305 self.desired
306 .canisters
307 .iter()
308 .find(|configured| configured.name == name)
309 .and_then(|configured| configured.principal.as_deref())
310 })
311 }
312
313 fn protocol_principals(&self, state: &FleetEnsureStateRecord) -> BTreeMap<String, String> {
314 self.desired
315 .canisters
316 .iter()
317 .filter_map(|configured| {
318 state
319 .pending_principals
320 .get(&configured.name)
321 .or_else(|| state.principals.get(&configured.name))
322 .or(configured.principal.as_ref())
323 .map(|principal| (configured.name.clone(), principal.clone()))
324 })
325 .collect()
326 }
327
328 fn observed_protocol_action(
329 &self,
330 step: &crate::fleet_ensure::model::DesiredProtocolStep,
331 principal: String,
332 ) -> Result<EnsureAction, IcpEnsurePlatformError> {
333 Ok(EnsureAction::Protocol {
334 candid: step.candid.clone(),
335 candid_sha256: artifact_hash(&resolve_path(&self.root, &step.candid))?,
336 command_args: step.command_args.clone(),
337 command_args_sha256: artifact_hash(&resolve_path(&self.root, &step.command_args))?,
338 command_method: step.command_method.clone(),
339 expected_status: step.expected_status.clone(),
340 expected_status_sha256: artifact_hash(&resolve_path(
341 &self.root,
342 &step.expected_status,
343 ))?,
344 maximum_execution_burn_cycles: step
345 .maximum_execution_burn_cycles
346 .parse()
347 .map_err(|_| IcpEnsurePlatformError::Arithmetic("protocol burn"))?,
348 name: step.name.clone(),
349 principal,
350 status_args: step.status_args.clone(),
351 status_args_sha256: artifact_hash(&resolve_path(&self.root, &step.status_args))?,
352 status_method: step.status_method.clone(),
353 })
354 }
355
356 fn action_principal<'a>(
357 state: &'a FleetEnsureStateRecord,
358 principal: &'a str,
359 ) -> Result<&'a str, IcpEnsurePlatformError> {
360 if let Some(name) = principal.strip_prefix("created:") {
361 return state
362 .pending_principals
363 .get(name)
364 .map(String::as_str)
365 .ok_or_else(|| IcpEnsurePlatformError::UnresolvedCreated(name.to_string()));
366 }
367 Ok(principal)
368 }
369
370 fn status_optional(
371 &self,
372 principal: &str,
373 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
374 let report = match self.icp.canister_status_report(principal) {
375 Ok(report) => report,
376 Err(error)
377 if matches!(
378 error.diagnostic(),
379 Some(IcpDiagnostic::CanisterNotFound { .. })
380 ) =>
381 {
382 return Ok(None);
383 }
384 Err(error) => return Err(error.into()),
385 };
386 let cycles_text = report.cycles.unwrap_or_else(|| "0".to_string());
387 let cycles = parse_status_cycles(&cycles_text).ok_or_else(|| {
388 IcpEnsurePlatformError::InvalidStatusCycles {
389 canister: principal.to_string(),
390 value: cycles_text,
391 }
392 })?;
393 let mut controllers = report
394 .settings
395 .map(|settings| settings.controllers)
396 .unwrap_or_default();
397 controllers.sort();
398 let status = match report.status.to_ascii_lowercase().as_str() {
399 "stopped" => CanisterRuntimeStatus::Stopped,
400 "stopping" => CanisterRuntimeStatus::Stopping,
401 _ => CanisterRuntimeStatus::Running,
402 };
403 Ok(Some(LiveCanister {
404 controllers,
405 cycles,
406 module_sha256: report.module_hash.map(|hash| normalize_hash(&hash)),
407 principal: principal.to_string(),
408 root_owned_lifecycle: None,
409 status,
410 }))
411 }
412
413 fn observe_configured_canister(
414 &self,
415 configured: &crate::fleet_ensure::model::DesiredCanister,
416 principal: &str,
417 state: &FleetEnsureStateRecord,
418 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
419 if configured.kind == DesiredCanisterKind::Pool {
420 return self.observe_root_owned_canister(configured, principal, state);
421 }
422 match self.status_optional(principal) {
423 Ok(observed) => Ok(observed),
424 Err(error)
425 if matches!(
426 configured.kind,
427 DesiredCanisterKind::Store | DesiredCanisterKind::Pool
428 ) && !matches!(
429 error,
430 IcpEnsurePlatformError::Icp(ref source)
431 if matches!(
432 source.diagnostic(),
433 Some(IcpDiagnostic::CanisterNotFound { .. })
434 )
435 ) =>
436 {
437 self.observe_root_owned_canister(configured, principal, state)
438 }
439 Err(error) => Err(error),
440 }
441 }
442
443 fn observe_root_owned_canister(
444 &self,
445 configured: &crate::fleet_ensure::model::DesiredCanister,
446 principal: &str,
447 state: &FleetEnsureStateRecord,
448 ) -> Result<Option<LiveCanister>, IcpEnsurePlatformError> {
449 let protocol_intent = self.desired.protocol.as_ref().ok_or_else(|| {
450 current_protocol::CurrentProtocolError::Configuration(
451 "Root-owned observation requires typed Fleet protocol".to_string(),
452 )
453 })?;
454 let parent = configured.parent.as_deref().ok_or_else(|| {
455 current_protocol::CurrentProtocolError::Configuration(format!(
456 "Root-owned canister {} has no Root parent",
457 configured.name
458 ))
459 })?;
460 let root = self.current_principal(state, parent).ok_or_else(|| {
461 current_protocol::CurrentProtocolError::Configuration(format!(
462 "Root-owned canister {} has no resolved Root",
463 configured.name
464 ))
465 })?;
466 let candid = resolve_path(&self.root, &protocol_intent.root_candid);
467 let target = parse_principal("Root-owned canister", principal)?;
468 let mut start_after = None;
469 loop {
470 let response: RootPoolStatusResponse = query_with_candid(
471 &self.icp,
472 &candid,
473 parse_principal("Fleet Subnet Root", root)?,
474 canic_protocol::CANIC_STATUS,
475 &RootPoolStatusRequest::Pool(CanisterPoolStatusRequest {
476 start_after,
477 limit: 256,
478 }),
479 )
480 .map_err(current_protocol::CurrentProtocolError::from)?;
481 let RootPoolStatusResponse::Pool(page) = response;
482 if let Some(asset) = page
483 .entries
484 .into_iter()
485 .find(|asset| asset.canister_id == target)
486 {
487 let Some(root_owned_lifecycle) =
488 root_owned_lifecycle(configured.kind, &asset.status)
489 else {
490 return Err(current_protocol::CurrentProtocolError::Configuration(format!(
491 "Root-owned canister {} has a live pool role incompatible with desired {:?}",
492 configured.name, configured.kind
493 ))
494 .into());
495 };
496 return Ok(Some(LiveCanister {
497 controllers: vec![root.to_string()],
498 cycles: asset.cycles.to_u128(),
499 module_sha256: None,
500 principal: principal.to_string(),
501 root_owned_lifecycle: Some(root_owned_lifecycle),
502 status: match root_owned_lifecycle {
503 RootOwnedCanisterLifecycle::Store
504 | RootOwnedCanisterLifecycle::Workload => CanisterRuntimeStatus::Running,
505 RootOwnedCanisterLifecycle::Claimed | RootOwnedCanisterLifecycle::Idle => {
506 CanisterRuntimeStatus::Stopped
507 }
508 },
509 }));
510 }
511 let next = page.next_start_after;
512 if next.is_none() {
513 return Ok(None);
514 }
515 if next == start_after {
516 return Err(
517 current_protocol::CurrentProtocolError::Configuration(format!(
518 "Root {parent} pool cursor did not advance"
519 ))
520 .into(),
521 );
522 }
523 start_after = next;
524 }
525 }
526
527 fn apply_create(
528 &self,
529 controllers: &[String],
530 created_at_time: u64,
531 ledger: &str,
532 requested_initial_cycles: u128,
533 subnet: &str,
534 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
535 let creation_fee = self
536 .desired
537 .management_creation_fee_cycles
538 .parse::<u128>()
539 .map_err(|_| IcpEnsurePlatformError::Arithmetic("management creation fee"))?;
540 let amount = requested_initial_cycles
541 .checked_add(creation_fee)
542 .ok_or(IcpEnsurePlatformError::Arithmetic("creation amount"))?;
543 let controllers = controllers
544 .iter()
545 .map(|value| parse_principal("controller", value))
546 .collect::<Result<Vec<_>, _>>()?;
547 let request = CreateCanisterArgs {
548 amount: Nat::from(amount),
549 created_at_time: Some(created_at_time),
550 creation_args: Some(CmcCreateCanisterArgs {
551 settings: Some(CanisterSettings {
552 compute_allocation: None,
553 controllers: Some(controllers),
554 freezing_threshold: None,
555 memory_allocation: None,
556 reserved_cycles_limit: None,
557 }),
558 subnet_selection: Some(SubnetSelection::Subnet {
559 subnet: parse_principal("subnet", subnet)?,
560 }),
561 }),
562 from_subaccount: None,
563 };
564 let response: Result<CreateCanisterSuccess, CreateCanisterError> = self
565 .icp
566 .canister_call_candid(ledger, "create_canister", &request, None)?;
567 match response {
568 Ok(success) => Ok(EffectOutcome {
569 created_principal: Some(success.canister_id.to_text()),
570 post_cycles: None,
571 receipt: Some(success.block_id.to_string()),
572 }),
573 Err(CreateCanisterError::Duplicate {
574 duplicate_of,
575 canister_id: Some(canister_id),
576 }) => Ok(EffectOutcome {
577 created_principal: Some(canister_id.to_text()),
578 post_cycles: None,
579 receipt: Some(duplicate_of.to_string()),
580 }),
581 Err(CreateCanisterError::Duplicate {
582 canister_id: None, ..
583 }) => Err(IcpEnsurePlatformError::LedgerCreatePending),
584 Err(error) => Err(IcpEnsurePlatformError::LedgerCreate(render_create_error(
585 error,
586 ))),
587 }
588 }
589
590 fn apply_fund(
591 &self,
592 amount: u128,
593 created_at_time: u64,
594 ledger: &str,
595 principal: &str,
596 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
597 let request = WithdrawArgs {
598 amount: Nat::from(amount),
599 created_at_time: Some(created_at_time),
600 from_subaccount: None,
601 to: parse_principal("funding target", principal)?,
602 };
603 let response: Result<Nat, WithdrawError> = self
604 .icp
605 .canister_call_candid(ledger, "withdraw", &request, None)?;
606 match response {
607 Ok(block) => Ok(EffectOutcome {
608 created_principal: None,
609 post_cycles: None,
610 receipt: Some(block.to_string()),
611 }),
612 Err(WithdrawError::Duplicate { duplicate_of }) => Ok(EffectOutcome {
613 created_principal: None,
614 post_cycles: None,
615 receipt: Some(duplicate_of.to_string()),
616 }),
617 Err(error) => Err(IcpEnsurePlatformError::LedgerWithdraw(
618 render_withdraw_error(error),
619 )),
620 }
621 }
622
623 #[expect(
624 clippy::too_many_arguments,
625 reason = "the install boundary verifies one complete immutable action tuple"
626 )]
627 fn apply_install(
628 &self,
629 operation_id: &str,
630 principals: &BTreeMap<String, String>,
631 canic_init: Option<&crate::fleet_ensure::model::DesiredCanisterInit>,
632 init_arg: Option<&str>,
633 init_arg_sha256: Option<&str>,
634 init_candid: Option<&str>,
635 init_candid_sha256: Option<&str>,
636 mode: InstallMode,
637 principal: &str,
638 wasm: &str,
639 wasm_sha256: &str,
640 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
641 verify_artifact(&resolve_path(&self.root, wasm), wasm_sha256, "Wasm")?;
642 let mut command = self.icp.canister_command();
643 command.args(["install", principal, "--mode"]);
644 command.arg(match mode {
645 InstallMode::Install => "install",
646 InstallMode::Reinstall => "reinstall",
647 });
648 command.args(["--yes", "--wasm"]);
649 command.arg(resolve_path(&self.root, wasm));
650 let generated_init = if let Some(canic_init) = canic_init {
651 let path = canic_init::write_arguments(canic_init::CanicInitRequest {
652 desired: &self.desired,
653 init: canic_init,
654 operation_id,
655 principals,
656 root: &self.root,
657 wasm,
658 wasm_sha256,
659 })?;
660 command.arg("--args-file");
661 command.arg(&path);
662 command.args(["--args-format", "bin"]);
663 Some(path)
664 } else if let Some(init_arg) = init_arg {
665 let expected =
666 init_arg_sha256.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
667 actual: "missing".to_string(),
668 expected: "reviewed init argument identity".to_string(),
669 kind: "init argument",
670 })?;
671 let init_candid =
672 init_candid.ok_or_else(|| IcpEnsurePlatformError::ArtifactDigestMismatch {
673 actual: "missing".to_string(),
674 expected: "reviewed init Candid".to_string(),
675 kind: "init Candid",
676 })?;
677 let init_candid_sha256 = init_candid_sha256.ok_or_else(|| {
678 IcpEnsurePlatformError::ArtifactDigestMismatch {
679 actual: "missing".to_string(),
680 expected: "reviewed init Candid identity".to_string(),
681 kind: "init Candid",
682 }
683 })?;
684 let path = protocol::write_init_arguments(
685 &self.root,
686 operation_id,
687 principals,
688 init_candid,
689 init_candid_sha256,
690 init_arg,
691 expected,
692 )?;
693 command.arg("--args-file");
694 command.arg(&path);
695 command.args(["--args-format", "bin"]);
696 Some(path)
697 } else {
698 command.args(["--args", "()"]);
699 None
700 };
701 self.icp.add_target_args(&mut command);
702 let result = run_status(&mut command).map_err(IcpEnsurePlatformError::from);
703 if let Some(path) = generated_init {
704 std::fs::remove_file(&path)
705 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path))?;
706 }
707 result?;
708 Ok(empty_outcome())
709 }
710
711 fn apply_controllers(
712 &self,
713 principal: &str,
714 controllers: &[String],
715 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
716 let mut command = self.icp.canister_command();
717 command.args([
718 "settings",
719 "update",
720 principal,
721 "--force",
722 "--remove-all-controllers",
723 ]);
724 for controller in controllers {
725 parse_principal("controller", controller)?;
726 command.args(["--add-controller", controller]);
727 }
728 self.icp.add_target_args(&mut command);
729 run_status(&mut command)?;
730 Ok(empty_outcome())
731 }
732
733 #[expect(
734 clippy::too_many_arguments,
735 reason = "one typed transfer effect carries the complete reviewed authority tuple"
736 )]
737 fn apply_transfer(
738 &self,
739 amount: u128,
740 candid: &str,
741 candid_sha256: &str,
742 destination: &str,
743 method: &str,
744 operation_id: &str,
745 principal: &str,
746 ) -> Result<EffectOutcome, IcpEnsurePlatformError> {
747 let candid = resolve_path(&self.root, candid);
748 if !candid.is_file() {
749 return Err(IcpEnsurePlatformError::CandidUnavailable(candid));
750 }
751 verify_artifact(&candid, candid_sha256, "drain Candid")?;
752 let response: DrainResponse = self.icp.canister_call_candid(
753 principal,
754 method,
755 &DrainRequest {
756 operation_id: operation_id.to_string(),
757 destination: parse_principal("treasury", destination)?,
758 cycles: Nat::from(amount),
759 },
760 Some(&candid),
761 )?;
762 let transferred = match response {
763 DrainResponse::Accepted { transferred_cycles }
764 | DrainResponse::Replayed { transferred_cycles } => transferred_cycles,
765 };
766 if transferred != amount {
767 return Err(IcpEnsurePlatformError::WrongTransferAmount {
768 actual: transferred,
769 expected: amount,
770 });
771 }
772 Ok(EffectOutcome {
773 created_principal: None,
774 post_cycles: None,
775 receipt: Some(operation_id.to_string()),
776 })
777 }
778}
779
780impl EnsurePlatform for IcpEnsurePlatform {
781 type Error = IcpEnsurePlatformError;
782
783 fn observe(
784 &mut self,
785 operation_id: &str,
786 state: &FleetEnsureStateRecord,
787 ) -> Result<FleetObservation, Self::Error> {
788 self.require_operator()?;
789 let mut canisters = BTreeMap::new();
790 for configured in &self.desired.canisters {
791 let observed = self
792 .current_principal(state, &configured.name)
793 .map(|principal| self.observe_configured_canister(configured, principal, state))
794 .transpose()?
795 .flatten();
796 canisters.insert(configured.name.clone(), observed);
797 }
798 let principals = self.protocol_principals(state);
799 let protocol_ready = self
800 .desired
801 .protocol_steps
802 .iter()
803 .map(|step| {
804 let Some(principal) = principals.get(&step.canister) else {
805 return Ok((step.name.clone(), false));
806 };
807 let live = canisters.get(&step.canister).and_then(Option::as_ref);
808 let Some(live) = live else {
809 return Ok((step.name.clone(), false));
810 };
811 let configured = self
812 .desired
813 .canisters
814 .iter()
815 .find(|configured| configured.name == step.canister)
816 .expect("protocol target was validated against desired canisters");
817 if let Some(wasm) = &configured.wasm {
818 let desired_hash = artifact_hash(&resolve_path(&self.root, wasm))?;
819 if live.module_sha256.as_deref() != Some(desired_hash.as_str()) {
820 return Ok((step.name.clone(), false));
821 }
822 }
823 let action = self.observed_protocol_action(step, principal.clone())?;
824 protocol::observe(&self.icp, &self.root, operation_id, &principals, &action)
825 .map(|observation| (step.name.clone(), observation.applied))
826 .map_err(IcpEnsurePlatformError::from)
827 })
828 .collect::<Result<BTreeMap<_, _>, _>>()?;
829 Ok(FleetObservation {
830 additional_controlled_cycles: BTreeMap::new(),
831 canisters,
832 ledger_fee_cycles: ledger_fee_cycles(self.icp.canister_query_candid(
833 &self.desired.cycles_ledger,
834 "icrc1_fee",
835 &(),
836 None,
837 )?)?,
838 operator_cycles: self
839 .icp
840 .identity_cycles_balance()
841 .map_err(|error| IcpEnsurePlatformError::LedgerWithdraw(error.to_string()))?,
842 protocol_ready,
843 })
844 }
845
846 fn protocol_actions(
847 &mut self,
848 operation_id: &str,
849 state: &FleetEnsureStateRecord,
850 ) -> Result<Vec<EnsureAction>, Self::Error> {
851 if self.desired.protocol.is_none() {
852 return Ok(Vec::new());
853 }
854 let store_control = current_protocol::compile_store_control_actions(
855 &self.icp,
856 &self.root,
857 &self.desired,
858 operation_id,
859 state,
860 )?;
861 if !store_control.is_empty() {
862 return Ok(store_control);
863 }
864 let coordinator = self
865 .desired
866 .canisters
867 .iter()
868 .find(|configured| {
869 configured.presence == crate::fleet_ensure::model::DesiredPresence::Present
870 && configured.kind
871 == crate::fleet_ensure::model::DesiredCanisterKind::Coordinator
872 })
873 .expect("typed topology validation requires one Coordinator");
874 let Some(principal) = self.current_principal(state, &coordinator.name) else {
875 return Ok(store_control);
876 };
877 let Some(live) = self.status_optional(principal)? else {
878 return Ok(store_control);
879 };
880 if let Some(wasm) = &coordinator.wasm {
881 let expected = artifact_hash(&resolve_path(&self.root, wasm))?;
882 if live.module_sha256.as_deref() != Some(expected.as_str()) {
883 return Ok(store_control);
884 }
885 }
886 current_protocol::compile(&self.icp, &self.root, &self.desired, operation_id, state)
887 .map_err(Into::into)
888 }
889
890 fn terminal_inventory(
891 &mut self,
892 operation_id: &str,
893 state: &FleetEnsureStateRecord,
894 ) -> Result<TerminalFleetInventory, Self::Error> {
895 if self.desired.protocol.is_none() {
896 if state.active_registry.is_some() {
897 return Err(current_protocol::CurrentProtocolError::Configuration(
898 "managed terminal Fleet cannot drop its typed protocol intent".to_string(),
899 )
900 .into());
901 }
902 return Ok(TerminalFleetInventory::default());
903 }
904 super::current_inventory::terminal_inventory(
905 &self.icp,
906 &self.root,
907 &self.desired,
908 operation_id,
909 state,
910 )
911 .map_err(Into::into)
912 }
913
914 #[expect(
915 clippy::too_many_lines,
916 reason = "the platform keeps every effect's exact live terminal predicate in one exhaustive match"
917 )]
918 fn observe_effect(
919 &mut self,
920 operation_id: &str,
921 action: &EnsureAction,
922 record: &EffectRecord,
923 state: &FleetEnsureStateRecord,
924 ) -> Result<EffectObservation, Self::Error> {
925 let (applied, progress_identity) = match action {
926 EnsureAction::Create { .. } => (
927 record.created_principal.is_some(),
928 format!("created:{:?}", record.created_principal),
929 ),
930 EnsureAction::Delete { principal, .. } => {
931 let live = self.status_optional(Self::action_principal(state, principal)?)?;
932 (live.is_none(), format!("delete:{live:?}"))
933 }
934 EnsureAction::Fund { .. } => (
935 record.receipt.is_some(),
936 format!("fund:{}", record.receipt.as_deref().unwrap_or("pending")),
937 ),
938 EnsureAction::Install {
939 principal,
940 wasm_sha256,
941 ..
942 } => {
943 let live = self.status_optional(Self::action_principal(state, principal)?)?;
944 (
945 live.as_ref()
946 .is_some_and(|live| live.module_sha256.as_deref() == Some(wasm_sha256)),
947 format!("install:{:?}", live.and_then(|live| live.module_sha256)),
948 )
949 }
950 EnsureAction::FleetProtocol { .. } => {
951 let observation = current_protocol::observe(&self.icp, &self.root, action)?;
952 (observation.applied, observation.progress_identity)
953 }
954 EnsureAction::Protocol { .. } => {
955 let observation = protocol::observe(
956 &self.icp,
957 &self.root,
958 operation_id,
959 &self.protocol_principals(state),
960 &resolved_protocol_action(action, state)?,
961 )?;
962 (observation.applied, observation.progress_identity)
963 }
964 EnsureAction::SetControllers {
965 controllers,
966 principal,
967 ..
968 } => {
969 let mut expected = controllers.clone();
970 expected.sort();
971 let live = self.status_optional(Self::action_principal(state, principal)?)?;
972 (
973 live.as_ref()
974 .is_some_and(|live| live.controllers == expected),
975 format!("controllers:{:?}", live.map(|live| live.controllers)),
976 )
977 }
978 EnsureAction::Start { principal, .. } => {
979 let live = self.status_optional(Self::action_principal(state, principal)?)?;
980 (
981 live.as_ref()
982 .is_some_and(|live| live.status == CanisterRuntimeStatus::Running),
983 format!("start:{:?}", live.map(|live| live.status)),
984 )
985 }
986 EnsureAction::Stop { principal, .. } => {
987 let live = self.status_optional(Self::action_principal(state, principal)?)?;
988 (
989 live.as_ref()
990 .is_some_and(|live| live.status == CanisterRuntimeStatus::Stopped),
991 format!("stop:{:?}", live.map(|live| live.status)),
992 )
993 }
994 EnsureAction::Transfer {
995 amount,
996 maximum_execution_burn_cycles,
997 name,
998 principal,
999 destination,
1000 ..
1001 } => {
1002 let source = self
1003 .status_optional(Self::action_principal(state, principal)?)?
1004 .map(|live| live.cycles)
1005 .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
1006 canister: name.clone(),
1007 side: "live source",
1008 })?;
1009 let destination = self
1010 .status_optional(destination)?
1011 .map(|live| live.cycles)
1012 .ok_or_else(|| IcpEnsurePlatformError::MissingTransferBalance {
1013 canister: name.clone(),
1014 side: "live treasury",
1015 })?;
1016 let source_pre = record.pre_cycles.ok_or_else(|| {
1017 IcpEnsurePlatformError::MissingTransferBalance {
1018 canister: name.clone(),
1019 side: "retained source",
1020 }
1021 })?;
1022 let destination_pre = record.destination_pre_cycles.ok_or_else(|| {
1023 IcpEnsurePlatformError::MissingTransferBalance {
1024 canister: name.clone(),
1025 side: "retained treasury",
1026 }
1027 })?;
1028 let reconciliation = reconcile_retirement_transfer(RetirementTransferBalances {
1029 destination_after: destination,
1030 destination_before: destination_pre,
1031 maximum_execution_burn: *maximum_execution_burn_cycles,
1032 source_after: source,
1033 source_before: source_pre,
1034 transfer_amount: *amount,
1035 });
1036 let (applied, source_debit, destination_credit) = match reconciliation {
1037 Ok(RetirementTransferReconciliation::Pending) => (false, 0, 0),
1038 Ok(RetirementTransferReconciliation::Conserved {
1039 destination_credit,
1040 source_debit,
1041 }) => (true, source_debit, destination_credit),
1042 Err(RetirementTransferInvariantError::ArithmeticOverflow) => {
1043 return Err(IcpEnsurePlatformError::Arithmetic(
1044 "retirement transfer debit",
1045 ));
1046 }
1047 Err(RetirementTransferInvariantError::BalanceMovedInInvalidDirection) => {
1048 return Err(IcpEnsurePlatformError::InvalidTransferBalance {
1049 canister: name.clone(),
1050 });
1051 }
1052 Err(RetirementTransferInvariantError::Unbalanced {
1053 destination_credit,
1054 maximum_source_debit,
1055 source_debit,
1056 }) => {
1057 return Err(IcpEnsurePlatformError::UnbalancedTransfer {
1058 canister: name.clone(),
1059 destination_credit,
1060 expected: *amount,
1061 maximum_source_debit,
1062 source_debit,
1063 });
1064 }
1065 };
1066 (
1067 applied,
1068 format!("transfer:{source_debit}:{destination_credit}"),
1069 )
1070 }
1071 };
1072 Ok(EffectObservation {
1073 applied,
1074 progress_identity,
1075 })
1076 }
1077
1078 #[expect(
1079 clippy::too_many_lines,
1080 reason = "the platform keeps every approved single-step effect in one exhaustive match"
1081 )]
1082 fn apply(
1083 &mut self,
1084 operation_id: &str,
1085 action: &EnsureAction,
1086 _record: &EffectRecord,
1087 state: &FleetEnsureStateRecord,
1088 ) -> Result<EffectOutcome, Self::Error> {
1089 self.require_operator()?;
1090 match action {
1091 EnsureAction::Create {
1092 controllers,
1093 created_at_time,
1094 ledger,
1095 requested_initial_cycles,
1096 subnet,
1097 ..
1098 } => self.apply_create(
1099 controllers,
1100 *created_at_time,
1101 ledger,
1102 *requested_initial_cycles,
1103 subnet,
1104 ),
1105 EnsureAction::Delete {
1106 maximum_remaining_cycles,
1107 principal,
1108 ..
1109 } => {
1110 let principal = Self::action_principal(state, principal)?;
1111 if let Some(live) = self.status_optional(principal)? {
1112 if live.cycles > *maximum_remaining_cycles {
1113 return Err(IcpEnsurePlatformError::MaterialCycles {
1114 actual: live.cycles,
1115 canister: principal.to_string(),
1116 maximum: *maximum_remaining_cycles,
1117 });
1118 }
1119 if live.status != CanisterRuntimeStatus::Stopped {
1120 return Err(IcpEnsurePlatformError::NotStopped {
1121 canister: principal.to_string(),
1122 });
1123 }
1124 self.icp.delete_canister_without_cycle_recovery(principal)?;
1125 }
1126 Ok(empty_outcome())
1127 }
1128 EnsureAction::Fund {
1129 amount,
1130 created_at_time,
1131 ledger,
1132 principal,
1133 ..
1134 } => self.apply_fund(
1135 *amount,
1136 *created_at_time,
1137 ledger,
1138 Self::action_principal(state, principal)?,
1139 ),
1140 EnsureAction::Install {
1141 canic_init,
1142 init_arg,
1143 init_arg_sha256,
1144 init_candid,
1145 init_candid_sha256,
1146 mode,
1147 principal,
1148 wasm,
1149 wasm_sha256,
1150 ..
1151 } => self.apply_install(
1152 operation_id,
1153 &self.protocol_principals(state),
1154 canic_init.as_ref(),
1155 init_arg.as_deref(),
1156 init_arg_sha256.as_deref(),
1157 init_candid.as_deref(),
1158 init_candid_sha256.as_deref(),
1159 *mode,
1160 Self::action_principal(state, principal)?,
1161 wasm,
1162 wasm_sha256,
1163 ),
1164 EnsureAction::FleetProtocol { .. } => {
1165 current_protocol::apply(&self.icp, &self.root, action).map_err(Into::into)
1166 }
1167 EnsureAction::Protocol { .. } => {
1168 let action = resolved_protocol_action(action, state)?;
1169 protocol::apply(
1170 &self.icp,
1171 &self.root,
1172 operation_id,
1173 &self.protocol_principals(state),
1174 &action,
1175 )?;
1176 Ok(EffectOutcome {
1177 created_principal: None,
1178 post_cycles: None,
1179 receipt: Some(operation_id.to_string()),
1180 })
1181 }
1182 EnsureAction::SetControllers {
1183 controllers,
1184 principal,
1185 ..
1186 } => self.apply_controllers(Self::action_principal(state, principal)?, controllers),
1187 EnsureAction::Start { principal, .. } => {
1188 self.icp
1189 .start_canister(Self::action_principal(state, principal)?)?;
1190 Ok(empty_outcome())
1191 }
1192 EnsureAction::Stop { principal, .. } => {
1193 self.icp
1194 .stop_canister(Self::action_principal(state, principal)?)?;
1195 Ok(empty_outcome())
1196 }
1197 EnsureAction::Transfer {
1198 amount,
1199 candid,
1200 candid_sha256,
1201 destination,
1202 method,
1203 principal,
1204 ..
1205 } => self.apply_transfer(
1206 *amount,
1207 candid,
1208 candid_sha256,
1209 destination,
1210 method,
1211 operation_id,
1212 Self::action_principal(state, principal)?,
1213 ),
1214 }
1215 }
1216
1217 fn action_cycles(
1218 &mut self,
1219 action: &EnsureAction,
1220 state: &FleetEnsureStateRecord,
1221 ) -> Result<Option<u128>, Self::Error> {
1222 let (name, principal) = match action {
1223 EnsureAction::Create { .. } => return Ok(None),
1224 EnsureAction::Delete {
1225 name, principal, ..
1226 }
1227 | EnsureAction::FleetProtocol {
1228 name, principal, ..
1229 }
1230 | EnsureAction::Fund {
1231 name, principal, ..
1232 }
1233 | EnsureAction::Install {
1234 name, principal, ..
1235 }
1236 | EnsureAction::Protocol {
1237 name, principal, ..
1238 }
1239 | EnsureAction::SetControllers {
1240 name, principal, ..
1241 }
1242 | EnsureAction::Start {
1243 name, principal, ..
1244 }
1245 | EnsureAction::Stop {
1246 name, principal, ..
1247 }
1248 | EnsureAction::Transfer {
1249 name, principal, ..
1250 } => (name, Self::action_principal(state, principal)?),
1251 };
1252 let observed = self
1253 .desired
1254 .canisters
1255 .iter()
1256 .find(|configured| configured.name == *name)
1257 .map_or_else(
1258 || self.status_optional(principal),
1259 |configured| self.observe_configured_canister(configured, principal, state),
1260 )?;
1261 Ok(observed.map(|live| live.cycles))
1262 }
1263
1264 fn action_destination_cycles(
1265 &mut self,
1266 action: &EnsureAction,
1267 _state: &FleetEnsureStateRecord,
1268 ) -> Result<Option<u128>, Self::Error> {
1269 let EnsureAction::Transfer { destination, .. } = action else {
1270 return Ok(None);
1271 };
1272 Ok(self.status_optional(destination)?.map(|live| live.cycles))
1273 }
1274}
1275
1276fn ledger_fee_cycles(value: Nat) -> Result<u128, IcpEnsurePlatformError> {
1277 let rendered = value.to_string();
1278 u128::try_from(value.0).map_err(|_| IcpEnsurePlatformError::InvalidLedgerFee(rendered))
1279}
1280
1281const fn empty_outcome() -> EffectOutcome {
1282 EffectOutcome {
1283 created_principal: None,
1284 post_cycles: None,
1285 receipt: None,
1286 }
1287}
1288
1289fn parse_status_cycles(value: &str) -> Option<u128> {
1290 value.replace('_', "").trim().parse().ok()
1291}
1292
1293fn normalize_hash(value: &str) -> String {
1294 value
1295 .strip_prefix("0x")
1296 .unwrap_or(value)
1297 .to_ascii_lowercase()
1298}
1299
1300fn parse_principal(field: &'static str, value: &str) -> Result<Principal, IcpEnsurePlatformError> {
1301 Principal::from_text(value).map_err(|_| IcpEnsurePlatformError::InvalidPrincipal {
1302 field,
1303 value: value.to_string(),
1304 })
1305}
1306
1307fn resolve_path(root: &Path, path: &str) -> PathBuf {
1308 let path = Path::new(path);
1309 if path.is_absolute() {
1310 path.to_path_buf()
1311 } else {
1312 root.join(path)
1313 }
1314}
1315
1316fn verify_artifact(
1317 path: &Path,
1318 expected: &str,
1319 kind: &'static str,
1320) -> Result<(), IcpEnsurePlatformError> {
1321 let bytes = std::fs::read(path)
1322 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
1323 let actual = canic_core::cdk::utils::hash::sha256_hex(&bytes);
1324 if actual != expected {
1325 return Err(IcpEnsurePlatformError::ArtifactDigestMismatch {
1326 actual,
1327 expected: expected.to_string(),
1328 kind,
1329 });
1330 }
1331 Ok(())
1332}
1333
1334fn artifact_hash(path: &Path) -> Result<String, IcpEnsurePlatformError> {
1335 let bytes = std::fs::read(path)
1336 .map_err(|_| IcpEnsurePlatformError::ArtifactUnavailable(path.to_path_buf()))?;
1337 Ok(canic_core::cdk::utils::hash::sha256_hex(&bytes))
1338}
1339
1340fn resolved_protocol_action(
1341 action: &EnsureAction,
1342 state: &FleetEnsureStateRecord,
1343) -> Result<EnsureAction, IcpEnsurePlatformError> {
1344 let mut resolved = action.clone();
1345 if let EnsureAction::Protocol { principal, .. } = &mut resolved {
1346 *principal = IcpEnsurePlatform::action_principal(state, principal)?.to_string();
1347 return Ok(resolved);
1348 }
1349 Err(IcpEnsurePlatformError::Protocol(
1350 protocol::ProtocolEffectError::WrongAction,
1351 ))
1352}
1353
1354fn render_create_error(error: CreateCanisterError) -> String {
1355 match error {
1356 CreateCanisterError::CreatedInFuture { ledger_time } => {
1357 format!("created in future of ledger time {ledger_time}")
1358 }
1359 CreateCanisterError::Duplicate { duplicate_of, .. } => {
1360 format!("duplicate block {duplicate_of}")
1361 }
1362 CreateCanisterError::FailedToCreate {
1363 error,
1364 fee_block,
1365 refund_block,
1366 } => format!("failed: {error}; fee_block={fee_block:?}; refund_block={refund_block:?}"),
1367 CreateCanisterError::GenericError {
1368 error_code,
1369 message,
1370 } => format!("error {error_code}: {message}"),
1371 CreateCanisterError::InsufficientFunds { balance } => {
1372 format!("insufficient funds: balance={balance}")
1373 }
1374 CreateCanisterError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
1375 CreateCanisterError::TooOld => "request is too old".to_string(),
1376 }
1377}
1378
1379fn render_withdraw_error(error: WithdrawError) -> String {
1380 match error {
1381 WithdrawError::BadFee { expected_fee } => format!("bad fee; expected {expected_fee}"),
1382 WithdrawError::CreatedInFuture { ledger_time } => {
1383 format!("created in future of ledger time {ledger_time}")
1384 }
1385 WithdrawError::Duplicate { duplicate_of } => format!("duplicate block {duplicate_of}"),
1386 WithdrawError::FailedToWithdraw {
1387 fee_block,
1388 rejection_reason,
1389 ..
1390 } => format!("withdraw failed: {rejection_reason}; fee_block={fee_block:?}"),
1391 WithdrawError::GenericError {
1392 error_code,
1393 message,
1394 } => format!("error {error_code}: {message}"),
1395 WithdrawError::InsufficientFunds { balance } => {
1396 format!("insufficient funds: balance={balance}")
1397 }
1398 WithdrawError::InvalidReceiver { receiver } => format!("invalid receiver {receiver}"),
1399 WithdrawError::TemporarilyUnavailable => "temporarily unavailable".to_string(),
1400 WithdrawError::TooOld => "request is too old".to_string(),
1401 }
1402}
1403
1404#[expect(
1405 dead_code,
1406 reason = "Candid variant is retained for exact response decoding"
1407)]
1408const fn rejection_code_name(code: RejectionCode) -> &'static str {
1409 match code {
1410 RejectionCode::CanisterError => "canister_error",
1411 RejectionCode::CanisterReject => "canister_reject",
1412 RejectionCode::DestinationInvalid => "destination_invalid",
1413 RejectionCode::NoError => "no_error",
1414 RejectionCode::SysFatal => "sys_fatal",
1415 RejectionCode::SysTransient => "sys_transient",
1416 RejectionCode::Unknown => "unknown",
1417 }
1418}
1419
1420#[cfg(test)]
1421mod tests {
1422 use super::*;
1423 use canic_core::dto::pool::CanisterPoolAssetStatus;
1424
1425 #[test]
1426 fn root_owned_observation_classifies_bootstrap_and_workload_lifecycle() {
1427 assert_eq!(
1428 root_owned_lifecycle(DesiredCanisterKind::Store, &CanisterPoolAssetStatus::Store),
1429 Some(RootOwnedCanisterLifecycle::Store)
1430 );
1431 assert_eq!(
1432 root_owned_lifecycle(DesiredCanisterKind::Pool, &CanisterPoolAssetStatus::Ready),
1433 Some(RootOwnedCanisterLifecycle::Idle)
1434 );
1435 assert_eq!(
1436 root_owned_lifecycle(
1437 DesiredCanisterKind::Pool,
1438 &CanisterPoolAssetStatus::Claimed {
1439 claim: canic_core::dto::pool::CanisterPoolClaim {
1440 component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
1441 [1; 32]
1442 ),
1443 operation_id: [2; 32],
1444 },
1445 }
1446 ),
1447 Some(RootOwnedCanisterLifecycle::Claimed)
1448 );
1449 assert_eq!(
1450 root_owned_lifecycle(
1451 DesiredCanisterKind::Pool,
1452 &CanisterPoolAssetStatus::Workload {
1453 claim: canic_core::dto::pool::CanisterPoolClaim {
1454 component: canic_core::ids::ComponentInstanceId::from_generated_bytes(
1455 [1; 32]
1456 ),
1457 operation_id: [2; 32],
1458 },
1459 }
1460 ),
1461 Some(RootOwnedCanisterLifecycle::Workload)
1462 );
1463 assert_eq!(
1464 root_owned_lifecycle(
1465 DesiredCanisterKind::Pool,
1466 &CanisterPoolAssetStatus::HandingOff {
1467 recipient: Principal::anonymous(),
1468 }
1469 ),
1470 None
1471 );
1472 assert_eq!(
1473 root_owned_lifecycle(
1474 DesiredCanisterKind::Store,
1475 &CanisterPoolAssetStatus::PendingReset
1476 ),
1477 None
1478 );
1479 }
1480}