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