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