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