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