ic_backup/model/operation_plan/
mod.rs1mod budget;
4mod context;
5mod operation;
6pub use budget::{AllocatedAttemptsView, MAX_PLAN_ATTEMPTS, PlanBudgetRecord};
7pub use context::{PlanContextRecord, PlanContextRequest};
8pub use operation::{PlannedOperationRecord, PlannedOperationRequest};
9
10use crate::model::{
11 artifacts::{ArtifactChecksumRecord, ChecksumError},
12 attempt_journal::{
13 AttemptAuthorityRecord, AttemptJournalRecordError, OperationBindingRecord,
14 OperationBindingRequest,
15 },
16 bounded_sequence,
17 effect_graph::{EffectGraphRecord, MAX_EFFECT_OPERATIONS},
18 inventory::{InventoryRecord, InventoryRecordError, MAX_INVENTORY_TARGETS},
19};
20use serde::{Deserialize, Deserializer, Serialize};
21use std::collections::BTreeSet;
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 selected: BTreeSet<_> = request
128 .selected_targets
129 .iter()
130 .map(String::as_str)
131 .collect();
132 let mut used = BTreeSet::new();
133 let mut allocated = AllocatedAttemptsView {
134 mutations: 0,
135 observations: 0,
136 };
137 for (node, operation) in request.graph.nodes().iter().zip(&request.operations) {
138 if node.operation_sequence() != operation.operation_sequence() {
139 return Err(OperationPlanError::OperationGraphMismatch {
140 expected: node.operation_sequence(),
141 actual: operation.operation_sequence(),
142 });
143 }
144 if !selected.contains(operation.target()) {
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 let binding = OperationBindingRecord::new(&OperationBindingRequest {
256 intent: intent.hash().into(),
257 operation_sequence: operation.operation_sequence(),
258 network: self.context.network().into(),
259 caller: self.context.caller().into(),
260 target: operation.target().into(),
261 release: self.context.release().into(),
262 request: operation.request().into(),
263 })?;
264 Ok(AttemptAuthorityRecord::new(
265 binding,
266 operation.budget().clone(),
267 ))
268 }
269 #[must_use]
273 pub fn digest(&self) -> ArtifactChecksumRecord {
274 let mut bytes = b"ic-backup/operation-plan/v1\0".to_vec();
275 bytes.extend_from_slice(self.context.network().as_bytes());
276 append_principal(&mut bytes, self.context.caller());
277 bytes.extend_from_slice(self.context.release().as_bytes());
278 bytes.extend_from_slice(self.inventory.digest().hash().as_bytes());
279 bytes.extend_from_slice(self.graph.digest().hash().as_bytes());
280 append_count(&mut bytes, self.selected_targets.len());
281 for target in &self.selected_targets {
282 append_principal(&mut bytes, target);
283 }
284 bytes.extend_from_slice(&self.budget.mutations().to_be_bytes());
285 bytes.extend_from_slice(&self.budget.observations().to_be_bytes());
286 append_count(&mut bytes, self.operations.len());
287 for operation in &self.operations {
288 bytes.extend_from_slice(&operation.operation_sequence().to_be_bytes());
289 append_principal(&mut bytes, operation.target());
290 bytes.extend_from_slice(operation.request().as_bytes());
291 bytes.extend_from_slice(&operation.budget().mutations().to_be_bytes());
292 bytes.extend_from_slice(&operation.budget().observations().to_be_bytes());
293 }
294 ArtifactChecksumRecord::from_bytes(&bytes)
295 }
296}
297fn append_principal(bytes: &mut Vec<u8>, text: &str) {
298 bytes.push(text.len().to_le_bytes()[0]);
300 bytes.extend_from_slice(text.as_bytes());
301}
302#[expect(
303 clippy::cast_possible_truncation,
304 reason = "validated selection/operation counts are at most 8192"
305)]
306fn append_count(bytes: &mut Vec<u8>, count: usize) {
307 bytes.extend_from_slice(&(count as u32).to_be_bytes());
308}
309fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
310 bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
311}
312fn bounded_operations<'de, D: Deserializer<'de>>(
313 deserializer: D,
314) -> Result<Vec<PlannedOperationRecord>, D::Error> {
315 bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
316}
317fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
318where
319 D: Deserializer<'de>,
320 T: Deserialize<'de>,
321{
322 bounded_sequence::deserialize::<_, _, LIMIT>(
323 deserializer,
324 format!("at most {LIMIT} plan entries"),
325 format!("plan list exceeds {LIMIT} entries"),
326 )
327}
328
329#[derive(Debug, Error)]
331pub enum OperationPlanError {
332 #[error("unsupported operation plan version {0}")]
334 UnsupportedVersion(u16),
335 #[error("operation plan has no selected targets")]
337 EmptySelection,
338 #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
340 TooManySelected,
341 #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
343 TooManyOperations,
344 #[error("invalid operation plan principal in {0}")]
346 InvalidPrincipal(&'static str),
347 #[error("duplicate selected plan target {0}")]
349 DuplicateSelected(String),
350 #[error("duplicate planned operation {0}")]
352 DuplicateOperation(u64),
353 #[error("operation table count differs from dependency graph")]
355 OperationCountMismatch,
356 #[error("operation table differs from graph: expected {expected}, actual {actual}")]
358 OperationGraphMismatch {
359 expected: u64,
361 actual: u64,
363 },
364 #[error("operation targets unselected physical identity {0}")]
366 UnselectedTarget(String),
367 #[error("selected plan target has no operation {0}")]
369 UnusedTarget(String),
370 #[error("unknown planned operation {0}")]
372 UnknownOperation(u64),
373 #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
375 BudgetTooLarge,
376 #[error("assigned operation attempts exceed original plan ceilings")]
378 AssignedBudgetExceeded,
379 #[error(transparent)]
381 Checksum(#[from] ChecksumError),
382 #[error(transparent)]
384 Inventory(#[from] InventoryRecordError),
385 #[error(transparent)]
387 Attempt(#[from] AttemptJournalRecordError),
388}
389
390#[cfg(test)]
391pub(crate) mod tests;