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 #[must_use]
274 pub fn digest(&self) -> ArtifactChecksumRecord {
275 let mut bytes = EvidenceDigest::new(b"ic-backup/operation-plan/v1\0");
276 bytes.append(self.context.network().as_bytes());
277 append_principal(&mut bytes, self.context.caller());
278 bytes.append(self.context.release().as_bytes());
279 bytes.append(self.inventory.digest().hash().as_bytes());
280 bytes.append(self.graph.digest().hash().as_bytes());
281 append_count(&mut bytes, self.selected_targets.len());
282 for target in &self.selected_targets {
283 append_principal(&mut bytes, target);
284 }
285 bytes.append(&self.budget.mutations().to_be_bytes());
286 bytes.append(&self.budget.observations().to_be_bytes());
287 append_count(&mut bytes, self.operations.len());
288 for operation in &self.operations {
289 bytes.append(&operation.operation_sequence().to_be_bytes());
290 append_principal(&mut bytes, operation.target());
291 bytes.append(operation.request().as_bytes());
292 bytes.append(&operation.budget().mutations().to_be_bytes());
293 bytes.append(&operation.budget().observations().to_be_bytes());
294 }
295 bytes.finish()
296 }
297}
298fn append_principal(bytes: &mut EvidenceDigest, text: &str) {
299 bytes.byte(text.len().to_le_bytes()[0]);
301 bytes.append(text.as_bytes());
302}
303#[expect(
304 clippy::cast_possible_truncation,
305 reason = "validated selection/operation counts are at most 8192"
306)]
307fn append_count(bytes: &mut EvidenceDigest, count: usize) {
308 bytes.append(&(count as u32).to_be_bytes());
309}
310fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
311 bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
312}
313fn bounded_operations<'de, D: Deserializer<'de>>(
314 deserializer: D,
315) -> Result<Vec<PlannedOperationRecord>, D::Error> {
316 bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
317}
318fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
319where
320 D: Deserializer<'de>,
321 T: Deserialize<'de>,
322{
323 bounded_sequence::deserialize::<_, _, LIMIT>(
324 deserializer,
325 format!("at most {LIMIT} plan entries"),
326 format!("plan list exceeds {LIMIT} entries"),
327 )
328}
329
330#[derive(Debug, Error)]
332pub enum OperationPlanError {
333 #[error("unsupported operation plan version {0}")]
335 UnsupportedVersion(u16),
336 #[error("operation plan has no selected targets")]
338 EmptySelection,
339 #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
341 TooManySelected,
342 #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
344 TooManyOperations,
345 #[error("invalid operation plan principal in {0}")]
347 InvalidPrincipal(&'static str),
348 #[error("duplicate selected plan target {0}")]
350 DuplicateSelected(String),
351 #[error("duplicate planned operation {0}")]
353 DuplicateOperation(u64),
354 #[error("operation table count differs from dependency graph")]
356 OperationCountMismatch,
357 #[error("operation table differs from graph: expected {expected}, actual {actual}")]
359 OperationGraphMismatch {
360 expected: u64,
362 actual: u64,
364 },
365 #[error("operation targets unselected physical identity {0}")]
367 UnselectedTarget(String),
368 #[error("selected plan target has no operation {0}")]
370 UnusedTarget(String),
371 #[error("unknown planned operation {0}")]
373 UnknownOperation(u64),
374 #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
376 BudgetTooLarge,
377 #[error("assigned operation attempts exceed original plan ceilings")]
379 AssignedBudgetExceeded,
380 #[error(transparent)]
382 Checksum(#[from] ChecksumError),
383 #[error(transparent)]
385 Inventory(#[from] InventoryRecordError),
386 #[error(transparent)]
388 Attempt(#[from] AttemptJournalRecordError),
389}
390
391#[cfg(test)]
392pub(crate) mod tests;