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