1use std::collections::BTreeMap;
2
3use candid::Principal;
4use pocket_ic::{PocketIc, RejectResponse};
5
6use super::{PocketIcCallFailure, 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(Debug)]
28pub struct SnapshotCleanupFailure {
29 pub canister_id: Principal,
31 pub sender: Option<Principal>,
33 pub source: Box<PocketIcCallFailure>,
35}
36
37#[non_exhaustive]
39#[derive(Clone, Copy, Debug, Eq, PartialEq)]
40pub enum SnapshotRestoreFunding {
41 Preserve,
43 TopUpTo {
45 minimum_cycles: u128,
47 },
48}
49
50#[non_exhaustive]
52#[derive(Debug)]
53pub enum ControllerSnapshotError {
54 EmptyCanisterSet,
56 DuplicateCanisterId {
58 canister_id: Principal,
60 },
61 CaptureFailed {
63 canister_id: Principal,
65 sender: Option<Principal>,
67 source: Box<PocketIcCallFailure>,
69 cleanup_failures: Vec<SnapshotCleanupFailure>,
71 },
72 RestoreFailed {
74 canister_id: Principal,
76 sender: Option<Principal>,
78 source: Box<PocketIcCallFailure>,
80 },
81}
82
83pub trait PocketIcSnapshotExt {
85 fn capture_snapshots<I>(
91 &self,
92 targets: I,
93 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
94 where
95 I: IntoIterator<Item = CanisterSnapshotTarget>;
96
97 fn restore_snapshots(
103 &self,
104 snapshots: &ControllerSnapshots,
105 funding: SnapshotRestoreFunding,
106 ) -> Result<(), ControllerSnapshotError>;
107}
108
109impl PocketIcSnapshotExt for PocketIc {
110 fn capture_snapshots<I>(
111 &self,
112 targets: I,
113 ) -> Result<ControllerSnapshots, ControllerSnapshotError>
114 where
115 I: IntoIterator<Item = CanisterSnapshotTarget>,
116 {
117 capture_snapshot_set(self, targets)
118 }
119
120 fn restore_snapshots(
121 &self,
122 snapshots: &ControllerSnapshots,
123 funding: SnapshotRestoreFunding,
124 ) -> Result<(), ControllerSnapshotError> {
125 for (canister_id, snapshot_id, sender) in snapshots.iter() {
126 restore_controller_snapshot(self, canister_id, snapshot_id, funding, sender)?;
127 }
128 Ok(())
129 }
130}
131
132impl CanisterSnapshotTarget {
133 #[must_use]
135 pub const fn new(canister_id: Principal, sender: Option<Principal>) -> Self {
136 Self {
137 canister_id,
138 sender,
139 }
140 }
141
142 #[must_use]
144 pub const fn canister_id(self) -> Principal {
145 self.canister_id
146 }
147
148 #[must_use]
150 pub const fn sender(self) -> Option<Principal> {
151 self.sender
152 }
153}
154
155fn capture_snapshot_set<I>(
156 pocket_ic: &PocketIc,
157 targets: I,
158) -> Result<ControllerSnapshots, ControllerSnapshotError>
159where
160 I: IntoIterator<Item = CanisterSnapshotTarget>,
161{
162 let mut ordered_targets = BTreeMap::new();
165 for target in targets {
166 if ordered_targets
167 .insert(target.canister_id, target.sender)
168 .is_some()
169 {
170 return Err(ControllerSnapshotError::DuplicateCanisterId {
171 canister_id: target.canister_id,
172 });
173 }
174 }
175 let mut snapshots = BTreeMap::new();
176 for (canister_id, sender) in ordered_targets {
177 let failure = match try_take_snapshot(pocket_ic, canister_id, sender) {
178 Ok(snapshot) => {
179 snapshots.insert(canister_id, snapshot);
180 continue;
181 }
182 Err(failure) => failure,
183 };
184 let cleanup_failures =
185 cleanup_captured_snapshots(snapshots, |canister_id, sender, snapshot_id| {
186 pocket_ic.delete_canister_snapshot(canister_id, sender, snapshot_id)
187 });
188 return Err(ControllerSnapshotError::CaptureFailed {
189 canister_id,
190 sender,
191 source: Box::new(failure),
192 cleanup_failures,
193 });
194 }
195 Ok(ControllerSnapshots(snapshots))
196}
197
198impl ControllerSnapshots {
199 #[must_use]
201 pub fn len(&self) -> usize {
202 self.0.len()
203 }
204
205 #[must_use]
207 pub fn is_empty(&self) -> bool {
208 self.0.is_empty()
209 }
210
211 pub fn canister_ids(&self) -> impl Iterator<Item = Principal> + '_ {
213 self.0.keys().copied()
214 }
215
216 pub(super) fn iter(&self) -> impl Iterator<Item = (Principal, &[u8], Option<Principal>)> + '_ {
217 self.0.iter().map(|(canister_id, snapshot)| {
218 (
219 *canister_id,
220 snapshot.snapshot_id.as_slice(),
221 snapshot.sender,
222 )
223 })
224 }
225}
226
227impl std::fmt::Display for ControllerSnapshotError {
228 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
229 match self {
230 Self::EmptyCanisterSet => {
231 f.write_str("a cached baseline requires at least one canister")
232 }
233 Self::DuplicateCanisterId { canister_id } => {
234 write!(f, "duplicate canister id in snapshot set: {canister_id}")
235 }
236 Self::CaptureFailed {
237 canister_id,
238 sender,
239 source,
240 cleanup_failures,
241 } => write!(
242 f,
243 "failed to capture snapshot for {canister_id} with sender {sender:?}: {source}; {} partial snapshots could not be cleaned up",
244 cleanup_failures.len()
245 ),
246 Self::RestoreFailed {
247 canister_id,
248 sender,
249 source,
250 } => write!(
251 f,
252 "failed to restore snapshot for {canister_id} with sender {sender:?}: {source}",
253 ),
254 }
255 }
256}
257
258impl std::error::Error for ControllerSnapshotError {
259 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
260 match self {
261 Self::CaptureFailed { source, .. } | Self::RestoreFailed { source, .. } => {
262 Some(source.as_ref())
263 }
264 _ => None,
265 }
266 }
267}
268
269fn try_take_snapshot(
270 pocket_ic: &PocketIc,
271 canister_id: Principal,
272 sender: Option<Principal>,
273) -> Result<ControllerSnapshot, PocketIcCallFailure> {
274 let snapshot =
275 transport::capture_call(|| pocket_ic.take_canister_snapshot(canister_id, sender, None))?;
276 Ok(ControllerSnapshot {
277 snapshot_id: snapshot.id,
278 sender,
279 })
280}
281
282fn cleanup_captured_snapshots(
283 snapshots: BTreeMap<Principal, ControllerSnapshot>,
284 mut remove: impl FnMut(Principal, Option<Principal>, Vec<u8>) -> Result<(), RejectResponse>,
285) -> Vec<SnapshotCleanupFailure> {
286 let mut failures = Vec::new();
287 for (canister_id, snapshot) in snapshots {
288 let cleanup =
289 transport::capture_call(|| remove(canister_id, snapshot.sender, snapshot.snapshot_id));
290 if let Err(source) = cleanup {
291 failures.push(SnapshotCleanupFailure {
292 canister_id,
293 sender: snapshot.sender,
294 source: Box::new(source),
295 });
296 }
297 }
298 failures
299}
300
301fn restore_controller_snapshot(
302 pocket_ic: &PocketIc,
303 canister_id: Principal,
304 snapshot_id: &[u8],
305 funding: SnapshotRestoreFunding,
306 sender: Option<Principal>,
307) -> Result<(), ControllerSnapshotError> {
308 transport::capture_call(|| {
309 apply_snapshot_restore_funding(pocket_ic, canister_id, funding);
310 pocket_ic.load_canister_snapshot(canister_id, sender, snapshot_id.to_vec())
311 })
312 .map_err(|source| ControllerSnapshotError::RestoreFailed {
313 canister_id,
314 sender,
315 source: Box::new(source),
316 })
317}
318
319fn apply_snapshot_restore_funding(
320 pocket_ic: &PocketIc,
321 canister_id: Principal,
322 funding: SnapshotRestoreFunding,
323) {
324 if funding == SnapshotRestoreFunding::Preserve {
325 return;
326 }
327
328 let balance = pocket_ic.cycle_balance(canister_id);
329 let top_up = snapshot_restore_top_up(balance, funding);
330 if top_up > 0 {
331 let _ = pocket_ic.add_cycles(canister_id, top_up);
332 }
333}
334
335const fn snapshot_restore_top_up(balance: u128, funding: SnapshotRestoreFunding) -> u128 {
336 match funding {
337 SnapshotRestoreFunding::Preserve => 0,
338 SnapshotRestoreFunding::TopUpTo { minimum_cycles } => {
339 minimum_cycles.saturating_sub(balance)
340 }
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use super::{SnapshotRestoreFunding, snapshot_restore_top_up};
347
348 #[test]
349 fn cleanup_preserves_causes_and_senders_and_continues_after_each_failure() {
350 use super::{
351 ControllerSnapshot, PocketIcCallFailure, SnapshotCleanupFailure,
352 cleanup_captured_snapshots,
353 };
354 use candid::Principal;
355 use pocket_ic::{ErrorCode, RejectCode, RejectResponse};
356
357 let ids = [1, 2, 3, 4].map(|byte| Principal::from_slice(&[byte]));
358 let sender = Some(Principal::anonymous());
359 let snapshots = ids
360 .map(|id| {
361 (
362 id,
363 ControllerSnapshot {
364 snapshot_id: vec![7],
365 sender,
366 },
367 )
368 })
369 .into_iter()
370 .collect();
371 let rejection = RejectResponse {
372 reject_code: RejectCode::CanisterReject,
373 reject_message: "cleanup rejected".into(),
374 error_code: ErrorCode::CanisterCalledTrap,
375 certified: false,
376 };
377 let transport = "reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/update/delete_canister_snapshot\", source: hyper::Error(IncompleteMessage) }";
378 let mut calls = Vec::new();
379 let failures = cleanup_captured_snapshots(snapshots, |id, selected_sender, snapshot_id| {
380 assert_eq!(snapshot_id, [7]);
381 calls.push((id, selected_sender));
382 if id == ids[1] {
383 return Err(rejection.clone());
384 }
385 if id == ids[2] {
386 std::panic::panic_any(transport.to_owned());
387 }
388 Ok(())
389 });
390 assert_eq!(calls, ids.map(|id| (id, sender)));
391 let [
392 SnapshotCleanupFailure {
393 canister_id: rejected_id,
394 sender: rejected_sender,
395 source: rejected,
396 },
397 SnapshotCleanupFailure {
398 canister_id: panicked_id,
399 sender: panicked_sender,
400 source: panicked,
401 },
402 ] = failures.as_slice()
403 else {
404 panic!("expected both cleanup failures: {failures:?}");
405 };
406 assert_eq!(*rejected_id, ids[1]);
407 assert_eq!(*rejected_sender, sender);
408 let PocketIcCallFailure::Rejected(response) = rejected.as_ref() else {
409 panic!("expected rejection: {rejected:?}");
410 };
411 assert_eq!(response, &rejection);
412 assert_eq!(*panicked_id, ids[2]);
413 assert_eq!(*panicked_sender, sender);
414 let PocketIcCallFailure::Operation(source) = panicked.as_ref() else {
415 panic!("expected operation failure: {panicked:?}");
416 };
417 assert!(source.is_transport());
418 assert_eq!(source.message(), transport);
419 }
420
421 #[test]
422 fn snapshot_restore_funding_is_explicit() {
423 assert_eq!(
424 snapshot_restore_top_up(10, SnapshotRestoreFunding::Preserve),
425 0
426 );
427 assert_eq!(
428 snapshot_restore_top_up(10, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
429 15
430 );
431 assert_eq!(
432 snapshot_restore_top_up(30, SnapshotRestoreFunding::TopUpTo { minimum_cycles: 25 }),
433 0
434 );
435 }
436}