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 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 fn attempt_authorities(&self) -> Result<Vec<AttemptAuthorityRecord>, OperationPlanError> {
246 let intent = self.digest();
247 self.operations
248 .iter()
249 .map(|operation| self.authority_for_operation(operation, &intent))
250 .collect()
251 }
252 fn authority_for_operation(
253 &self,
254 operation: &PlannedOperationRecord,
255 intent: &ArtifactChecksumRecord,
256 ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
257 Ok(AttemptAuthorityRecord::new(
258 self.binding_for_operation(operation, intent)?,
259 operation.budget().clone(),
260 ))
261 }
262
263 pub(crate) fn operation_binding(
264 &self,
265 sequence: u64,
266 ) -> Result<OperationBindingRecord, OperationPlanError> {
267 self.binding_for_operation(self.operation(sequence)?, &self.digest())
268 }
269
270 fn binding_for_operation(
271 &self,
272 operation: &PlannedOperationRecord,
273 intent: &ArtifactChecksumRecord,
274 ) -> Result<OperationBindingRecord, OperationPlanError> {
275 Ok(OperationBindingRecord::new(&OperationBindingRequest {
276 intent: intent.hash().into(),
277 operation_sequence: operation.operation_sequence(),
278 network: self.context.network().into(),
279 caller: self.context.caller().into(),
280 target: operation.target().into(),
281 release: self.context.release().into(),
282 request: operation.request().into(),
283 })?)
284 }
285 pub(crate) fn matches_authority(
288 &self,
289 operation: &PlannedOperationRecord,
290 intent: &ArtifactChecksumRecord,
291 authority: &AttemptAuthorityRecord,
292 ) -> bool {
293 let binding = authority.binding();
294 let identity_matches = binding.intent() == intent.hash()
295 && binding.operation_sequence() == operation.operation_sequence()
296 && binding.target() == operation.target()
297 && binding.request() == operation.request();
298 let context_matches = self.context.binding_mismatch(binding).is_none();
299 identity_matches && context_matches && authority.budget() == operation.budget()
300 }
301 #[must_use]
305 pub fn digest(&self) -> ArtifactChecksumRecord {
306 let mut bytes = EvidenceDigest::new(b"ic-backup/operation-plan/v1\0");
307 bytes.append(self.context.network().as_bytes());
308 bytes.bytes_u8(self.context.caller().as_bytes());
309 bytes.append(self.context.release().as_bytes());
310 bytes.checksum(&self.inventory.digest());
311 bytes.checksum(&self.graph.digest());
312 append_count(&mut bytes, self.selected_targets.len());
313 for target in &self.selected_targets {
314 bytes.bytes_u8(target.as_bytes());
315 }
316 bytes.u32_be(self.budget.mutations());
317 bytes.u32_be(self.budget.observations());
318 append_count(&mut bytes, self.operations.len());
319 for operation in &self.operations {
320 bytes.u64_be(operation.operation_sequence());
321 bytes.bytes_u8(operation.target().as_bytes());
322 bytes.append(operation.request().as_bytes());
323 bytes.u32_be(operation.budget().mutations());
324 bytes.u32_be(operation.budget().observations());
325 }
326 bytes.finish()
327 }
328}
329#[expect(
330 clippy::cast_possible_truncation,
331 reason = "validated selection/operation counts are at most 8192"
332)]
333fn append_count(bytes: &mut EvidenceDigest, count: usize) {
334 bytes.u32_be(count as u32);
335}
336fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
337 bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
338}
339fn bounded_operations<'de, D: Deserializer<'de>>(
340 deserializer: D,
341) -> Result<Vec<PlannedOperationRecord>, D::Error> {
342 bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
343}
344fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
345where
346 D: Deserializer<'de>,
347 T: Deserialize<'de>,
348{
349 bounded_sequence::deserialize::<_, _, LIMIT>(
350 deserializer,
351 format!("at most {LIMIT} plan entries"),
352 format!("plan list exceeds {LIMIT} entries"),
353 )
354}
355
356#[derive(Debug, Error)]
358pub enum OperationPlanError {
359 #[error("unsupported operation plan version {0}")]
361 UnsupportedVersion(u16),
362 #[error("operation plan has no selected targets")]
364 EmptySelection,
365 #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
367 TooManySelected,
368 #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
370 TooManyOperations,
371 #[error("invalid operation plan principal in {0}")]
373 InvalidPrincipal(&'static str),
374 #[error("duplicate selected plan target {0}")]
376 DuplicateSelected(String),
377 #[error("duplicate planned operation {0}")]
379 DuplicateOperation(u64),
380 #[error("operation table count differs from dependency graph")]
382 OperationCountMismatch,
383 #[error("operation table differs from graph: expected {expected}, actual {actual}")]
385 OperationGraphMismatch {
386 expected: u64,
388 actual: u64,
390 },
391 #[error("operation targets unselected physical identity {0}")]
393 UnselectedTarget(String),
394 #[error("selected plan target has no operation {0}")]
396 UnusedTarget(String),
397 #[error("unknown planned operation {0}")]
399 UnknownOperation(u64),
400 #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
402 BudgetTooLarge,
403 #[error("assigned operation attempts exceed original plan ceilings")]
405 AssignedBudgetExceeded,
406 #[error(transparent)]
408 Checksum(#[from] ChecksumError),
409 #[error(transparent)]
411 Inventory(#[from] InventoryRecordError),
412 #[error(transparent)]
414 Attempt(#[from] AttemptJournalRecordError),
415}
416
417#[cfg(test)]
418mod tests;