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