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 let binding = OperationBindingRecord::new(&OperationBindingRequest {
257 intent: intent.hash().into(),
258 operation_sequence: operation.operation_sequence(),
259 network: self.context.network().into(),
260 caller: self.context.caller().into(),
261 target: operation.target().into(),
262 release: self.context.release().into(),
263 request: operation.request().into(),
264 })?;
265 Ok(AttemptAuthorityRecord::new(
266 binding,
267 operation.budget().clone(),
268 ))
269 }
270 pub(crate) fn matches_authority(
273 &self,
274 operation: &PlannedOperationRecord,
275 intent: &ArtifactChecksumRecord,
276 authority: &AttemptAuthorityRecord,
277 ) -> bool {
278 let binding = authority.binding();
279 let identity_matches = binding.intent() == intent.hash()
280 && binding.operation_sequence() == operation.operation_sequence()
281 && binding.target() == operation.target()
282 && binding.request() == operation.request();
283 let context_matches = binding.network() == self.context.network()
284 && binding.caller() == self.context.caller()
285 && binding.release() == self.context.release();
286 identity_matches && context_matches && authority.budget() == operation.budget()
287 }
288 #[must_use]
292 pub fn digest(&self) -> ArtifactChecksumRecord {
293 let mut bytes = EvidenceDigest::new(b"ic-backup/operation-plan/v1\0");
294 bytes.append(self.context.network().as_bytes());
295 append_principal(&mut bytes, self.context.caller());
296 bytes.append(self.context.release().as_bytes());
297 bytes.append(self.inventory.digest().hash().as_bytes());
298 bytes.append(self.graph.digest().hash().as_bytes());
299 append_count(&mut bytes, self.selected_targets.len());
300 for target in &self.selected_targets {
301 append_principal(&mut bytes, target);
302 }
303 bytes.append(&self.budget.mutations().to_be_bytes());
304 bytes.append(&self.budget.observations().to_be_bytes());
305 append_count(&mut bytes, self.operations.len());
306 for operation in &self.operations {
307 bytes.append(&operation.operation_sequence().to_be_bytes());
308 append_principal(&mut bytes, operation.target());
309 bytes.append(operation.request().as_bytes());
310 bytes.append(&operation.budget().mutations().to_be_bytes());
311 bytes.append(&operation.budget().observations().to_be_bytes());
312 }
313 bytes.finish()
314 }
315}
316fn append_principal(bytes: &mut EvidenceDigest, text: &str) {
317 bytes.byte(text.len().to_le_bytes()[0]);
319 bytes.append(text.as_bytes());
320}
321#[expect(
322 clippy::cast_possible_truncation,
323 reason = "validated selection/operation counts are at most 8192"
324)]
325fn append_count(bytes: &mut EvidenceDigest, count: usize) {
326 bytes.append(&(count as u32).to_be_bytes());
327}
328fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
329 bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
330}
331fn bounded_operations<'de, D: Deserializer<'de>>(
332 deserializer: D,
333) -> Result<Vec<PlannedOperationRecord>, D::Error> {
334 bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
335}
336fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
337where
338 D: Deserializer<'de>,
339 T: Deserialize<'de>,
340{
341 bounded_sequence::deserialize::<_, _, LIMIT>(
342 deserializer,
343 format!("at most {LIMIT} plan entries"),
344 format!("plan list exceeds {LIMIT} entries"),
345 )
346}
347
348#[derive(Debug, Error)]
350pub enum OperationPlanError {
351 #[error("unsupported operation plan version {0}")]
353 UnsupportedVersion(u16),
354 #[error("operation plan has no selected targets")]
356 EmptySelection,
357 #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
359 TooManySelected,
360 #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
362 TooManyOperations,
363 #[error("invalid operation plan principal in {0}")]
365 InvalidPrincipal(&'static str),
366 #[error("duplicate selected plan target {0}")]
368 DuplicateSelected(String),
369 #[error("duplicate planned operation {0}")]
371 DuplicateOperation(u64),
372 #[error("operation table count differs from dependency graph")]
374 OperationCountMismatch,
375 #[error("operation table differs from graph: expected {expected}, actual {actual}")]
377 OperationGraphMismatch {
378 expected: u64,
380 actual: u64,
382 },
383 #[error("operation targets unselected physical identity {0}")]
385 UnselectedTarget(String),
386 #[error("selected plan target has no operation {0}")]
388 UnusedTarget(String),
389 #[error("unknown planned operation {0}")]
391 UnknownOperation(u64),
392 #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
394 BudgetTooLarge,
395 #[error("assigned operation attempts exceed original plan ceilings")]
397 AssignedBudgetExceeded,
398 #[error(transparent)]
400 Checksum(#[from] ChecksumError),
401 #[error(transparent)]
403 Inventory(#[from] InventoryRecordError),
404 #[error(transparent)]
406 Attempt(#[from] AttemptJournalRecordError),
407}
408
409#[cfg(test)]
410pub(crate) mod tests;