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 effect_graph::{EffectGraphRecord, MAX_EFFECT_OPERATIONS},
17 inventory::{InventoryRecord, InventoryRecordError, MAX_INVENTORY_TARGETS},
18};
19use serde::{Deserialize, Deserializer, Serialize, de};
20use std::{collections::BTreeSet, fmt};
21use thiserror::Error;
22
23pub const MAX_OPERATION_PLAN_BYTES: u64 = 1024 * 1024;
25
26#[derive(Clone, Debug)]
28pub struct OperationPlanRequest {
29 pub context: PlanContextRecord,
31 pub inventory: InventoryRecord,
33 pub selected_targets: Vec<String>,
35 pub graph: EffectGraphRecord,
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 graph: EffectGraphRecord,
55 operations: Vec<PlannedOperationRecord>,
56 budget: PlanBudgetRecord,
57 #[serde(skip)]
58 allocated: AllocatedAttemptsView,
59}
60#[derive(Deserialize)]
61#[serde(deny_unknown_fields)]
62struct PlanFields {
63 version: u16,
64 context: PlanContextRecord,
65 inventory: InventoryRecord,
66 #[serde(deserialize_with = "bounded_selected")]
67 selected_targets: Vec<String>,
68 graph: EffectGraphRecord,
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 graph: fields.graph,
84 operations: fields.operations,
85 budget: fields.budget,
86 })
87 }
88}
89impl OperationPlanRecord {
90 pub fn new(mut request: OperationPlanRequest) -> Result<Self, OperationPlanError> {
95 if request.selected_targets.is_empty() {
96 return Err(OperationPlanError::EmptySelection);
97 }
98 if request.selected_targets.len() > MAX_INVENTORY_TARGETS {
99 return Err(OperationPlanError::TooManySelected);
100 }
101 for id in &mut request.selected_targets {
102 *id = request.inventory.target(id)?.canister_id().into();
103 }
104 request.selected_targets.sort();
105 for pair in request.selected_targets.windows(2) {
106 if pair[0] == pair[1] {
107 return Err(OperationPlanError::DuplicateSelected(pair[0].clone()));
108 }
109 }
110 if request.operations.len() > MAX_EFFECT_OPERATIONS {
111 return Err(OperationPlanError::TooManyOperations);
112 }
113 request
114 .operations
115 .sort_by_key(PlannedOperationRecord::operation_sequence);
116 for pair in request.operations.windows(2) {
117 if pair[0].operation_sequence() == pair[1].operation_sequence() {
118 return Err(OperationPlanError::DuplicateOperation(
119 pair[0].operation_sequence(),
120 ));
121 }
122 }
123 if request.operations.len() != request.graph.nodes().len() {
124 return Err(OperationPlanError::OperationCountMismatch);
125 }
126 let selected: BTreeSet<_> = request
127 .selected_targets
128 .iter()
129 .map(String::as_str)
130 .collect();
131 let mut used = BTreeSet::new();
132 let mut allocated = AllocatedAttemptsView {
133 mutations: 0,
134 observations: 0,
135 };
136 for (node, operation) in request.graph.nodes().iter().zip(&request.operations) {
137 if node.operation_sequence() != operation.operation_sequence() {
138 return Err(OperationPlanError::OperationGraphMismatch {
139 expected: node.operation_sequence(),
140 actual: operation.operation_sequence(),
141 });
142 }
143 if !selected.contains(operation.target()) {
144 return Err(OperationPlanError::UnselectedTarget(
145 operation.target().into(),
146 ));
147 }
148 used.insert(operation.target());
149 allocated.mutations = allocated
150 .mutations
151 .checked_add(operation.budget().mutations())
152 .ok_or(OperationPlanError::BudgetTooLarge)?;
153 allocated.observations = allocated
154 .observations
155 .checked_add(operation.budget().observations())
156 .ok_or(OperationPlanError::BudgetTooLarge)?;
157 }
158 if !request.budget.admits(allocated) {
159 return Err(OperationPlanError::AssignedBudgetExceeded);
160 }
161 for id in &request.selected_targets {
162 if !used.contains(id.as_str()) {
163 return Err(OperationPlanError::UnusedTarget(id.clone()));
164 }
165 }
166 Ok(Self {
167 version: 1,
168 context: request.context,
169 inventory: request.inventory,
170 selected_targets: request.selected_targets,
171 graph: request.graph,
172 operations: request.operations,
173 budget: request.budget,
174 allocated,
175 })
176 }
177 #[must_use]
179 pub const fn context(&self) -> &PlanContextRecord {
180 &self.context
181 }
182 #[must_use]
184 pub const fn inventory(&self) -> &InventoryRecord {
185 &self.inventory
186 }
187 #[must_use]
189 pub fn selected_targets(&self) -> &[String] {
190 &self.selected_targets
191 }
192 #[must_use]
194 pub const fn graph(&self) -> &EffectGraphRecord {
195 &self.graph
196 }
197 #[must_use]
199 pub fn operations(&self) -> &[PlannedOperationRecord] {
200 &self.operations
201 }
202 #[must_use]
204 pub const fn budget(&self) -> &PlanBudgetRecord {
205 &self.budget
206 }
207 #[must_use]
209 pub const fn allocated_attempts(&self) -> AllocatedAttemptsView {
210 self.allocated
211 }
212 pub fn operation(&self, sequence: u64) -> Result<&PlannedOperationRecord, OperationPlanError> {
217 self.operations
218 .binary_search_by_key(&sequence, PlannedOperationRecord::operation_sequence)
219 .map(|index| &self.operations[index])
220 .map_err(|_| OperationPlanError::UnknownOperation(sequence))
221 }
222 pub fn attempt_authority(
230 &self,
231 sequence: u64,
232 ) -> Result<AttemptAuthorityRecord, OperationPlanError> {
233 let operation = self.operation(sequence)?;
234 let binding = OperationBindingRecord::new(&OperationBindingRequest {
235 intent: self.digest().hash().into(),
236 operation_sequence: sequence,
237 network: self.context.network().into(),
238 caller: self.context.caller().into(),
239 target: operation.target().into(),
240 release: self.context.release().into(),
241 request: operation.request().into(),
242 })?;
243 Ok(AttemptAuthorityRecord::new(
244 binding,
245 operation.budget().clone(),
246 ))
247 }
248 #[must_use]
252 pub fn digest(&self) -> ArtifactChecksumRecord {
253 let mut bytes = b"ic-backup/operation-plan/v1\0".to_vec();
254 bytes.extend_from_slice(self.context.network().as_bytes());
255 append_principal(&mut bytes, self.context.caller());
256 bytes.extend_from_slice(self.context.release().as_bytes());
257 bytes.extend_from_slice(self.inventory.digest().hash().as_bytes());
258 bytes.extend_from_slice(self.graph.digest().hash().as_bytes());
259 append_count(&mut bytes, self.selected_targets.len());
260 for target in &self.selected_targets {
261 append_principal(&mut bytes, target);
262 }
263 bytes.extend_from_slice(&self.budget.mutations().to_be_bytes());
264 bytes.extend_from_slice(&self.budget.observations().to_be_bytes());
265 append_count(&mut bytes, self.operations.len());
266 for operation in &self.operations {
267 bytes.extend_from_slice(&operation.operation_sequence().to_be_bytes());
268 append_principal(&mut bytes, operation.target());
269 bytes.extend_from_slice(operation.request().as_bytes());
270 bytes.extend_from_slice(&operation.budget().mutations().to_be_bytes());
271 bytes.extend_from_slice(&operation.budget().observations().to_be_bytes());
272 }
273 ArtifactChecksumRecord::from_bytes(&bytes)
274 }
275}
276fn append_principal(bytes: &mut Vec<u8>, text: &str) {
277 bytes.push(text.len().to_le_bytes()[0]);
279 bytes.extend_from_slice(text.as_bytes());
280}
281#[expect(
282 clippy::cast_possible_truncation,
283 reason = "validated selection/operation counts are at most 8192"
284)]
285fn append_count(bytes: &mut Vec<u8>, count: usize) {
286 bytes.extend_from_slice(&(count as u32).to_be_bytes());
287}
288fn canonical_hash(text: &str) -> Result<String, OperationPlanError> {
289 Ok(ArtifactChecksumRecord::from_hash(text)?.hash().into())
290}
291
292fn bounded_selected<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Vec<String>, D::Error> {
293 bounded::<D, String, MAX_INVENTORY_TARGETS>(deserializer)
294}
295fn bounded_operations<'de, D: Deserializer<'de>>(
296 deserializer: D,
297) -> Result<Vec<PlannedOperationRecord>, D::Error> {
298 bounded::<D, PlannedOperationRecord, MAX_EFFECT_OPERATIONS>(deserializer)
299}
300fn bounded<'de, D, T, const LIMIT: usize>(deserializer: D) -> Result<Vec<T>, D::Error>
301where
302 D: Deserializer<'de>,
303 T: Deserialize<'de>,
304{
305 struct Visitor<T, const LIMIT: usize>(std::marker::PhantomData<T>);
306 impl<'de, T: Deserialize<'de>, const LIMIT: usize> de::Visitor<'de> for Visitor<T, LIMIT> {
307 type Value = Vec<T>;
308 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309 write!(f, "at most {LIMIT} plan entries")
310 }
311 fn visit_seq<A: de::SeqAccess<'de>>(
312 self,
313 mut sequence: A,
314 ) -> Result<Self::Value, A::Error> {
315 let mut entries = Vec::new();
316 while entries.len() < LIMIT {
317 match sequence.next_element()? {
318 Some(entry) => entries.push(entry),
319 None => return Ok(entries),
320 }
321 }
322 if sequence.next_element::<de::IgnoredAny>()?.is_some() {
323 return Err(de::Error::custom(format!(
324 "plan list exceeds {LIMIT} entries"
325 )));
326 }
327 Ok(entries)
328 }
329 }
330 deserializer.deserialize_seq(Visitor::<T, LIMIT>(std::marker::PhantomData))
331}
332
333#[derive(Debug, Error)]
335pub enum OperationPlanError {
336 #[error("unsupported operation plan version {0}")]
338 UnsupportedVersion(u16),
339 #[error("operation plan has no selected targets")]
341 EmptySelection,
342 #[error("operation plan exceeds {MAX_INVENTORY_TARGETS} selected targets")]
344 TooManySelected,
345 #[error("operation plan exceeds {MAX_EFFECT_OPERATIONS} operations")]
347 TooManyOperations,
348 #[error("invalid operation plan principal in {0}")]
350 InvalidPrincipal(&'static str),
351 #[error("duplicate selected plan target {0}")]
353 DuplicateSelected(String),
354 #[error("duplicate planned operation {0}")]
356 DuplicateOperation(u64),
357 #[error("operation table count differs from dependency graph")]
359 OperationCountMismatch,
360 #[error("operation table differs from graph: expected {expected}, actual {actual}")]
362 OperationGraphMismatch {
363 expected: u64,
365 actual: u64,
367 },
368 #[error("operation targets unselected physical identity {0}")]
370 UnselectedTarget(String),
371 #[error("selected plan target has no operation {0}")]
373 UnusedTarget(String),
374 #[error("unknown planned operation {0}")]
376 UnknownOperation(u64),
377 #[error("operation plan attempt ceiling exceeds {MAX_PLAN_ATTEMPTS}")]
379 BudgetTooLarge,
380 #[error("assigned operation attempts exceed original plan ceilings")]
382 AssignedBudgetExceeded,
383 #[error(transparent)]
385 Checksum(#[from] ChecksumError),
386 #[error(transparent)]
388 Inventory(#[from] InventoryRecordError),
389 #[error(transparent)]
391 Attempt(#[from] AttemptJournalRecordError),
392}
393
394#[cfg(test)]
395pub(crate) mod tests;