1use std::collections::BTreeMap;
2
3use candid::Principal;
4use pocket_ic::{PocketIc, RejectResponse};
5
6use super::{PocketIcCallFailure, PocketIcOperationError, transport};
7
8#[derive(Clone, Debug, Eq, PartialEq)]
9struct ControllerSnapshot {
10 snapshot_id: Vec<u8>,
11 sender: Option<Principal>,
12}
13
14#[derive(Clone, Debug, Eq, PartialEq)]
16pub struct ControllerSnapshots(BTreeMap<Principal, ControllerSnapshot>);
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct CanisterSnapshotTarget {
21 canister_id: Principal,
22 sender: Option<Principal>,
23}
24
25#[non_exhaustive]
27#[derive(Clone, Debug, Eq, PartialEq)]
28pub enum SnapshotCleanupFailure {
29 Rejected {
31 canister_id: Principal,
33 sender: Option<Principal>,
35 response: Box<RejectResponse>,
37 },
38 Panicked {
40 canister_id: Principal,
42 sender: Option<Principal>,
44 source: PocketIcOperationError,
46 },
47}
48
49#[non_exhaustive]
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub enum SnapshotRestoreFunding {
53 Preserve,
55 TopUpTo {
57 minimum_cycles: u128,
59 },
60}
61
62#[non_exhaustive]
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub enum ControllerSnapshotError {
66 EmptyCanisterSet,
68 DuplicateCanisterId {
70 canister_id: Principal,
72 },
73 CaptureFailed {
75 canister_id: Principal,
77 sender: Option<Principal>,
79 response: Box<RejectResponse>,
81 cleanup_failures: Vec<SnapshotCleanupFailure>,
83 },
84 CapturePanicked {
86 canister_id: Principal,
88 source: PocketIcOperationError,
90 cleanup_failures: Vec<SnapshotCleanupFailure>,
92 },
93 RestoreFailed {
95 canister_id: Principal,
97 sender: Option<Principal>,
99 response: Box<RejectResponse>,
101 },
102 RestorePanicked {
104 canister_id: Principal,
106 source: PocketIcOperationError,
108 },
109}
110
111pub trait PocketIcSnapshotExt {
113 fn capture_snapshots<I>(
119 &self,
120 targets: I,
121 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
122 where
123 I: IntoIterator<Item = CanisterSnapshotTarget>;
124
125 fn restore_snapshots(
131 &self,
132 snapshots: &ControllerSnapshots,
133 funding: SnapshotRestoreFunding,
134 ) -> Result<(), ControllerSnapshotError>;
135}
136
137impl PocketIcSnapshotExt for PocketIc {
138 fn capture_snapshots<I>(
139 &self,
140 targets: I,
141 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
142 where
143 I: IntoIterator<Item = CanisterSnapshotTarget>,
144 {
145 capture_snapshot_set(self, targets)
146 }
147
148 fn restore_snapshots(
149 &self,
150 snapshots: &ControllerSnapshots,
151 funding: SnapshotRestoreFunding,
152 ) -> Result<(), ControllerSnapshotError> {
153 for (canister_id, snapshot_id, sender) in snapshots.iter() {
154 restore_controller_snapshot(self, canister_id, snapshot_id, funding, sender)?;
155 }
156 Ok(())
157 }
158}
159
160impl CanisterSnapshotTarget {
161 #[must_use]
163 pub const fn new(canister_id: Principal, sender: Option<Principal>) -> Self {
164 Self {
165 canister_id,
166 sender,
167 }
168 }
169
170 #[must_use]
172 pub const fn canister_id(self) -> Principal {
173 self.canister_id
174 }
175
176 #[must_use]
178 pub const fn sender(self) -> Option<Principal> {
179 self.sender
180 }
181}
182
183fn capture_snapshot_set<I>(
184 pocket_ic: &PocketIc,
185 targets: I,
186) -> Result<ControllerSnapshots, ControllerSnapshotError>
187where
188 I: IntoIterator<Item = CanisterSnapshotTarget>,
189{
190 let mut ordered_targets = BTreeMap::new();
193 for target in targets {
194 if ordered_targets
195 .insert(target.canister_id, target.sender)
196 .is_some()
197 {
198 return Err(ControllerSnapshotError::DuplicateCanisterId {
199 canister_id: target.canister_id,
200 });
201 }
202 }
203 let mut snapshots = BTreeMap::new();
204 for (canister_id, sender) in ordered_targets {
205 let failure = match try_take_snapshot(pocket_ic, canister_id, sender) {
206 Ok(snapshot) => {
207 snapshots.insert(canister_id, snapshot);
208 continue;
209 }
210 Err(failure) => failure,
211 };
212 let cleanup_failures =
213 cleanup_captured_snapshots(snapshots, |canister_id, sender, snapshot_id| {
214 pocket_ic.delete_canister_snapshot(canister_id, sender, snapshot_id)
215 });
216 return Err(match failure {
217 PocketIcCallFailure::Rejected(response) => ControllerSnapshotError::CaptureFailed {
218 canister_id,
219 sender,
220 response: Box::new(response),
221 cleanup_failures,
222 },
223 PocketIcCallFailure::Operation(source) => ControllerSnapshotError::CapturePanicked {
224 canister_id,
225 source,
226 cleanup_failures,
227 },
228 });
229 }
230 Ok(ControllerSnapshots(snapshots))
231}
232
233impl ControllerSnapshots {
234 #[must_use]
236 pub fn len(&self) -> usize {
237 self.0.len()
238 }
239
240 #[must_use]
242 pub fn is_empty(&self) -> bool {
243 self.0.is_empty()
244 }
245
246 pub fn canister_ids(&self) -> impl Iterator<Item = Principal> + '_ {
248 self.0.keys().copied()
249 }
250
251 pub(super) fn iter(&self) -> impl Iterator<Item = (Principal, &[u8], Option<Principal>)> + '_ {
252 self.0.iter().map(|(canister_id, snapshot)| {
253 (
254 *canister_id,
255 snapshot.snapshot_id.as_slice(),
256 snapshot.sender,
257 )
258 })
259 }
260}
261
262impl std::fmt::Display for ControllerSnapshotError {
263 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
264 match self {
265 Self::EmptyCanisterSet => {
266 f.write_str("a cached baseline requires at least one canister")
267 }
268 Self::DuplicateCanisterId { canister_id } => {
269 write!(f, "duplicate canister id in snapshot set: {canister_id}")
270 }
271 Self::CaptureFailed {
272 canister_id,
273 sender,
274 response,
275 cleanup_failures,
276 } => write!(
277 f,
278 "failed to capture snapshot for {canister_id} with sender {sender:?}: {response:?}; {} partial snapshots could not be cleaned up",
279 cleanup_failures.len()
280 ),
281 Self::CapturePanicked {
282 canister_id,
283 source,
284 cleanup_failures,
285 } => write!(
286 f,
287 "snapshot capture panicked for {canister_id}: {source}; {} partial snapshots could not be cleaned up",
288 cleanup_failures.len()
289 ),
290 Self::RestoreFailed {
291 canister_id,
292 sender,
293 response,
294 } => write!(
295 f,
296 "failed to restore snapshot for {canister_id} with sender {sender:?}: {response:?}",
297 ),
298 Self::RestorePanicked {
299 canister_id,
300 source,
301 } => write!(f, "snapshot restore panicked for {canister_id}: {source}"),
302 }
303 }
304}
305
306impl std::error::Error for ControllerSnapshotError {
307 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
308 match self {
309 Self::CapturePanicked { source, .. } | Self::RestorePanicked { source, .. } => {
310 Some(source)
311 }
312 _ => None,
313 }
314 }
315}
316
317fn try_take_snapshot(
318 pocket_ic: &PocketIc,
319 canister_id: Principal,
320 sender: Option<Principal>,
321) -> Result<ControllerSnapshot, PocketIcCallFailure> {
322 let snapshot =
323 transport::capture_call(|| pocket_ic.take_canister_snapshot(canister_id, sender, None))?;
324 Ok(ControllerSnapshot {
325 snapshot_id: snapshot.id,
326 sender,
327 })
328}
329
330fn cleanup_captured_snapshots(
331 snapshots: BTreeMap<Principal, ControllerSnapshot>,
332 mut remove: impl FnMut(Principal, Option<Principal>, Vec<u8>) -> Result<(), RejectResponse>,
333) -> Vec<SnapshotCleanupFailure> {
334 let mut failures = Vec::new();
335 for (canister_id, snapshot) in snapshots {
336 let cleanup =
337 transport::capture_call(|| remove(canister_id, snapshot.sender, snapshot.snapshot_id));
338 match cleanup {
339 Ok(()) => {}
340 Err(PocketIcCallFailure::Rejected(response)) => {
341 failures.push(SnapshotCleanupFailure::Rejected {
342 canister_id,
343 sender: snapshot.sender,
344 response: Box::new(response),
345 });
346 }
347 Err(PocketIcCallFailure::Operation(source)) => {
348 failures.push(SnapshotCleanupFailure::Panicked {
349 canister_id,
350 sender: snapshot.sender,
351 source,
352 });
353 }
354 }
355 }
356 failures
357}
358
359fn restore_controller_snapshot(
360 pocket_ic: &PocketIc,
361 canister_id: Principal,
362 snapshot_id: &[u8],
363 funding: SnapshotRestoreFunding,
364 sender: Option<Principal>,
365) -> Result<(), ControllerSnapshotError> {
366 transport::capture_call(|| {
367 apply_snapshot_restore_funding(pocket_ic, canister_id, funding);
368 pocket_ic.load_canister_snapshot(canister_id, sender, snapshot_id.to_vec())
369 })
370 .map_err(|failure| match failure {
371 PocketIcCallFailure::Rejected(response) => ControllerSnapshotError::RestoreFailed {
372 canister_id,
373 sender,
374 response: Box::new(response),
375 },
376 PocketIcCallFailure::Operation(source) => ControllerSnapshotError::RestorePanicked {
377 canister_id,
378 source,
379 },
380 })
381}
382
383fn apply_snapshot_restore_funding(
384 pocket_ic: &PocketIc,
385 canister_id: Principal,
386 funding: SnapshotRestoreFunding,
387) {
388 if funding == SnapshotRestoreFunding::Preserve {
389 return;
390 }
391
392 let balance = pocket_ic.cycle_balance(canister_id);
393 let top_up = snapshot_restore_top_up(balance, funding);
394 if top_up > 0 {
395 let _ = pocket_ic.add_cycles(canister_id, top_up);
396 }
397}
398
399const fn snapshot_restore_top_up(balance: u128, funding: SnapshotRestoreFunding) -> u128 {
400 match funding {
401 SnapshotRestoreFunding::Preserve => 0,
402 SnapshotRestoreFunding::TopUpTo { minimum_cycles } => {
403 minimum_cycles.saturating_sub(balance)
404 }
405 }
406}
407
408#[cfg(test)]
409mod tests {
410 use super::{SnapshotRestoreFunding, snapshot_restore_top_up};
411
412 #[test]
413 fn cleanup_preserves_causes_and_senders_and_continues_after_each_failure() {
414 use super::{ControllerSnapshot, SnapshotCleanupFailure, cleanup_captured_snapshots};
415 use candid::Principal;
416 use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
417
418 let ids = [1, 2, 3, 4].map(|byte| Principal::from_slice(&[byte]));
419 let sender = Some(Principal::anonymous());
420 let snapshots = ids
421 .map(|id| {
422 (
423 id,
424 ControllerSnapshot {
425 snapshot_id: vec![7],
426 sender,
427 },
428 )
429 })
430 .into_iter()
431 .collect();
432 let rejection = RejectResponse {
433 reject_code: RejectCode::CanisterReject,
434 reject_message: "cleanup rejected".into(),
435 error_code: ErrorCode::CanisterCalledTrap,
436 certified: false,
437 };
438 let transport = "reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/update/delete_canister_snapshot\", source: hyper::Error(IncompleteMessage) }";
439 let mut calls = Vec::new();
440 let failures = cleanup_captured_snapshots(snapshots, |id, selected_sender, snapshot_id| {
441 assert_eq!(snapshot_id, [7]);
442 calls.push((id, selected_sender));
443 if id == ids[1] {
444 return Err(rejection.clone());
445 }
446 if id == ids[2] {
447 std::panic::panic_any(transport.to_owned());
448 }
449 Ok(())
450 });
451 assert_eq!(calls, ids.map(|id| (id, sender)));
452 let [
453 SnapshotCleanupFailure::Rejected {
454 canister_id: rejected_id,
455 sender: rejected_sender,
456 response,
457 },
458 SnapshotCleanupFailure::Panicked {
459 canister_id: panicked_id,
460 sender: panicked_sender,
461 source,
462 },
463 ] = failures.as_slice()
464 else {
465 panic!("expected both cleanup failures: {failures:?}");
466 };
467 assert_eq!(*rejected_id, ids[1]);
468 assert_eq!(*rejected_sender, sender);
469 assert_eq!(response.as_ref(), &rejection);
470 assert_eq!(*panicked_id, ids[2]);
471 assert_eq!(*panicked_sender, sender);
472 assert!(source.is_transport());
473 assert_eq!(source.message(), transport);
474 }
475
476 #[test]
477 fn snapshot_restore_funding_is_explicit() {
478 assert_eq!(
479 snapshot_restore_top_up(10, SnapshotRestoreFunding::Preserve),
480 0
481 );
482 assert_eq!(
483 snapshot_restore_top_up(10, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
484 15
485 );
486 assert_eq!(
487 snapshot_restore_top_up(30, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
488 0
489 );
490 }
491}