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, AttemptJournalRecord, AttemptJournalRecordError,
15 OperationBindingRecord, 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 thiserror::Error;
23
24pub const MAX_OPERATION_PLAN_BYTES: u64 = 1024 * 1024;
26
27#[derive(Clone, Debug)]
29pub struct OperationPlanRequest {
30 pub context: PlanContextRecord,
32 pub inventory: InventoryRecord,
34 pub selected_targets: Vec<String>,
36 pub graph: EffectGraphRecord,
38 pub operations: Vec<PlannedOperationRecord>,
40 pub budget: PlanBudgetRecord,
42}
43
44#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
49#[serde(try_from = "PlanFields")]
50pub struct OperationPlanRecord {
51 version: u16,
52 context: PlanContextRecord,
53 inventory: InventoryRecord,
54 selected_targets: Vec<String>,
55 graph: EffectGraphRecord,
56 operations: Vec<PlannedOperationRecord>,
57 budget: PlanBudgetRecord,
58 #[serde(skip)]
59 allocated: AllocatedAttemptsView,
60}
61#[derive(Deserialize)]
62#[serde(deny_unknown_fields)]
63struct PlanFields {
64 version: u16,
65 context: PlanContextRecord,
66 inventory: InventoryRecord,
67 #[serde(deserialize_with = "bounded_selected")]
68 selected_targets: Vec<String>,
69 graph: EffectGraphRecord,
70 #[serde(deserialize_with = "bounded_operations")]
71 operations: Vec<PlannedOperationRecord>,
72 budget: PlanBudgetRecord,
73}
74impl TryFrom<PlanFields> for OperationPlanRecord {
75 type Error = OperationPlanError;
76 fn try_from(fields: PlanFields) -> Result<Self, Self::Error> {
77 if fields.version != 1 {
78 return Err(OperationPlanError::UnsupportedVersion(fields.version));
79 }
80 Self::new(OperationPlanRequest {
81 context: fields.context,
82 inventory: fields.inventory,
83 selected_targets: fields.selected_targets,
84 graph: fields.graph,
85 operations: fields.operations,
86 budget: fields.budget,
87 })
88 }
89}
90impl OperationPlanRecord {
91 pub fn new(mut request: OperationPlanRequest) -> Result<Self, OperationPlanError> {
96 if request.selected_targets.is_empty() {
97 return Err(OperationPlanError::EmptySelection);
98 }
99 if request.selected_targets.len() > MAX_INVENTORY_TARGETS {
100 return Err(OperationPlanError::TooManySelected);
101 }
102 for id in &mut request.selected_targets {
103 *id = request.inventory.target(id)?.canister_id().into();
104 }
105 request.selected_targets.sort();
106 for pair in request.selected_targets.windows(2) {
107 if pair[0] == pair[1] {
108 return Err(OperationPlanError::DuplicateSelected(pair[0].clone()));
109 }
110 }
111 if request.operations.len() > MAX_EFFECT_OPERATIONS {
112 return Err(OperationPlanError::TooManyOperations);
113 }
114 request
115 .operations
116 .sort_by_key(PlannedOperationRecord::operation_sequence);
117 for pair in request.operations.windows(2) {
118 if pair[0].operation_sequence() == pair[1].operation_sequence() {
119 return Err(OperationPlanError::DuplicateOperation(
120 pair[0].operation_sequence(),
121 ));
122 }
123 }
124 if request.operations.len() != request.graph.nodes().len() {
125 return Err(OperationPlanError::OperationCountMismatch);
126 }
127 let mut used = vec![false; request.selected_targets.len()];
128 let mut allocated = AllocatedAttemptsView {
129 mutations: 0,
130 observations: 0,
131 };
132 for (node, operation) in request.graph.nodes().iter().zip(&request.operations) {
133 if node.operation_sequence() != operation.operation_sequence() {
134 return Err(OperationPlanError::OperationGraphMismatch {
135 expected: node.operation_sequence(),
136 actual: operation.operation_sequence(),
137 });
138 }
139 let selected = request
140 .selected_targets
141 .binary_search_by(|target| target.as_str().cmp(operation.target()))
142 .map_err(|_| OperationPlanError::UnselectedTarget(operation.target().into()))?;
143 used[selected] = true;
144 allocated.mutations = allocated
145 .mutations
146 .checked_add(operation.budget().mutations())
147 .ok_or(OperationPlanError::BudgetTooLarge)?;
148 allocated.observations = allocated
149 .observations
150 .checked_add(operation.budget().observations())
151 .ok_or(OperationPlanError::BudgetTooLarge)?;
152 }
153 if !request.budget.admits(allocated) {
154 return Err(OperationPlanError::AssignedBudgetExceeded);
155 }
156 for (id, used) in request.selected_targets.iter().zip(used) {
157 if !used {
158 return Err(OperationPlanError::UnusedTarget(id.clone()));
159 }
160 }
161 Ok(Self {
162 version: 1,
163 context: request.context,
164 inventory: request.inventory,
165 selected_targets: request.selected_targets,
166 graph: request.graph,
167 operations: request.operations,
168 budget: request.budget,
169 allocated,
170 })
171 }
172 #[must_use]
174 pub const fn context(&self) -> &PlanContextRecord {
175 &self.context
176 }
177 #[must_use]
179 pub const fn inventory(&self) -> &InventoryRecord {
180 &self.inventory
181 }
182 #[must_use]
184 pub fn selected_targets(&self) -> &[String] {
185 &self.selected_targets
186 }
187 #[must_use]
189 pub const fn graph(&self) -> &EffectGraphRecord {
190 &self.graph
191 }
192 #[must_use]
194 pub fn operations(&self) -> &[PlannedOperationRecord] {
195 &self.operations
196 }
197 pub(crate) fn singleton_operation(&self, sequence: u64) -> Option<&PlannedOperationRecord> {
199 match self.operations() {
200 [operation] if operation.operation_sequence() == sequence => Some(operation),
201 _ => None,
202 }
203 }
204
205 #[must_use]
207 pub const fn budget(&self) -> &PlanBudgetRecord {
208 &self.budget
209 }
210 #[must_use]
212 pub const fn allocated_attempts(&self) -> AllocatedAttemptsView {
213 self.allocated
214 }
215 pub fn operation(&self, sequence: u64) -> Result<&PlannedOperationRecord, OperationPlanError> {
220 self.operations
221 .binary_search_by_key(&sequence, PlannedOperationRecord::operation_sequence)
222 .map(|index| &self.operations[index])
223 .map_err(|_| OperationPlanError::UnknownOperation(sequence))
224 }
225 pub fn attempt_authority(
233 &self,
234 sequence: u64,
235 ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
236 let operation = self.operation(sequence)?;
237 self.authority_for_operation(operation, &self.digest())
238 }
239 pub(crate) fn attempt_authority_for_journal<E: From<OperationPlanError>>(
242 &self,
243 sequence: u64,
244 journal: &AttemptJournalRecord,
245 mismatch: E,
246 ) -> Result<AttemptAuthorityRecord, E> {
247 let authority = self.attempt_authority(sequence)?;
248 if journal.authority() != &authority {
249 return Err(mismatch);
250 }
251 Ok(authority)
252 }
253
254 pub fn attempt_authorities(&self) -> Result<Vec<AttemptAuthorityRecord>, OperationPlanError> {
261 let intent = self.digest();
262 self.operations
263 .iter()
264 .map(|operation| self.authority_for_operation(operation, &intent))
265 .collect()
266 }
267 fn authority_for_operation(
268 &self,
269 operation: &PlannedOperationRecord,
270 intent: &ArtifactChecksumRecord,
271 ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
272 Ok(AttemptAuthorityRecord::new(
273 self.binding_for_operation(operation, intent)?,
274 operation.budget().clone(),
275 ))
276 }
277
278 pub(crate) fn operation_binding(
279 &self,
280 sequence: u64,
281 ) -> Result<OperationBindingRecord, OperationPlanError> {
282 self.binding_for_operation(self.operation(sequence)?, &self.digest())
283 }
284
285 fn binding_for_operation(
286 &self,
287 operation: &PlannedOperationRecord,
288 intent: &ArtifactChecksumRecord,
289 ) -> Result<OperationBindingRecord, OperationPlanError> {
290 Ok(OperationBindingRecord::new(&OperationBindingRequest {
291 intent: intent.hash().into(),
292 operation_sequence: operation.operation_sequence(),
293 network: self.context.network().into(),
294 caller: self.context.caller().into(),
295 target: operation.target().into(),
296 release: self.context.release().into(),
297 request: operation.request().into(),
298 })?)
299 }
300 pub(crate) fn matches_authority(
303 &self,
304 operation: &PlannedOperationRecord,
305 intent: &ArtifactChecksumRecord,
306 authority: &AttemptAuthorityRecord,
307 ) -> bool {
308 let binding = authority.binding();
309 let identity_matches = binding.intent() == intent.hash()
310 && binding.operation_sequence() == operation.operation_sequence()
311 && binding.target() == operation.target()
312 && binding.request() == operation.request();
313 let context_matches = self.context.binding_mismatch(binding).is_none();
314 identity_matches && context_matches && authority.budget() == operation.budget()
315 }
316 #[must_use]
320 pub fn digest(&self) -> ArtifactChecksumRecord {
321 let mut bytes = EvidenceDigest::new(b"ic-backup/operation-plan/v1\0");
322 bytes.append(self.context.network().as_bytes());
323 bytes.bytes_u8(self.context.caller().as_bytes());
324 bytes.append(self.context.release().as_bytes());
325 bytes.checksum(&self.inventory.digest());
326 bytes.checksum(&self.graph.digest());
327 append_count(&mut bytes, self.selected_targets.len());
328 for target in &self.selected_targets {
329 bytes.bytes_u8(target.as_bytes());
330 }
331 bytes.u32_be(self.budget.mutations());
332 bytes.u32_be(self.budget.observations());
333 append_count(&mut bytes, self.operations.len());
334 for operation in &self.operations {
335 bytes.u64_be(operation.operation_sequence());
336 bytes.bytes_u8(operation.target().as_bytes());
337 bytes.append(operation.request().as_bytes());
338 bytes.u32_be(operation.budget().mutations());
339 bytes.u32_be(operation.budget().observations());
340 }
341 bytes.finish()
342 }
343}
344#[expect(
345 clippy::cast_possible_truncation,
346 reason = "validated selection/operation counts are at most 8192"
347)]
348fn append_count(bytes: &mut EvidenceDigest, count: usize) {
349 bytes.u32_be(count as u32);
350}
351fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
352 bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
353}
354fn bounded_operations<'de, D: Deserializer<'de>>(
355 deserializer: D,
356) -> Result<Vec<PlannedOperationRecord>, D::Error> {
357 bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
358}
359fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
360where
361 D: Deserializer<'de>,
362 T: Deserialize<'de>,
363{
364 bounded_sequence::deserialize::<_, _, LIMIT>(
365 deserializer,
366 format!("at most {LIMIT} plan entries"),
367 format!("plan list exceeds {LIMIT} entries"),
368 )
369}
370
371#[derive(Debug, Error)]
373pub enum OperationPlanError {
374 #[error("unsupported operation plan version {0}")]
376 UnsupportedVersion(u16),
377 #[error("operation plan has no selected targets")]
379 EmptySelection,
380 #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
382 TooManySelected,
383 #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
385 TooManyOperations,
386 #[error("invalid operation plan principal in {0}")]
388 InvalidPrincipal(&'static str),
389 #[error("duplicate selected plan target {0}")]
391 DuplicateSelected(String),
392 #[error("duplicate planned operation {0}")]
394 DuplicateOperation(u64),
395 #[error("operation table count differs from dependency graph")]
397 OperationCountMismatch,
398 #[error("operation table differs from graph: expected {expected}, actual {actual}")]
400 OperationGraphMismatch {
401 expected: u64,
403 actual: u64,
405 },
406 #[error("operation targets unselected physical identity {0}")]
408 UnselectedTarget(String),
409 #[error("selected plan target has no operation {0}")]
411 UnusedTarget(String),
412 #[error("unknown planned operation {0}")]
414 UnknownOperation(u64),
415 #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
417 BudgetTooLarge,
418 #[error("assigned operation attempts exceed original plan ceilings")]
420 AssignedBudgetExceeded,
421 #[error(transparent)]
423 Checksum(#[from] ChecksumError),
424 #[error(transparent)]
426 Inventory(#[from] InventoryRecordError),
427 #[error(transparent)]
429 Attempt(#[from] AttemptJournalRecordError),
430}
431
432#[cfg(test)]
433mod tests;