1pub mod retirement;
7mod store;
8#[cfg(test)]
9mod tests;
10
11use crate::fleet_ensure::{
12 model::{
13 EffectRecord, EffectState, EnsureAction,
14 capacity_import::{
15 CapacityImportHandoffRecord, CapacityImportHandoffRequestRecord,
16 CapacityImportJournalRecord, CapacityImportPlanRecord, CapacityImportReservationRecord,
17 },
18 },
19 ops::{
20 action_sha256,
21 capacity_import::{
22 CapacityImportReviewError,
23 transport::{CompletedHandoff, validate_request},
24 verify_review,
25 },
26 },
27 policy::capacity_import::{
28 CapacityImportPolicyError, admit_handoffs, admit_source_handoff, retained_source_debit,
29 source_total,
30 },
31 view::capacity_import::{CapacityImportDestinationView, CapacityImportSourceView},
32};
33use candid::Principal;
34use thiserror::Error;
35
36pub use store::CapacityImportJournalStore;
37pub(in crate::fleet_ensure) use store::require_completion_fits;
38pub(in crate::fleet_ensure::ops) use store::require_no_approved_import;
39
40#[derive(Clone, Copy, Debug, Eq, PartialEq)]
42pub enum CapacityImportInventoryStage {
43 Decode,
44 Membership,
45 Page,
46 Policy,
47 Query,
48 Summary,
49}
50
51#[derive(Debug, Error)]
53pub enum CapacityImportJournalError {
54 #[error(
55 "no original capacity observation is retained for {canister}; keep the original review inputs when declaring additional funding, or review this source before declaring a credit"
56 )]
57 FundingBaselineMissing { canister: Principal },
58 #[error(
59 "capacity import requires a completed current Fleet or receipted bootstrap setup with exact installed infrastructure records"
60 )]
61 InfrastructureRequired,
62 #[error("capacity import infrastructure module, controllers, placement or Registry changed")]
63 InfrastructureChanged,
64 #[error(
65 "capacity import requires an exact operator disposition declaration for every candidate"
66 )]
67 DispositionInvalid,
68 #[error(
69 "capacity import inventory is incomplete, changed, duplicated or exceeds its observation bound"
70 )]
71 InventoryInvalid,
72 #[error("capacity import inventory observation of {canister} failed at {stage:?}")]
73 InventoryObservation {
74 canister: Principal,
75 stage: CapacityImportInventoryStage,
76 },
77 #[error(
78 "management observation failed for {canister}; verify operator controller access before retrying"
79 )]
80 ObservationUnavailable { canister: Principal },
81 #[error(transparent)]
82 Inspection(Box<crate::canister_protocol::CanisterProtocolError>),
83 #[error(transparent)]
84 Prerequisite(
85 #[from] crate::fleet_ensure::ops::capacity_import::CapacityImportPrerequisiteError,
86 ),
87 #[error(
88 "Coordinator {coordinator} is unavailable; verify its initialization, network and operator access"
89 )]
90 CoordinatorUnavailable { coordinator: Principal },
91 #[error("Coordinator rejected the import prerequisite query: {0}")]
92 CoordinatorRejected(canic_contracts::dto::error::Error),
93 #[error(transparent)]
94 Review(#[from] CapacityImportReviewError),
95 #[error(transparent)]
96 Policy(#[from] CapacityImportPolicyError),
97 #[error("capacity import journal does not match the reviewed handoff sequence")]
98 Integrity,
99 #[error("capacity import request differs from the exact reviewed controller handoff")]
100 RequestInvalid,
101 #[error("capacity import transport differs from the reviewed network or signer")]
102 ReaderMismatch,
103 #[error("Root rejected the capacity import request: {0}")]
104 RootRejected(canic_contracts::dto::error::Error),
105 #[error(
106 "capacity import budget cannot cover the complete operation: {paid_calls} calls, at least {minimum_calls} required; debit ceiling {maximum_debit_cycles}, required {required_debit_cycles}"
107 )]
108 InsufficientRootBudget {
109 paid_calls: u32,
110 minimum_calls: u32,
111 maximum_debit_cycles: u128,
112 required_debit_cycles: u128,
113 },
114 #[error(
115 "Root capacity import hit a capacity limit in phase {phase:?}: {reserved_debit_cycles}/{maximum_debit_cycles} cycles reserved, {observed_debit_cycles} observed debit, {paid_calls}/{maximum_paid_calls} calls; preserve issued operation authority"
116 )]
117 RootCapacityLimit {
118 phase: canic_contracts::dto::pool_import::PoolImportPhase,
119 reserved_debit_cycles: u128,
120 maximum_debit_cycles: u128,
121 observed_debit_cycles: u128,
122 paid_calls: u32,
123 maximum_paid_calls: u32,
124 },
125 #[error("Root capacity import response has an invalid current-contract encoding")]
126 RootResponseInvalid,
127 #[error("capacity import requires exact approval and a protected Root reservation")]
128 ReservationRequired,
129 #[error("capacity import effect outcome is unresolved; retain its original intent")]
130 Unresolved,
131
132 #[error(
133 "capacity import exhausted its reviewed {step} attempts; preserve the original journal and review continuation with canic --environment <environment> fleet recover-attempts <fleet>"
134 )]
135 BudgetExhausted { step: String },
136 #[error("capacity import inventory inputs or publication evidence changed")]
137 PublicationConflict,
138 #[error("capacity import generator inputs are invalid: {0}")]
139 InventoryProjection(
140 Box<crate::fleet_ensure::generate::capacity_import::CapacityImportInventoryError>,
141 ),
142 #[error("another capacity import already retains approval or effect intent")]
143 Conflict,
144 #[error(transparent)]
145 State(#[from] crate::fleet_ensure::ops::EnsureStateError),
146 #[error("capacity import journal file operation failed")]
147 Io(#[from] std::io::Error),
148 #[error("capacity import journal cannot be decoded or encoded")]
149 Encoding(#[from] serde_json::Error),
150}
151
152impl From<crate::canister_protocol::CanisterProtocolError> for CapacityImportJournalError {
153 fn from(error: crate::canister_protocol::CanisterProtocolError) -> Self {
154 Self::Inspection(Box::new(error))
155 }
156}
157
158pub fn reviewed(
160 plan: CapacityImportPlanRecord,
161) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
162 verify_review(&plan, plan.plan_sha256)?;
163 Ok(CapacityImportJournalRecord {
164 handoffs: plan
165 .sources
166 .iter()
167 .map(|source| CapacityImportHandoffRecord {
168 canister_id: source.binding.canister_id,
169 retirements: Vec::new(),
170 effect: None,
171 request: None,
172 before_reserved_cycles: None,
173 after_reserved_cycles: None,
174 })
175 .collect(),
176 plan,
177 operation: None,
178 approved: false,
179 reservation: None,
180 })
181}
182
183pub fn approve(
185 journal: &CapacityImportJournalRecord,
186 digest: [u8; 32],
187 destination: &CapacityImportDestinationView,
188 sources: &[CapacityImportSourceView],
189) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
190 validate(journal)?;
191 verify_review(&journal.plan, digest)?;
192 if journal.approved {
193 return Ok(journal.clone());
194 }
195 admit_handoffs(&journal.plan, destination, sources)?;
196 let mut updated = journal.clone();
197 updated.approved = true;
198 Ok(updated)
199}
200
201pub fn reserve(
203 journal: &CapacityImportJournalRecord,
204 reservation: CapacityImportReservationRecord,
205) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
206 validate(journal)?;
207 if !journal.approved || !reservation_matches(&journal.plan, &reservation) {
208 return Err(CapacityImportJournalError::ReservationRequired);
209 }
210 if let Some(retained) = &journal.reservation {
211 if *retained != reservation {
212 return Err(CapacityImportJournalError::Integrity);
213 }
214 return Ok(journal.clone());
215 }
216 let mut updated = journal.clone();
217 updated.reservation = Some(reservation);
218 Ok(updated)
219}
220
221pub fn prepare_handoff(
224 journal: &CapacityImportJournalRecord,
225 observed: &CapacityImportSourceView,
226 request: CapacityImportHandoffRequestRecord,
227) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
228 validate(journal)?;
229 if !journal.approved || journal.reservation.is_none() {
230 return Err(CapacityImportJournalError::ReservationRequired);
231 }
232 let index = source_index(journal, observed.binding.canister_id)?;
233 if journal.handoffs[index].effect.is_some() {
234 return Ok(journal.clone());
235 }
236 let source = &journal.plan.sources[index];
237 if !crate::fleet_ensure::policy::capacity_import::requires_handoff(&journal.plan, source) {
238 return Err(CapacityImportJournalError::Integrity);
239 }
240 admit_source_handoff(source, observed)?;
241 validate_request(&journal.plan, observed.binding.canister_id, &request)?;
242 let mut updated = journal.clone();
243 updated.handoffs[index].effect = Some(handoff_intent(&journal.plan, index, observed.cycles));
244 updated.handoffs[index].before_reserved_cycles = Some(observed.reserved_cycles);
245 updated.handoffs[index].request = Some(request);
246 Ok(updated)
247}
248
249fn handoff_intent(
250 plan: &CapacityImportPlanRecord,
251 index: usize,
252 observed_cycles: u128,
253) -> EffectRecord {
254 EffectRecord {
255 maintenance_attempts: 0,
256 publication_attempts: 0,
257 action_sha256: action_sha256(&handoff_action(plan, index)),
258 created_principal: None,
259 destination_post_cycles: None,
260 destination_pre_cycles: None,
261 post_cycles: None,
262 pre_cycles: Some(observed_cycles),
263 pre_canister_version: Some(plan.sources[index].binding.canister_version),
264 progress_identity: None,
265 receipt: None,
266 state: EffectState::Intent,
267 }
268}
269
270pub fn issue_handoff(
272 journal: &CapacityImportJournalRecord,
273 canister: Principal,
274) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
275 validate(journal)?;
276 let index = source_index(journal, canister)?;
277 let mut updated = journal.clone();
278 let effect = updated.handoffs[index]
279 .effect
280 .as_mut()
281 .ok_or(CapacityImportJournalError::Integrity)?;
282 if effect.state != EffectState::Intent {
283 return Err(CapacityImportJournalError::Unresolved);
284 }
285 effect.state = EffectState::Issued;
286 Ok(updated)
287}
288
289pub fn observe_handoff(
292 journal: &CapacityImportJournalRecord,
293 observed: &CapacityImportSourceView,
294 completion: &CompletedHandoff,
295) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
296 validate(journal)?;
297 let index = source_index(journal, observed.binding.canister_id)?;
298 let source = &journal.plan.sources[index];
299 let request = journal.handoffs[index]
300 .request
301 .as_ref()
302 .ok_or(CapacityImportJournalError::Integrity)?;
303 if !completion.matches(&journal.plan, source.binding.canister_id, request) {
304 return Err(CapacityImportJournalError::Unresolved);
305 }
306 apply_observed_handoff(journal, observed)
307}
308
309fn apply_observed_handoff(
311 journal: &CapacityImportJournalRecord,
312 observed: &CapacityImportSourceView,
313) -> Result<CapacityImportJournalRecord, CapacityImportJournalError> {
314 validate(journal)?;
315 let index = source_index(journal, observed.binding.canister_id)?;
316 let effect = journal.handoffs[index]
317 .effect
318 .as_ref()
319 .ok_or(CapacityImportJournalError::Integrity)?;
320 if effect.state == EffectState::Applied {
321 return Ok(journal.clone());
322 }
323 if effect.state != EffectState::Issued {
324 return Err(CapacityImportJournalError::Integrity);
325 }
326 let source = &journal.plan.sources[index];
327 let mut expected = source.binding.clone();
328 expected
329 .controllers
330 .clone_from(&journal.plan.transitional_controllers);
331 expected.canister_version = expected
332 .canister_version
333 .checked_add(1)
334 .ok_or(CapacityImportJournalError::Integrity)?;
335 if observed.binding != expected {
336 return Err(CapacityImportJournalError::Unresolved);
337 }
338 retained_source_debit(source, observed.cycles, observed.reserved_cycles)?;
339 let before = source_total(
340 effect
341 .pre_cycles
342 .ok_or(CapacityImportJournalError::Integrity)?,
343 journal.handoffs[index]
344 .before_reserved_cycles
345 .ok_or(CapacityImportJournalError::Integrity)?,
346 )?;
347 if before.saturating_sub(source_total(observed.cycles, observed.reserved_cycles)?)
348 > source.maximum_debit_cycles
349 {
350 return Err(CapacityImportJournalError::Unresolved);
351 }
352 let mut updated = journal.clone();
353 let effect = updated.handoffs[index]
354 .effect
355 .as_mut()
356 .ok_or(CapacityImportJournalError::Integrity)?;
357 effect.state = EffectState::Applied;
358 effect.post_cycles = Some(observed.cycles);
359 effect.receipt = Some(expected.canister_version.to_string());
361 updated.handoffs[index].after_reserved_cycles = Some(observed.reserved_cycles);
362 validate(&updated)?;
363 Ok(updated)
364}
365
366pub fn validate(journal: &CapacityImportJournalRecord) -> Result<(), CapacityImportJournalError> {
368 verify_review(&journal.plan, journal.plan.plan_sha256)?;
369 crate::fleet_ensure::ops::capacity_import::publication::validate(journal)?;
370 if journal.handoffs.len() != journal.plan.sources.len() {
371 return Err(CapacityImportJournalError::Integrity);
372 }
373 if let Some(reservation) = &journal.reservation
374 && (!journal.approved || !reservation_matches(&journal.plan, reservation))
375 {
376 return Err(CapacityImportJournalError::Integrity);
377 }
378 for (index, handoff) in journal.handoffs.iter().enumerate() {
379 let source = &journal.plan.sources[index];
380 retirement::validate(
381 &journal.plan,
382 handoff,
383 retirement::maximum_requests(journal, handoff.canister_id)?,
384 )?;
385 if handoff.canister_id != source.binding.canister_id {
386 return Err(CapacityImportJournalError::Integrity);
387 }
388 let Some(effect) = &handoff.effect else {
389 if !handoff.retirements.is_empty()
390 || handoff.request.is_some()
391 || handoff.before_reserved_cycles.is_some()
392 || handoff.after_reserved_cycles.is_some()
393 {
394 return Err(CapacityImportJournalError::Integrity);
395 }
396 continue;
397 };
398 if !crate::fleet_ensure::policy::capacity_import::requires_handoff(&journal.plan, source) {
399 return Err(CapacityImportJournalError::Integrity);
400 }
401 validate_request(
402 &journal.plan,
403 handoff.canister_id,
404 handoff
405 .request
406 .as_ref()
407 .ok_or(CapacityImportJournalError::Integrity)?,
408 )?;
409 let action_bound =
410 effect.action_sha256 == action_sha256(&handoff_action(&journal.plan, index));
411 let authority_bound = journal.approved
412 && journal.reservation.is_some()
413 && effect.pre_canister_version == Some(source.binding.canister_version);
414 let counters_empty = effect.maintenance_attempts == 0 && effect.publication_attempts == 0;
415 let unrelated_empty = effect.created_principal.is_none()
416 && effect.destination_pre_cycles.is_none()
417 && effect.destination_post_cycles.is_none()
418 && effect.progress_identity.is_none();
419 if !action_bound || !authority_bound || !counters_empty || !unrelated_empty {
420 return Err(CapacityImportJournalError::Integrity);
421 }
422 let before = effect
423 .pre_cycles
424 .ok_or(CapacityImportJournalError::Integrity)?;
425 let before_reserved = handoff
426 .before_reserved_cycles
427 .ok_or(CapacityImportJournalError::Integrity)?;
428 retained_source_debit(source, before, before_reserved)?;
429 match effect.state {
430 EffectState::Intent | EffectState::Issued => {
431 if effect.post_cycles.is_some()
432 || effect.receipt.is_some()
433 || handoff.after_reserved_cycles.is_some()
434 {
435 return Err(CapacityImportJournalError::Integrity);
436 }
437 }
438 EffectState::Applied => {
439 let after = effect
440 .post_cycles
441 .ok_or(CapacityImportJournalError::Integrity)?;
442 let version = source
443 .binding
444 .canister_version
445 .checked_add(1)
446 .ok_or(CapacityImportJournalError::Integrity)?;
447 let after_reserved = handoff
448 .after_reserved_cycles
449 .ok_or(CapacityImportJournalError::Integrity)?;
450 let interval_debit = source_total(before, before_reserved)?
451 .saturating_sub(source_total(after, after_reserved)?);
452 let receipt_matches =
453 effect.receipt.as_deref() == Some(version.to_string().as_str());
454 if interval_debit > source.maximum_debit_cycles || !receipt_matches {
455 return Err(CapacityImportJournalError::Integrity);
456 }
457 retained_source_debit(source, after, after_reserved)?;
458 }
459 }
460 }
461 Ok(())
462}
463
464#[must_use]
467pub fn custody_ready(journal: &CapacityImportJournalRecord, canister: Principal) -> bool {
468 let Some(index) = journal
469 .plan
470 .sources
471 .iter()
472 .position(|source| source.binding.canister_id == canister)
473 else {
474 return false;
475 };
476 let Some(handoff) = journal.handoffs.get(index) else {
477 return false;
478 };
479 if handoff.canister_id != canister {
480 return false;
481 }
482 if crate::fleet_ensure::policy::capacity_import::requires_handoff(
483 &journal.plan,
484 &journal.plan.sources[index],
485 ) {
486 handoff
487 .effect
488 .as_ref()
489 .is_some_and(|effect| effect.state == EffectState::Applied)
490 } else {
491 handoff.effect.is_none()
492 && handoff.request.is_none()
493 && handoff.retirements.is_empty()
494 && handoff.before_reserved_cycles.is_none()
495 && handoff.after_reserved_cycles.is_none()
496 }
497}
498
499#[must_use]
501pub fn all_custody_ready(journal: &CapacityImportJournalRecord) -> bool {
502 journal.handoffs.len() == journal.plan.sources.len()
503 && journal
504 .plan
505 .sources
506 .iter()
507 .all(|source| custody_ready(journal, source.binding.canister_id))
508}
509
510fn source_index(
511 journal: &CapacityImportJournalRecord,
512 canister: Principal,
513) -> Result<usize, CapacityImportJournalError> {
514 journal
515 .plan
516 .sources
517 .iter()
518 .position(|source| source.binding.canister_id == canister)
519 .ok_or(CapacityImportJournalError::Integrity)
520}
521
522fn handoff_action(plan: &CapacityImportPlanRecord, index: usize) -> EnsureAction {
523 let canister = plan.sources[index].binding.canister_id.to_text();
524 EnsureAction::SetControllers {
525 controller_canisters: Vec::new(),
526 controllers: plan
527 .transitional_controllers
528 .iter()
529 .map(Principal::to_text)
530 .collect(),
531 name: canister.clone(),
532 principal: canister,
533 }
534}
535
536fn reservation_matches(
537 plan: &CapacityImportPlanRecord,
538 reservation: &CapacityImportReservationRecord,
539) -> bool {
540 reservation.plan_sha256 == plan.plan_sha256
541 && reservation.authority == plan.authority
542 && reservation.sources
543 == plan
544 .sources
545 .iter()
546 .map(|source| source.binding.canister_id)
547 .collect::<Vec<_>>()
548}