ic_backup/model/operation_plan/
mod.rs1mod budget;
4mod context;
5mod operation;
6pub use budget::{AllocatedAttemptsView, MAX_PLAN_ATTEMPTS, PlanBudgetRecord};
7pub use context::{PlanContextRecord, PlanContextRequest};
8pub use operation::{PlannedOperationRecord, PlannedOperationRequest};
9
10use crate::model::{
11 artifacts::{ArtifactChecksumRecord, ChecksumError},
12 attempt_journal::{
13 AttemptAuthorityRecord, AttemptJournalRecordError, OperationBindingRecord,
14 OperationBindingRequest,
15 },
16 effect_graph::{EffectGraphRecord, MAX_EFFECT_OPERATIONS},
17 inventory::{InventoryRecord, InventoryRecordError, MAX_INVENTORY_TARGETS},
18};
19use serde::{Deserialize, Deserializer, Serialize, de};
20use std::{collections::BTreeSet, fmt};
21use thiserror::Error;
22
23pub const MAX_OPERATION_PLAN_BYTES: u64 = 1024 * 1024;
25
26#[derive(Clone, Debug)]
28pub struct OperationPlanRequest {
29 pub context: PlanContextRecord,
31 pub inventory: InventoryRecord,
33 pub selected_targets: Vec<String>,
35 pub graph: EffectGraphRecord,
37 pub operations: Vec<PlannedOperationRecord>,
39 pub budget: PlanBudgetRecord,
41}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48#[serde(try_from = "PlanFields")]
49pub struct OperationPlanRecord {
50 version: u16,
51 context: PlanContextRecord,
52 inventory: InventoryRecord,
53 selected_targets: Vec<String>,
54 graph: EffectGraphRecord,
55 operations: Vec<PlannedOperationRecord>,
56 budget: PlanBudgetRecord,
57 #[serde(skip)]
58 allocated: AllocatedAttemptsView,
59}
60#[derive(Deserialize)]
61#[serde(deny_unknown_fields)]
62struct PlanFields {
63 version: u16,
64 context: PlanContextRecord,
65 inventory: InventoryRecord,
66 #[serde(deserialize_with = "bounded_selected")]
67 selected_targets: Vec<String>,
68 graph: EffectGraphRecord,
69 #[serde(deserialize_with = "bounded_operations")]
70 operations: Vec<PlannedOperationRecord>,
71 budget: PlanBudgetRecord,
72}
73impl TryFrom<PlanFields> for OperationPlanRecord {
74 type Error = OperationPlanError;
75 fn try_from(fields: PlanFields) -> Result<Self, Self::Error> {
76 if fields.version != 1 {
77 return Err(OperationPlanError::UnsupportedVersion(fields.version));
78 }
79 Self::new(OperationPlanRequest {
80 context: fields.context,
81 inventory: fields.inventory,
82 selected_targets: fields.selected_targets,
83 graph: fields.graph,
84 operations: fields.operations,
85 budget: fields.budget,
86 })
87 }
88}
89impl OperationPlanRecord {
90 pub fn new(mut request: OperationPlanRequest) -> Result<Self, OperationPlanError> {
95 if request.selected_targets.is_empty() {
96 return Err(OperationPlanError::EmptySelection);
97 }
98 if request.selected_targets.len() > MAX_INVENTORY_TARGETS {
99 return Err(OperationPlanError::TooManySelected);
100 }
101 for id in &mut request.selected_targets {
102 *id = request.inventory.target(id)?.canister_id().into();
103 }
104 request.selected_targets.sort();
105 for pair in request.selected_targets.windows(2) {
106 if pair[0] == pair[1] {
107 return Err(OperationPlanError::DuplicateSelected(pair[0].clone()));
108 }
109 }
110 if request.operations.len() > MAX_EFFECT_OPERATIONS {
111 return Err(OperationPlanError::TooManyOperations);
112 }
113 request
114 .operations
115 .sort_by_key(PlannedOperationRecord::operation_sequence);
116 for pair in request.operations.windows(2) {
117 if pair[0].operation_sequence() == pair[1].operation_sequence() {
118 return Err(OperationPlanError::DuplicateOperation(
119 pair[0].operation_sequence(),
120 ));
121 }
122 }
123 if request.operations.len() != request.graph.nodes().len() {
124 return Err(OperationPlanError::OperationCountMismatch);
125 }
126 let selected: BTreeSet<_> = request
127 .selected_targets
128 .iter()
129 .map(String::as_str)
130 .collect();
131 let mut used = BTreeSet::new();
132 let mut allocated = AllocatedAttemptsView {
133 mutations: 0,
134 observations: 0,
135 };
136 for (node, operation) in request.graph.nodes().iter().zip(&request.operations) {
137 if node.operation_sequence() != operation.operation_sequence() {
138 return Err(OperationPlanError::OperationGraphMismatch {
139 expected: node.operation_sequence(),
140 actual: operation.operation_sequence(),
141 });
142 }
143 if !selected.contains(operation.target()) {
144 return Err(OperationPlanError::UnselectedTarget(
145 operation.target().into(),
146 ));
147 }
148 used.insert(operation.target());
149 allocated.mutations = allocated
150 .mutations
151 .checked_add(operation.budget().mutations())
152 .ok_or(OperationPlanError::BudgetTooLarge)?;
153 allocated.observations = allocated
154 .observations
155 .checked_add(operation.budget().observations())
156 .ok_or(OperationPlanError::BudgetTooLarge)?;
157 }
158 if !request.budget.admits(allocated) {
159 return Err(OperationPlanError::AssignedBudgetExceeded);
160 }
161 for id in &request.selected_targets {
162 if !used.contains(id.as_str()) {
163 return Err(OperationPlanError::UnusedTarget(id.clone()));
164 }
165 }
166 Ok(Self {
167 version: 1,
168 context: request.context,
169 inventory: request.inventory,
170 selected_targets: request.selected_targets,
171 graph: request.graph,
172 operations: request.operations,
173 budget: request.budget,
174 allocated,
175 })
176 }
177 #[must_use]
179 pub const fn context(&self) -> &PlanContextRecord {
180 &self.context
181 }
182 #[must_use]
184 pub const fn inventory(&self) -> &InventoryRecord {
185 &self.inventory
186 }
187 #[must_use]
189 pub fn selected_targets(&self) -> &[String] {
190 &self.selected_targets
191 }
192 #[must_use]
194 pub const fn graph(&self) -> &EffectGraphRecord {
195 &self.graph
196 }
197 #[must_use]
199 pub fn operations(&self) -> &[PlannedOperationRecord] {
200 &self.operations
201 }
202 #[must_use]
204 pub const fn budget(&self) -> &PlanBudgetRecord {
205 &self.budget
206 }
207 #[must_use]
209 pub const fn allocated_attempts(&self) -> AllocatedAttemptsView {
210 self.allocated
211 }
212 pub fn operation(&self, sequence: u64) -> Result<&PlannedOperationRecord, OperationPlanError> {
217 self.operations
218 .binary_search_by_key(&sequence, PlannedOperationRecord::operation_sequence)
219 .map(|index| &self.operations[index])
220 .map_err(|_| OperationPlanError::UnknownOperation(sequence))
221 }
222 pub fn attempt_authority(
230 &self,
231 sequence: u64,
232 ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
233 let operation = self.operation(sequence)?;
234 self.authority_for_operation(operation, &self.digest())
235 }
236 pub fn attempt_authorities(&self) -> Result<Vec<AttemptAuthorityRecord>, OperationPlanError> {
243 let intent = self.digest();
244 self.operations
245 .iter()
246 .map(|operation| self.authority_for_operation(operation, &intent))
247 .collect()
248 }
249 fn authority_for_operation(
250 &self,
251 operation: &PlannedOperationRecord,
252 intent: &ArtifactChecksumRecord,
253 ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
254 let binding = OperationBindingRecord::new(&OperationBindingRequest {
255 intent: intent.hash().into(),
256 operation_sequence: operation.operation_sequence(),
257 network: self.context.network().into(),
258 caller: self.context.caller().into(),
259 target: operation.target().into(),
260 release: self.context.release().into(),
261 request: operation.request().into(),
262 })?;
263 Ok(AttemptAuthorityRecord::new(
264 binding,
265 operation.budget().clone(),
266 ))
267 }
268 #[must_use]
272 pub fn digest(&self) -> ArtifactChecksumRecord {
273 let mut bytes = b"ic-backup/operation-plan/v1\0".to_vec();
274 bytes.extend_from_slice(self.context.network().as_bytes());
275 append_principal(&mut bytes, self.context.caller());
276 bytes.extend_from_slice(self.context.release().as_bytes());
277 bytes.extend_from_slice(self.inventory.digest().hash().as_bytes());
278 bytes.extend_from_slice(self.graph.digest().hash().as_bytes());
279 append_count(&mut bytes, self.selected_targets.len());
280 for target in &self.selected_targets {
281 append_principal(&mut bytes, target);
282 }
283 bytes.extend_from_slice(&self.budget.mutations().to_be_bytes());
284 bytes.extend_from_slice(&self.budget.observations().to_be_bytes());
285 append_count(&mut bytes, self.operations.len());
286 for operation in &self.operations {
287 bytes.extend_from_slice(&operation.operation_sequence().to_be_bytes());
288 append_principal(&mut bytes, operation.target());
289 bytes.extend_from_slice(operation.request().as_bytes());
290 bytes.extend_from_slice(&operation.budget().mutations().to_be_bytes());
291 bytes.extend_from_slice(&operation.budget().observations().to_be_bytes());
292 }
293 ArtifactChecksumRecord::from_bytes(&bytes)
294 }
295}
296fn append_principal(bytes: &mut Vec<u8>, text: &str) {
297 bytes.push(text.len().to_le_bytes()[0]);
299 bytes.extend_from_slice(text.as_bytes());
300}
301#[expect(
302 clippy::cast_possible_truncation,
303 reason = "validated selection/operation counts are at most 8192"
304)]
305fn append_count(bytes: &mut Vec<u8>, count: usize) {
306 bytes.extend_from_slice(&(count as u32).to_be_bytes());
307}
308fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
309 bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
310}
311fn bounded_operations<'de, D: Deserializer<'de>>(
312 deserializer: D,
313) -> Result<Vec<PlannedOperationRecord>, D::Error> {
314 bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
315}
316fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
317where
318 D: Deserializer<'de>,
319 T: Deserialize<'de>,
320{
321 struct Visitor<T, const LIMIT: usize>(std::marker::PhantomData<T>);
322 impl<'de, T: Deserialize<'de>, const LIMIT: usize> de::Visitor<'de> for Visitor<T, LIMIT> {
323 type Value = Vec<T>;
324 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325 write!(f, "at most {LIMIT} plan entries")
326 }
327 fn visit_seq<A: de::SeqAccess<'de>>(
328 self,
329 mut sequence: A,
330 ) -> Result<Self::Value, A::Error> {
331 let mut entries = Vec::new();
332 while entries.len() < LIMIT {
333 match sequence.next_element()? {
334 Some(entry) => entries.push(entry),
335 None => return Ok(entries),
336 }
337 }
338 if sequence.next_element::<de::IgnoredAny>()?.is_some() {
339 return Err(de::Error::custom(format!(
340 "plan list exceeds {LIMIT} entries"
341 )));
342 }
343 Ok(entries)
344 }
345 }
346 deserializer.deserialize_seq(Visitor::<T, LIMIT>(std::marker::PhantomData))
347}
348
349#[derive(Debug, Error)]
351pub enum OperationPlanError {
352 #[error("unsupported operation plan version {0}")]
354 UnsupportedVersion(u16),
355 #[error("operation plan has no selected targets")]
357 EmptySelection,
358 #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
360 TooManySelected,
361 #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
363 TooManyOperations,
364 #[error("invalid operation plan principal in {0}")]
366 InvalidPrincipal(&'static str),
367 #[error("duplicate selected plan target {0}")]
369 DuplicateSelected(String),
370 #[error("duplicate planned operation {0}")]
372 DuplicateOperation(u64),
373 #[error("operation table count differs from dependency graph")]
375 OperationCountMismatch,
376 #[error("operation table differs from graph: expected {expected}, actual {actual}")]
378 OperationGraphMismatch {
379 expected: u64,
381 actual: u64,
383 },
384 #[error("operation targets unselected physical identity {0}")]
386 UnselectedTarget(String),
387 #[error("selected plan target has no operation {0}")]
389 UnusedTarget(String),
390 #[error("unknown planned operation {0}")]
392 UnknownOperation(u64),
393 #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
395 BudgetTooLarge,
396 #[error("assigned operation attempts exceed original plan ceilings")]
398 AssignedBudgetExceeded,
399 #[error(transparent)]
401 Checksum(#[from] ChecksumError),
402 #[error(transparent)]
404 Inventory(#[from] InventoryRecordError),
405 #[error(transparent)]
407 Attempt(#[from] AttemptJournalRecordError),
408}
409
410#[cfg(test)]
411pub(crate) mod tests;