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 selected: BTreeSet<_> = request
129 .selected_targets
130 .iter()
131 .map(String::as_str)
132 .collect();
133 let mut used = BTreeSet::new();
134 let mut allocated = AllocatedAttemptsView {
135 mutations: 0,
136 observations: 0,
137 };
138 for (node, operation) in request.graph.nodes().iter().zip(&request.operations) {
139 if node.operation_sequence() != operation.operation_sequence() {
140 return Err(OperationPlanError::OperationGraphMismatch {
141 expected: node.operation_sequence(),
142 actual: operation.operation_sequence(),
143 });
144 }
145 if !selected.contains(operation.target()) {
146 return Err(OperationPlanError::UnselectedTarget(
147 operation.target().into(),
148 ));
149 }
150 used.insert(operation.target());
151 allocated.mutations = allocated
152 .mutations
153 .checked_add(operation.budget().mutations())
154 .ok_or(OperationPlanError::BudgetTooLarge)?;
155 allocated.observations = allocated
156 .observations
157 .checked_add(operation.budget().observations())
158 .ok_or(OperationPlanError::BudgetTooLarge)?;
159 }
160 if !request.budget.admits(allocated) {
161 return Err(OperationPlanError::AssignedBudgetExceeded);
162 }
163 for id in &request.selected_targets {
164 if !used.contains(id.as_str()) {
165 return Err(OperationPlanError::UnusedTarget(id.clone()));
166 }
167 }
168 Ok(Self {
169 version: 1,
170 context: request.context,
171 inventory: request.inventory,
172 selected_targets: request.selected_targets,
173 graph: request.graph,
174 operations: request.operations,
175 budget: request.budget,
176 allocated,
177 })
178 }
179 #[must_use]
181 pub const fn context(&self) -> &PlanContextRecord {
182 &self.context
183 }
184 #[must_use]
186 pub const fn inventory(&self) -> &InventoryRecord {
187 &self.inventory
188 }
189 #[must_use]
191 pub fn selected_targets(&self) -> &[String] {
192 &self.selected_targets
193 }
194 #[must_use]
196 pub const fn graph(&self) -> &EffectGraphRecord {
197 &self.graph
198 }
199 #[must_use]
201 pub fn operations(&self) -> &[PlannedOperationRecord] {
202 &self.operations
203 }
204 #[must_use]
206 pub const fn budget(&self) -> &PlanBudgetRecord {
207 &self.budget
208 }
209 #[must_use]
211 pub const fn allocated_attempts(&self) -> AllocatedAttemptsView {
212 self.allocated
213 }
214 pub fn operation(&self, sequence: u64) -> Result<&PlannedOperationRecord, OperationPlanError> {
219 self.operations
220 .binary_search_by_key(&sequence, PlannedOperationRecord::operation_sequence)
221 .map(|index| &self.operations[index])
222 .map_err(|_| OperationPlanError::UnknownOperation(sequence))
223 }
224 pub fn attempt_authority(
232 &self,
233 sequence: u64,
234 ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
235 let operation = self.operation(sequence)?;
236 self.authority_for_operation(operation, &self.digest())
237 }
238 pub fn attempt_authorities(&self) -> Result<Vec<AttemptAuthorityRecord>, OperationPlanError> {
245 let intent = self.digest();
246 self.operations
247 .iter()
248 .map(|operation| self.authority_for_operation(operation, &intent))
249 .collect()
250 }
251 fn authority_for_operation(
252 &self,
253 operation: &PlannedOperationRecord,
254 intent: &ArtifactChecksumRecord,
255 ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
256 Ok(AttemptAuthorityRecord::new(
257 self.binding_for_operation(operation, intent)?,
258 operation.budget().clone(),
259 ))
260 }
261
262 pub(crate) fn operation_binding(
263 &self,
264 sequence: u64,
265 ) -> Result<OperationBindingRecord, OperationPlanError> {
266 self.binding_for_operation(self.operation(sequence)?, &self.digest())
267 }
268
269 fn binding_for_operation(
270 &self,
271 operation: &PlannedOperationRecord,
272 intent: &ArtifactChecksumRecord,
273 ) -> Result<OperationBindingRecord, OperationPlanError> {
274 Ok(OperationBindingRecord::new(&OperationBindingRequest {
275 intent: intent.hash().into(),
276 operation_sequence: operation.operation_sequence(),
277 network: self.context.network().into(),
278 caller: self.context.caller().into(),
279 target: operation.target().into(),
280 release: self.context.release().into(),
281 request: operation.request().into(),
282 })?)
283 }
284 pub(crate) fn matches_authority(
287 &self,
288 operation: &PlannedOperationRecord,
289 intent: &ArtifactChecksumRecord,
290 authority: &AttemptAuthorityRecord,
291 ) -> bool {
292 let binding = authority.binding();
293 let identity_matches = binding.intent() == intent.hash()
294 && binding.operation_sequence() == operation.operation_sequence()
295 && binding.target() == operation.target()
296 && binding.request() == operation.request();
297 let context_matches = binding.network() == self.context.network()
298 && binding.caller() == self.context.caller()
299 && binding.release() == self.context.release();
300 identity_matches && context_matches && authority.budget() == operation.budget()
301 }
302 #[must_use]
306 pub fn digest(&self) -> ArtifactChecksumRecord {
307 let mut bytes = EvidenceDigest::new(b"ic-backup/operation-plan/v1\0");
308 bytes.append(self.context.network().as_bytes());
309 append_principal(&mut bytes, self.context.caller());
310 bytes.append(self.context.release().as_bytes());
311 bytes.append(self.inventory.digest().hash().as_bytes());
312 bytes.append(self.graph.digest().hash().as_bytes());
313 append_count(&mut bytes, self.selected_targets.len());
314 for target in &self.selected_targets {
315 append_principal(&mut bytes, target);
316 }
317 bytes.append(&self.budget.mutations().to_be_bytes());
318 bytes.append(&self.budget.observations().to_be_bytes());
319 append_count(&mut bytes, self.operations.len());
320 for operation in &self.operations {
321 bytes.append(&operation.operation_sequence().to_be_bytes());
322 append_principal(&mut bytes, operation.target());
323 bytes.append(operation.request().as_bytes());
324 bytes.append(&operation.budget().mutations().to_be_bytes());
325 bytes.append(&operation.budget().observations().to_be_bytes());
326 }
327 bytes.finish()
328 }
329}
330fn append_principal(bytes: &mut EvidenceDigest, text: &str) {
331 bytes.byte(text.len().to_le_bytes()[0]);
333 bytes.append(text.as_bytes());
334}
335#[expect(
336 clippy::cast_possible_truncation,
337 reason = "validated selection/operation counts are at most 8192"
338)]
339fn append_count(bytes: &mut EvidenceDigest, count: usize) {
340 bytes.append(&(count as u32).to_be_bytes());
341}
342fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
343 bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
344}
345fn bounded_operations<'de, D: Deserializer<'de>>(
346 deserializer: D,
347) -> Result<Vec<PlannedOperationRecord>, D::Error> {
348 bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
349}
350fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
351where
352 D: Deserializer<'de>,
353 T: Deserialize<'de>,
354{
355 bounded_sequence::deserialize::<_, _, LIMIT>(
356 deserializer,
357 format!("at most {LIMIT} plan entries"),
358 format!("plan list exceeds {LIMIT} entries"),
359 )
360}
361
362#[derive(Debug, Error)]
364pub enum OperationPlanError {
365 #[error("unsupported operation plan version {0}")]
367 UnsupportedVersion(u16),
368 #[error("operation plan has no selected targets")]
370 EmptySelection,
371 #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
373 TooManySelected,
374 #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
376 TooManyOperations,
377 #[error("invalid operation plan principal in {0}")]
379 InvalidPrincipal(&'static str),
380 #[error("duplicate selected plan target {0}")]
382 DuplicateSelected(String),
383 #[error("duplicate planned operation {0}")]
385 DuplicateOperation(u64),
386 #[error("operation table count differs from dependency graph")]
388 OperationCountMismatch,
389 #[error("operation table differs from graph: expected {expected}, actual {actual}")]
391 OperationGraphMismatch {
392 expected: u64,
394 actual: u64,
396 },
397 #[error("operation targets unselected physical identity {0}")]
399 UnselectedTarget(String),
400 #[error("selected plan target has no operation {0}")]
402 UnusedTarget(String),
403 #[error("unknown planned operation {0}")]
405 UnknownOperation(u64),
406 #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
408 BudgetTooLarge,
409 #[error("assigned operation attempts exceed original plan ceilings")]
411 AssignedBudgetExceeded,
412 #[error(transparent)]
414 Checksum(#[from] ChecksumError),
415 #[error(transparent)]
417 Inventory(#[from] InventoryRecordError),
418 #[error(transparent)]
420 Attempt(#[from] AttemptJournalRecordError),
421}
422
423#[cfg(test)]
424pub(crate) mod tests;