1use crate::{
2 artifacts::{ArtifactChecksum, ArtifactChecksumError},
3 manifest::{
4 FleetBackupManifest, FleetMember, IdentityMode, ManifestDesignConformanceReport,
5 ManifestValidationError, SourceSnapshot, VerificationCheck, VerificationPlan,
6 },
7};
8use candid::Principal;
9use serde::{Deserialize, Serialize};
10use std::{
11 collections::{BTreeMap, BTreeSet},
12 path::{Component, Path, PathBuf},
13 str::FromStr,
14};
15use thiserror::Error as ThisError;
16
17#[derive(Clone, Debug, Default, Deserialize, Serialize)]
22pub struct RestoreMapping {
23 pub members: Vec<RestoreMappingEntry>,
24}
25
26impl RestoreMapping {
27 fn target_for(&self, source_canister: &str) -> Option<&str> {
29 self.members
30 .iter()
31 .find(|entry| entry.source_canister == source_canister)
32 .map(|entry| entry.target_canister.as_str())
33 }
34}
35
36#[derive(Clone, Debug, Deserialize, Serialize)]
41pub struct RestoreMappingEntry {
42 pub source_canister: String,
43 pub target_canister: String,
44}
45
46#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
51pub struct RestorePlan {
52 pub backup_id: String,
53 pub source_environment: String,
54 pub source_root_canister: String,
55 pub topology_hash: String,
56 pub member_count: usize,
57 pub identity_summary: RestoreIdentitySummary,
58 pub snapshot_summary: RestoreSnapshotSummary,
59 pub verification_summary: RestoreVerificationSummary,
60 pub readiness_summary: RestoreReadinessSummary,
61 pub operation_summary: RestoreOperationSummary,
62 pub ordering_summary: RestoreOrderingSummary,
63 #[serde(default)]
64 pub design_conformance: Option<ManifestDesignConformanceReport>,
65 #[serde(default)]
66 pub fleet_verification_checks: Vec<VerificationCheck>,
67 pub phases: Vec<RestorePhase>,
68}
69
70impl RestorePlan {
71 #[must_use]
73 pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
74 self.phases
75 .iter()
76 .flat_map(|phase| phase.members.iter())
77 .collect()
78 }
79}
80
81#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
86pub struct RestoreStatus {
87 pub status_version: u16,
88 pub backup_id: String,
89 pub source_environment: String,
90 pub source_root_canister: String,
91 pub topology_hash: String,
92 pub ready: bool,
93 pub readiness_reasons: Vec<String>,
94 pub verification_required: bool,
95 pub member_count: usize,
96 pub phase_count: usize,
97 #[serde(default)]
98 pub planned_snapshot_uploads: usize,
99 pub planned_snapshot_loads: usize,
100 pub planned_code_reinstalls: usize,
101 pub planned_verification_checks: usize,
102 #[serde(default)]
103 pub planned_operations: usize,
104 pub phases: Vec<RestoreStatusPhase>,
105}
106
107impl RestoreStatus {
108 #[must_use]
110 pub fn from_plan(plan: &RestorePlan) -> Self {
111 Self {
112 status_version: 1,
113 backup_id: plan.backup_id.clone(),
114 source_environment: plan.source_environment.clone(),
115 source_root_canister: plan.source_root_canister.clone(),
116 topology_hash: plan.topology_hash.clone(),
117 ready: plan.readiness_summary.ready,
118 readiness_reasons: plan.readiness_summary.reasons.clone(),
119 verification_required: plan.verification_summary.verification_required,
120 member_count: plan.member_count,
121 phase_count: plan.ordering_summary.phase_count,
122 planned_snapshot_uploads: plan
123 .operation_summary
124 .effective_planned_snapshot_uploads(plan.member_count),
125 planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
126 planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
127 planned_verification_checks: plan.operation_summary.planned_verification_checks,
128 planned_operations: plan
129 .operation_summary
130 .effective_planned_operations(plan.member_count),
131 phases: plan
132 .phases
133 .iter()
134 .map(RestoreStatusPhase::from_plan_phase)
135 .collect(),
136 }
137 }
138}
139
140#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
145pub struct RestoreStatusPhase {
146 pub restore_group: u16,
147 pub members: Vec<RestoreStatusMember>,
148}
149
150impl RestoreStatusPhase {
151 fn from_plan_phase(phase: &RestorePhase) -> Self {
153 Self {
154 restore_group: phase.restore_group,
155 members: phase
156 .members
157 .iter()
158 .map(RestoreStatusMember::from_plan_member)
159 .collect(),
160 }
161 }
162}
163
164#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
169pub struct RestoreStatusMember {
170 pub source_canister: String,
171 pub target_canister: String,
172 pub role: String,
173 pub restore_group: u16,
174 pub phase_order: usize,
175 pub snapshot_id: String,
176 pub artifact_path: String,
177 pub state: RestoreMemberState,
178}
179
180impl RestoreStatusMember {
181 fn from_plan_member(member: &RestorePlanMember) -> Self {
183 Self {
184 source_canister: member.source_canister.clone(),
185 target_canister: member.target_canister.clone(),
186 role: member.role.clone(),
187 restore_group: member.restore_group,
188 phase_order: member.phase_order,
189 snapshot_id: member.source_snapshot.snapshot_id.clone(),
190 artifact_path: member.source_snapshot.artifact_path.clone(),
191 state: RestoreMemberState::Planned,
192 }
193 }
194}
195
196#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
201#[serde(rename_all = "kebab-case")]
202pub enum RestoreMemberState {
203 Planned,
204}
205
206#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
211pub struct RestoreApplyDryRun {
212 pub dry_run_version: u16,
213 pub backup_id: String,
214 pub ready: bool,
215 pub readiness_reasons: Vec<String>,
216 pub member_count: usize,
217 pub phase_count: usize,
218 pub status_supplied: bool,
219 #[serde(default)]
220 pub planned_snapshot_uploads: usize,
221 pub planned_snapshot_loads: usize,
222 pub planned_code_reinstalls: usize,
223 pub planned_verification_checks: usize,
224 #[serde(default)]
225 pub planned_operations: usize,
226 pub rendered_operations: usize,
227 #[serde(default)]
228 pub operation_counts: RestoreApplyOperationKindCounts,
229 pub artifact_validation: Option<RestoreApplyArtifactValidation>,
230 pub phases: Vec<RestoreApplyDryRunPhase>,
231}
232
233impl RestoreApplyDryRun {
234 pub fn try_from_plan(
236 plan: &RestorePlan,
237 status: Option<&RestoreStatus>,
238 ) -> Result<Self, RestoreApplyDryRunError> {
239 if let Some(status) = status {
240 validate_restore_status_matches_plan(plan, status)?;
241 }
242
243 Ok(Self::from_validated_plan(plan, status))
244 }
245
246 pub fn try_from_plan_with_artifacts(
248 plan: &RestorePlan,
249 status: Option<&RestoreStatus>,
250 backup_root: &Path,
251 ) -> Result<Self, RestoreApplyDryRunError> {
252 let mut dry_run = Self::try_from_plan(plan, status)?;
253 dry_run.artifact_validation = Some(validate_restore_apply_artifacts(plan, backup_root)?);
254 Ok(dry_run)
255 }
256
257 fn from_validated_plan(plan: &RestorePlan, status: Option<&RestoreStatus>) -> Self {
259 let mut next_sequence = 0;
260 let phases = plan
261 .phases
262 .iter()
263 .map(|phase| RestoreApplyDryRunPhase::from_plan_phase(phase, &mut next_sequence))
264 .collect::<Vec<_>>();
265 let mut phases = phases;
266 append_fleet_verification_operations(plan, &mut phases, &mut next_sequence);
267 let rendered_operations = phases
268 .iter()
269 .map(|phase| phase.operations.len())
270 .sum::<usize>();
271 let operation_counts = RestoreApplyOperationKindCounts::from_dry_run_phases(&phases);
272
273 Self {
274 dry_run_version: 1,
275 backup_id: plan.backup_id.clone(),
276 ready: status.map_or(plan.readiness_summary.ready, |status| status.ready),
277 readiness_reasons: status.map_or_else(
278 || plan.readiness_summary.reasons.clone(),
279 |status| status.readiness_reasons.clone(),
280 ),
281 member_count: plan.member_count,
282 phase_count: plan.ordering_summary.phase_count,
283 status_supplied: status.is_some(),
284 planned_snapshot_uploads: plan
285 .operation_summary
286 .effective_planned_snapshot_uploads(plan.member_count),
287 planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
288 planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
289 planned_verification_checks: plan.operation_summary.planned_verification_checks,
290 planned_operations: plan
291 .operation_summary
292 .effective_planned_operations(plan.member_count),
293 rendered_operations,
294 operation_counts,
295 artifact_validation: None,
296 phases,
297 }
298 }
299}
300
301#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
306pub struct RestoreApplyJournal {
307 pub journal_version: u16,
308 pub backup_id: String,
309 pub ready: bool,
310 pub blocked_reasons: Vec<String>,
311 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub backup_root: Option<String>,
313 pub operation_count: usize,
314 #[serde(default)]
315 pub operation_counts: RestoreApplyOperationKindCounts,
316 pub pending_operations: usize,
317 pub ready_operations: usize,
318 pub blocked_operations: usize,
319 pub completed_operations: usize,
320 pub failed_operations: usize,
321 pub operations: Vec<RestoreApplyJournalOperation>,
322 #[serde(default, skip_serializing_if = "Vec::is_empty")]
323 pub operation_receipts: Vec<RestoreApplyOperationReceipt>,
324}
325
326impl RestoreApplyJournal {
327 #[must_use]
329 pub fn from_dry_run(dry_run: &RestoreApplyDryRun) -> Self {
330 let blocked_reasons = restore_apply_blocked_reasons(dry_run);
331 let initial_state = if blocked_reasons.is_empty() {
332 RestoreApplyOperationState::Ready
333 } else {
334 RestoreApplyOperationState::Blocked
335 };
336 let operations = dry_run
337 .phases
338 .iter()
339 .flat_map(|phase| phase.operations.iter())
340 .map(|operation| {
341 RestoreApplyJournalOperation::from_dry_run_operation(
342 operation,
343 initial_state.clone(),
344 &blocked_reasons,
345 )
346 })
347 .collect::<Vec<_>>();
348 let ready_operations = operations
349 .iter()
350 .filter(|operation| operation.state == RestoreApplyOperationState::Ready)
351 .count();
352 let blocked_operations = operations
353 .iter()
354 .filter(|operation| operation.state == RestoreApplyOperationState::Blocked)
355 .count();
356 let operation_counts = RestoreApplyOperationKindCounts::from_operations(&operations);
357
358 Self {
359 journal_version: 1,
360 backup_id: dry_run.backup_id.clone(),
361 ready: blocked_reasons.is_empty(),
362 blocked_reasons,
363 backup_root: dry_run
364 .artifact_validation
365 .as_ref()
366 .map(|validation| validation.backup_root.clone()),
367 operation_count: operations.len(),
368 operation_counts,
369 pending_operations: 0,
370 ready_operations,
371 blocked_operations,
372 completed_operations: 0,
373 failed_operations: 0,
374 operations,
375 operation_receipts: Vec::new(),
376 }
377 }
378
379 pub fn validate(&self) -> Result<(), RestoreApplyJournalError> {
381 validate_apply_journal_version(self.journal_version)?;
382 validate_apply_journal_nonempty("backup_id", &self.backup_id)?;
383 if let Some(backup_root) = &self.backup_root {
384 validate_apply_journal_nonempty("backup_root", backup_root)?;
385 }
386 validate_apply_journal_count(
387 "operation_count",
388 self.operation_count,
389 self.operations.len(),
390 )?;
391
392 let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
393 let operation_counts = RestoreApplyOperationKindCounts::from_operations(&self.operations);
394 self.operation_counts
395 .validate_matches_if_supplied(&operation_counts)?;
396 validate_apply_journal_count(
397 "pending_operations",
398 self.pending_operations,
399 state_counts.pending,
400 )?;
401 validate_apply_journal_count(
402 "ready_operations",
403 self.ready_operations,
404 state_counts.ready,
405 )?;
406 validate_apply_journal_count(
407 "blocked_operations",
408 self.blocked_operations,
409 state_counts.blocked,
410 )?;
411 validate_apply_journal_count(
412 "completed_operations",
413 self.completed_operations,
414 state_counts.completed,
415 )?;
416 validate_apply_journal_count(
417 "failed_operations",
418 self.failed_operations,
419 state_counts.failed,
420 )?;
421
422 if self.ready && (!self.blocked_reasons.is_empty() || self.blocked_operations > 0) {
423 return Err(RestoreApplyJournalError::ReadyJournalHasBlockingState);
424 }
425
426 validate_apply_journal_sequences(&self.operations)?;
427 for operation in &self.operations {
428 operation.validate()?;
429 }
430 for receipt in &self.operation_receipts {
431 receipt.validate_against(self)?;
432 }
433
434 Ok(())
435 }
436
437 #[must_use]
439 pub fn status(&self) -> RestoreApplyJournalStatus {
440 RestoreApplyJournalStatus::from_journal(self)
441 }
442
443 #[must_use]
445 pub fn report(&self) -> RestoreApplyJournalReport {
446 RestoreApplyJournalReport::from_journal(self)
447 }
448
449 #[must_use]
451 pub fn next_ready_operation(&self) -> Option<&RestoreApplyJournalOperation> {
452 self.operations
453 .iter()
454 .filter(|operation| operation.state == RestoreApplyOperationState::Ready)
455 .min_by_key(|operation| operation.sequence)
456 }
457
458 #[must_use]
460 pub fn next_transition_operation(&self) -> Option<&RestoreApplyJournalOperation> {
461 self.operations
462 .iter()
463 .filter(|operation| {
464 matches!(
465 operation.state,
466 RestoreApplyOperationState::Ready
467 | RestoreApplyOperationState::Pending
468 | RestoreApplyOperationState::Failed
469 )
470 })
471 .min_by_key(|operation| operation.sequence)
472 }
473
474 #[must_use]
476 pub fn next_operation(&self) -> RestoreApplyNextOperation {
477 RestoreApplyNextOperation::from_journal(self)
478 }
479
480 #[must_use]
482 pub fn next_command_preview(&self) -> RestoreApplyCommandPreview {
483 RestoreApplyCommandPreview::from_journal(self)
484 }
485
486 #[must_use]
488 pub fn next_command_preview_with_config(
489 &self,
490 config: &RestoreApplyCommandConfig,
491 ) -> RestoreApplyCommandPreview {
492 RestoreApplyCommandPreview::from_journal_with_config(self, config)
493 }
494
495 pub fn record_operation_receipt(
497 &mut self,
498 receipt: RestoreApplyOperationReceipt,
499 ) -> Result<(), RestoreApplyJournalError> {
500 self.operation_receipts.push(receipt);
501 if let Err(error) = self.validate() {
502 self.operation_receipts.pop();
503 return Err(error);
504 }
505
506 Ok(())
507 }
508
509 pub fn mark_next_operation_pending(&mut self) -> Result<(), RestoreApplyJournalError> {
511 self.mark_next_operation_pending_at(None)
512 }
513
514 pub fn mark_next_operation_pending_at(
516 &mut self,
517 updated_at: Option<String>,
518 ) -> Result<(), RestoreApplyJournalError> {
519 let sequence = self
520 .next_transition_sequence()
521 .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
522 self.mark_operation_pending_at(sequence, updated_at)
523 }
524
525 pub fn mark_operation_pending(
527 &mut self,
528 sequence: usize,
529 ) -> Result<(), RestoreApplyJournalError> {
530 self.mark_operation_pending_at(sequence, None)
531 }
532
533 pub fn mark_operation_pending_at(
535 &mut self,
536 sequence: usize,
537 updated_at: Option<String>,
538 ) -> Result<(), RestoreApplyJournalError> {
539 self.transition_operation(
540 sequence,
541 RestoreApplyOperationState::Pending,
542 Vec::new(),
543 updated_at,
544 )
545 }
546
547 pub fn mark_next_operation_ready(&mut self) -> Result<(), RestoreApplyJournalError> {
549 self.mark_next_operation_ready_at(None)
550 }
551
552 pub fn mark_next_operation_ready_at(
554 &mut self,
555 updated_at: Option<String>,
556 ) -> Result<(), RestoreApplyJournalError> {
557 let operation = self
558 .next_transition_operation()
559 .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
560 if operation.state != RestoreApplyOperationState::Pending {
561 return Err(RestoreApplyJournalError::NoPendingOperation);
562 }
563
564 self.mark_operation_ready_at(operation.sequence, updated_at)
565 }
566
567 pub fn mark_operation_ready(
569 &mut self,
570 sequence: usize,
571 ) -> Result<(), RestoreApplyJournalError> {
572 self.mark_operation_ready_at(sequence, None)
573 }
574
575 pub fn mark_operation_ready_at(
577 &mut self,
578 sequence: usize,
579 updated_at: Option<String>,
580 ) -> Result<(), RestoreApplyJournalError> {
581 self.transition_operation(
582 sequence,
583 RestoreApplyOperationState::Ready,
584 Vec::new(),
585 updated_at,
586 )
587 }
588
589 pub fn retry_failed_operation_at(
591 &mut self,
592 sequence: usize,
593 updated_at: Option<String>,
594 ) -> Result<(), RestoreApplyJournalError> {
595 self.transition_operation(
596 sequence,
597 RestoreApplyOperationState::Ready,
598 Vec::new(),
599 updated_at,
600 )
601 }
602
603 pub fn mark_operation_completed(
605 &mut self,
606 sequence: usize,
607 ) -> Result<(), RestoreApplyJournalError> {
608 self.mark_operation_completed_at(sequence, None)
609 }
610
611 pub fn mark_operation_completed_at(
613 &mut self,
614 sequence: usize,
615 updated_at: Option<String>,
616 ) -> Result<(), RestoreApplyJournalError> {
617 self.transition_operation(
618 sequence,
619 RestoreApplyOperationState::Completed,
620 Vec::new(),
621 updated_at,
622 )
623 }
624
625 pub fn mark_operation_failed(
627 &mut self,
628 sequence: usize,
629 reason: String,
630 ) -> Result<(), RestoreApplyJournalError> {
631 self.mark_operation_failed_at(sequence, reason, None)
632 }
633
634 pub fn mark_operation_failed_at(
636 &mut self,
637 sequence: usize,
638 reason: String,
639 updated_at: Option<String>,
640 ) -> Result<(), RestoreApplyJournalError> {
641 if reason.trim().is_empty() {
642 return Err(RestoreApplyJournalError::FailureReasonRequired(sequence));
643 }
644
645 self.transition_operation(
646 sequence,
647 RestoreApplyOperationState::Failed,
648 vec![reason],
649 updated_at,
650 )
651 }
652
653 fn transition_operation(
655 &mut self,
656 sequence: usize,
657 next_state: RestoreApplyOperationState,
658 blocking_reasons: Vec<String>,
659 updated_at: Option<String>,
660 ) -> Result<(), RestoreApplyJournalError> {
661 let index = self
662 .operations
663 .iter()
664 .position(|operation| operation.sequence == sequence)
665 .ok_or(RestoreApplyJournalError::OperationNotFound(sequence))?;
666 let operation = &self.operations[index];
667
668 if !operation.can_transition_to(&next_state) {
669 return Err(RestoreApplyJournalError::InvalidOperationTransition {
670 sequence,
671 from: operation.state.clone(),
672 to: next_state,
673 });
674 }
675
676 self.validate_operation_transition_order(operation, &next_state)?;
677
678 let operation = &mut self.operations[index];
679 operation.state = next_state;
680 operation.blocking_reasons = blocking_reasons;
681 operation.state_updated_at = updated_at;
682 self.refresh_operation_counts();
683 self.validate()
684 }
685
686 fn validate_operation_transition_order(
688 &self,
689 operation: &RestoreApplyJournalOperation,
690 next_state: &RestoreApplyOperationState,
691 ) -> Result<(), RestoreApplyJournalError> {
692 if operation.state == *next_state {
693 return Ok(());
694 }
695
696 let next_sequence = self
697 .next_transition_sequence()
698 .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
699
700 if operation.sequence == next_sequence {
701 return Ok(());
702 }
703
704 Err(RestoreApplyJournalError::OutOfOrderOperationTransition {
705 requested: operation.sequence,
706 next: next_sequence,
707 })
708 }
709
710 fn next_transition_sequence(&self) -> Option<usize> {
712 self.next_transition_operation()
713 .map(|operation| operation.sequence)
714 }
715
716 fn refresh_operation_counts(&mut self) {
718 let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
719 self.operation_count = self.operations.len();
720 self.operation_counts = RestoreApplyOperationKindCounts::from_operations(&self.operations);
721 self.pending_operations = state_counts.pending;
722 self.ready_operations = state_counts.ready;
723 self.blocked_operations = state_counts.blocked;
724 self.completed_operations = state_counts.completed;
725 self.failed_operations = state_counts.failed;
726 }
727
728 const fn operation_counts_supplied(&self) -> bool {
730 !self.operation_counts.is_empty() || self.operations.is_empty()
731 }
732
733 fn uploaded_snapshot_id_for_load(&self, load: &RestoreApplyJournalOperation) -> Option<&str> {
735 self.operation_receipts
736 .iter()
737 .find(|receipt| {
738 receipt.matches_load_operation(load)
739 && self.operations.iter().any(|operation| {
740 operation.sequence == receipt.sequence
741 && operation.operation == RestoreApplyOperationKind::UploadSnapshot
742 && operation.state == RestoreApplyOperationState::Completed
743 })
744 })
745 .and_then(|receipt| receipt.uploaded_snapshot_id.as_deref())
746 }
747}
748
749const fn validate_apply_journal_version(version: u16) -> Result<(), RestoreApplyJournalError> {
751 if version == 1 {
752 return Ok(());
753 }
754
755 Err(RestoreApplyJournalError::UnsupportedVersion(version))
756}
757
758fn validate_apply_journal_nonempty(
760 field: &'static str,
761 value: &str,
762) -> Result<(), RestoreApplyJournalError> {
763 if !value.trim().is_empty() {
764 return Ok(());
765 }
766
767 Err(RestoreApplyJournalError::MissingField(field))
768}
769
770const fn validate_apply_journal_count(
772 field: &'static str,
773 reported: usize,
774 actual: usize,
775) -> Result<(), RestoreApplyJournalError> {
776 if reported == actual {
777 return Ok(());
778 }
779
780 Err(RestoreApplyJournalError::CountMismatch {
781 field,
782 reported,
783 actual,
784 })
785}
786
787fn validate_apply_journal_sequences(
789 operations: &[RestoreApplyJournalOperation],
790) -> Result<(), RestoreApplyJournalError> {
791 let mut sequences = BTreeSet::new();
792 for operation in operations {
793 if !sequences.insert(operation.sequence) {
794 return Err(RestoreApplyJournalError::DuplicateSequence(
795 operation.sequence,
796 ));
797 }
798 }
799
800 for expected in 0..operations.len() {
801 if !sequences.contains(&expected) {
802 return Err(RestoreApplyJournalError::MissingSequence(expected));
803 }
804 }
805
806 Ok(())
807}
808
809#[derive(Clone, Debug, Default, Eq, PartialEq)]
814struct RestoreApplyJournalStateCounts {
815 pending: usize,
816 ready: usize,
817 blocked: usize,
818 completed: usize,
819 failed: usize,
820}
821
822impl RestoreApplyJournalStateCounts {
823 fn from_operations(operations: &[RestoreApplyJournalOperation]) -> Self {
825 let mut counts = Self::default();
826 for operation in operations {
827 match operation.state {
828 RestoreApplyOperationState::Pending => counts.pending += 1,
829 RestoreApplyOperationState::Ready => counts.ready += 1,
830 RestoreApplyOperationState::Blocked => counts.blocked += 1,
831 RestoreApplyOperationState::Completed => counts.completed += 1,
832 RestoreApplyOperationState::Failed => counts.failed += 1,
833 }
834 }
835 counts
836 }
837}
838
839#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
844pub struct RestoreApplyOperationKindCounts {
845 pub snapshot_uploads: usize,
846 pub snapshot_loads: usize,
847 pub code_reinstalls: usize,
848 pub member_verifications: usize,
849 pub fleet_verifications: usize,
850 pub verification_operations: usize,
851}
852
853impl RestoreApplyOperationKindCounts {
854 #[must_use]
856 pub fn from_operations(operations: &[RestoreApplyJournalOperation]) -> Self {
857 let mut counts = Self::default();
858 for operation in operations {
859 counts.record(&operation.operation);
860 }
861 counts
862 }
863
864 pub fn validate_matches_if_supplied(
866 &self,
867 expected: &Self,
868 ) -> Result<(), RestoreApplyJournalError> {
869 if self.is_empty() && !expected.is_empty() {
870 return Ok(());
871 }
872
873 validate_apply_journal_count(
874 "operation_counts.snapshot_uploads",
875 self.snapshot_uploads,
876 expected.snapshot_uploads,
877 )?;
878 validate_apply_journal_count(
879 "operation_counts.snapshot_loads",
880 self.snapshot_loads,
881 expected.snapshot_loads,
882 )?;
883 validate_apply_journal_count(
884 "operation_counts.code_reinstalls",
885 self.code_reinstalls,
886 expected.code_reinstalls,
887 )?;
888 validate_apply_journal_count(
889 "operation_counts.member_verifications",
890 self.member_verifications,
891 expected.member_verifications,
892 )?;
893 validate_apply_journal_count(
894 "operation_counts.fleet_verifications",
895 self.fleet_verifications,
896 expected.fleet_verifications,
897 )?;
898 validate_apply_journal_count(
899 "operation_counts.verification_operations",
900 self.verification_operations,
901 expected.verification_operations,
902 )
903 }
904
905 const fn is_empty(&self) -> bool {
907 self.snapshot_uploads == 0
908 && self.snapshot_loads == 0
909 && self.code_reinstalls == 0
910 && self.member_verifications == 0
911 && self.fleet_verifications == 0
912 && self.verification_operations == 0
913 }
914
915 #[must_use]
917 pub fn from_dry_run_phases(phases: &[RestoreApplyDryRunPhase]) -> Self {
918 let mut counts = Self::default();
919 for operation in phases.iter().flat_map(|phase| {
920 phase
921 .operations
922 .iter()
923 .map(|operation| &operation.operation)
924 }) {
925 counts.record(operation);
926 }
927 counts
928 }
929
930 const fn record(&mut self, operation: &RestoreApplyOperationKind) {
932 match operation {
933 RestoreApplyOperationKind::UploadSnapshot => self.snapshot_uploads += 1,
934 RestoreApplyOperationKind::LoadSnapshot => self.snapshot_loads += 1,
935 RestoreApplyOperationKind::ReinstallCode => self.code_reinstalls += 1,
936 RestoreApplyOperationKind::VerifyMember => {
937 self.member_verifications += 1;
938 self.verification_operations += 1;
939 }
940 RestoreApplyOperationKind::VerifyFleet => {
941 self.fleet_verifications += 1;
942 self.verification_operations += 1;
943 }
944 }
945 }
946}
947
948#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
953pub struct RestoreApplyOperationReceipt {
954 pub sequence: usize,
955 pub operation: RestoreApplyOperationKind,
956 #[serde(default)]
957 pub outcome: RestoreApplyOperationReceiptOutcome,
958 pub source_canister: String,
959 pub target_canister: String,
960 #[serde(default)]
961 pub attempt: usize,
962 #[serde(skip_serializing_if = "Option::is_none")]
963 pub updated_at: Option<String>,
964 #[serde(skip_serializing_if = "Option::is_none")]
965 pub command: Option<RestoreApplyRunnerCommand>,
966 #[serde(skip_serializing_if = "Option::is_none")]
967 pub status: Option<String>,
968 #[serde(skip_serializing_if = "Option::is_none")]
969 pub stdout: Option<RestoreApplyCommandOutput>,
970 #[serde(skip_serializing_if = "Option::is_none")]
971 pub stderr: Option<RestoreApplyCommandOutput>,
972 #[serde(skip_serializing_if = "Option::is_none")]
973 pub failure_reason: Option<String>,
974 #[serde(skip_serializing_if = "Option::is_none")]
975 pub source_snapshot_id: Option<String>,
976 #[serde(skip_serializing_if = "Option::is_none")]
977 pub artifact_path: Option<String>,
978 #[serde(skip_serializing_if = "Option::is_none")]
979 pub uploaded_snapshot_id: Option<String>,
980}
981
982impl RestoreApplyOperationReceipt {
983 #[must_use]
985 pub fn completed_upload(
986 operation: &RestoreApplyJournalOperation,
987 uploaded_snapshot_id: String,
988 ) -> Self {
989 Self {
990 sequence: operation.sequence,
991 operation: RestoreApplyOperationKind::UploadSnapshot,
992 outcome: RestoreApplyOperationReceiptOutcome::CommandCompleted,
993 source_canister: operation.source_canister.clone(),
994 target_canister: operation.target_canister.clone(),
995 attempt: 1,
996 updated_at: None,
997 command: None,
998 status: None,
999 stdout: None,
1000 stderr: None,
1001 failure_reason: None,
1002 source_snapshot_id: operation.snapshot_id.clone(),
1003 artifact_path: operation.artifact_path.clone(),
1004 uploaded_snapshot_id: Some(uploaded_snapshot_id),
1005 }
1006 }
1007
1008 #[must_use]
1010 pub fn command_completed(
1011 operation: &RestoreApplyJournalOperation,
1012 command: RestoreApplyRunnerCommand,
1013 status: String,
1014 updated_at: Option<String>,
1015 output: RestoreApplyCommandOutputPair,
1016 attempt: usize,
1017 uploaded_snapshot_id: Option<String>,
1018 ) -> Self {
1019 Self {
1020 sequence: operation.sequence,
1021 operation: operation.operation.clone(),
1022 outcome: RestoreApplyOperationReceiptOutcome::CommandCompleted,
1023 source_canister: operation.source_canister.clone(),
1024 target_canister: operation.target_canister.clone(),
1025 attempt,
1026 updated_at,
1027 command: Some(command),
1028 status: Some(status),
1029 stdout: Some(output.stdout),
1030 stderr: Some(output.stderr),
1031 failure_reason: None,
1032 source_snapshot_id: operation.snapshot_id.clone(),
1033 artifact_path: operation.artifact_path.clone(),
1034 uploaded_snapshot_id,
1035 }
1036 }
1037
1038 #[must_use]
1040 pub fn command_failed(
1041 operation: &RestoreApplyJournalOperation,
1042 command: RestoreApplyRunnerCommand,
1043 status: String,
1044 updated_at: Option<String>,
1045 output: RestoreApplyCommandOutputPair,
1046 attempt: usize,
1047 failure_reason: String,
1048 ) -> Self {
1049 Self {
1050 sequence: operation.sequence,
1051 operation: operation.operation.clone(),
1052 outcome: RestoreApplyOperationReceiptOutcome::CommandFailed,
1053 source_canister: operation.source_canister.clone(),
1054 target_canister: operation.target_canister.clone(),
1055 attempt,
1056 updated_at,
1057 command: Some(command),
1058 status: Some(status),
1059 stdout: Some(output.stdout),
1060 stderr: Some(output.stderr),
1061 failure_reason: Some(failure_reason),
1062 source_snapshot_id: operation.snapshot_id.clone(),
1063 artifact_path: operation.artifact_path.clone(),
1064 uploaded_snapshot_id: None,
1065 }
1066 }
1067
1068 fn matches_load_operation(&self, load: &RestoreApplyJournalOperation) -> bool {
1070 self.operation == RestoreApplyOperationKind::UploadSnapshot
1071 && self.outcome == RestoreApplyOperationReceiptOutcome::CommandCompleted
1072 && load.operation == RestoreApplyOperationKind::LoadSnapshot
1073 && self.source_canister == load.source_canister
1074 && self.target_canister == load.target_canister
1075 && self.source_snapshot_id == load.snapshot_id
1076 && self.artifact_path == load.artifact_path
1077 && self
1078 .uploaded_snapshot_id
1079 .as_ref()
1080 .is_some_and(|id| !id.trim().is_empty())
1081 }
1082
1083 fn validate_against(
1085 &self,
1086 journal: &RestoreApplyJournal,
1087 ) -> Result<(), RestoreApplyJournalError> {
1088 let operation = journal
1089 .operations
1090 .iter()
1091 .find(|operation| operation.sequence == self.sequence)
1092 .ok_or(RestoreApplyJournalError::OperationReceiptOperationNotFound(
1093 self.sequence,
1094 ))?;
1095 if operation.operation != self.operation
1096 || operation.source_canister != self.source_canister
1097 || operation.target_canister != self.target_canister
1098 {
1099 return Err(RestoreApplyJournalError::OperationReceiptMismatch {
1100 sequence: self.sequence,
1101 });
1102 }
1103 if self.operation == RestoreApplyOperationKind::UploadSnapshot {
1104 validate_apply_journal_nonempty(
1105 "operation_receipts[].source_snapshot_id",
1106 self.source_snapshot_id.as_deref().unwrap_or_default(),
1107 )?;
1108 validate_apply_journal_nonempty(
1109 "operation_receipts[].artifact_path",
1110 self.artifact_path.as_deref().unwrap_or_default(),
1111 )?;
1112 if self.outcome == RestoreApplyOperationReceiptOutcome::CommandCompleted {
1113 validate_apply_journal_nonempty(
1114 "operation_receipts[].uploaded_snapshot_id",
1115 self.uploaded_snapshot_id.as_deref().unwrap_or_default(),
1116 )?;
1117 }
1118 }
1119 if self.outcome == RestoreApplyOperationReceiptOutcome::CommandFailed {
1120 validate_apply_journal_nonempty(
1121 "operation_receipts[].failure_reason",
1122 self.failure_reason.as_deref().unwrap_or_default(),
1123 )?;
1124 }
1125
1126 Ok(())
1127 }
1128}
1129
1130#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
1135#[serde(rename_all = "kebab-case")]
1136pub enum RestoreApplyOperationReceiptOutcome {
1137 #[default]
1138 CommandCompleted,
1139 CommandFailed,
1140}
1141
1142#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1147pub struct RestoreApplyCommandOutput {
1148 pub text: String,
1149 pub truncated: bool,
1150 pub original_bytes: usize,
1151}
1152
1153impl RestoreApplyCommandOutput {
1154 #[must_use]
1156 pub fn from_bytes(bytes: &[u8], limit: usize) -> Self {
1157 let original_bytes = bytes.len();
1158 let start = original_bytes.saturating_sub(limit);
1159 Self {
1160 text: String::from_utf8_lossy(&bytes[start..]).to_string(),
1161 truncated: start > 0,
1162 original_bytes,
1163 }
1164 }
1165}
1166
1167#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1172pub struct RestoreApplyCommandOutputPair {
1173 pub stdout: RestoreApplyCommandOutput,
1174 pub stderr: RestoreApplyCommandOutput,
1175}
1176
1177impl RestoreApplyCommandOutputPair {
1178 #[must_use]
1180 pub fn from_bytes(stdout: &[u8], stderr: &[u8], limit: usize) -> Self {
1181 Self {
1182 stdout: RestoreApplyCommandOutput::from_bytes(stdout, limit),
1183 stderr: RestoreApplyCommandOutput::from_bytes(stderr, limit),
1184 }
1185 }
1186}
1187
1188fn restore_apply_blocked_reasons(dry_run: &RestoreApplyDryRun) -> Vec<String> {
1190 let mut reasons = dry_run.readiness_reasons.clone();
1191
1192 match &dry_run.artifact_validation {
1193 Some(validation) => {
1194 if !validation.artifacts_present {
1195 reasons.push("missing-artifacts".to_string());
1196 }
1197 if !validation.checksums_verified {
1198 reasons.push("artifact-checksum-validation-incomplete".to_string());
1199 }
1200 }
1201 None => reasons.push("missing-artifact-validation".to_string()),
1202 }
1203
1204 reasons.sort();
1205 reasons.dedup();
1206 reasons
1207}
1208
1209#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1214pub struct RestoreApplyJournalStatus {
1215 pub status_version: u16,
1216 pub backup_id: String,
1217 pub ready: bool,
1218 pub complete: bool,
1219 pub blocked_reasons: Vec<String>,
1220 pub operation_count: usize,
1221 #[serde(default)]
1222 pub operation_counts: RestoreApplyOperationKindCounts,
1223 pub operation_counts_supplied: bool,
1224 pub progress: RestoreApplyProgressSummary,
1225 pub pending_summary: RestoreApplyPendingSummary,
1226 pub pending_operations: usize,
1227 pub ready_operations: usize,
1228 pub blocked_operations: usize,
1229 pub completed_operations: usize,
1230 pub failed_operations: usize,
1231 pub next_ready_sequence: Option<usize>,
1232 pub next_ready_operation: Option<RestoreApplyOperationKind>,
1233 pub next_transition_sequence: Option<usize>,
1234 pub next_transition_state: Option<RestoreApplyOperationState>,
1235 pub next_transition_operation: Option<RestoreApplyOperationKind>,
1236 pub next_transition_updated_at: Option<String>,
1237}
1238
1239impl RestoreApplyJournalStatus {
1240 #[must_use]
1242 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1243 let next_ready = journal.next_ready_operation();
1244 let next_transition = journal.next_transition_operation();
1245
1246 Self {
1247 status_version: 1,
1248 backup_id: journal.backup_id.clone(),
1249 ready: journal.ready,
1250 complete: journal.operation_count > 0
1251 && journal.completed_operations == journal.operation_count,
1252 blocked_reasons: journal.blocked_reasons.clone(),
1253 operation_count: journal.operation_count,
1254 operation_counts: RestoreApplyOperationKindCounts::from_operations(&journal.operations),
1255 operation_counts_supplied: journal.operation_counts_supplied(),
1256 progress: RestoreApplyProgressSummary::from_journal(journal),
1257 pending_summary: RestoreApplyPendingSummary::from_journal(journal),
1258 pending_operations: journal.pending_operations,
1259 ready_operations: journal.ready_operations,
1260 blocked_operations: journal.blocked_operations,
1261 completed_operations: journal.completed_operations,
1262 failed_operations: journal.failed_operations,
1263 next_ready_sequence: next_ready.map(|operation| operation.sequence),
1264 next_ready_operation: next_ready.map(|operation| operation.operation.clone()),
1265 next_transition_sequence: next_transition.map(|operation| operation.sequence),
1266 next_transition_state: next_transition.map(|operation| operation.state.clone()),
1267 next_transition_operation: next_transition.map(|operation| operation.operation.clone()),
1268 next_transition_updated_at: next_transition
1269 .and_then(|operation| operation.state_updated_at.clone()),
1270 }
1271 }
1272}
1273
1274#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1279#[expect(
1280 clippy::struct_excessive_bools,
1281 reason = "apply reports intentionally expose stable JSON flags for operators and CI"
1282)]
1283pub struct RestoreApplyJournalReport {
1284 pub report_version: u16,
1285 pub backup_id: String,
1286 pub outcome: RestoreApplyReportOutcome,
1287 pub attention_required: bool,
1288 pub ready: bool,
1289 pub complete: bool,
1290 pub blocked_reasons: Vec<String>,
1291 pub operation_count: usize,
1292 #[serde(default)]
1293 pub operation_counts: RestoreApplyOperationKindCounts,
1294 pub operation_counts_supplied: bool,
1295 pub progress: RestoreApplyProgressSummary,
1296 pub pending_summary: RestoreApplyPendingSummary,
1297 pub pending_operations: usize,
1298 pub ready_operations: usize,
1299 pub blocked_operations: usize,
1300 pub completed_operations: usize,
1301 pub failed_operations: usize,
1302 pub next_transition: Option<RestoreApplyReportOperation>,
1303 pub pending: Vec<RestoreApplyReportOperation>,
1304 pub failed: Vec<RestoreApplyReportOperation>,
1305 pub blocked: Vec<RestoreApplyReportOperation>,
1306}
1307
1308impl RestoreApplyJournalReport {
1309 #[must_use]
1311 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1312 let complete =
1313 journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
1314 let outcome = RestoreApplyReportOutcome::from_journal(journal, complete);
1315 let pending = report_operations_with_state(journal, RestoreApplyOperationState::Pending);
1316 let failed = report_operations_with_state(journal, RestoreApplyOperationState::Failed);
1317 let blocked = report_operations_with_state(journal, RestoreApplyOperationState::Blocked);
1318
1319 Self {
1320 report_version: 1,
1321 backup_id: journal.backup_id.clone(),
1322 outcome: outcome.clone(),
1323 attention_required: outcome.attention_required(),
1324 ready: journal.ready,
1325 complete,
1326 blocked_reasons: journal.blocked_reasons.clone(),
1327 operation_count: journal.operation_count,
1328 operation_counts: RestoreApplyOperationKindCounts::from_operations(&journal.operations),
1329 operation_counts_supplied: journal.operation_counts_supplied(),
1330 progress: RestoreApplyProgressSummary::from_journal(journal),
1331 pending_summary: RestoreApplyPendingSummary::from_journal(journal),
1332 pending_operations: journal.pending_operations,
1333 ready_operations: journal.ready_operations,
1334 blocked_operations: journal.blocked_operations,
1335 completed_operations: journal.completed_operations,
1336 failed_operations: journal.failed_operations,
1337 next_transition: journal
1338 .next_transition_operation()
1339 .map(RestoreApplyReportOperation::from_journal_operation),
1340 pending,
1341 failed,
1342 blocked,
1343 }
1344 }
1345}
1346
1347#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1352pub struct RestoreApplyPendingSummary {
1353 pub pending_operations: usize,
1354 pub pending_operation_available: bool,
1355 pub pending_sequence: Option<usize>,
1356 pub pending_operation: Option<RestoreApplyOperationKind>,
1357 pub pending_updated_at: Option<String>,
1358 pub pending_updated_at_known: bool,
1359}
1360
1361impl RestoreApplyPendingSummary {
1362 #[must_use]
1364 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1365 let pending = journal
1366 .operations
1367 .iter()
1368 .filter(|operation| operation.state == RestoreApplyOperationState::Pending)
1369 .min_by_key(|operation| operation.sequence);
1370 let pending_updated_at = pending.and_then(|operation| operation.state_updated_at.clone());
1371 let pending_updated_at_known = pending_updated_at
1372 .as_deref()
1373 .is_some_and(known_state_update_marker);
1374
1375 Self {
1376 pending_operations: journal.pending_operations,
1377 pending_operation_available: pending.is_some(),
1378 pending_sequence: pending.map(|operation| operation.sequence),
1379 pending_operation: pending.map(|operation| operation.operation.clone()),
1380 pending_updated_at,
1381 pending_updated_at_known,
1382 }
1383 }
1384}
1385
1386fn known_state_update_marker(value: &str) -> bool {
1388 !value.trim().is_empty() && value != "unknown"
1389}
1390
1391#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1396pub struct RestoreApplyProgressSummary {
1397 pub operation_count: usize,
1398 pub completed_operations: usize,
1399 pub remaining_operations: usize,
1400 pub transitionable_operations: usize,
1401 pub attention_operations: usize,
1402 pub completion_basis_points: usize,
1403}
1404
1405impl RestoreApplyProgressSummary {
1406 #[must_use]
1408 pub const fn from_journal(journal: &RestoreApplyJournal) -> Self {
1409 let remaining_operations = journal
1410 .operation_count
1411 .saturating_sub(journal.completed_operations);
1412 let transitionable_operations = journal.ready_operations + journal.pending_operations;
1413 let attention_operations =
1414 journal.pending_operations + journal.blocked_operations + journal.failed_operations;
1415 let completion_basis_points =
1416 completion_basis_points(journal.completed_operations, journal.operation_count);
1417
1418 Self {
1419 operation_count: journal.operation_count,
1420 completed_operations: journal.completed_operations,
1421 remaining_operations,
1422 transitionable_operations,
1423 attention_operations,
1424 completion_basis_points,
1425 }
1426 }
1427}
1428
1429const fn completion_basis_points(completed_operations: usize, operation_count: usize) -> usize {
1431 if operation_count == 0 {
1432 return 0;
1433 }
1434
1435 completed_operations.saturating_mul(10_000) / operation_count
1436}
1437
1438#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1443#[serde(rename_all = "kebab-case")]
1444pub enum RestoreApplyReportOutcome {
1445 Empty,
1446 Complete,
1447 Failed,
1448 Blocked,
1449 Pending,
1450 InProgress,
1451}
1452
1453impl RestoreApplyReportOutcome {
1454 const fn from_journal(journal: &RestoreApplyJournal, complete: bool) -> Self {
1456 if journal.operation_count == 0 {
1457 return Self::Empty;
1458 }
1459 if complete {
1460 return Self::Complete;
1461 }
1462 if journal.failed_operations > 0 {
1463 return Self::Failed;
1464 }
1465 if !journal.ready || journal.blocked_operations > 0 {
1466 return Self::Blocked;
1467 }
1468 if journal.pending_operations > 0 {
1469 return Self::Pending;
1470 }
1471 Self::InProgress
1472 }
1473
1474 const fn attention_required(&self) -> bool {
1476 matches!(self, Self::Failed | Self::Blocked | Self::Pending)
1477 }
1478}
1479
1480#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1485pub struct RestoreApplyReportOperation {
1486 pub sequence: usize,
1487 pub operation: RestoreApplyOperationKind,
1488 pub state: RestoreApplyOperationState,
1489 pub restore_group: u16,
1490 pub phase_order: usize,
1491 pub role: String,
1492 pub source_canister: String,
1493 pub target_canister: String,
1494 pub state_updated_at: Option<String>,
1495 pub reasons: Vec<String>,
1496}
1497
1498impl RestoreApplyReportOperation {
1499 fn from_journal_operation(operation: &RestoreApplyJournalOperation) -> Self {
1501 Self {
1502 sequence: operation.sequence,
1503 operation: operation.operation.clone(),
1504 state: operation.state.clone(),
1505 restore_group: operation.restore_group,
1506 phase_order: operation.phase_order,
1507 role: operation.role.clone(),
1508 source_canister: operation.source_canister.clone(),
1509 target_canister: operation.target_canister.clone(),
1510 state_updated_at: operation.state_updated_at.clone(),
1511 reasons: operation.blocking_reasons.clone(),
1512 }
1513 }
1514}
1515
1516fn report_operations_with_state(
1518 journal: &RestoreApplyJournal,
1519 state: RestoreApplyOperationState,
1520) -> Vec<RestoreApplyReportOperation> {
1521 journal
1522 .operations
1523 .iter()
1524 .filter(|operation| operation.state == state)
1525 .map(RestoreApplyReportOperation::from_journal_operation)
1526 .collect()
1527}
1528
1529#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1534pub struct RestoreApplyNextOperation {
1535 pub response_version: u16,
1536 pub backup_id: String,
1537 pub ready: bool,
1538 pub complete: bool,
1539 pub operation_available: bool,
1540 pub blocked_reasons: Vec<String>,
1541 pub operation: Option<RestoreApplyJournalOperation>,
1542}
1543
1544impl RestoreApplyNextOperation {
1545 #[must_use]
1547 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1548 let complete =
1549 journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
1550 let operation = journal.next_transition_operation().cloned();
1551
1552 Self {
1553 response_version: 1,
1554 backup_id: journal.backup_id.clone(),
1555 ready: journal.ready,
1556 complete,
1557 operation_available: operation.is_some(),
1558 blocked_reasons: journal.blocked_reasons.clone(),
1559 operation,
1560 }
1561 }
1562}
1563
1564#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1569#[expect(
1570 clippy::struct_excessive_bools,
1571 reason = "runner preview exposes machine-readable availability and safety flags"
1572)]
1573pub struct RestoreApplyCommandPreview {
1574 pub response_version: u16,
1575 pub backup_id: String,
1576 pub ready: bool,
1577 pub complete: bool,
1578 pub operation_available: bool,
1579 pub command_available: bool,
1580 pub blocked_reasons: Vec<String>,
1581 pub operation: Option<RestoreApplyJournalOperation>,
1582 pub command: Option<RestoreApplyRunnerCommand>,
1583}
1584
1585impl RestoreApplyCommandPreview {
1586 #[must_use]
1588 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1589 Self::from_journal_with_config(journal, &RestoreApplyCommandConfig::default())
1590 }
1591
1592 #[must_use]
1594 pub fn from_journal_with_config(
1595 journal: &RestoreApplyJournal,
1596 config: &RestoreApplyCommandConfig,
1597 ) -> Self {
1598 let complete =
1599 journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
1600 let operation = journal.next_transition_operation().cloned();
1601 let command = operation.as_ref().and_then(|operation| {
1602 RestoreApplyRunnerCommand::from_operation(operation, journal, config)
1603 });
1604
1605 Self {
1606 response_version: 1,
1607 backup_id: journal.backup_id.clone(),
1608 ready: journal.ready,
1609 complete,
1610 operation_available: operation.is_some(),
1611 command_available: command.is_some(),
1612 blocked_reasons: journal.blocked_reasons.clone(),
1613 operation,
1614 command,
1615 }
1616 }
1617}
1618
1619#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1624pub struct RestoreApplyCommandConfig {
1625 pub program: String,
1626 pub network: Option<String>,
1627}
1628
1629impl Default for RestoreApplyCommandConfig {
1630 fn default() -> Self {
1632 Self {
1633 program: "dfx".to_string(),
1634 network: None,
1635 }
1636 }
1637}
1638
1639#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1644pub struct RestoreApplyRunnerCommand {
1645 pub program: String,
1646 pub args: Vec<String>,
1647 pub mutates: bool,
1648 pub requires_stopped_canister: bool,
1649 pub note: String,
1650}
1651
1652impl RestoreApplyRunnerCommand {
1653 fn from_operation(
1655 operation: &RestoreApplyJournalOperation,
1656 journal: &RestoreApplyJournal,
1657 config: &RestoreApplyCommandConfig,
1658 ) -> Option<Self> {
1659 match operation.operation {
1660 RestoreApplyOperationKind::UploadSnapshot => {
1661 let artifact_path = upload_artifact_command_path(operation, journal)?;
1662 Some(Self {
1663 program: config.program.clone(),
1664 args: dfx_canister_args(
1665 config,
1666 vec![
1667 "snapshot".to_string(),
1668 "upload".to_string(),
1669 "--dir".to_string(),
1670 artifact_path,
1671 operation.target_canister.clone(),
1672 ],
1673 ),
1674 mutates: true,
1675 requires_stopped_canister: false,
1676 note: "uploads the downloaded snapshot artifact to the target canister"
1677 .to_string(),
1678 })
1679 }
1680 RestoreApplyOperationKind::LoadSnapshot => {
1681 let snapshot_id = journal.uploaded_snapshot_id_for_load(operation)?;
1682 Some(Self {
1683 program: config.program.clone(),
1684 args: dfx_canister_args(
1685 config,
1686 vec![
1687 "snapshot".to_string(),
1688 "load".to_string(),
1689 operation.target_canister.clone(),
1690 snapshot_id.to_string(),
1691 ],
1692 ),
1693 mutates: true,
1694 requires_stopped_canister: true,
1695 note: "loads the uploaded snapshot into the target canister".to_string(),
1696 })
1697 }
1698 RestoreApplyOperationKind::ReinstallCode => Some(Self {
1699 program: config.program.clone(),
1700 args: dfx_canister_args(
1701 config,
1702 vec![
1703 "install".to_string(),
1704 "--mode".to_string(),
1705 "reinstall".to_string(),
1706 "--yes".to_string(),
1707 operation.target_canister.clone(),
1708 ],
1709 ),
1710 mutates: true,
1711 requires_stopped_canister: false,
1712 note: "reinstalls target canister code using the local dfx project configuration"
1713 .to_string(),
1714 }),
1715 RestoreApplyOperationKind::VerifyMember | RestoreApplyOperationKind::VerifyFleet => {
1716 match operation.verification_kind.as_deref() {
1717 Some("status") => Some(Self {
1718 program: config.program.clone(),
1719 args: dfx_canister_args(
1720 config,
1721 vec!["status".to_string(), operation.target_canister.clone()],
1722 ),
1723 mutates: false,
1724 requires_stopped_canister: false,
1725 note: verification_command_note(
1726 &operation.operation,
1727 "checks target canister status",
1728 "checks target fleet root canister status",
1729 )
1730 .to_string(),
1731 }),
1732 Some(_) => {
1733 let method = operation.verification_method.as_ref()?;
1734 Some(Self {
1735 program: config.program.clone(),
1736 args: dfx_canister_args(
1737 config,
1738 vec![
1739 "call".to_string(),
1740 "--query".to_string(),
1741 operation.target_canister.clone(),
1742 method.clone(),
1743 ],
1744 ),
1745 mutates: false,
1746 requires_stopped_canister: false,
1747 note: verification_command_note(
1748 &operation.operation,
1749 "runs the declared verification method as a query call",
1750 "runs the declared fleet verification method as a query call",
1751 )
1752 .to_string(),
1753 })
1754 }
1755 None => None,
1756 }
1757 }
1758 }
1759 }
1760}
1761
1762const fn verification_command_note(
1764 operation: &RestoreApplyOperationKind,
1765 member_note: &'static str,
1766 fleet_note: &'static str,
1767) -> &'static str {
1768 match operation {
1769 RestoreApplyOperationKind::VerifyFleet => fleet_note,
1770 RestoreApplyOperationKind::UploadSnapshot
1771 | RestoreApplyOperationKind::LoadSnapshot
1772 | RestoreApplyOperationKind::ReinstallCode
1773 | RestoreApplyOperationKind::VerifyMember => member_note,
1774 }
1775}
1776
1777fn dfx_canister_args(config: &RestoreApplyCommandConfig, mut tail: Vec<String>) -> Vec<String> {
1779 let mut args = vec!["canister".to_string()];
1780 if let Some(network) = &config.network {
1781 args.push("--network".to_string());
1782 args.push(network.clone());
1783 }
1784 args.append(&mut tail);
1785 args
1786}
1787
1788fn upload_artifact_command_path(
1790 operation: &RestoreApplyJournalOperation,
1791 journal: &RestoreApplyJournal,
1792) -> Option<String> {
1793 let artifact_path = operation.artifact_path.as_ref()?;
1794 let path = Path::new(artifact_path);
1795 if path.is_absolute() {
1796 return Some(artifact_path.clone());
1797 }
1798
1799 let backup_root = journal.backup_root.as_ref()?;
1800 let is_safe = path
1801 .components()
1802 .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
1803 if !is_safe {
1804 return None;
1805 }
1806
1807 Some(
1808 Path::new(backup_root)
1809 .join(path)
1810 .to_string_lossy()
1811 .to_string(),
1812 )
1813}
1814
1815#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1820pub struct RestoreApplyJournalOperation {
1821 pub sequence: usize,
1822 pub operation: RestoreApplyOperationKind,
1823 pub state: RestoreApplyOperationState,
1824 #[serde(default, skip_serializing_if = "Option::is_none")]
1825 pub state_updated_at: Option<String>,
1826 pub blocking_reasons: Vec<String>,
1827 pub restore_group: u16,
1828 pub phase_order: usize,
1829 pub source_canister: String,
1830 pub target_canister: String,
1831 pub role: String,
1832 pub snapshot_id: Option<String>,
1833 pub artifact_path: Option<String>,
1834 pub verification_kind: Option<String>,
1835 pub verification_method: Option<String>,
1836}
1837
1838impl RestoreApplyJournalOperation {
1839 fn from_dry_run_operation(
1841 operation: &RestoreApplyDryRunOperation,
1842 state: RestoreApplyOperationState,
1843 blocked_reasons: &[String],
1844 ) -> Self {
1845 Self {
1846 sequence: operation.sequence,
1847 operation: operation.operation.clone(),
1848 state: state.clone(),
1849 state_updated_at: None,
1850 blocking_reasons: if state == RestoreApplyOperationState::Blocked {
1851 blocked_reasons.to_vec()
1852 } else {
1853 Vec::new()
1854 },
1855 restore_group: operation.restore_group,
1856 phase_order: operation.phase_order,
1857 source_canister: operation.source_canister.clone(),
1858 target_canister: operation.target_canister.clone(),
1859 role: operation.role.clone(),
1860 snapshot_id: operation.snapshot_id.clone(),
1861 artifact_path: operation.artifact_path.clone(),
1862 verification_kind: operation.verification_kind.clone(),
1863 verification_method: operation.verification_method.clone(),
1864 }
1865 }
1866
1867 fn validate(&self) -> Result<(), RestoreApplyJournalError> {
1869 validate_apply_journal_nonempty("operations[].source_canister", &self.source_canister)?;
1870 validate_apply_journal_nonempty("operations[].target_canister", &self.target_canister)?;
1871 validate_apply_journal_nonempty("operations[].role", &self.role)?;
1872 if let Some(updated_at) = &self.state_updated_at {
1873 validate_apply_journal_nonempty("operations[].state_updated_at", updated_at)?;
1874 }
1875 self.validate_operation_fields()?;
1876
1877 match self.state {
1878 RestoreApplyOperationState::Blocked if self.blocking_reasons.is_empty() => Err(
1879 RestoreApplyJournalError::BlockedOperationMissingReason(self.sequence),
1880 ),
1881 RestoreApplyOperationState::Failed if self.blocking_reasons.is_empty() => Err(
1882 RestoreApplyJournalError::FailureReasonRequired(self.sequence),
1883 ),
1884 RestoreApplyOperationState::Pending
1885 | RestoreApplyOperationState::Ready
1886 | RestoreApplyOperationState::Completed
1887 if !self.blocking_reasons.is_empty() =>
1888 {
1889 Err(RestoreApplyJournalError::UnblockedOperationHasReasons(
1890 self.sequence,
1891 ))
1892 }
1893 RestoreApplyOperationState::Blocked
1894 | RestoreApplyOperationState::Failed
1895 | RestoreApplyOperationState::Pending
1896 | RestoreApplyOperationState::Ready
1897 | RestoreApplyOperationState::Completed => Ok(()),
1898 }
1899 }
1900
1901 fn validate_operation_fields(&self) -> Result<(), RestoreApplyJournalError> {
1903 match self.operation {
1904 RestoreApplyOperationKind::UploadSnapshot => self
1905 .validate_required_field("operations[].artifact_path", self.artifact_path.as_ref())
1906 .map(|_| ()),
1907 RestoreApplyOperationKind::LoadSnapshot => self
1908 .validate_required_field("operations[].snapshot_id", self.snapshot_id.as_ref())
1909 .map(|_| ()),
1910 RestoreApplyOperationKind::ReinstallCode => Ok(()),
1911 RestoreApplyOperationKind::VerifyMember | RestoreApplyOperationKind::VerifyFleet => {
1912 let kind = self.validate_required_field(
1913 "operations[].verification_kind",
1914 self.verification_kind.as_ref(),
1915 )?;
1916 if kind == "status" {
1917 return Ok(());
1918 }
1919 self.validate_required_field(
1920 "operations[].verification_method",
1921 self.verification_method.as_ref(),
1922 )
1923 .map(|_| ())
1924 }
1925 }
1926 }
1927
1928 fn validate_required_field<'a>(
1930 &self,
1931 field: &'static str,
1932 value: Option<&'a String>,
1933 ) -> Result<&'a str, RestoreApplyJournalError> {
1934 let value = value.map(String::as_str).ok_or_else(|| {
1935 RestoreApplyJournalError::OperationMissingField {
1936 sequence: self.sequence,
1937 operation: self.operation.clone(),
1938 field,
1939 }
1940 })?;
1941 if value.trim().is_empty() {
1942 return Err(RestoreApplyJournalError::OperationMissingField {
1943 sequence: self.sequence,
1944 operation: self.operation.clone(),
1945 field,
1946 });
1947 }
1948
1949 Ok(value)
1950 }
1951
1952 const fn can_transition_to(&self, next_state: &RestoreApplyOperationState) -> bool {
1954 match (&self.state, next_state) {
1955 (
1956 RestoreApplyOperationState::Ready | RestoreApplyOperationState::Pending,
1957 RestoreApplyOperationState::Pending,
1958 )
1959 | (
1960 RestoreApplyOperationState::Pending | RestoreApplyOperationState::Failed,
1961 RestoreApplyOperationState::Ready,
1962 )
1963 | (
1964 RestoreApplyOperationState::Ready
1965 | RestoreApplyOperationState::Pending
1966 | RestoreApplyOperationState::Completed,
1967 RestoreApplyOperationState::Completed,
1968 )
1969 | (
1970 RestoreApplyOperationState::Ready
1971 | RestoreApplyOperationState::Pending
1972 | RestoreApplyOperationState::Failed,
1973 RestoreApplyOperationState::Failed,
1974 ) => true,
1975 (
1976 RestoreApplyOperationState::Blocked
1977 | RestoreApplyOperationState::Completed
1978 | RestoreApplyOperationState::Failed
1979 | RestoreApplyOperationState::Pending
1980 | RestoreApplyOperationState::Ready,
1981 _,
1982 ) => false,
1983 }
1984 }
1985}
1986
1987#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1992#[serde(rename_all = "kebab-case")]
1993pub enum RestoreApplyOperationState {
1994 Pending,
1995 Ready,
1996 Blocked,
1997 Completed,
1998 Failed,
1999}
2000
2001#[derive(Debug, ThisError)]
2006pub enum RestoreApplyJournalError {
2007 #[error("unsupported restore apply journal version {0}")]
2008 UnsupportedVersion(u16),
2009
2010 #[error("restore apply journal field {0} is required")]
2011 MissingField(&'static str),
2012
2013 #[error("restore apply journal count {field} mismatch: reported={reported}, actual={actual}")]
2014 CountMismatch {
2015 field: &'static str,
2016 reported: usize,
2017 actual: usize,
2018 },
2019
2020 #[error("restore apply journal has duplicate operation sequence {0}")]
2021 DuplicateSequence(usize),
2022
2023 #[error("restore apply journal is missing operation sequence {0}")]
2024 MissingSequence(usize),
2025
2026 #[error("ready restore apply journal cannot include blocked reasons or blocked operations")]
2027 ReadyJournalHasBlockingState,
2028
2029 #[error("blocked restore apply journal operation {0} is missing a blocking reason")]
2030 BlockedOperationMissingReason(usize),
2031
2032 #[error("unblocked restore apply journal operation {0} cannot have blocking reasons")]
2033 UnblockedOperationHasReasons(usize),
2034
2035 #[error("restore apply journal operation {sequence} {operation:?} is missing field {field}")]
2036 OperationMissingField {
2037 sequence: usize,
2038 operation: RestoreApplyOperationKind,
2039 field: &'static str,
2040 },
2041
2042 #[error("restore apply journal operation {0} was not found")]
2043 OperationNotFound(usize),
2044
2045 #[error("restore apply journal operation {sequence} cannot transition from {from:?} to {to:?}")]
2046 InvalidOperationTransition {
2047 sequence: usize,
2048 from: RestoreApplyOperationState,
2049 to: RestoreApplyOperationState,
2050 },
2051
2052 #[error("failed restore apply journal operation {0} requires a reason")]
2053 FailureReasonRequired(usize),
2054
2055 #[error("restore apply journal has no operation that can be advanced")]
2056 NoTransitionableOperation,
2057
2058 #[error("restore apply journal has no pending operation to release")]
2059 NoPendingOperation,
2060
2061 #[error("restore apply journal operation {requested} cannot advance before operation {next}")]
2062 OutOfOrderOperationTransition { requested: usize, next: usize },
2063
2064 #[error("restore apply journal receipt references missing operation {0}")]
2065 OperationReceiptOperationNotFound(usize),
2066
2067 #[error("restore apply journal receipt does not match operation {sequence}")]
2068 OperationReceiptMismatch { sequence: usize },
2069}
2070
2071fn validate_restore_apply_artifacts(
2073 plan: &RestorePlan,
2074 backup_root: &Path,
2075) -> Result<RestoreApplyArtifactValidation, RestoreApplyDryRunError> {
2076 let mut checks = Vec::new();
2077
2078 for member in plan.ordered_members() {
2079 checks.push(validate_restore_apply_artifact(member, backup_root)?);
2080 }
2081
2082 let members_with_expected_checksums = checks
2083 .iter()
2084 .filter(|check| check.checksum_expected.is_some())
2085 .count();
2086 let artifacts_present = checks.iter().all(|check| check.exists);
2087 let checksums_verified = members_with_expected_checksums == plan.member_count
2088 && checks.iter().all(|check| check.checksum_verified);
2089
2090 Ok(RestoreApplyArtifactValidation {
2091 backup_root: backup_root.to_string_lossy().to_string(),
2092 checked_members: checks.len(),
2093 artifacts_present,
2094 checksums_verified,
2095 members_with_expected_checksums,
2096 checks,
2097 })
2098}
2099
2100fn validate_restore_apply_artifact(
2102 member: &RestorePlanMember,
2103 backup_root: &Path,
2104) -> Result<RestoreApplyArtifactCheck, RestoreApplyDryRunError> {
2105 let artifact_path = safe_restore_artifact_path(
2106 &member.source_canister,
2107 &member.source_snapshot.artifact_path,
2108 )?;
2109 let resolved_path = backup_root.join(&artifact_path);
2110
2111 if !resolved_path.exists() {
2112 return Err(RestoreApplyDryRunError::ArtifactMissing {
2113 source_canister: member.source_canister.clone(),
2114 artifact_path: member.source_snapshot.artifact_path.clone(),
2115 resolved_path: resolved_path.to_string_lossy().to_string(),
2116 });
2117 }
2118
2119 let (checksum_actual, checksum_verified) =
2120 if let Some(expected) = &member.source_snapshot.checksum {
2121 let checksum = ArtifactChecksum::from_path(&resolved_path).map_err(|source| {
2122 RestoreApplyDryRunError::ArtifactChecksum {
2123 source_canister: member.source_canister.clone(),
2124 artifact_path: member.source_snapshot.artifact_path.clone(),
2125 source,
2126 }
2127 })?;
2128 checksum.verify(expected).map_err(|source| {
2129 RestoreApplyDryRunError::ArtifactChecksum {
2130 source_canister: member.source_canister.clone(),
2131 artifact_path: member.source_snapshot.artifact_path.clone(),
2132 source,
2133 }
2134 })?;
2135 (Some(checksum.hash), true)
2136 } else {
2137 (None, false)
2138 };
2139
2140 Ok(RestoreApplyArtifactCheck {
2141 source_canister: member.source_canister.clone(),
2142 target_canister: member.target_canister.clone(),
2143 snapshot_id: member.source_snapshot.snapshot_id.clone(),
2144 artifact_path: member.source_snapshot.artifact_path.clone(),
2145 resolved_path: resolved_path.to_string_lossy().to_string(),
2146 exists: true,
2147 checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
2148 checksum_expected: member.source_snapshot.checksum.clone(),
2149 checksum_actual,
2150 checksum_verified,
2151 })
2152}
2153
2154fn safe_restore_artifact_path(
2156 source_canister: &str,
2157 artifact_path: &str,
2158) -> Result<PathBuf, RestoreApplyDryRunError> {
2159 let path = Path::new(artifact_path);
2160 let is_safe = path
2161 .components()
2162 .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
2163
2164 if is_safe {
2165 return Ok(path.to_path_buf());
2166 }
2167
2168 Err(RestoreApplyDryRunError::ArtifactPathEscapesBackup {
2169 source_canister: source_canister.to_string(),
2170 artifact_path: artifact_path.to_string(),
2171 })
2172}
2173
2174fn validate_restore_status_matches_plan(
2176 plan: &RestorePlan,
2177 status: &RestoreStatus,
2178) -> Result<(), RestoreApplyDryRunError> {
2179 validate_status_string_field("backup_id", &plan.backup_id, &status.backup_id)?;
2180 validate_status_string_field(
2181 "source_environment",
2182 &plan.source_environment,
2183 &status.source_environment,
2184 )?;
2185 validate_status_string_field(
2186 "source_root_canister",
2187 &plan.source_root_canister,
2188 &status.source_root_canister,
2189 )?;
2190 validate_status_string_field("topology_hash", &plan.topology_hash, &status.topology_hash)?;
2191 validate_status_usize_field("member_count", plan.member_count, status.member_count)?;
2192 validate_status_usize_field(
2193 "phase_count",
2194 plan.ordering_summary.phase_count,
2195 status.phase_count,
2196 )?;
2197 Ok(())
2198}
2199
2200fn validate_status_string_field(
2202 field: &'static str,
2203 plan: &str,
2204 status: &str,
2205) -> Result<(), RestoreApplyDryRunError> {
2206 if plan == status {
2207 return Ok(());
2208 }
2209
2210 Err(RestoreApplyDryRunError::StatusPlanMismatch {
2211 field,
2212 plan: plan.to_string(),
2213 status: status.to_string(),
2214 })
2215}
2216
2217const fn validate_status_usize_field(
2219 field: &'static str,
2220 plan: usize,
2221 status: usize,
2222) -> Result<(), RestoreApplyDryRunError> {
2223 if plan == status {
2224 return Ok(());
2225 }
2226
2227 Err(RestoreApplyDryRunError::StatusPlanCountMismatch {
2228 field,
2229 plan,
2230 status,
2231 })
2232}
2233
2234#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2239pub struct RestoreApplyArtifactValidation {
2240 pub backup_root: String,
2241 pub checked_members: usize,
2242 pub artifacts_present: bool,
2243 pub checksums_verified: bool,
2244 pub members_with_expected_checksums: usize,
2245 pub checks: Vec<RestoreApplyArtifactCheck>,
2246}
2247
2248#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2253pub struct RestoreApplyArtifactCheck {
2254 pub source_canister: String,
2255 pub target_canister: String,
2256 pub snapshot_id: String,
2257 pub artifact_path: String,
2258 pub resolved_path: String,
2259 pub exists: bool,
2260 pub checksum_algorithm: String,
2261 pub checksum_expected: Option<String>,
2262 pub checksum_actual: Option<String>,
2263 pub checksum_verified: bool,
2264}
2265
2266#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2271pub struct RestoreApplyDryRunPhase {
2272 pub restore_group: u16,
2273 pub operations: Vec<RestoreApplyDryRunOperation>,
2274}
2275
2276impl RestoreApplyDryRunPhase {
2277 fn from_plan_phase(phase: &RestorePhase, next_sequence: &mut usize) -> Self {
2279 let mut operations = Vec::new();
2280
2281 for member in &phase.members {
2282 push_member_operation(
2283 &mut operations,
2284 next_sequence,
2285 RestoreApplyOperationKind::UploadSnapshot,
2286 member,
2287 None,
2288 );
2289 push_member_operation(
2290 &mut operations,
2291 next_sequence,
2292 RestoreApplyOperationKind::LoadSnapshot,
2293 member,
2294 None,
2295 );
2296
2297 for check in &member.verification_checks {
2298 push_member_operation(
2299 &mut operations,
2300 next_sequence,
2301 RestoreApplyOperationKind::VerifyMember,
2302 member,
2303 Some(check),
2304 );
2305 }
2306 }
2307
2308 Self {
2309 restore_group: phase.restore_group,
2310 operations,
2311 }
2312 }
2313}
2314
2315fn push_member_operation(
2317 operations: &mut Vec<RestoreApplyDryRunOperation>,
2318 next_sequence: &mut usize,
2319 operation: RestoreApplyOperationKind,
2320 member: &RestorePlanMember,
2321 check: Option<&VerificationCheck>,
2322) {
2323 let sequence = *next_sequence;
2324 *next_sequence += 1;
2325
2326 operations.push(RestoreApplyDryRunOperation {
2327 sequence,
2328 operation,
2329 restore_group: member.restore_group,
2330 phase_order: member.phase_order,
2331 source_canister: member.source_canister.clone(),
2332 target_canister: member.target_canister.clone(),
2333 role: member.role.clone(),
2334 snapshot_id: Some(member.source_snapshot.snapshot_id.clone()),
2335 artifact_path: Some(member.source_snapshot.artifact_path.clone()),
2336 verification_kind: check.map(|check| check.kind.clone()),
2337 verification_method: check.and_then(|check| check.method.clone()),
2338 });
2339}
2340
2341fn append_fleet_verification_operations(
2343 plan: &RestorePlan,
2344 phases: &mut [RestoreApplyDryRunPhase],
2345 next_sequence: &mut usize,
2346) {
2347 if plan.fleet_verification_checks.is_empty() {
2348 return;
2349 }
2350
2351 let Some(phase) = phases.last_mut() else {
2352 return;
2353 };
2354 let root = plan
2355 .phases
2356 .iter()
2357 .flat_map(|phase| phase.members.iter())
2358 .find(|member| member.source_canister == plan.source_root_canister);
2359 let source_canister = root.map_or_else(
2360 || plan.source_root_canister.clone(),
2361 |member| member.source_canister.clone(),
2362 );
2363 let target_canister = root.map_or_else(
2364 || plan.source_root_canister.clone(),
2365 |member| member.target_canister.clone(),
2366 );
2367 let restore_group = phase.restore_group;
2368
2369 for check in &plan.fleet_verification_checks {
2370 push_fleet_operation(
2371 &mut phase.operations,
2372 next_sequence,
2373 restore_group,
2374 &source_canister,
2375 &target_canister,
2376 check,
2377 );
2378 }
2379}
2380
2381fn push_fleet_operation(
2383 operations: &mut Vec<RestoreApplyDryRunOperation>,
2384 next_sequence: &mut usize,
2385 restore_group: u16,
2386 source_canister: &str,
2387 target_canister: &str,
2388 check: &VerificationCheck,
2389) {
2390 let sequence = *next_sequence;
2391 *next_sequence += 1;
2392 let phase_order = operations.len();
2393
2394 operations.push(RestoreApplyDryRunOperation {
2395 sequence,
2396 operation: RestoreApplyOperationKind::VerifyFleet,
2397 restore_group,
2398 phase_order,
2399 source_canister: source_canister.to_string(),
2400 target_canister: target_canister.to_string(),
2401 role: "fleet".to_string(),
2402 snapshot_id: None,
2403 artifact_path: None,
2404 verification_kind: Some(check.kind.clone()),
2405 verification_method: check.method.clone(),
2406 });
2407}
2408
2409#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2414pub struct RestoreApplyDryRunOperation {
2415 pub sequence: usize,
2416 pub operation: RestoreApplyOperationKind,
2417 pub restore_group: u16,
2418 pub phase_order: usize,
2419 pub source_canister: String,
2420 pub target_canister: String,
2421 pub role: String,
2422 pub snapshot_id: Option<String>,
2423 pub artifact_path: Option<String>,
2424 pub verification_kind: Option<String>,
2425 pub verification_method: Option<String>,
2426}
2427
2428#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2433#[serde(rename_all = "kebab-case")]
2434pub enum RestoreApplyOperationKind {
2435 UploadSnapshot,
2436 LoadSnapshot,
2437 ReinstallCode,
2438 VerifyMember,
2439 VerifyFleet,
2440}
2441
2442#[derive(Debug, ThisError)]
2447pub enum RestoreApplyDryRunError {
2448 #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
2449 StatusPlanMismatch {
2450 field: &'static str,
2451 plan: String,
2452 status: String,
2453 },
2454
2455 #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
2456 StatusPlanCountMismatch {
2457 field: &'static str,
2458 plan: usize,
2459 status: usize,
2460 },
2461
2462 #[error("restore artifact path for {source_canister} escapes backup root: {artifact_path}")]
2463 ArtifactPathEscapesBackup {
2464 source_canister: String,
2465 artifact_path: String,
2466 },
2467
2468 #[error(
2469 "restore artifact for {source_canister} is missing: {artifact_path} at {resolved_path}"
2470 )]
2471 ArtifactMissing {
2472 source_canister: String,
2473 artifact_path: String,
2474 resolved_path: String,
2475 },
2476
2477 #[error("restore artifact checksum failed for {source_canister} at {artifact_path}: {source}")]
2478 ArtifactChecksum {
2479 source_canister: String,
2480 artifact_path: String,
2481 #[source]
2482 source: ArtifactChecksumError,
2483 },
2484}
2485
2486#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2491pub struct RestoreIdentitySummary {
2492 pub mapping_supplied: bool,
2493 pub all_sources_mapped: bool,
2494 pub fixed_members: usize,
2495 pub relocatable_members: usize,
2496 pub in_place_members: usize,
2497 pub mapped_members: usize,
2498 pub remapped_members: usize,
2499}
2500
2501#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2506#[expect(
2507 clippy::struct_excessive_bools,
2508 reason = "restore summaries intentionally expose machine-readable readiness flags"
2509)]
2510pub struct RestoreSnapshotSummary {
2511 pub all_members_have_module_hash: bool,
2512 pub all_members_have_wasm_hash: bool,
2513 pub all_members_have_code_version: bool,
2514 pub all_members_have_checksum: bool,
2515 pub members_with_module_hash: usize,
2516 pub members_with_wasm_hash: usize,
2517 pub members_with_code_version: usize,
2518 pub members_with_checksum: usize,
2519}
2520
2521#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2526pub struct RestoreVerificationSummary {
2527 pub verification_required: bool,
2528 pub all_members_have_checks: bool,
2529 pub fleet_checks: usize,
2530 pub member_check_groups: usize,
2531 pub member_checks: usize,
2532 pub members_with_checks: usize,
2533 pub total_checks: usize,
2534}
2535
2536#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2541pub struct RestoreReadinessSummary {
2542 pub ready: bool,
2543 pub reasons: Vec<String>,
2544}
2545
2546#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2551pub struct RestoreOperationSummary {
2552 #[serde(default)]
2553 pub planned_snapshot_uploads: usize,
2554 pub planned_snapshot_loads: usize,
2555 pub planned_code_reinstalls: usize,
2556 pub planned_verification_checks: usize,
2557 #[serde(default)]
2558 pub planned_operations: usize,
2559 pub planned_phases: usize,
2560}
2561
2562impl RestoreOperationSummary {
2563 #[must_use]
2565 pub const fn effective_planned_snapshot_uploads(&self, member_count: usize) -> usize {
2566 if self.planned_snapshot_uploads == 0 && member_count > 0 {
2567 return member_count;
2568 }
2569
2570 self.planned_snapshot_uploads
2571 }
2572
2573 #[must_use]
2575 pub const fn effective_planned_operations(&self, member_count: usize) -> usize {
2576 if self.planned_operations == 0 {
2577 return self.effective_planned_snapshot_uploads(member_count)
2578 + self.planned_snapshot_loads
2579 + self.planned_code_reinstalls
2580 + self.planned_verification_checks;
2581 }
2582
2583 self.planned_operations
2584 }
2585}
2586
2587#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2592pub struct RestoreOrderingSummary {
2593 pub phase_count: usize,
2594 pub dependency_free_members: usize,
2595 pub in_group_parent_edges: usize,
2596 pub cross_group_parent_edges: usize,
2597}
2598
2599#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2604pub struct RestorePhase {
2605 pub restore_group: u16,
2606 pub members: Vec<RestorePlanMember>,
2607}
2608
2609#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2614pub struct RestorePlanMember {
2615 pub source_canister: String,
2616 pub target_canister: String,
2617 pub role: String,
2618 pub parent_source_canister: Option<String>,
2619 pub parent_target_canister: Option<String>,
2620 pub ordering_dependency: Option<RestoreOrderingDependency>,
2621 pub phase_order: usize,
2622 pub restore_group: u16,
2623 pub identity_mode: IdentityMode,
2624 pub verification_class: String,
2625 pub verification_checks: Vec<VerificationCheck>,
2626 pub source_snapshot: SourceSnapshot,
2627}
2628
2629#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2634pub struct RestoreOrderingDependency {
2635 pub source_canister: String,
2636 pub target_canister: String,
2637 pub relationship: RestoreOrderingRelationship,
2638}
2639
2640#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
2645#[serde(rename_all = "kebab-case")]
2646pub enum RestoreOrderingRelationship {
2647 ParentInSameGroup,
2648 ParentInEarlierGroup,
2649}
2650
2651pub struct RestorePlanner;
2656
2657impl RestorePlanner {
2658 pub fn plan(
2660 manifest: &FleetBackupManifest,
2661 mapping: Option<&RestoreMapping>,
2662 ) -> Result<RestorePlan, RestorePlanError> {
2663 manifest.validate()?;
2664 if let Some(mapping) = mapping {
2665 validate_mapping(mapping)?;
2666 validate_mapping_sources(manifest, mapping)?;
2667 }
2668
2669 let members = resolve_members(manifest, mapping)?;
2670 let identity_summary = restore_identity_summary(&members, mapping.is_some());
2671 let snapshot_summary = restore_snapshot_summary(&members);
2672 let verification_summary = restore_verification_summary(manifest, &members);
2673 let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
2674 validate_restore_group_dependencies(&members)?;
2675 let phases = group_and_order_members(members)?;
2676 let ordering_summary = restore_ordering_summary(&phases);
2677 let operation_summary =
2678 restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
2679
2680 Ok(RestorePlan {
2681 backup_id: manifest.backup_id.clone(),
2682 source_environment: manifest.source.environment.clone(),
2683 source_root_canister: manifest.source.root_canister.clone(),
2684 topology_hash: manifest.fleet.topology_hash.clone(),
2685 member_count: manifest.fleet.members.len(),
2686 identity_summary,
2687 snapshot_summary,
2688 verification_summary,
2689 readiness_summary,
2690 operation_summary,
2691 ordering_summary,
2692 design_conformance: Some(manifest.design_conformance_report()),
2693 fleet_verification_checks: manifest.verification.fleet_checks.clone(),
2694 phases,
2695 })
2696 }
2697}
2698
2699#[derive(Debug, ThisError)]
2704pub enum RestorePlanError {
2705 #[error(transparent)]
2706 InvalidManifest(#[from] ManifestValidationError),
2707
2708 #[error("field {field} must be a valid principal: {value}")]
2709 InvalidPrincipal { field: &'static str, value: String },
2710
2711 #[error("mapping contains duplicate source canister {0}")]
2712 DuplicateMappingSource(String),
2713
2714 #[error("mapping contains duplicate target canister {0}")]
2715 DuplicateMappingTarget(String),
2716
2717 #[error("mapping references unknown source canister {0}")]
2718 UnknownMappingSource(String),
2719
2720 #[error("mapping is missing source canister {0}")]
2721 MissingMappingSource(String),
2722
2723 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
2724 FixedIdentityRemap {
2725 source_canister: String,
2726 target_canister: String,
2727 },
2728
2729 #[error("restore plan contains duplicate target canister {0}")]
2730 DuplicatePlanTarget(String),
2731
2732 #[error("restore group {0} contains a parent cycle or unresolved dependency")]
2733 RestoreOrderCycle(u16),
2734
2735 #[error(
2736 "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
2737 )]
2738 ParentRestoreGroupAfterChild {
2739 child_source_canister: String,
2740 parent_source_canister: String,
2741 child_restore_group: u16,
2742 parent_restore_group: u16,
2743 },
2744}
2745
2746fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
2748 let mut sources = BTreeSet::new();
2749 let mut targets = BTreeSet::new();
2750
2751 for entry in &mapping.members {
2752 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
2753 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
2754
2755 if !sources.insert(entry.source_canister.clone()) {
2756 return Err(RestorePlanError::DuplicateMappingSource(
2757 entry.source_canister.clone(),
2758 ));
2759 }
2760
2761 if !targets.insert(entry.target_canister.clone()) {
2762 return Err(RestorePlanError::DuplicateMappingTarget(
2763 entry.target_canister.clone(),
2764 ));
2765 }
2766 }
2767
2768 Ok(())
2769}
2770
2771fn validate_mapping_sources(
2773 manifest: &FleetBackupManifest,
2774 mapping: &RestoreMapping,
2775) -> Result<(), RestorePlanError> {
2776 let sources = manifest
2777 .fleet
2778 .members
2779 .iter()
2780 .map(|member| member.canister_id.as_str())
2781 .collect::<BTreeSet<_>>();
2782
2783 for entry in &mapping.members {
2784 if !sources.contains(entry.source_canister.as_str()) {
2785 return Err(RestorePlanError::UnknownMappingSource(
2786 entry.source_canister.clone(),
2787 ));
2788 }
2789 }
2790
2791 Ok(())
2792}
2793
2794fn resolve_members(
2796 manifest: &FleetBackupManifest,
2797 mapping: Option<&RestoreMapping>,
2798) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
2799 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
2800 let mut targets = BTreeSet::new();
2801 let mut source_to_target = BTreeMap::new();
2802
2803 for member in &manifest.fleet.members {
2804 let target = resolve_target(member, mapping)?;
2805 if !targets.insert(target.clone()) {
2806 return Err(RestorePlanError::DuplicatePlanTarget(target));
2807 }
2808
2809 source_to_target.insert(member.canister_id.clone(), target.clone());
2810 plan_members.push(RestorePlanMember {
2811 source_canister: member.canister_id.clone(),
2812 target_canister: target,
2813 role: member.role.clone(),
2814 parent_source_canister: member.parent_canister_id.clone(),
2815 parent_target_canister: None,
2816 ordering_dependency: None,
2817 phase_order: 0,
2818 restore_group: member.restore_group,
2819 identity_mode: member.identity_mode.clone(),
2820 verification_class: member.verification_class.clone(),
2821 verification_checks: concrete_member_verification_checks(
2822 member,
2823 &manifest.verification,
2824 ),
2825 source_snapshot: member.source_snapshot.clone(),
2826 });
2827 }
2828
2829 for member in &mut plan_members {
2830 member.parent_target_canister = member
2831 .parent_source_canister
2832 .as_ref()
2833 .and_then(|parent| source_to_target.get(parent))
2834 .cloned();
2835 }
2836
2837 Ok(plan_members)
2838}
2839
2840fn concrete_member_verification_checks(
2842 member: &FleetMember,
2843 verification: &VerificationPlan,
2844) -> Vec<VerificationCheck> {
2845 let mut checks = member
2846 .verification_checks
2847 .iter()
2848 .filter(|check| verification_check_applies_to_role(check, &member.role))
2849 .cloned()
2850 .collect::<Vec<_>>();
2851
2852 for group in &verification.member_checks {
2853 if group.role != member.role {
2854 continue;
2855 }
2856
2857 checks.extend(
2858 group
2859 .checks
2860 .iter()
2861 .filter(|check| verification_check_applies_to_role(check, &member.role))
2862 .cloned(),
2863 );
2864 }
2865
2866 checks
2867}
2868
2869fn verification_check_applies_to_role(check: &VerificationCheck, role: &str) -> bool {
2871 check.roles.is_empty() || check.roles.iter().any(|check_role| check_role == role)
2872}
2873
2874fn resolve_target(
2876 member: &FleetMember,
2877 mapping: Option<&RestoreMapping>,
2878) -> Result<String, RestorePlanError> {
2879 let target = match mapping {
2880 Some(mapping) => mapping
2881 .target_for(&member.canister_id)
2882 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
2883 .to_string(),
2884 None => member.canister_id.clone(),
2885 };
2886
2887 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
2888 return Err(RestorePlanError::FixedIdentityRemap {
2889 source_canister: member.canister_id.clone(),
2890 target_canister: target,
2891 });
2892 }
2893
2894 Ok(target)
2895}
2896
2897fn restore_identity_summary(
2899 members: &[RestorePlanMember],
2900 mapping_supplied: bool,
2901) -> RestoreIdentitySummary {
2902 let mut summary = RestoreIdentitySummary {
2903 mapping_supplied,
2904 all_sources_mapped: false,
2905 fixed_members: 0,
2906 relocatable_members: 0,
2907 in_place_members: 0,
2908 mapped_members: 0,
2909 remapped_members: 0,
2910 };
2911
2912 for member in members {
2913 match member.identity_mode {
2914 IdentityMode::Fixed => summary.fixed_members += 1,
2915 IdentityMode::Relocatable => summary.relocatable_members += 1,
2916 }
2917
2918 if member.source_canister == member.target_canister {
2919 summary.in_place_members += 1;
2920 } else {
2921 summary.remapped_members += 1;
2922 }
2923 if mapping_supplied {
2924 summary.mapped_members += 1;
2925 }
2926 }
2927
2928 summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
2929
2930 summary
2931}
2932
2933fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
2935 let members_with_module_hash = members
2936 .iter()
2937 .filter(|member| member.source_snapshot.module_hash.is_some())
2938 .count();
2939 let members_with_wasm_hash = members
2940 .iter()
2941 .filter(|member| member.source_snapshot.wasm_hash.is_some())
2942 .count();
2943 let members_with_code_version = members
2944 .iter()
2945 .filter(|member| member.source_snapshot.code_version.is_some())
2946 .count();
2947 let members_with_checksum = members
2948 .iter()
2949 .filter(|member| member.source_snapshot.checksum.is_some())
2950 .count();
2951
2952 RestoreSnapshotSummary {
2953 all_members_have_module_hash: members_with_module_hash == members.len(),
2954 all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
2955 all_members_have_code_version: members_with_code_version == members.len(),
2956 all_members_have_checksum: members_with_checksum == members.len(),
2957 members_with_module_hash,
2958 members_with_wasm_hash,
2959 members_with_code_version,
2960 members_with_checksum,
2961 }
2962}
2963
2964fn restore_readiness_summary(
2966 snapshot: &RestoreSnapshotSummary,
2967 verification: &RestoreVerificationSummary,
2968) -> RestoreReadinessSummary {
2969 let mut reasons = Vec::new();
2970
2971 if !snapshot.all_members_have_module_hash {
2972 reasons.push("missing-module-hash".to_string());
2973 }
2974 if !snapshot.all_members_have_wasm_hash {
2975 reasons.push("missing-wasm-hash".to_string());
2976 }
2977 if !snapshot.all_members_have_code_version {
2978 reasons.push("missing-code-version".to_string());
2979 }
2980 if !snapshot.all_members_have_checksum {
2981 reasons.push("missing-snapshot-checksum".to_string());
2982 }
2983 if !verification.all_members_have_checks {
2984 reasons.push("missing-verification-checks".to_string());
2985 }
2986
2987 RestoreReadinessSummary {
2988 ready: reasons.is_empty(),
2989 reasons,
2990 }
2991}
2992
2993fn restore_verification_summary(
2995 manifest: &FleetBackupManifest,
2996 members: &[RestorePlanMember],
2997) -> RestoreVerificationSummary {
2998 let fleet_checks = manifest.verification.fleet_checks.len();
2999 let member_check_groups = manifest.verification.member_checks.len();
3000 let member_checks = members
3001 .iter()
3002 .map(|member| member.verification_checks.len())
3003 .sum::<usize>();
3004 let members_with_checks = members
3005 .iter()
3006 .filter(|member| !member.verification_checks.is_empty())
3007 .count();
3008
3009 RestoreVerificationSummary {
3010 verification_required: true,
3011 all_members_have_checks: members_with_checks == members.len(),
3012 fleet_checks,
3013 member_check_groups,
3014 member_checks,
3015 members_with_checks,
3016 total_checks: fleet_checks + member_checks,
3017 }
3018}
3019
3020const fn restore_operation_summary(
3022 member_count: usize,
3023 verification_summary: &RestoreVerificationSummary,
3024 phases: &[RestorePhase],
3025) -> RestoreOperationSummary {
3026 RestoreOperationSummary {
3027 planned_snapshot_uploads: member_count,
3028 planned_snapshot_loads: member_count,
3029 planned_code_reinstalls: 0,
3030 planned_verification_checks: verification_summary.total_checks,
3031 planned_operations: member_count + member_count + verification_summary.total_checks,
3032 planned_phases: phases.len(),
3033 }
3034}
3035
3036fn validate_restore_group_dependencies(
3038 members: &[RestorePlanMember],
3039) -> Result<(), RestorePlanError> {
3040 let groups_by_source = members
3041 .iter()
3042 .map(|member| (member.source_canister.as_str(), member.restore_group))
3043 .collect::<BTreeMap<_, _>>();
3044
3045 for member in members {
3046 let Some(parent) = &member.parent_source_canister else {
3047 continue;
3048 };
3049 let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
3050 continue;
3051 };
3052
3053 if *parent_group > member.restore_group {
3054 return Err(RestorePlanError::ParentRestoreGroupAfterChild {
3055 child_source_canister: member.source_canister.clone(),
3056 parent_source_canister: parent.clone(),
3057 child_restore_group: member.restore_group,
3058 parent_restore_group: *parent_group,
3059 });
3060 }
3061 }
3062
3063 Ok(())
3064}
3065
3066fn group_and_order_members(
3068 members: Vec<RestorePlanMember>,
3069) -> Result<Vec<RestorePhase>, RestorePlanError> {
3070 let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
3071 for member in members {
3072 groups.entry(member.restore_group).or_default().push(member);
3073 }
3074
3075 groups
3076 .into_iter()
3077 .map(|(restore_group, members)| {
3078 let members = order_group(restore_group, members)?;
3079 Ok(RestorePhase {
3080 restore_group,
3081 members,
3082 })
3083 })
3084 .collect()
3085}
3086
3087fn order_group(
3089 restore_group: u16,
3090 members: Vec<RestorePlanMember>,
3091) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
3092 let mut remaining = members;
3093 let group_sources = remaining
3094 .iter()
3095 .map(|member| member.source_canister.clone())
3096 .collect::<BTreeSet<_>>();
3097 let mut emitted = BTreeSet::new();
3098 let mut ordered = Vec::with_capacity(remaining.len());
3099
3100 while !remaining.is_empty() {
3101 let Some(index) = remaining
3102 .iter()
3103 .position(|member| parent_satisfied(member, &group_sources, &emitted))
3104 else {
3105 return Err(RestorePlanError::RestoreOrderCycle(restore_group));
3106 };
3107
3108 let mut member = remaining.remove(index);
3109 member.phase_order = ordered.len();
3110 member.ordering_dependency = ordering_dependency(&member, &group_sources);
3111 emitted.insert(member.source_canister.clone());
3112 ordered.push(member);
3113 }
3114
3115 Ok(ordered)
3116}
3117
3118fn ordering_dependency(
3120 member: &RestorePlanMember,
3121 group_sources: &BTreeSet<String>,
3122) -> Option<RestoreOrderingDependency> {
3123 let parent_source = member.parent_source_canister.as_ref()?;
3124 let parent_target = member.parent_target_canister.as_ref()?;
3125 let relationship = if group_sources.contains(parent_source) {
3126 RestoreOrderingRelationship::ParentInSameGroup
3127 } else {
3128 RestoreOrderingRelationship::ParentInEarlierGroup
3129 };
3130
3131 Some(RestoreOrderingDependency {
3132 source_canister: parent_source.clone(),
3133 target_canister: parent_target.clone(),
3134 relationship,
3135 })
3136}
3137
3138fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
3140 let mut summary = RestoreOrderingSummary {
3141 phase_count: phases.len(),
3142 dependency_free_members: 0,
3143 in_group_parent_edges: 0,
3144 cross_group_parent_edges: 0,
3145 };
3146
3147 for member in phases.iter().flat_map(|phase| phase.members.iter()) {
3148 match &member.ordering_dependency {
3149 Some(dependency)
3150 if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
3151 {
3152 summary.in_group_parent_edges += 1;
3153 }
3154 Some(dependency)
3155 if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
3156 {
3157 summary.cross_group_parent_edges += 1;
3158 }
3159 Some(_) => {}
3160 None => summary.dependency_free_members += 1,
3161 }
3162 }
3163
3164 summary
3165}
3166
3167fn parent_satisfied(
3169 member: &RestorePlanMember,
3170 group_sources: &BTreeSet<String>,
3171 emitted: &BTreeSet<String>,
3172) -> bool {
3173 match &member.parent_source_canister {
3174 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
3175 _ => true,
3176 }
3177}
3178
3179fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
3181 Principal::from_str(value)
3182 .map(|_| ())
3183 .map_err(|_| RestorePlanError::InvalidPrincipal {
3184 field,
3185 value: value.to_string(),
3186 })
3187}
3188
3189#[cfg(test)]
3190mod tests;