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 mut cleanup_failures = cleanup_snapshot_ids(pocket_ic, canister_id, sender, pending);
213 cleanup_failures.extend(cleanup_captured_snapshots(
214 snapshots,
215 |canister_id, sender, snapshot_id| {
216 pocket_ic.delete_canister_snapshot(canister_id, sender, snapshot_id)
217 },
218 ));
219 return Err(match failure {
220 transfer::Failure::Call(failure) => ControllerSnapshotError::CaptureFailed {
221 canister_id,
222 sender,
223 source: Box::new(failure),
224 cleanup_failures,
225 },
226 transfer::Failure::Invalid(message) => ControllerSnapshotError::InvalidSnapshot {
227 canister_id,
228 message,
229 cleanup_failures,
230 },
231 transfer::Failure::Codec(source) => ControllerSnapshotError::SnapshotCodec {
232 canister_id,
233 source,
234 cleanup_failures,
235 },
236 });
237 }
238 Ok(ControllerSnapshots(snapshots))
239}
240
241impl ControllerSnapshots {
242 #[must_use]
244 pub fn len(&self) -> usize {
245 self.0.len()
246 }
247
248 #[must_use]
250 pub fn is_empty(&self) -> bool {
251 self.0.is_empty()
252 }
253
254 pub fn canister_ids(&self) -> impl Iterator<Item = Principal> + '_ {
256 self.0.keys().copied()
257 }
258
259 pub(super) fn iter(&self) -> impl Iterator<Item = (Principal, &[u8], Option<Principal>)> + '_ {
260 self.0.iter().map(|(canister_id, snapshot)| {
261 (
262 *canister_id,
263 snapshot.snapshot_id.as_slice(),
264 snapshot.sender,
265 )
266 })
267 }
268}
269
270fn try_take_snapshot(
271 pocket_ic: &PocketIc,
272 canister_id: Principal,
273 sender: Option<Principal>,
274 pending: &mut Vec<Vec<u8>>,
275) -> Result<ControllerSnapshot, transfer::Failure> {
276 let snapshot =
277 transport::capture_call(|| pocket_ic.take_canister_snapshot(canister_id, sender, None))?;
278 pending.push(snapshot.id.clone());
279 let uploaded = transfer::upload(pocket_ic, canister_id, sender, &snapshot.id, pending)?;
280 transport::capture_call(|| {
281 pocket_ic.delete_canister_snapshot(canister_id, sender, snapshot.id)
282 })?;
283 pending.clear();
284 Ok(ControllerSnapshot {
285 snapshot_id: uploaded,
286 sender,
287 })
288}
289
290fn cleanup_snapshot_ids(
291 pic: &PocketIc,
292 canister_id: Principal,
293 sender: Option<Principal>,
294 ids: Vec<Vec<u8>>,
295) -> Vec<SnapshotCleanupFailure> {
296 ids.into_iter()
297 .filter_map(|id| {
298 transport::capture_call(|| {
299 pic.delete_canister_snapshot(canister_id, sender, id.clone())
300 })
301 .err()
302 .map(|source| SnapshotCleanupFailure {
303 canister_id,
304 sender,
305 snapshot_id: id,
306 source: Box::new(source),
307 })
308 })
309 .collect()
310}
311
312fn cleanup_captured_snapshots(
313 snapshots: BTreeMap<Principal, ControllerSnapshot>,
314 mut remove: impl FnMut(Principal, Option<Principal>, Vec<u8>) -> Result<(), RejectResponse>,
315) -> Vec<SnapshotCleanupFailure> {
316 let mut failures = Vec::new();
317 for (canister_id, snapshot) in snapshots {
318 let cleanup = transport::capture_call(|| {
319 remove(canister_id, snapshot.sender, snapshot.snapshot_id.clone())
320 });
321 if let Err(source) = cleanup {
322 failures.push(SnapshotCleanupFailure {
323 canister_id,
324 sender: snapshot.sender,
325 snapshot_id: snapshot.snapshot_id,
326 source: Box::new(source),
327 });
328 }
329 }
330 failures
331}
332
333fn restore_controller_snapshot(
334 pocket_ic: &PocketIc,
335 canister_id: Principal,
336 snapshot_id: &[u8],
337 funding: SnapshotRestoreFunding,
338 sender: Option<Principal>,
339) -> Result<(), ControllerSnapshotError> {
340 transport::capture_call(|| {
341 apply_snapshot_restore_funding(pocket_ic, canister_id, funding);
342 pocket_ic.load_canister_snapshot(canister_id, sender, snapshot_id.to_vec())
343 })
344 .map_err(|source| ControllerSnapshotError::RestoreFailed {
345 canister_id,
346 sender,
347 source: Box::new(source),
348 })
349}
350
351fn apply_snapshot_restore_funding(
352 pocket_ic: &PocketIc,
353 canister_id: Principal,
354 funding: SnapshotRestoreFunding,
355) {
356 match funding {
357 SnapshotRestoreFunding::Preserve => {}
358 SnapshotRestoreFunding::TopUpTo { minimum_cycles } => {
359 let top_up = minimum_cycles.saturating_sub(pocket_ic.cycle_balance(canister_id));
360 if top_up > 0 {
361 let _ = pocket_ic.add_cycles(canister_id, top_up);
362 }
363 }
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 #[test]
370 fn cleanup_preserves_causes_and_senders_and_continues_after_each_failure() {
371 use super::{
372 ControllerSnapshot, PocketIcCallFailure, SnapshotCleanupFailure,
373 cleanup_captured_snapshots,
374 };
375 use ic_platform::pocket_ic::{ErrorCode, RejectCode, RejectResponse};
376 use ic_platform::principal::ExternalPrincipal as Principal;
377
378 let ids = [1, 2, 3, 4].map(|byte| Principal::from_slice(&[byte]));
379 let sender = Some(Principal::anonymous());
380 let snapshots = ids
381 .map(|id| {
382 (
383 id,
384 ControllerSnapshot {
385 snapshot_id: vec![7],
386 sender,
387 },
388 )
389 })
390 .into_iter()
391 .collect();
392 let rejection = RejectResponse {
393 reject_code: RejectCode::CanisterReject,
394 reject_message: "cleanup rejected".into(),
395 error_code: ErrorCode::CanisterCalledTrap,
396 certified: false,
397 };
398 let transport = "reqwest::Error { kind: Request, url: \"http://127.0.0.1:1234/instances/0/update/delete_canister_snapshot\", source: hyper::Error(IncompleteMessage) }";
399 let mut calls = Vec::new();
400 let failures = cleanup_captured_snapshots(snapshots, |id, selected_sender, snapshot_id| {
401 assert_eq!(snapshot_id, [7]);
402 calls.push((id, selected_sender));
403 if id == ids[1] {
404 return Err(rejection.clone());
405 }
406 if id == ids[2] {
407 std::panic::panic_any(transport.to_owned());
408 }
409 Ok(())
410 });
411 assert_eq!(calls, ids.map(|id| (id, sender)));
412 let [
413 SnapshotCleanupFailure {
414 canister_id: rejected_id,
415 sender: rejected_sender,
416 source: rejected,
417 snapshot_id: rejected_snapshot,
418 },
419 SnapshotCleanupFailure {
420 canister_id: panicked_id,
421 sender: panicked_sender,
422 source: panicked,
423 snapshot_id: panicked_snapshot,
424 },
425 ] = failures.as_slice()
426 else {
427 panic!("expected both cleanup failures: {failures:?}");
428 };
429 assert_eq!(*rejected_id, ids[1]);
430 assert_eq!(rejected_snapshot, &[7]);
431 assert_eq!(panicked_snapshot, &[7]);
432 assert_eq!(*rejected_sender, sender);
433 let PocketIcCallFailure::Rejected(response) = rejected.as_ref() else {
434 panic!("expected rejection: {rejected:?}");
435 };
436 assert_eq!(response, &rejection);
437 assert_eq!(*panicked_id, ids[2]);
438 assert_eq!(*panicked_sender, sender);
439 let PocketIcCallFailure::Operation(source) = panicked.as_ref() else {
440 panic!("expected operation failure: {panicked:?}");
441 };
442 assert!(source.is_transport());
443 assert_eq!(source.message(), transport);
444 }
445}