1use crate::model::evidence_digest::EvidenceDigest;
4mod budget;
5mod context;
6mod dependencies;
7mod operation;
8pub use budget::{AllocatedAttemptsView, MAX_PLAN_ATTEMPTS, PlanBudgetRecord};
9pub use context::{PlanContextRecord, PlanContextRequest};
10pub use dependencies::{MAX_PLAN_DEPENDENCIES, MAX_PLAN_EDGES, MAX_PLAN_OPERATIONS};
11pub use operation::{PlannedOperationRecord, PlannedOperationRequest};
12
13use crate::model::{
14 artifacts::{ArtifactChecksumRecord, ChecksumError},
15 attempt_journal::{
16 AttemptAuthorityRecord, AttemptJournalRecord, AttemptJournalRecordError,
17 OperationBindingRecord, OperationBindingRequest,
18 },
19 bounded_sequence,
20 inventory::{InventoryRecord, InventoryRecordError, MAX_INVENTORY_TARGETS},
21};
22use serde::{Deserialize, Deserializer, Serialize};
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 operations: Vec<PlannedOperationRecord>,
39 pub budget: PlanBudgetRecord,
41}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48#[serde(try_from = "PlanFields")]
49pub struct OperationPlanRecord {
50 version: u16,
51 context: PlanContextRecord,
52 inventory: InventoryRecord,
53 selected_targets: Vec<String>,
54 operations: Vec<PlannedOperationRecord>,
55 budget: PlanBudgetRecord,
56 #[serde(skip)]
57 allocated: AllocatedAttemptsView,
58 #[serde(skip)]
59 order: Vec<usize>,
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 #[serde(deserialize_with = "bounded_operations")]
70 operations: Vec<PlannedOperationRecord>,
71 budget: PlanBudgetRecord,
72}
73impl TryFrom<PlanFields> for OperationPlanRecord {
74 type Error = OperationPlanError;
75 fn try_from(fields: PlanFields) -> Result<Self, Self::Error> {
76 if fields.version != 1 {
77 return Err(OperationPlanError::UnsupportedVersion(fields.version));
78 }
79 Self::new(OperationPlanRequest {
80 context: fields.context,
81 inventory: fields.inventory,
82 selected_targets: fields.selected_targets,
83 operations: fields.operations,
84 budget: fields.budget,
85 })
86 }
87}
88impl OperationPlanRecord {
89 pub fn new(mut request: OperationPlanRequest) -> Result<Self, OperationPlanError> {
94 if request.selected_targets.is_empty() {
95 return Err(OperationPlanError::EmptySelection);
96 }
97 if request.selected_targets.len() > MAX_INVENTORY_TARGETS {
98 return Err(OperationPlanError::TooManySelected);
99 }
100 for id in &mut request.selected_targets {
101 *id = request.inventory.target(id)?.canister_id().into();
102 }
103 request.selected_targets.sort();
104 for pair in request.selected_targets.windows(2) {
105 if pair[0] == pair[1] {
106 return Err(OperationPlanError::DuplicateSelected(pair[0].clone()));
107 }
108 }
109 if request.operations.is_empty() {
110 return Err(OperationPlanError::EmptyOperations);
111 }
112 if request.operations.len() > MAX_PLAN_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 let order = dependencies::order(&request.operations)?;
126 let mut used = vec![false; request.selected_targets.len()];
127 let mut allocated = AllocatedAttemptsView {
128 mutations: 0,
129 observations: 0,
130 };
131 for operation in &request.operations {
132 let selected = request
133 .selected_targets
134 .binary_search_by(|target| target.as_str().cmp(operation.target()))
135 .map_err(|_| OperationPlanError::UnselectedTarget(operation.target().into()))?;
136 used[selected] = true;
137 allocated.mutations = allocated
138 .mutations
139 .checked_add(operation.budget().mutations())
140 .ok_or(OperationPlanError::BudgetTooLarge)?;
141 allocated.observations = allocated
142 .observations
143 .checked_add(operation.budget().observations())
144 .ok_or(OperationPlanError::BudgetTooLarge)?;
145 }
146 if !request.budget.admits(allocated) {
147 return Err(OperationPlanError::AssignedBudgetExceeded);
148 }
149 for (id, used) in request.selected_targets.iter().zip(used) {
150 if !used {
151 return Err(OperationPlanError::UnusedTarget(id.clone()));
152 }
153 }
154 Ok(Self {
155 version: 1,
156 context: request.context,
157 inventory: request.inventory,
158 selected_targets: request.selected_targets,
159 order,
160 operations: request.operations,
161 budget: request.budget,
162 allocated,
163 })
164 }
165 #[must_use]
167 pub const fn context(&self) -> &PlanContextRecord {
168 &self.context
169 }
170 #[must_use]
172 pub const fn inventory(&self) -> &InventoryRecord {
173 &self.inventory
174 }
175 #[must_use]
177 pub fn selected_targets(&self) -> &[String] {
178 &self.selected_targets
179 }
180 pub fn ordered_operations(
183 &self,
184 ) -> impl ExactSizeIterator<Item = &PlannedOperationRecord> + DoubleEndedIterator + '_ {
185 self.order.iter().map(|index| &self.operations[*index])
186 }
187 #[must_use]
189 pub fn operations(&self) -> &[PlannedOperationRecord] {
190 &self.operations
191 }
192 pub(crate) fn singleton_operation(&self, sequence: u64) -> Option<&PlannedOperationRecord> {
194 match self.operations() {
195 [operation] if operation.operation_sequence() == sequence => Some(operation),
196 _ => None,
197 }
198 }
199
200 #[must_use]
202 pub const fn budget(&self) -> &PlanBudgetRecord {
203 &self.budget
204 }
205 #[must_use]
207 pub const fn allocated_attempts(&self) -> AllocatedAttemptsView {
208 self.allocated
209 }
210 pub fn operation(&self, sequence: u64) -> Result<&PlannedOperationRecord, OperationPlanError> {
215 self.operations
216 .binary_search_by_key(&sequence, PlannedOperationRecord::operation_sequence)
217 .map(|index| &self.operations[index])
218 .map_err(|_| OperationPlanError::UnknownOperation(sequence))
219 }
220 pub fn attempt_authority(
228 &self,
229 sequence: u64,
230 ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
231 let operation = self.operation(sequence)?;
232 self.authority_for_operation(operation, &self.digest())
233 }
234 pub(crate) fn attempt_authority_for_journal<E: From<OperationPlanError>>(
237 &self,
238 sequence: u64,
239 journal: &AttemptJournalRecord,
240 mismatch: E,
241 ) -> Result<AttemptAuthorityRecord, E> {
242 let authority = self.attempt_authority(sequence)?;
243 if journal.authority() != &authority {
244 return Err(mismatch);
245 }
246 Ok(authority)
247 }
248
249 pub fn attempt_authorities(&self) -> Result<Vec<AttemptAuthorityRecord>, OperationPlanError> {
256 let intent = self.digest();
257 self.operations
258 .iter()
259 .map(|operation| self.authority_for_operation(operation, &intent))
260 .collect()
261 }
262 fn authority_for_operation(
263 &self,
264 operation: &PlannedOperationRecord,
265 intent: &ArtifactChecksumRecord,
266 ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
267 Ok(AttemptAuthorityRecord::new(
268 self.binding_for_operation(operation, intent)?,
269 operation.budget().clone(),
270 ))
271 }
272
273 pub(crate) fn operation_binding(
274 &self,
275 sequence: u64,
276 ) -> Result<OperationBindingRecord, OperationPlanError> {
277 self.binding_for_operation(self.operation(sequence)?, &self.digest())
278 }
279
280 fn binding_for_operation(
281 &self,
282 operation: &PlannedOperationRecord,
283 intent: &ArtifactChecksumRecord,
284 ) -> Result<OperationBindingRecord, OperationPlanError> {
285 Ok(OperationBindingRecord::new(&OperationBindingRequest {
286 intent: intent.hash().into(),
287 operation_sequence: operation.operation_sequence(),
288 network: self.context.network().into(),
289 caller: self.context.caller().into(),
290 target: operation.target().into(),
291 release: self.context.release().into(),
292 request: operation.request().into(),
293 })?)
294 }
295 pub(crate) fn matches_authority(
298 &self,
299 operation: &PlannedOperationRecord,
300 intent: &ArtifactChecksumRecord,
301 authority: &AttemptAuthorityRecord,
302 ) -> bool {
303 let binding = authority.binding();
304 let identity_matches = binding.intent() == intent.hash()
305 && binding.operation_sequence() == operation.operation_sequence()
306 && binding.target() == operation.target()
307 && binding.request() == operation.request();
308 let context_matches = self.context.binding_mismatch(binding).is_none();
309 identity_matches && context_matches && authority.budget() == operation.budget()
310 }
311 #[must_use]
315 pub fn digest(&self) -> ArtifactChecksumRecord {
316 let mut bytes = EvidenceDigest::new(b"ic-backup/operation-plan/dependency-rows/v1\0");
317 bytes.append(self.context.network().as_bytes());
318 bytes.bytes_u8(self.context.caller().as_bytes());
319 bytes.append(self.context.release().as_bytes());
320 bytes.checksum(&self.inventory.digest());
321 bytes.count_u32(self.selected_targets.len());
322 for target in &self.selected_targets {
323 bytes.bytes_u8(target.as_bytes());
324 }
325 bytes.u32_be(self.budget.mutations());
326 bytes.u32_be(self.budget.observations());
327 bytes.count_u32(self.operations.len());
328 for operation in &self.operations {
329 bytes.u64_be(operation.operation_sequence());
330 bytes.count_u32(operation.depends_on().len());
331 for dependency in operation.depends_on() {
332 bytes.u64_be(*dependency);
333 }
334 bytes.bytes_u8(operation.target().as_bytes());
335 bytes.append(operation.request().as_bytes());
336 bytes.u32_be(operation.budget().mutations());
337 bytes.u32_be(operation.budget().observations());
338 }
339 bytes.finish()
340 }
341}
342fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
343 bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
344}
345fn bounded_operations<'de, D: Deserializer<'de>>(
346 deserializer: D,
347) -> Result<Vec<PlannedOperationRecord>, D::Error> {
348 let mut edges = 0;
349 bounded_sequence::deserialize_checked::<D, PlannedOperationRecord, _, _, MAX_PLAN_OPERATIONS>(
350 deserializer,
351 "bounded dependency-bearing operations",
352 OperationPlanError::TooManyOperations,
353 |operation| {
354 edges = dependencies::add_edges(edges, operation.depends_on().len())?;
355 Ok::<(), OperationPlanError>(())
356 },
357 )
358}
359fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
360where
361 D: Deserializer<'de>,
362 T: Deserialize<'de>,
363{
364 bounded_sequence::deserialize::<_, _, LIMIT>(
365 deserializer,
366 format!("at most {LIMIT} plan entries"),
367 format!("plan list exceeds {LIMIT} entries"),
368 )
369}
370
371#[derive(Debug, Error)]
373pub enum OperationPlanError {
374 #[error("unsupported operation plan version {0}")]
376 UnsupportedVersion(u16),
377 #[error("operation plan has no selected targets")]
379 EmptySelection,
380 #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
382 TooManySelected,
383 #[error("operation plan exceeds {MAX_PLAN_OPERATIONS} operations")]
385 TooManyOperations,
386 #[error("invalid operation plan principal in {0}")]
388 InvalidPrincipal(&'static str),
389 #[error("duplicate selected plan target {0}")]
391 DuplicateSelected(String),
392 #[error("duplicate planned operation {0}")]
394 DuplicateOperation(u64),
395 #[error("operation plan contains no operations")]
397 EmptyOperations,
398 #[error("operation exceeds {MAX_PLAN_DEPENDENCIES} dependencies")]
400 TooManyDependencies,
401 #[error("operation plan exceeds {MAX_PLAN_EDGES} edges")]
403 TooManyEdges,
404 #[error("operation {operation_sequence} repeats dependency {dependency}")]
406 DuplicateDependency {
407 operation_sequence: u64,
409 dependency: u64,
411 },
412 #[error("operation {operation_sequence} has absent dependency {dependency}")]
414 MissingDependency {
415 operation_sequence: u64,
417 dependency: u64,
419 },
420 #[error("operation plan contains a dependency cycle")]
422 Cycle,
423 #[error("operation targets unselected physical identity {0}")]
425 UnselectedTarget(String),
426 #[error("selected plan target has no operation {0}")]
428 UnusedTarget(String),
429 #[error("unknown planned operation {0}")]
431 UnknownOperation(u64),
432 #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
434 BudgetTooLarge,
435 #[error("assigned operation attempts exceed original plan ceilings")]
437 AssignedBudgetExceeded,
438 #[error(transparent)]
440 Checksum(#[from] ChecksumError),
441 #[error(transparent)]
443 Inventory(#[from] InventoryRecordError),
444 #[error(transparent)]
446 Attempt(#[from] AttemptJournalRecordError),
447}
448
449#[cfg(test)]
450mod tests;