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