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