1use crate::{
2 artifacts::{ArtifactChecksum, ArtifactChecksumError},
3 manifest::{
4 FleetBackupManifest, FleetMember, IdentityMode, ManifestValidationError, SourceSnapshot,
5 VerificationCheck,
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 fleet_verification_checks: Vec<VerificationCheck>,
65 pub phases: Vec<RestorePhase>,
66}
67
68impl RestorePlan {
69 #[must_use]
71 pub fn ordered_members(&self) -> Vec<&RestorePlanMember> {
72 self.phases
73 .iter()
74 .flat_map(|phase| phase.members.iter())
75 .collect()
76 }
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
84pub struct RestoreStatus {
85 pub status_version: u16,
86 pub backup_id: String,
87 pub source_environment: String,
88 pub source_root_canister: String,
89 pub topology_hash: String,
90 pub ready: bool,
91 pub readiness_reasons: Vec<String>,
92 pub verification_required: bool,
93 pub member_count: usize,
94 pub phase_count: usize,
95 pub planned_snapshot_loads: usize,
96 pub planned_code_reinstalls: usize,
97 pub planned_verification_checks: usize,
98 pub phases: Vec<RestoreStatusPhase>,
99}
100
101impl RestoreStatus {
102 #[must_use]
104 pub fn from_plan(plan: &RestorePlan) -> Self {
105 Self {
106 status_version: 1,
107 backup_id: plan.backup_id.clone(),
108 source_environment: plan.source_environment.clone(),
109 source_root_canister: plan.source_root_canister.clone(),
110 topology_hash: plan.topology_hash.clone(),
111 ready: plan.readiness_summary.ready,
112 readiness_reasons: plan.readiness_summary.reasons.clone(),
113 verification_required: plan.verification_summary.verification_required,
114 member_count: plan.member_count,
115 phase_count: plan.ordering_summary.phase_count,
116 planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
117 planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
118 planned_verification_checks: plan.operation_summary.planned_verification_checks,
119 phases: plan
120 .phases
121 .iter()
122 .map(RestoreStatusPhase::from_plan_phase)
123 .collect(),
124 }
125 }
126}
127
128#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
133pub struct RestoreStatusPhase {
134 pub restore_group: u16,
135 pub members: Vec<RestoreStatusMember>,
136}
137
138impl RestoreStatusPhase {
139 fn from_plan_phase(phase: &RestorePhase) -> Self {
141 Self {
142 restore_group: phase.restore_group,
143 members: phase
144 .members
145 .iter()
146 .map(RestoreStatusMember::from_plan_member)
147 .collect(),
148 }
149 }
150}
151
152#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
157pub struct RestoreStatusMember {
158 pub source_canister: String,
159 pub target_canister: String,
160 pub role: String,
161 pub restore_group: u16,
162 pub phase_order: usize,
163 pub snapshot_id: String,
164 pub artifact_path: String,
165 pub state: RestoreMemberState,
166}
167
168impl RestoreStatusMember {
169 fn from_plan_member(member: &RestorePlanMember) -> Self {
171 Self {
172 source_canister: member.source_canister.clone(),
173 target_canister: member.target_canister.clone(),
174 role: member.role.clone(),
175 restore_group: member.restore_group,
176 phase_order: member.phase_order,
177 snapshot_id: member.source_snapshot.snapshot_id.clone(),
178 artifact_path: member.source_snapshot.artifact_path.clone(),
179 state: RestoreMemberState::Planned,
180 }
181 }
182}
183
184#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
189#[serde(rename_all = "kebab-case")]
190pub enum RestoreMemberState {
191 Planned,
192}
193
194#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
199pub struct RestoreApplyDryRun {
200 pub dry_run_version: u16,
201 pub backup_id: String,
202 pub ready: bool,
203 pub readiness_reasons: Vec<String>,
204 pub member_count: usize,
205 pub phase_count: usize,
206 pub status_supplied: bool,
207 pub planned_snapshot_loads: usize,
208 pub planned_code_reinstalls: usize,
209 pub planned_verification_checks: usize,
210 pub rendered_operations: usize,
211 pub artifact_validation: Option<RestoreApplyArtifactValidation>,
212 pub phases: Vec<RestoreApplyDryRunPhase>,
213}
214
215impl RestoreApplyDryRun {
216 pub fn try_from_plan(
218 plan: &RestorePlan,
219 status: Option<&RestoreStatus>,
220 ) -> Result<Self, RestoreApplyDryRunError> {
221 if let Some(status) = status {
222 validate_restore_status_matches_plan(plan, status)?;
223 }
224
225 Ok(Self::from_validated_plan(plan, status))
226 }
227
228 pub fn try_from_plan_with_artifacts(
230 plan: &RestorePlan,
231 status: Option<&RestoreStatus>,
232 backup_root: &Path,
233 ) -> Result<Self, RestoreApplyDryRunError> {
234 let mut dry_run = Self::try_from_plan(plan, status)?;
235 dry_run.artifact_validation = Some(validate_restore_apply_artifacts(plan, backup_root)?);
236 Ok(dry_run)
237 }
238
239 fn from_validated_plan(plan: &RestorePlan, status: Option<&RestoreStatus>) -> Self {
241 let mut next_sequence = 0;
242 let phases = plan
243 .phases
244 .iter()
245 .map(|phase| RestoreApplyDryRunPhase::from_plan_phase(phase, &mut next_sequence))
246 .collect::<Vec<_>>();
247 let mut phases = phases;
248 append_fleet_verification_operations(plan, &mut phases, &mut next_sequence);
249 let rendered_operations = phases
250 .iter()
251 .map(|phase| phase.operations.len())
252 .sum::<usize>();
253
254 Self {
255 dry_run_version: 1,
256 backup_id: plan.backup_id.clone(),
257 ready: status.map_or(plan.readiness_summary.ready, |status| status.ready),
258 readiness_reasons: status.map_or_else(
259 || plan.readiness_summary.reasons.clone(),
260 |status| status.readiness_reasons.clone(),
261 ),
262 member_count: plan.member_count,
263 phase_count: plan.ordering_summary.phase_count,
264 status_supplied: status.is_some(),
265 planned_snapshot_loads: plan.operation_summary.planned_snapshot_loads,
266 planned_code_reinstalls: plan.operation_summary.planned_code_reinstalls,
267 planned_verification_checks: plan.operation_summary.planned_verification_checks,
268 rendered_operations,
269 artifact_validation: None,
270 phases,
271 }
272 }
273}
274
275#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
280pub struct RestoreApplyJournal {
281 pub journal_version: u16,
282 pub backup_id: String,
283 pub ready: bool,
284 pub blocked_reasons: Vec<String>,
285 pub operation_count: usize,
286 pub pending_operations: usize,
287 pub ready_operations: usize,
288 pub blocked_operations: usize,
289 pub completed_operations: usize,
290 pub failed_operations: usize,
291 pub operations: Vec<RestoreApplyJournalOperation>,
292}
293
294impl RestoreApplyJournal {
295 #[must_use]
297 pub fn from_dry_run(dry_run: &RestoreApplyDryRun) -> Self {
298 let blocked_reasons = restore_apply_blocked_reasons(dry_run);
299 let initial_state = if blocked_reasons.is_empty() {
300 RestoreApplyOperationState::Ready
301 } else {
302 RestoreApplyOperationState::Blocked
303 };
304 let operations = dry_run
305 .phases
306 .iter()
307 .flat_map(|phase| phase.operations.iter())
308 .map(|operation| {
309 RestoreApplyJournalOperation::from_dry_run_operation(
310 operation,
311 initial_state.clone(),
312 &blocked_reasons,
313 )
314 })
315 .collect::<Vec<_>>();
316 let ready_operations = operations
317 .iter()
318 .filter(|operation| operation.state == RestoreApplyOperationState::Ready)
319 .count();
320 let blocked_operations = operations
321 .iter()
322 .filter(|operation| operation.state == RestoreApplyOperationState::Blocked)
323 .count();
324
325 Self {
326 journal_version: 1,
327 backup_id: dry_run.backup_id.clone(),
328 ready: blocked_reasons.is_empty(),
329 blocked_reasons,
330 operation_count: operations.len(),
331 pending_operations: 0,
332 ready_operations,
333 blocked_operations,
334 completed_operations: 0,
335 failed_operations: 0,
336 operations,
337 }
338 }
339
340 pub fn validate(&self) -> Result<(), RestoreApplyJournalError> {
342 validate_apply_journal_version(self.journal_version)?;
343 validate_apply_journal_nonempty("backup_id", &self.backup_id)?;
344 validate_apply_journal_count(
345 "operation_count",
346 self.operation_count,
347 self.operations.len(),
348 )?;
349
350 let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
351 validate_apply_journal_count(
352 "pending_operations",
353 self.pending_operations,
354 state_counts.pending,
355 )?;
356 validate_apply_journal_count(
357 "ready_operations",
358 self.ready_operations,
359 state_counts.ready,
360 )?;
361 validate_apply_journal_count(
362 "blocked_operations",
363 self.blocked_operations,
364 state_counts.blocked,
365 )?;
366 validate_apply_journal_count(
367 "completed_operations",
368 self.completed_operations,
369 state_counts.completed,
370 )?;
371 validate_apply_journal_count(
372 "failed_operations",
373 self.failed_operations,
374 state_counts.failed,
375 )?;
376
377 if self.ready && (!self.blocked_reasons.is_empty() || self.blocked_operations > 0) {
378 return Err(RestoreApplyJournalError::ReadyJournalHasBlockingState);
379 }
380
381 validate_apply_journal_sequences(&self.operations)?;
382 for operation in &self.operations {
383 operation.validate()?;
384 }
385
386 Ok(())
387 }
388
389 #[must_use]
391 pub fn status(&self) -> RestoreApplyJournalStatus {
392 RestoreApplyJournalStatus::from_journal(self)
393 }
394
395 #[must_use]
397 pub fn report(&self) -> RestoreApplyJournalReport {
398 RestoreApplyJournalReport::from_journal(self)
399 }
400
401 #[must_use]
403 pub fn next_ready_operation(&self) -> Option<&RestoreApplyJournalOperation> {
404 self.operations
405 .iter()
406 .filter(|operation| operation.state == RestoreApplyOperationState::Ready)
407 .min_by_key(|operation| operation.sequence)
408 }
409
410 #[must_use]
412 pub fn next_transition_operation(&self) -> Option<&RestoreApplyJournalOperation> {
413 self.operations
414 .iter()
415 .filter(|operation| {
416 matches!(
417 operation.state,
418 RestoreApplyOperationState::Ready | RestoreApplyOperationState::Pending
419 )
420 })
421 .min_by_key(|operation| operation.sequence)
422 }
423
424 #[must_use]
426 pub fn next_operation(&self) -> RestoreApplyNextOperation {
427 RestoreApplyNextOperation::from_journal(self)
428 }
429
430 #[must_use]
432 pub fn next_command_preview(&self) -> RestoreApplyCommandPreview {
433 RestoreApplyCommandPreview::from_journal(self)
434 }
435
436 #[must_use]
438 pub fn next_command_preview_with_config(
439 &self,
440 config: &RestoreApplyCommandConfig,
441 ) -> RestoreApplyCommandPreview {
442 RestoreApplyCommandPreview::from_journal_with_config(self, config)
443 }
444
445 pub fn mark_next_operation_pending(&mut self) -> Result<(), RestoreApplyJournalError> {
447 self.mark_next_operation_pending_at(None)
448 }
449
450 pub fn mark_next_operation_pending_at(
452 &mut self,
453 updated_at: Option<String>,
454 ) -> Result<(), RestoreApplyJournalError> {
455 let sequence = self
456 .next_transition_sequence()
457 .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
458 self.mark_operation_pending_at(sequence, updated_at)
459 }
460
461 pub fn mark_operation_pending(
463 &mut self,
464 sequence: usize,
465 ) -> Result<(), RestoreApplyJournalError> {
466 self.mark_operation_pending_at(sequence, None)
467 }
468
469 pub fn mark_operation_pending_at(
471 &mut self,
472 sequence: usize,
473 updated_at: Option<String>,
474 ) -> Result<(), RestoreApplyJournalError> {
475 self.transition_operation(
476 sequence,
477 RestoreApplyOperationState::Pending,
478 Vec::new(),
479 updated_at,
480 )
481 }
482
483 pub fn mark_next_operation_ready(&mut self) -> Result<(), RestoreApplyJournalError> {
485 self.mark_next_operation_ready_at(None)
486 }
487
488 pub fn mark_next_operation_ready_at(
490 &mut self,
491 updated_at: Option<String>,
492 ) -> Result<(), RestoreApplyJournalError> {
493 let operation = self
494 .next_transition_operation()
495 .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
496 if operation.state != RestoreApplyOperationState::Pending {
497 return Err(RestoreApplyJournalError::NoPendingOperation);
498 }
499
500 self.mark_operation_ready_at(operation.sequence, updated_at)
501 }
502
503 pub fn mark_operation_ready(
505 &mut self,
506 sequence: usize,
507 ) -> Result<(), RestoreApplyJournalError> {
508 self.mark_operation_ready_at(sequence, None)
509 }
510
511 pub fn mark_operation_ready_at(
513 &mut self,
514 sequence: usize,
515 updated_at: Option<String>,
516 ) -> Result<(), RestoreApplyJournalError> {
517 self.transition_operation(
518 sequence,
519 RestoreApplyOperationState::Ready,
520 Vec::new(),
521 updated_at,
522 )
523 }
524
525 pub fn mark_operation_completed(
527 &mut self,
528 sequence: usize,
529 ) -> Result<(), RestoreApplyJournalError> {
530 self.mark_operation_completed_at(sequence, None)
531 }
532
533 pub fn mark_operation_completed_at(
535 &mut self,
536 sequence: usize,
537 updated_at: Option<String>,
538 ) -> Result<(), RestoreApplyJournalError> {
539 self.transition_operation(
540 sequence,
541 RestoreApplyOperationState::Completed,
542 Vec::new(),
543 updated_at,
544 )
545 }
546
547 pub fn mark_operation_failed(
549 &mut self,
550 sequence: usize,
551 reason: String,
552 ) -> Result<(), RestoreApplyJournalError> {
553 self.mark_operation_failed_at(sequence, reason, None)
554 }
555
556 pub fn mark_operation_failed_at(
558 &mut self,
559 sequence: usize,
560 reason: String,
561 updated_at: Option<String>,
562 ) -> Result<(), RestoreApplyJournalError> {
563 if reason.trim().is_empty() {
564 return Err(RestoreApplyJournalError::FailureReasonRequired(sequence));
565 }
566
567 self.transition_operation(
568 sequence,
569 RestoreApplyOperationState::Failed,
570 vec![reason],
571 updated_at,
572 )
573 }
574
575 fn transition_operation(
577 &mut self,
578 sequence: usize,
579 next_state: RestoreApplyOperationState,
580 blocking_reasons: Vec<String>,
581 updated_at: Option<String>,
582 ) -> Result<(), RestoreApplyJournalError> {
583 let index = self
584 .operations
585 .iter()
586 .position(|operation| operation.sequence == sequence)
587 .ok_or(RestoreApplyJournalError::OperationNotFound(sequence))?;
588 let operation = &self.operations[index];
589
590 if !operation.can_transition_to(&next_state) {
591 return Err(RestoreApplyJournalError::InvalidOperationTransition {
592 sequence,
593 from: operation.state.clone(),
594 to: next_state,
595 });
596 }
597
598 self.validate_operation_transition_order(operation, &next_state)?;
599
600 let operation = &mut self.operations[index];
601 operation.state = next_state;
602 operation.blocking_reasons = blocking_reasons;
603 operation.state_updated_at = updated_at;
604 self.refresh_operation_counts();
605 self.validate()
606 }
607
608 fn validate_operation_transition_order(
610 &self,
611 operation: &RestoreApplyJournalOperation,
612 next_state: &RestoreApplyOperationState,
613 ) -> Result<(), RestoreApplyJournalError> {
614 if operation.state == *next_state {
615 return Ok(());
616 }
617
618 let next_sequence = self
619 .next_transition_sequence()
620 .ok_or(RestoreApplyJournalError::NoTransitionableOperation)?;
621
622 if operation.sequence == next_sequence {
623 return Ok(());
624 }
625
626 Err(RestoreApplyJournalError::OutOfOrderOperationTransition {
627 requested: operation.sequence,
628 next: next_sequence,
629 })
630 }
631
632 fn next_transition_sequence(&self) -> Option<usize> {
634 self.next_transition_operation()
635 .map(|operation| operation.sequence)
636 }
637
638 fn refresh_operation_counts(&mut self) {
640 let state_counts = RestoreApplyJournalStateCounts::from_operations(&self.operations);
641 self.operation_count = self.operations.len();
642 self.pending_operations = state_counts.pending;
643 self.ready_operations = state_counts.ready;
644 self.blocked_operations = state_counts.blocked;
645 self.completed_operations = state_counts.completed;
646 self.failed_operations = state_counts.failed;
647 }
648}
649
650const fn validate_apply_journal_version(version: u16) -> Result<(), RestoreApplyJournalError> {
652 if version == 1 {
653 return Ok(());
654 }
655
656 Err(RestoreApplyJournalError::UnsupportedVersion(version))
657}
658
659fn validate_apply_journal_nonempty(
661 field: &'static str,
662 value: &str,
663) -> Result<(), RestoreApplyJournalError> {
664 if !value.trim().is_empty() {
665 return Ok(());
666 }
667
668 Err(RestoreApplyJournalError::MissingField(field))
669}
670
671const fn validate_apply_journal_count(
673 field: &'static str,
674 reported: usize,
675 actual: usize,
676) -> Result<(), RestoreApplyJournalError> {
677 if reported == actual {
678 return Ok(());
679 }
680
681 Err(RestoreApplyJournalError::CountMismatch {
682 field,
683 reported,
684 actual,
685 })
686}
687
688fn validate_apply_journal_sequences(
690 operations: &[RestoreApplyJournalOperation],
691) -> Result<(), RestoreApplyJournalError> {
692 let mut sequences = BTreeSet::new();
693 for operation in operations {
694 if !sequences.insert(operation.sequence) {
695 return Err(RestoreApplyJournalError::DuplicateSequence(
696 operation.sequence,
697 ));
698 }
699 }
700
701 for expected in 0..operations.len() {
702 if !sequences.contains(&expected) {
703 return Err(RestoreApplyJournalError::MissingSequence(expected));
704 }
705 }
706
707 Ok(())
708}
709
710#[derive(Clone, Debug, Default, Eq, PartialEq)]
715struct RestoreApplyJournalStateCounts {
716 pending: usize,
717 ready: usize,
718 blocked: usize,
719 completed: usize,
720 failed: usize,
721}
722
723impl RestoreApplyJournalStateCounts {
724 fn from_operations(operations: &[RestoreApplyJournalOperation]) -> Self {
726 let mut counts = Self::default();
727 for operation in operations {
728 match operation.state {
729 RestoreApplyOperationState::Pending => counts.pending += 1,
730 RestoreApplyOperationState::Ready => counts.ready += 1,
731 RestoreApplyOperationState::Blocked => counts.blocked += 1,
732 RestoreApplyOperationState::Completed => counts.completed += 1,
733 RestoreApplyOperationState::Failed => counts.failed += 1,
734 }
735 }
736 counts
737 }
738}
739
740fn restore_apply_blocked_reasons(dry_run: &RestoreApplyDryRun) -> Vec<String> {
742 let mut reasons = dry_run.readiness_reasons.clone();
743
744 match &dry_run.artifact_validation {
745 Some(validation) => {
746 if !validation.artifacts_present {
747 reasons.push("missing-artifacts".to_string());
748 }
749 if !validation.checksums_verified {
750 reasons.push("artifact-checksum-validation-incomplete".to_string());
751 }
752 }
753 None => reasons.push("missing-artifact-validation".to_string()),
754 }
755
756 reasons.sort();
757 reasons.dedup();
758 reasons
759}
760
761#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
766pub struct RestoreApplyJournalStatus {
767 pub status_version: u16,
768 pub backup_id: String,
769 pub ready: bool,
770 pub complete: bool,
771 pub blocked_reasons: Vec<String>,
772 pub operation_count: usize,
773 pub pending_operations: usize,
774 pub ready_operations: usize,
775 pub blocked_operations: usize,
776 pub completed_operations: usize,
777 pub failed_operations: usize,
778 pub next_ready_sequence: Option<usize>,
779 pub next_ready_operation: Option<RestoreApplyOperationKind>,
780 pub next_transition_sequence: Option<usize>,
781 pub next_transition_state: Option<RestoreApplyOperationState>,
782 pub next_transition_operation: Option<RestoreApplyOperationKind>,
783 pub next_transition_updated_at: Option<String>,
784}
785
786impl RestoreApplyJournalStatus {
787 #[must_use]
789 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
790 let next_ready = journal.next_ready_operation();
791 let next_transition = journal.next_transition_operation();
792
793 Self {
794 status_version: 1,
795 backup_id: journal.backup_id.clone(),
796 ready: journal.ready,
797 complete: journal.operation_count > 0
798 && journal.completed_operations == journal.operation_count,
799 blocked_reasons: journal.blocked_reasons.clone(),
800 operation_count: journal.operation_count,
801 pending_operations: journal.pending_operations,
802 ready_operations: journal.ready_operations,
803 blocked_operations: journal.blocked_operations,
804 completed_operations: journal.completed_operations,
805 failed_operations: journal.failed_operations,
806 next_ready_sequence: next_ready.map(|operation| operation.sequence),
807 next_ready_operation: next_ready.map(|operation| operation.operation.clone()),
808 next_transition_sequence: next_transition.map(|operation| operation.sequence),
809 next_transition_state: next_transition.map(|operation| operation.state.clone()),
810 next_transition_operation: next_transition.map(|operation| operation.operation.clone()),
811 next_transition_updated_at: next_transition
812 .and_then(|operation| operation.state_updated_at.clone()),
813 }
814 }
815}
816
817#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
822pub struct RestoreApplyJournalReport {
823 pub report_version: u16,
824 pub backup_id: String,
825 pub outcome: RestoreApplyReportOutcome,
826 pub attention_required: bool,
827 pub ready: bool,
828 pub complete: bool,
829 pub blocked_reasons: Vec<String>,
830 pub operation_count: usize,
831 pub pending_operations: usize,
832 pub ready_operations: usize,
833 pub blocked_operations: usize,
834 pub completed_operations: usize,
835 pub failed_operations: usize,
836 pub next_transition: Option<RestoreApplyReportOperation>,
837 pub pending: Vec<RestoreApplyReportOperation>,
838 pub failed: Vec<RestoreApplyReportOperation>,
839 pub blocked: Vec<RestoreApplyReportOperation>,
840}
841
842impl RestoreApplyJournalReport {
843 #[must_use]
845 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
846 let complete =
847 journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
848 let outcome = RestoreApplyReportOutcome::from_journal(journal, complete);
849 let pending = report_operations_with_state(journal, RestoreApplyOperationState::Pending);
850 let failed = report_operations_with_state(journal, RestoreApplyOperationState::Failed);
851 let blocked = report_operations_with_state(journal, RestoreApplyOperationState::Blocked);
852
853 Self {
854 report_version: 1,
855 backup_id: journal.backup_id.clone(),
856 outcome: outcome.clone(),
857 attention_required: outcome.attention_required(),
858 ready: journal.ready,
859 complete,
860 blocked_reasons: journal.blocked_reasons.clone(),
861 operation_count: journal.operation_count,
862 pending_operations: journal.pending_operations,
863 ready_operations: journal.ready_operations,
864 blocked_operations: journal.blocked_operations,
865 completed_operations: journal.completed_operations,
866 failed_operations: journal.failed_operations,
867 next_transition: journal
868 .next_transition_operation()
869 .map(RestoreApplyReportOperation::from_journal_operation),
870 pending,
871 failed,
872 blocked,
873 }
874 }
875}
876
877#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
882#[serde(rename_all = "kebab-case")]
883pub enum RestoreApplyReportOutcome {
884 Empty,
885 Complete,
886 Failed,
887 Blocked,
888 Pending,
889 InProgress,
890}
891
892impl RestoreApplyReportOutcome {
893 const fn from_journal(journal: &RestoreApplyJournal, complete: bool) -> Self {
895 if journal.operation_count == 0 {
896 return Self::Empty;
897 }
898 if complete {
899 return Self::Complete;
900 }
901 if journal.failed_operations > 0 {
902 return Self::Failed;
903 }
904 if !journal.ready || journal.blocked_operations > 0 {
905 return Self::Blocked;
906 }
907 if journal.pending_operations > 0 {
908 return Self::Pending;
909 }
910 Self::InProgress
911 }
912
913 const fn attention_required(&self) -> bool {
915 matches!(self, Self::Failed | Self::Blocked | Self::Pending)
916 }
917}
918
919#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
924pub struct RestoreApplyReportOperation {
925 pub sequence: usize,
926 pub operation: RestoreApplyOperationKind,
927 pub state: RestoreApplyOperationState,
928 pub restore_group: u16,
929 pub phase_order: usize,
930 pub role: String,
931 pub source_canister: String,
932 pub target_canister: String,
933 pub state_updated_at: Option<String>,
934 pub reasons: Vec<String>,
935}
936
937impl RestoreApplyReportOperation {
938 fn from_journal_operation(operation: &RestoreApplyJournalOperation) -> Self {
940 Self {
941 sequence: operation.sequence,
942 operation: operation.operation.clone(),
943 state: operation.state.clone(),
944 restore_group: operation.restore_group,
945 phase_order: operation.phase_order,
946 role: operation.role.clone(),
947 source_canister: operation.source_canister.clone(),
948 target_canister: operation.target_canister.clone(),
949 state_updated_at: operation.state_updated_at.clone(),
950 reasons: operation.blocking_reasons.clone(),
951 }
952 }
953}
954
955fn report_operations_with_state(
957 journal: &RestoreApplyJournal,
958 state: RestoreApplyOperationState,
959) -> Vec<RestoreApplyReportOperation> {
960 journal
961 .operations
962 .iter()
963 .filter(|operation| operation.state == state)
964 .map(RestoreApplyReportOperation::from_journal_operation)
965 .collect()
966}
967
968#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
973pub struct RestoreApplyNextOperation {
974 pub response_version: u16,
975 pub backup_id: String,
976 pub ready: bool,
977 pub complete: bool,
978 pub operation_available: bool,
979 pub blocked_reasons: Vec<String>,
980 pub operation: Option<RestoreApplyJournalOperation>,
981}
982
983impl RestoreApplyNextOperation {
984 #[must_use]
986 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
987 let complete =
988 journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
989 let operation = journal.next_transition_operation().cloned();
990
991 Self {
992 response_version: 1,
993 backup_id: journal.backup_id.clone(),
994 ready: journal.ready,
995 complete,
996 operation_available: operation.is_some(),
997 blocked_reasons: journal.blocked_reasons.clone(),
998 operation,
999 }
1000 }
1001}
1002
1003#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1008#[expect(
1009 clippy::struct_excessive_bools,
1010 reason = "runner preview exposes machine-readable availability and safety flags"
1011)]
1012pub struct RestoreApplyCommandPreview {
1013 pub response_version: u16,
1014 pub backup_id: String,
1015 pub ready: bool,
1016 pub complete: bool,
1017 pub operation_available: bool,
1018 pub command_available: bool,
1019 pub blocked_reasons: Vec<String>,
1020 pub operation: Option<RestoreApplyJournalOperation>,
1021 pub command: Option<RestoreApplyRunnerCommand>,
1022}
1023
1024impl RestoreApplyCommandPreview {
1025 #[must_use]
1027 pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
1028 Self::from_journal_with_config(journal, &RestoreApplyCommandConfig::default())
1029 }
1030
1031 #[must_use]
1033 pub fn from_journal_with_config(
1034 journal: &RestoreApplyJournal,
1035 config: &RestoreApplyCommandConfig,
1036 ) -> Self {
1037 let complete =
1038 journal.operation_count > 0 && journal.completed_operations == journal.operation_count;
1039 let operation = journal.next_transition_operation().cloned();
1040 let command = operation
1041 .as_ref()
1042 .and_then(|operation| RestoreApplyRunnerCommand::from_operation(operation, config));
1043
1044 Self {
1045 response_version: 1,
1046 backup_id: journal.backup_id.clone(),
1047 ready: journal.ready,
1048 complete,
1049 operation_available: operation.is_some(),
1050 command_available: command.is_some(),
1051 blocked_reasons: journal.blocked_reasons.clone(),
1052 operation,
1053 command,
1054 }
1055 }
1056}
1057
1058#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1063pub struct RestoreApplyCommandConfig {
1064 pub program: String,
1065 pub network: Option<String>,
1066}
1067
1068impl Default for RestoreApplyCommandConfig {
1069 fn default() -> Self {
1071 Self {
1072 program: "dfx".to_string(),
1073 network: None,
1074 }
1075 }
1076}
1077
1078#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1083pub struct RestoreApplyRunnerCommand {
1084 pub program: String,
1085 pub args: Vec<String>,
1086 pub mutates: bool,
1087 pub requires_stopped_canister: bool,
1088 pub note: String,
1089}
1090
1091impl RestoreApplyRunnerCommand {
1092 fn from_operation(
1094 operation: &RestoreApplyJournalOperation,
1095 config: &RestoreApplyCommandConfig,
1096 ) -> Option<Self> {
1097 match operation.operation {
1098 RestoreApplyOperationKind::UploadSnapshot => {
1099 let artifact_path = operation.artifact_path.as_ref()?;
1100 Some(Self {
1101 program: config.program.clone(),
1102 args: dfx_canister_args(
1103 config,
1104 vec![
1105 "snapshot".to_string(),
1106 "upload".to_string(),
1107 "--dir".to_string(),
1108 artifact_path.clone(),
1109 operation.target_canister.clone(),
1110 ],
1111 ),
1112 mutates: true,
1113 requires_stopped_canister: false,
1114 note: "uploads the downloaded snapshot artifact to the target canister"
1115 .to_string(),
1116 })
1117 }
1118 RestoreApplyOperationKind::LoadSnapshot => {
1119 let snapshot_id = operation.snapshot_id.as_ref()?;
1120 Some(Self {
1121 program: config.program.clone(),
1122 args: dfx_canister_args(
1123 config,
1124 vec![
1125 "snapshot".to_string(),
1126 "load".to_string(),
1127 operation.target_canister.clone(),
1128 snapshot_id.clone(),
1129 ],
1130 ),
1131 mutates: true,
1132 requires_stopped_canister: true,
1133 note: "loads the uploaded snapshot into the target canister".to_string(),
1134 })
1135 }
1136 RestoreApplyOperationKind::ReinstallCode => Some(Self {
1137 program: config.program.clone(),
1138 args: dfx_canister_args(
1139 config,
1140 vec![
1141 "install".to_string(),
1142 "--mode".to_string(),
1143 "reinstall".to_string(),
1144 "--yes".to_string(),
1145 operation.target_canister.clone(),
1146 ],
1147 ),
1148 mutates: true,
1149 requires_stopped_canister: false,
1150 note: "reinstalls target canister code using the local dfx project configuration"
1151 .to_string(),
1152 }),
1153 RestoreApplyOperationKind::VerifyMember | RestoreApplyOperationKind::VerifyFleet => {
1154 match operation.verification_kind.as_deref() {
1155 Some("status") => Some(Self {
1156 program: config.program.clone(),
1157 args: dfx_canister_args(
1158 config,
1159 vec!["status".to_string(), operation.target_canister.clone()],
1160 ),
1161 mutates: false,
1162 requires_stopped_canister: false,
1163 note: verification_command_note(
1164 &operation.operation,
1165 "checks target canister status",
1166 "checks target fleet root canister status",
1167 )
1168 .to_string(),
1169 }),
1170 Some(_) => {
1171 let method = operation.verification_method.as_ref()?;
1172 Some(Self {
1173 program: config.program.clone(),
1174 args: dfx_canister_args(
1175 config,
1176 vec![
1177 "call".to_string(),
1178 operation.target_canister.clone(),
1179 method.clone(),
1180 ],
1181 ),
1182 mutates: false,
1183 requires_stopped_canister: false,
1184 note: verification_command_note(
1185 &operation.operation,
1186 "calls the declared verification method",
1187 "calls the declared fleet verification method",
1188 )
1189 .to_string(),
1190 })
1191 }
1192 None => None,
1193 }
1194 }
1195 }
1196 }
1197}
1198
1199const fn verification_command_note(
1201 operation: &RestoreApplyOperationKind,
1202 member_note: &'static str,
1203 fleet_note: &'static str,
1204) -> &'static str {
1205 match operation {
1206 RestoreApplyOperationKind::VerifyFleet => fleet_note,
1207 RestoreApplyOperationKind::UploadSnapshot
1208 | RestoreApplyOperationKind::LoadSnapshot
1209 | RestoreApplyOperationKind::ReinstallCode
1210 | RestoreApplyOperationKind::VerifyMember => member_note,
1211 }
1212}
1213
1214fn dfx_canister_args(config: &RestoreApplyCommandConfig, mut tail: Vec<String>) -> Vec<String> {
1216 let mut args = vec!["canister".to_string()];
1217 if let Some(network) = &config.network {
1218 args.push("--network".to_string());
1219 args.push(network.clone());
1220 }
1221 args.append(&mut tail);
1222 args
1223}
1224
1225#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1230pub struct RestoreApplyJournalOperation {
1231 pub sequence: usize,
1232 pub operation: RestoreApplyOperationKind,
1233 pub state: RestoreApplyOperationState,
1234 #[serde(default, skip_serializing_if = "Option::is_none")]
1235 pub state_updated_at: Option<String>,
1236 pub blocking_reasons: Vec<String>,
1237 pub restore_group: u16,
1238 pub phase_order: usize,
1239 pub source_canister: String,
1240 pub target_canister: String,
1241 pub role: String,
1242 pub snapshot_id: Option<String>,
1243 pub artifact_path: Option<String>,
1244 pub verification_kind: Option<String>,
1245 pub verification_method: Option<String>,
1246}
1247
1248impl RestoreApplyJournalOperation {
1249 fn from_dry_run_operation(
1251 operation: &RestoreApplyDryRunOperation,
1252 state: RestoreApplyOperationState,
1253 blocked_reasons: &[String],
1254 ) -> Self {
1255 Self {
1256 sequence: operation.sequence,
1257 operation: operation.operation.clone(),
1258 state: state.clone(),
1259 state_updated_at: None,
1260 blocking_reasons: if state == RestoreApplyOperationState::Blocked {
1261 blocked_reasons.to_vec()
1262 } else {
1263 Vec::new()
1264 },
1265 restore_group: operation.restore_group,
1266 phase_order: operation.phase_order,
1267 source_canister: operation.source_canister.clone(),
1268 target_canister: operation.target_canister.clone(),
1269 role: operation.role.clone(),
1270 snapshot_id: operation.snapshot_id.clone(),
1271 artifact_path: operation.artifact_path.clone(),
1272 verification_kind: operation.verification_kind.clone(),
1273 verification_method: operation.verification_method.clone(),
1274 }
1275 }
1276
1277 fn validate(&self) -> Result<(), RestoreApplyJournalError> {
1279 validate_apply_journal_nonempty("operations[].source_canister", &self.source_canister)?;
1280 validate_apply_journal_nonempty("operations[].target_canister", &self.target_canister)?;
1281 validate_apply_journal_nonempty("operations[].role", &self.role)?;
1282 if let Some(updated_at) = &self.state_updated_at {
1283 validate_apply_journal_nonempty("operations[].state_updated_at", updated_at)?;
1284 }
1285
1286 match self.state {
1287 RestoreApplyOperationState::Blocked if self.blocking_reasons.is_empty() => Err(
1288 RestoreApplyJournalError::BlockedOperationMissingReason(self.sequence),
1289 ),
1290 RestoreApplyOperationState::Failed if self.blocking_reasons.is_empty() => Err(
1291 RestoreApplyJournalError::FailureReasonRequired(self.sequence),
1292 ),
1293 RestoreApplyOperationState::Pending
1294 | RestoreApplyOperationState::Ready
1295 | RestoreApplyOperationState::Completed
1296 if !self.blocking_reasons.is_empty() =>
1297 {
1298 Err(RestoreApplyJournalError::UnblockedOperationHasReasons(
1299 self.sequence,
1300 ))
1301 }
1302 RestoreApplyOperationState::Blocked
1303 | RestoreApplyOperationState::Failed
1304 | RestoreApplyOperationState::Pending
1305 | RestoreApplyOperationState::Ready
1306 | RestoreApplyOperationState::Completed => Ok(()),
1307 }
1308 }
1309
1310 const fn can_transition_to(&self, next_state: &RestoreApplyOperationState) -> bool {
1312 match (&self.state, next_state) {
1313 (
1314 RestoreApplyOperationState::Ready | RestoreApplyOperationState::Pending,
1315 RestoreApplyOperationState::Pending,
1316 )
1317 | (RestoreApplyOperationState::Pending, RestoreApplyOperationState::Ready)
1318 | (
1319 RestoreApplyOperationState::Ready
1320 | RestoreApplyOperationState::Pending
1321 | RestoreApplyOperationState::Completed,
1322 RestoreApplyOperationState::Completed,
1323 )
1324 | (
1325 RestoreApplyOperationState::Ready
1326 | RestoreApplyOperationState::Pending
1327 | RestoreApplyOperationState::Failed,
1328 RestoreApplyOperationState::Failed,
1329 ) => true,
1330 (
1331 RestoreApplyOperationState::Blocked
1332 | RestoreApplyOperationState::Completed
1333 | RestoreApplyOperationState::Failed
1334 | RestoreApplyOperationState::Pending
1335 | RestoreApplyOperationState::Ready,
1336 _,
1337 ) => false,
1338 }
1339 }
1340}
1341
1342#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1347#[serde(rename_all = "kebab-case")]
1348pub enum RestoreApplyOperationState {
1349 Pending,
1350 Ready,
1351 Blocked,
1352 Completed,
1353 Failed,
1354}
1355
1356#[derive(Debug, ThisError)]
1361pub enum RestoreApplyJournalError {
1362 #[error("unsupported restore apply journal version {0}")]
1363 UnsupportedVersion(u16),
1364
1365 #[error("restore apply journal field {0} is required")]
1366 MissingField(&'static str),
1367
1368 #[error("restore apply journal count {field} mismatch: reported={reported}, actual={actual}")]
1369 CountMismatch {
1370 field: &'static str,
1371 reported: usize,
1372 actual: usize,
1373 },
1374
1375 #[error("restore apply journal has duplicate operation sequence {0}")]
1376 DuplicateSequence(usize),
1377
1378 #[error("restore apply journal is missing operation sequence {0}")]
1379 MissingSequence(usize),
1380
1381 #[error("ready restore apply journal cannot include blocked reasons or blocked operations")]
1382 ReadyJournalHasBlockingState,
1383
1384 #[error("blocked restore apply journal operation {0} is missing a blocking reason")]
1385 BlockedOperationMissingReason(usize),
1386
1387 #[error("unblocked restore apply journal operation {0} cannot have blocking reasons")]
1388 UnblockedOperationHasReasons(usize),
1389
1390 #[error("restore apply journal operation {0} was not found")]
1391 OperationNotFound(usize),
1392
1393 #[error("restore apply journal operation {sequence} cannot transition from {from:?} to {to:?}")]
1394 InvalidOperationTransition {
1395 sequence: usize,
1396 from: RestoreApplyOperationState,
1397 to: RestoreApplyOperationState,
1398 },
1399
1400 #[error("failed restore apply journal operation {0} requires a reason")]
1401 FailureReasonRequired(usize),
1402
1403 #[error("restore apply journal has no operation that can be advanced")]
1404 NoTransitionableOperation,
1405
1406 #[error("restore apply journal has no pending operation to release")]
1407 NoPendingOperation,
1408
1409 #[error("restore apply journal operation {requested} cannot advance before operation {next}")]
1410 OutOfOrderOperationTransition { requested: usize, next: usize },
1411}
1412
1413fn validate_restore_apply_artifacts(
1415 plan: &RestorePlan,
1416 backup_root: &Path,
1417) -> Result<RestoreApplyArtifactValidation, RestoreApplyDryRunError> {
1418 let mut checks = Vec::new();
1419
1420 for member in plan.ordered_members() {
1421 checks.push(validate_restore_apply_artifact(member, backup_root)?);
1422 }
1423
1424 let members_with_expected_checksums = checks
1425 .iter()
1426 .filter(|check| check.checksum_expected.is_some())
1427 .count();
1428 let artifacts_present = checks.iter().all(|check| check.exists);
1429 let checksums_verified = members_with_expected_checksums == plan.member_count
1430 && checks.iter().all(|check| check.checksum_verified);
1431
1432 Ok(RestoreApplyArtifactValidation {
1433 backup_root: backup_root.to_string_lossy().to_string(),
1434 checked_members: checks.len(),
1435 artifacts_present,
1436 checksums_verified,
1437 members_with_expected_checksums,
1438 checks,
1439 })
1440}
1441
1442fn validate_restore_apply_artifact(
1444 member: &RestorePlanMember,
1445 backup_root: &Path,
1446) -> Result<RestoreApplyArtifactCheck, RestoreApplyDryRunError> {
1447 let artifact_path = safe_restore_artifact_path(
1448 &member.source_canister,
1449 &member.source_snapshot.artifact_path,
1450 )?;
1451 let resolved_path = backup_root.join(&artifact_path);
1452
1453 if !resolved_path.exists() {
1454 return Err(RestoreApplyDryRunError::ArtifactMissing {
1455 source_canister: member.source_canister.clone(),
1456 artifact_path: member.source_snapshot.artifact_path.clone(),
1457 resolved_path: resolved_path.to_string_lossy().to_string(),
1458 });
1459 }
1460
1461 let (checksum_actual, checksum_verified) =
1462 if let Some(expected) = &member.source_snapshot.checksum {
1463 let checksum = ArtifactChecksum::from_path(&resolved_path).map_err(|source| {
1464 RestoreApplyDryRunError::ArtifactChecksum {
1465 source_canister: member.source_canister.clone(),
1466 artifact_path: member.source_snapshot.artifact_path.clone(),
1467 source,
1468 }
1469 })?;
1470 checksum.verify(expected).map_err(|source| {
1471 RestoreApplyDryRunError::ArtifactChecksum {
1472 source_canister: member.source_canister.clone(),
1473 artifact_path: member.source_snapshot.artifact_path.clone(),
1474 source,
1475 }
1476 })?;
1477 (Some(checksum.hash), true)
1478 } else {
1479 (None, false)
1480 };
1481
1482 Ok(RestoreApplyArtifactCheck {
1483 source_canister: member.source_canister.clone(),
1484 target_canister: member.target_canister.clone(),
1485 snapshot_id: member.source_snapshot.snapshot_id.clone(),
1486 artifact_path: member.source_snapshot.artifact_path.clone(),
1487 resolved_path: resolved_path.to_string_lossy().to_string(),
1488 exists: true,
1489 checksum_algorithm: member.source_snapshot.checksum_algorithm.clone(),
1490 checksum_expected: member.source_snapshot.checksum.clone(),
1491 checksum_actual,
1492 checksum_verified,
1493 })
1494}
1495
1496fn safe_restore_artifact_path(
1498 source_canister: &str,
1499 artifact_path: &str,
1500) -> Result<PathBuf, RestoreApplyDryRunError> {
1501 let path = Path::new(artifact_path);
1502 let is_safe = path
1503 .components()
1504 .all(|component| matches!(component, Component::Normal(_) | Component::CurDir));
1505
1506 if is_safe {
1507 return Ok(path.to_path_buf());
1508 }
1509
1510 Err(RestoreApplyDryRunError::ArtifactPathEscapesBackup {
1511 source_canister: source_canister.to_string(),
1512 artifact_path: artifact_path.to_string(),
1513 })
1514}
1515
1516fn validate_restore_status_matches_plan(
1518 plan: &RestorePlan,
1519 status: &RestoreStatus,
1520) -> Result<(), RestoreApplyDryRunError> {
1521 validate_status_string_field("backup_id", &plan.backup_id, &status.backup_id)?;
1522 validate_status_string_field(
1523 "source_environment",
1524 &plan.source_environment,
1525 &status.source_environment,
1526 )?;
1527 validate_status_string_field(
1528 "source_root_canister",
1529 &plan.source_root_canister,
1530 &status.source_root_canister,
1531 )?;
1532 validate_status_string_field("topology_hash", &plan.topology_hash, &status.topology_hash)?;
1533 validate_status_usize_field("member_count", plan.member_count, status.member_count)?;
1534 validate_status_usize_field(
1535 "phase_count",
1536 plan.ordering_summary.phase_count,
1537 status.phase_count,
1538 )?;
1539 Ok(())
1540}
1541
1542fn validate_status_string_field(
1544 field: &'static str,
1545 plan: &str,
1546 status: &str,
1547) -> Result<(), RestoreApplyDryRunError> {
1548 if plan == status {
1549 return Ok(());
1550 }
1551
1552 Err(RestoreApplyDryRunError::StatusPlanMismatch {
1553 field,
1554 plan: plan.to_string(),
1555 status: status.to_string(),
1556 })
1557}
1558
1559const fn validate_status_usize_field(
1561 field: &'static str,
1562 plan: usize,
1563 status: usize,
1564) -> Result<(), RestoreApplyDryRunError> {
1565 if plan == status {
1566 return Ok(());
1567 }
1568
1569 Err(RestoreApplyDryRunError::StatusPlanCountMismatch {
1570 field,
1571 plan,
1572 status,
1573 })
1574}
1575
1576#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1581pub struct RestoreApplyArtifactValidation {
1582 pub backup_root: String,
1583 pub checked_members: usize,
1584 pub artifacts_present: bool,
1585 pub checksums_verified: bool,
1586 pub members_with_expected_checksums: usize,
1587 pub checks: Vec<RestoreApplyArtifactCheck>,
1588}
1589
1590#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1595pub struct RestoreApplyArtifactCheck {
1596 pub source_canister: String,
1597 pub target_canister: String,
1598 pub snapshot_id: String,
1599 pub artifact_path: String,
1600 pub resolved_path: String,
1601 pub exists: bool,
1602 pub checksum_algorithm: String,
1603 pub checksum_expected: Option<String>,
1604 pub checksum_actual: Option<String>,
1605 pub checksum_verified: bool,
1606}
1607
1608#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1613pub struct RestoreApplyDryRunPhase {
1614 pub restore_group: u16,
1615 pub operations: Vec<RestoreApplyDryRunOperation>,
1616}
1617
1618impl RestoreApplyDryRunPhase {
1619 fn from_plan_phase(phase: &RestorePhase, next_sequence: &mut usize) -> Self {
1621 let mut operations = Vec::new();
1622
1623 for member in &phase.members {
1624 push_member_operation(
1625 &mut operations,
1626 next_sequence,
1627 RestoreApplyOperationKind::UploadSnapshot,
1628 member,
1629 None,
1630 );
1631 push_member_operation(
1632 &mut operations,
1633 next_sequence,
1634 RestoreApplyOperationKind::LoadSnapshot,
1635 member,
1636 None,
1637 );
1638 push_member_operation(
1639 &mut operations,
1640 next_sequence,
1641 RestoreApplyOperationKind::ReinstallCode,
1642 member,
1643 None,
1644 );
1645
1646 for check in &member.verification_checks {
1647 push_member_operation(
1648 &mut operations,
1649 next_sequence,
1650 RestoreApplyOperationKind::VerifyMember,
1651 member,
1652 Some(check),
1653 );
1654 }
1655 }
1656
1657 Self {
1658 restore_group: phase.restore_group,
1659 operations,
1660 }
1661 }
1662}
1663
1664fn push_member_operation(
1666 operations: &mut Vec<RestoreApplyDryRunOperation>,
1667 next_sequence: &mut usize,
1668 operation: RestoreApplyOperationKind,
1669 member: &RestorePlanMember,
1670 check: Option<&VerificationCheck>,
1671) {
1672 let sequence = *next_sequence;
1673 *next_sequence += 1;
1674
1675 operations.push(RestoreApplyDryRunOperation {
1676 sequence,
1677 operation,
1678 restore_group: member.restore_group,
1679 phase_order: member.phase_order,
1680 source_canister: member.source_canister.clone(),
1681 target_canister: member.target_canister.clone(),
1682 role: member.role.clone(),
1683 snapshot_id: Some(member.source_snapshot.snapshot_id.clone()),
1684 artifact_path: Some(member.source_snapshot.artifact_path.clone()),
1685 verification_kind: check.map(|check| check.kind.clone()),
1686 verification_method: check.and_then(|check| check.method.clone()),
1687 });
1688}
1689
1690fn append_fleet_verification_operations(
1692 plan: &RestorePlan,
1693 phases: &mut [RestoreApplyDryRunPhase],
1694 next_sequence: &mut usize,
1695) {
1696 if plan.fleet_verification_checks.is_empty() {
1697 return;
1698 }
1699
1700 let Some(phase) = phases.last_mut() else {
1701 return;
1702 };
1703 let root = plan
1704 .phases
1705 .iter()
1706 .flat_map(|phase| phase.members.iter())
1707 .find(|member| member.source_canister == plan.source_root_canister);
1708 let source_canister = root.map_or_else(
1709 || plan.source_root_canister.clone(),
1710 |member| member.source_canister.clone(),
1711 );
1712 let target_canister = root.map_or_else(
1713 || plan.source_root_canister.clone(),
1714 |member| member.target_canister.clone(),
1715 );
1716 let restore_group = phase.restore_group;
1717
1718 for check in &plan.fleet_verification_checks {
1719 push_fleet_operation(
1720 &mut phase.operations,
1721 next_sequence,
1722 restore_group,
1723 &source_canister,
1724 &target_canister,
1725 check,
1726 );
1727 }
1728}
1729
1730fn push_fleet_operation(
1732 operations: &mut Vec<RestoreApplyDryRunOperation>,
1733 next_sequence: &mut usize,
1734 restore_group: u16,
1735 source_canister: &str,
1736 target_canister: &str,
1737 check: &VerificationCheck,
1738) {
1739 let sequence = *next_sequence;
1740 *next_sequence += 1;
1741 let phase_order = operations.len();
1742
1743 operations.push(RestoreApplyDryRunOperation {
1744 sequence,
1745 operation: RestoreApplyOperationKind::VerifyFleet,
1746 restore_group,
1747 phase_order,
1748 source_canister: source_canister.to_string(),
1749 target_canister: target_canister.to_string(),
1750 role: "fleet".to_string(),
1751 snapshot_id: None,
1752 artifact_path: None,
1753 verification_kind: Some(check.kind.clone()),
1754 verification_method: check.method.clone(),
1755 });
1756}
1757
1758#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1763pub struct RestoreApplyDryRunOperation {
1764 pub sequence: usize,
1765 pub operation: RestoreApplyOperationKind,
1766 pub restore_group: u16,
1767 pub phase_order: usize,
1768 pub source_canister: String,
1769 pub target_canister: String,
1770 pub role: String,
1771 pub snapshot_id: Option<String>,
1772 pub artifact_path: Option<String>,
1773 pub verification_kind: Option<String>,
1774 pub verification_method: Option<String>,
1775}
1776
1777#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1782#[serde(rename_all = "kebab-case")]
1783pub enum RestoreApplyOperationKind {
1784 UploadSnapshot,
1785 LoadSnapshot,
1786 ReinstallCode,
1787 VerifyMember,
1788 VerifyFleet,
1789}
1790
1791#[derive(Debug, ThisError)]
1796pub enum RestoreApplyDryRunError {
1797 #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
1798 StatusPlanMismatch {
1799 field: &'static str,
1800 plan: String,
1801 status: String,
1802 },
1803
1804 #[error("restore status field {field} does not match plan: plan={plan}, status={status}")]
1805 StatusPlanCountMismatch {
1806 field: &'static str,
1807 plan: usize,
1808 status: usize,
1809 },
1810
1811 #[error("restore artifact path for {source_canister} escapes backup root: {artifact_path}")]
1812 ArtifactPathEscapesBackup {
1813 source_canister: String,
1814 artifact_path: String,
1815 },
1816
1817 #[error(
1818 "restore artifact for {source_canister} is missing: {artifact_path} at {resolved_path}"
1819 )]
1820 ArtifactMissing {
1821 source_canister: String,
1822 artifact_path: String,
1823 resolved_path: String,
1824 },
1825
1826 #[error("restore artifact checksum failed for {source_canister} at {artifact_path}: {source}")]
1827 ArtifactChecksum {
1828 source_canister: String,
1829 artifact_path: String,
1830 #[source]
1831 source: ArtifactChecksumError,
1832 },
1833}
1834
1835#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1840pub struct RestoreIdentitySummary {
1841 pub mapping_supplied: bool,
1842 pub all_sources_mapped: bool,
1843 pub fixed_members: usize,
1844 pub relocatable_members: usize,
1845 pub in_place_members: usize,
1846 pub mapped_members: usize,
1847 pub remapped_members: usize,
1848}
1849
1850#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1855#[expect(
1856 clippy::struct_excessive_bools,
1857 reason = "restore summaries intentionally expose machine-readable readiness flags"
1858)]
1859pub struct RestoreSnapshotSummary {
1860 pub all_members_have_module_hash: bool,
1861 pub all_members_have_wasm_hash: bool,
1862 pub all_members_have_code_version: bool,
1863 pub all_members_have_checksum: bool,
1864 pub members_with_module_hash: usize,
1865 pub members_with_wasm_hash: usize,
1866 pub members_with_code_version: usize,
1867 pub members_with_checksum: usize,
1868}
1869
1870#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1875pub struct RestoreVerificationSummary {
1876 pub verification_required: bool,
1877 pub all_members_have_checks: bool,
1878 pub fleet_checks: usize,
1879 pub member_check_groups: usize,
1880 pub member_checks: usize,
1881 pub members_with_checks: usize,
1882 pub total_checks: usize,
1883}
1884
1885#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1890pub struct RestoreReadinessSummary {
1891 pub ready: bool,
1892 pub reasons: Vec<String>,
1893}
1894
1895#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1900pub struct RestoreOperationSummary {
1901 pub planned_snapshot_loads: usize,
1902 pub planned_code_reinstalls: usize,
1903 pub planned_verification_checks: usize,
1904 pub planned_phases: usize,
1905}
1906
1907#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1912pub struct RestoreOrderingSummary {
1913 pub phase_count: usize,
1914 pub dependency_free_members: usize,
1915 pub in_group_parent_edges: usize,
1916 pub cross_group_parent_edges: usize,
1917}
1918
1919#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1924pub struct RestorePhase {
1925 pub restore_group: u16,
1926 pub members: Vec<RestorePlanMember>,
1927}
1928
1929#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1934pub struct RestorePlanMember {
1935 pub source_canister: String,
1936 pub target_canister: String,
1937 pub role: String,
1938 pub parent_source_canister: Option<String>,
1939 pub parent_target_canister: Option<String>,
1940 pub ordering_dependency: Option<RestoreOrderingDependency>,
1941 pub phase_order: usize,
1942 pub restore_group: u16,
1943 pub identity_mode: IdentityMode,
1944 pub verification_class: String,
1945 pub verification_checks: Vec<VerificationCheck>,
1946 pub source_snapshot: SourceSnapshot,
1947}
1948
1949#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1954pub struct RestoreOrderingDependency {
1955 pub source_canister: String,
1956 pub target_canister: String,
1957 pub relationship: RestoreOrderingRelationship,
1958}
1959
1960#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1965#[serde(rename_all = "kebab-case")]
1966pub enum RestoreOrderingRelationship {
1967 ParentInSameGroup,
1968 ParentInEarlierGroup,
1969}
1970
1971pub struct RestorePlanner;
1976
1977impl RestorePlanner {
1978 pub fn plan(
1980 manifest: &FleetBackupManifest,
1981 mapping: Option<&RestoreMapping>,
1982 ) -> Result<RestorePlan, RestorePlanError> {
1983 manifest.validate()?;
1984 if let Some(mapping) = mapping {
1985 validate_mapping(mapping)?;
1986 validate_mapping_sources(manifest, mapping)?;
1987 }
1988
1989 let members = resolve_members(manifest, mapping)?;
1990 let identity_summary = restore_identity_summary(&members, mapping.is_some());
1991 let snapshot_summary = restore_snapshot_summary(&members);
1992 let verification_summary = restore_verification_summary(manifest, &members);
1993 let readiness_summary = restore_readiness_summary(&snapshot_summary, &verification_summary);
1994 validate_restore_group_dependencies(&members)?;
1995 let phases = group_and_order_members(members)?;
1996 let ordering_summary = restore_ordering_summary(&phases);
1997 let operation_summary =
1998 restore_operation_summary(manifest.fleet.members.len(), &verification_summary, &phases);
1999
2000 Ok(RestorePlan {
2001 backup_id: manifest.backup_id.clone(),
2002 source_environment: manifest.source.environment.clone(),
2003 source_root_canister: manifest.source.root_canister.clone(),
2004 topology_hash: manifest.fleet.topology_hash.clone(),
2005 member_count: manifest.fleet.members.len(),
2006 identity_summary,
2007 snapshot_summary,
2008 verification_summary,
2009 readiness_summary,
2010 operation_summary,
2011 ordering_summary,
2012 fleet_verification_checks: manifest.verification.fleet_checks.clone(),
2013 phases,
2014 })
2015 }
2016}
2017
2018#[derive(Debug, ThisError)]
2023pub enum RestorePlanError {
2024 #[error(transparent)]
2025 InvalidManifest(#[from] ManifestValidationError),
2026
2027 #[error("field {field} must be a valid principal: {value}")]
2028 InvalidPrincipal { field: &'static str, value: String },
2029
2030 #[error("mapping contains duplicate source canister {0}")]
2031 DuplicateMappingSource(String),
2032
2033 #[error("mapping contains duplicate target canister {0}")]
2034 DuplicateMappingTarget(String),
2035
2036 #[error("mapping references unknown source canister {0}")]
2037 UnknownMappingSource(String),
2038
2039 #[error("mapping is missing source canister {0}")]
2040 MissingMappingSource(String),
2041
2042 #[error("fixed-identity member {source_canister} cannot be mapped to {target_canister}")]
2043 FixedIdentityRemap {
2044 source_canister: String,
2045 target_canister: String,
2046 },
2047
2048 #[error("restore plan contains duplicate target canister {0}")]
2049 DuplicatePlanTarget(String),
2050
2051 #[error("restore group {0} contains a parent cycle or unresolved dependency")]
2052 RestoreOrderCycle(u16),
2053
2054 #[error(
2055 "restore plan places parent {parent_source_canister} in group {parent_restore_group} after child {child_source_canister} in group {child_restore_group}"
2056 )]
2057 ParentRestoreGroupAfterChild {
2058 child_source_canister: String,
2059 parent_source_canister: String,
2060 child_restore_group: u16,
2061 parent_restore_group: u16,
2062 },
2063}
2064
2065fn validate_mapping(mapping: &RestoreMapping) -> Result<(), RestorePlanError> {
2067 let mut sources = BTreeSet::new();
2068 let mut targets = BTreeSet::new();
2069
2070 for entry in &mapping.members {
2071 validate_principal("mapping.members[].source_canister", &entry.source_canister)?;
2072 validate_principal("mapping.members[].target_canister", &entry.target_canister)?;
2073
2074 if !sources.insert(entry.source_canister.clone()) {
2075 return Err(RestorePlanError::DuplicateMappingSource(
2076 entry.source_canister.clone(),
2077 ));
2078 }
2079
2080 if !targets.insert(entry.target_canister.clone()) {
2081 return Err(RestorePlanError::DuplicateMappingTarget(
2082 entry.target_canister.clone(),
2083 ));
2084 }
2085 }
2086
2087 Ok(())
2088}
2089
2090fn validate_mapping_sources(
2092 manifest: &FleetBackupManifest,
2093 mapping: &RestoreMapping,
2094) -> Result<(), RestorePlanError> {
2095 let sources = manifest
2096 .fleet
2097 .members
2098 .iter()
2099 .map(|member| member.canister_id.as_str())
2100 .collect::<BTreeSet<_>>();
2101
2102 for entry in &mapping.members {
2103 if !sources.contains(entry.source_canister.as_str()) {
2104 return Err(RestorePlanError::UnknownMappingSource(
2105 entry.source_canister.clone(),
2106 ));
2107 }
2108 }
2109
2110 Ok(())
2111}
2112
2113fn resolve_members(
2115 manifest: &FleetBackupManifest,
2116 mapping: Option<&RestoreMapping>,
2117) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
2118 let mut plan_members = Vec::with_capacity(manifest.fleet.members.len());
2119 let mut targets = BTreeSet::new();
2120 let mut source_to_target = BTreeMap::new();
2121
2122 for member in &manifest.fleet.members {
2123 let target = resolve_target(member, mapping)?;
2124 if !targets.insert(target.clone()) {
2125 return Err(RestorePlanError::DuplicatePlanTarget(target));
2126 }
2127
2128 source_to_target.insert(member.canister_id.clone(), target.clone());
2129 plan_members.push(RestorePlanMember {
2130 source_canister: member.canister_id.clone(),
2131 target_canister: target,
2132 role: member.role.clone(),
2133 parent_source_canister: member.parent_canister_id.clone(),
2134 parent_target_canister: None,
2135 ordering_dependency: None,
2136 phase_order: 0,
2137 restore_group: member.restore_group,
2138 identity_mode: member.identity_mode.clone(),
2139 verification_class: member.verification_class.clone(),
2140 verification_checks: member.verification_checks.clone(),
2141 source_snapshot: member.source_snapshot.clone(),
2142 });
2143 }
2144
2145 for member in &mut plan_members {
2146 member.parent_target_canister = member
2147 .parent_source_canister
2148 .as_ref()
2149 .and_then(|parent| source_to_target.get(parent))
2150 .cloned();
2151 }
2152
2153 Ok(plan_members)
2154}
2155
2156fn resolve_target(
2158 member: &FleetMember,
2159 mapping: Option<&RestoreMapping>,
2160) -> Result<String, RestorePlanError> {
2161 let target = match mapping {
2162 Some(mapping) => mapping
2163 .target_for(&member.canister_id)
2164 .ok_or_else(|| RestorePlanError::MissingMappingSource(member.canister_id.clone()))?
2165 .to_string(),
2166 None => member.canister_id.clone(),
2167 };
2168
2169 if matches!(member.identity_mode, IdentityMode::Fixed) && target != member.canister_id {
2170 return Err(RestorePlanError::FixedIdentityRemap {
2171 source_canister: member.canister_id.clone(),
2172 target_canister: target,
2173 });
2174 }
2175
2176 Ok(target)
2177}
2178
2179fn restore_identity_summary(
2181 members: &[RestorePlanMember],
2182 mapping_supplied: bool,
2183) -> RestoreIdentitySummary {
2184 let mut summary = RestoreIdentitySummary {
2185 mapping_supplied,
2186 all_sources_mapped: false,
2187 fixed_members: 0,
2188 relocatable_members: 0,
2189 in_place_members: 0,
2190 mapped_members: 0,
2191 remapped_members: 0,
2192 };
2193
2194 for member in members {
2195 match member.identity_mode {
2196 IdentityMode::Fixed => summary.fixed_members += 1,
2197 IdentityMode::Relocatable => summary.relocatable_members += 1,
2198 }
2199
2200 if member.source_canister == member.target_canister {
2201 summary.in_place_members += 1;
2202 } else {
2203 summary.remapped_members += 1;
2204 }
2205 if mapping_supplied {
2206 summary.mapped_members += 1;
2207 }
2208 }
2209
2210 summary.all_sources_mapped = mapping_supplied && summary.mapped_members == members.len();
2211
2212 summary
2213}
2214
2215fn restore_snapshot_summary(members: &[RestorePlanMember]) -> RestoreSnapshotSummary {
2217 let members_with_module_hash = members
2218 .iter()
2219 .filter(|member| member.source_snapshot.module_hash.is_some())
2220 .count();
2221 let members_with_wasm_hash = members
2222 .iter()
2223 .filter(|member| member.source_snapshot.wasm_hash.is_some())
2224 .count();
2225 let members_with_code_version = members
2226 .iter()
2227 .filter(|member| member.source_snapshot.code_version.is_some())
2228 .count();
2229 let members_with_checksum = members
2230 .iter()
2231 .filter(|member| member.source_snapshot.checksum.is_some())
2232 .count();
2233
2234 RestoreSnapshotSummary {
2235 all_members_have_module_hash: members_with_module_hash == members.len(),
2236 all_members_have_wasm_hash: members_with_wasm_hash == members.len(),
2237 all_members_have_code_version: members_with_code_version == members.len(),
2238 all_members_have_checksum: members_with_checksum == members.len(),
2239 members_with_module_hash,
2240 members_with_wasm_hash,
2241 members_with_code_version,
2242 members_with_checksum,
2243 }
2244}
2245
2246fn restore_readiness_summary(
2248 snapshot: &RestoreSnapshotSummary,
2249 verification: &RestoreVerificationSummary,
2250) -> RestoreReadinessSummary {
2251 let mut reasons = Vec::new();
2252
2253 if !snapshot.all_members_have_module_hash {
2254 reasons.push("missing-module-hash".to_string());
2255 }
2256 if !snapshot.all_members_have_wasm_hash {
2257 reasons.push("missing-wasm-hash".to_string());
2258 }
2259 if !snapshot.all_members_have_code_version {
2260 reasons.push("missing-code-version".to_string());
2261 }
2262 if !snapshot.all_members_have_checksum {
2263 reasons.push("missing-snapshot-checksum".to_string());
2264 }
2265 if !verification.all_members_have_checks {
2266 reasons.push("missing-verification-checks".to_string());
2267 }
2268
2269 RestoreReadinessSummary {
2270 ready: reasons.is_empty(),
2271 reasons,
2272 }
2273}
2274
2275fn restore_verification_summary(
2277 manifest: &FleetBackupManifest,
2278 members: &[RestorePlanMember],
2279) -> RestoreVerificationSummary {
2280 let fleet_checks = manifest.verification.fleet_checks.len();
2281 let member_check_groups = manifest.verification.member_checks.len();
2282 let role_check_counts = manifest
2283 .verification
2284 .member_checks
2285 .iter()
2286 .map(|group| (group.role.as_str(), group.checks.len()))
2287 .collect::<BTreeMap<_, _>>();
2288 let inline_member_checks = members
2289 .iter()
2290 .map(|member| member.verification_checks.len())
2291 .sum::<usize>();
2292 let role_member_checks = members
2293 .iter()
2294 .map(|member| {
2295 role_check_counts
2296 .get(member.role.as_str())
2297 .copied()
2298 .unwrap_or(0)
2299 })
2300 .sum::<usize>();
2301 let member_checks = inline_member_checks + role_member_checks;
2302 let members_with_checks = members
2303 .iter()
2304 .filter(|member| {
2305 !member.verification_checks.is_empty()
2306 || role_check_counts.contains_key(member.role.as_str())
2307 })
2308 .count();
2309
2310 RestoreVerificationSummary {
2311 verification_required: true,
2312 all_members_have_checks: members_with_checks == members.len(),
2313 fleet_checks,
2314 member_check_groups,
2315 member_checks,
2316 members_with_checks,
2317 total_checks: fleet_checks + member_checks,
2318 }
2319}
2320
2321const fn restore_operation_summary(
2323 member_count: usize,
2324 verification_summary: &RestoreVerificationSummary,
2325 phases: &[RestorePhase],
2326) -> RestoreOperationSummary {
2327 RestoreOperationSummary {
2328 planned_snapshot_loads: member_count,
2329 planned_code_reinstalls: member_count,
2330 planned_verification_checks: verification_summary.total_checks,
2331 planned_phases: phases.len(),
2332 }
2333}
2334
2335fn validate_restore_group_dependencies(
2337 members: &[RestorePlanMember],
2338) -> Result<(), RestorePlanError> {
2339 let groups_by_source = members
2340 .iter()
2341 .map(|member| (member.source_canister.as_str(), member.restore_group))
2342 .collect::<BTreeMap<_, _>>();
2343
2344 for member in members {
2345 let Some(parent) = &member.parent_source_canister else {
2346 continue;
2347 };
2348 let Some(parent_group) = groups_by_source.get(parent.as_str()) else {
2349 continue;
2350 };
2351
2352 if *parent_group > member.restore_group {
2353 return Err(RestorePlanError::ParentRestoreGroupAfterChild {
2354 child_source_canister: member.source_canister.clone(),
2355 parent_source_canister: parent.clone(),
2356 child_restore_group: member.restore_group,
2357 parent_restore_group: *parent_group,
2358 });
2359 }
2360 }
2361
2362 Ok(())
2363}
2364
2365fn group_and_order_members(
2367 members: Vec<RestorePlanMember>,
2368) -> Result<Vec<RestorePhase>, RestorePlanError> {
2369 let mut groups = BTreeMap::<u16, Vec<RestorePlanMember>>::new();
2370 for member in members {
2371 groups.entry(member.restore_group).or_default().push(member);
2372 }
2373
2374 groups
2375 .into_iter()
2376 .map(|(restore_group, members)| {
2377 let members = order_group(restore_group, members)?;
2378 Ok(RestorePhase {
2379 restore_group,
2380 members,
2381 })
2382 })
2383 .collect()
2384}
2385
2386fn order_group(
2388 restore_group: u16,
2389 members: Vec<RestorePlanMember>,
2390) -> Result<Vec<RestorePlanMember>, RestorePlanError> {
2391 let mut remaining = members;
2392 let group_sources = remaining
2393 .iter()
2394 .map(|member| member.source_canister.clone())
2395 .collect::<BTreeSet<_>>();
2396 let mut emitted = BTreeSet::new();
2397 let mut ordered = Vec::with_capacity(remaining.len());
2398
2399 while !remaining.is_empty() {
2400 let Some(index) = remaining
2401 .iter()
2402 .position(|member| parent_satisfied(member, &group_sources, &emitted))
2403 else {
2404 return Err(RestorePlanError::RestoreOrderCycle(restore_group));
2405 };
2406
2407 let mut member = remaining.remove(index);
2408 member.phase_order = ordered.len();
2409 member.ordering_dependency = ordering_dependency(&member, &group_sources);
2410 emitted.insert(member.source_canister.clone());
2411 ordered.push(member);
2412 }
2413
2414 Ok(ordered)
2415}
2416
2417fn ordering_dependency(
2419 member: &RestorePlanMember,
2420 group_sources: &BTreeSet<String>,
2421) -> Option<RestoreOrderingDependency> {
2422 let parent_source = member.parent_source_canister.as_ref()?;
2423 let parent_target = member.parent_target_canister.as_ref()?;
2424 let relationship = if group_sources.contains(parent_source) {
2425 RestoreOrderingRelationship::ParentInSameGroup
2426 } else {
2427 RestoreOrderingRelationship::ParentInEarlierGroup
2428 };
2429
2430 Some(RestoreOrderingDependency {
2431 source_canister: parent_source.clone(),
2432 target_canister: parent_target.clone(),
2433 relationship,
2434 })
2435}
2436
2437fn restore_ordering_summary(phases: &[RestorePhase]) -> RestoreOrderingSummary {
2439 let mut summary = RestoreOrderingSummary {
2440 phase_count: phases.len(),
2441 dependency_free_members: 0,
2442 in_group_parent_edges: 0,
2443 cross_group_parent_edges: 0,
2444 };
2445
2446 for member in phases.iter().flat_map(|phase| phase.members.iter()) {
2447 match &member.ordering_dependency {
2448 Some(dependency)
2449 if dependency.relationship == RestoreOrderingRelationship::ParentInSameGroup =>
2450 {
2451 summary.in_group_parent_edges += 1;
2452 }
2453 Some(dependency)
2454 if dependency.relationship == RestoreOrderingRelationship::ParentInEarlierGroup =>
2455 {
2456 summary.cross_group_parent_edges += 1;
2457 }
2458 Some(_) => {}
2459 None => summary.dependency_free_members += 1,
2460 }
2461 }
2462
2463 summary
2464}
2465
2466fn parent_satisfied(
2468 member: &RestorePlanMember,
2469 group_sources: &BTreeSet<String>,
2470 emitted: &BTreeSet<String>,
2471) -> bool {
2472 match &member.parent_source_canister {
2473 Some(parent) if group_sources.contains(parent) => emitted.contains(parent),
2474 _ => true,
2475 }
2476}
2477
2478fn validate_principal(field: &'static str, value: &str) -> Result<(), RestorePlanError> {
2480 Principal::from_str(value)
2481 .map(|_| ())
2482 .map_err(|_| RestorePlanError::InvalidPrincipal {
2483 field,
2484 value: value.to_string(),
2485 })
2486}
2487
2488#[cfg(test)]
2489mod tests {
2490 use super::*;
2491 use crate::manifest::{
2492 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetSection,
2493 MemberVerificationChecks, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
2494 VerificationPlan,
2495 };
2496 use std::{
2497 env, fs,
2498 path::{Path, PathBuf},
2499 time::{SystemTime, UNIX_EPOCH},
2500 };
2501
2502 const ROOT: &str = "aaaaa-aa";
2503 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
2504 const CHILD_TWO: &str = "r7inp-6aaaa-aaaaa-aaabq-cai";
2505 const TARGET: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
2506 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2507
2508 fn command_preview_journal(
2510 operation: RestoreApplyOperationKind,
2511 verification_kind: Option<&str>,
2512 verification_method: Option<&str>,
2513 ) -> RestoreApplyJournal {
2514 let journal = RestoreApplyJournal {
2515 journal_version: 1,
2516 backup_id: "fbk_test_001".to_string(),
2517 ready: true,
2518 blocked_reasons: Vec::new(),
2519 operation_count: 1,
2520 pending_operations: 0,
2521 ready_operations: 1,
2522 blocked_operations: 0,
2523 completed_operations: 0,
2524 failed_operations: 0,
2525 operations: vec![RestoreApplyJournalOperation {
2526 sequence: 0,
2527 operation,
2528 state: RestoreApplyOperationState::Ready,
2529 state_updated_at: None,
2530 blocking_reasons: Vec::new(),
2531 restore_group: 1,
2532 phase_order: 0,
2533 source_canister: ROOT.to_string(),
2534 target_canister: ROOT.to_string(),
2535 role: "root".to_string(),
2536 snapshot_id: Some("snap-root".to_string()),
2537 artifact_path: Some("artifacts/root".to_string()),
2538 verification_kind: verification_kind.map(str::to_string),
2539 verification_method: verification_method.map(str::to_string),
2540 }],
2541 };
2542
2543 journal.validate().expect("journal should validate");
2544 journal
2545 }
2546
2547 fn valid_manifest(identity_mode: IdentityMode) -> FleetBackupManifest {
2549 FleetBackupManifest {
2550 manifest_version: 1,
2551 backup_id: "fbk_test_001".to_string(),
2552 created_at: "2026-04-10T12:00:00Z".to_string(),
2553 tool: ToolMetadata {
2554 name: "canic".to_string(),
2555 version: "v1".to_string(),
2556 },
2557 source: SourceMetadata {
2558 environment: "local".to_string(),
2559 root_canister: ROOT.to_string(),
2560 },
2561 consistency: ConsistencySection {
2562 mode: ConsistencyMode::CrashConsistent,
2563 backup_units: vec![BackupUnit {
2564 unit_id: "whole-fleet".to_string(),
2565 kind: BackupUnitKind::WholeFleet,
2566 roles: vec!["root".to_string(), "app".to_string()],
2567 consistency_reason: None,
2568 dependency_closure: Vec::new(),
2569 topology_validation: "subtree-closed".to_string(),
2570 quiescence_strategy: None,
2571 }],
2572 },
2573 fleet: FleetSection {
2574 topology_hash_algorithm: "sha256".to_string(),
2575 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
2576 discovery_topology_hash: HASH.to_string(),
2577 pre_snapshot_topology_hash: HASH.to_string(),
2578 topology_hash: HASH.to_string(),
2579 members: vec![
2580 fleet_member("app", CHILD, Some(ROOT), identity_mode, 1),
2581 fleet_member("root", ROOT, None, IdentityMode::Fixed, 1),
2582 ],
2583 },
2584 verification: VerificationPlan {
2585 fleet_checks: Vec::new(),
2586 member_checks: Vec::new(),
2587 },
2588 }
2589 }
2590
2591 fn fleet_member(
2593 role: &str,
2594 canister_id: &str,
2595 parent_canister_id: Option<&str>,
2596 identity_mode: IdentityMode,
2597 restore_group: u16,
2598 ) -> FleetMember {
2599 FleetMember {
2600 role: role.to_string(),
2601 canister_id: canister_id.to_string(),
2602 parent_canister_id: parent_canister_id.map(str::to_string),
2603 subnet_canister_id: None,
2604 controller_hint: Some(ROOT.to_string()),
2605 identity_mode,
2606 restore_group,
2607 verification_class: "basic".to_string(),
2608 verification_checks: vec![VerificationCheck {
2609 kind: "call".to_string(),
2610 method: Some("canic_ready".to_string()),
2611 roles: Vec::new(),
2612 }],
2613 source_snapshot: SourceSnapshot {
2614 snapshot_id: format!("snap-{role}"),
2615 module_hash: Some(HASH.to_string()),
2616 wasm_hash: Some(HASH.to_string()),
2617 code_version: Some("v0.30.0".to_string()),
2618 artifact_path: format!("artifacts/{role}"),
2619 checksum_algorithm: "sha256".to_string(),
2620 checksum: Some(HASH.to_string()),
2621 },
2622 }
2623 }
2624
2625 #[test]
2627 fn in_place_plan_orders_parent_before_child() {
2628 let manifest = valid_manifest(IdentityMode::Relocatable);
2629
2630 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2631 let ordered = plan.ordered_members();
2632
2633 assert_eq!(plan.backup_id, "fbk_test_001");
2634 assert_eq!(plan.source_environment, "local");
2635 assert_eq!(plan.source_root_canister, ROOT);
2636 assert_eq!(plan.topology_hash, HASH);
2637 assert_eq!(plan.member_count, 2);
2638 assert_eq!(plan.identity_summary.fixed_members, 1);
2639 assert_eq!(plan.identity_summary.relocatable_members, 1);
2640 assert_eq!(plan.identity_summary.in_place_members, 2);
2641 assert_eq!(plan.identity_summary.mapped_members, 0);
2642 assert_eq!(plan.identity_summary.remapped_members, 0);
2643 assert!(plan.verification_summary.verification_required);
2644 assert!(plan.verification_summary.all_members_have_checks);
2645 assert!(plan.readiness_summary.ready);
2646 assert!(plan.readiness_summary.reasons.is_empty());
2647 assert_eq!(plan.verification_summary.fleet_checks, 0);
2648 assert_eq!(plan.verification_summary.member_check_groups, 0);
2649 assert_eq!(plan.verification_summary.member_checks, 2);
2650 assert_eq!(plan.verification_summary.members_with_checks, 2);
2651 assert_eq!(plan.verification_summary.total_checks, 2);
2652 assert_eq!(plan.ordering_summary.phase_count, 1);
2653 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
2654 assert_eq!(plan.ordering_summary.in_group_parent_edges, 1);
2655 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 0);
2656 assert_eq!(ordered[0].phase_order, 0);
2657 assert_eq!(ordered[1].phase_order, 1);
2658 assert_eq!(ordered[0].source_canister, ROOT);
2659 assert_eq!(ordered[1].source_canister, CHILD);
2660 assert_eq!(
2661 ordered[1].ordering_dependency,
2662 Some(RestoreOrderingDependency {
2663 source_canister: ROOT.to_string(),
2664 target_canister: ROOT.to_string(),
2665 relationship: RestoreOrderingRelationship::ParentInSameGroup,
2666 })
2667 );
2668 }
2669
2670 #[test]
2672 fn plan_reports_parent_dependency_from_earlier_group() {
2673 let mut manifest = valid_manifest(IdentityMode::Relocatable);
2674 manifest.fleet.members[0].restore_group = 2;
2675 manifest.fleet.members[1].restore_group = 1;
2676
2677 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2678 let ordered = plan.ordered_members();
2679
2680 assert_eq!(plan.phases.len(), 2);
2681 assert_eq!(plan.ordering_summary.phase_count, 2);
2682 assert_eq!(plan.ordering_summary.dependency_free_members, 1);
2683 assert_eq!(plan.ordering_summary.in_group_parent_edges, 0);
2684 assert_eq!(plan.ordering_summary.cross_group_parent_edges, 1);
2685 assert_eq!(ordered[0].source_canister, ROOT);
2686 assert_eq!(ordered[1].source_canister, CHILD);
2687 assert_eq!(
2688 ordered[1].ordering_dependency,
2689 Some(RestoreOrderingDependency {
2690 source_canister: ROOT.to_string(),
2691 target_canister: ROOT.to_string(),
2692 relationship: RestoreOrderingRelationship::ParentInEarlierGroup,
2693 })
2694 );
2695 }
2696
2697 #[test]
2699 fn plan_rejects_parent_in_later_restore_group() {
2700 let mut manifest = valid_manifest(IdentityMode::Relocatable);
2701 manifest.fleet.members[0].restore_group = 1;
2702 manifest.fleet.members[1].restore_group = 2;
2703
2704 let err = RestorePlanner::plan(&manifest, None)
2705 .expect_err("parent-after-child group ordering should fail");
2706
2707 assert!(matches!(
2708 err,
2709 RestorePlanError::ParentRestoreGroupAfterChild { .. }
2710 ));
2711 }
2712
2713 #[test]
2715 fn fixed_identity_member_cannot_be_remapped() {
2716 let manifest = valid_manifest(IdentityMode::Fixed);
2717 let mapping = RestoreMapping {
2718 members: vec![
2719 RestoreMappingEntry {
2720 source_canister: ROOT.to_string(),
2721 target_canister: ROOT.to_string(),
2722 },
2723 RestoreMappingEntry {
2724 source_canister: CHILD.to_string(),
2725 target_canister: TARGET.to_string(),
2726 },
2727 ],
2728 };
2729
2730 let err = RestorePlanner::plan(&manifest, Some(&mapping))
2731 .expect_err("fixed member remap should fail");
2732
2733 assert!(matches!(err, RestorePlanError::FixedIdentityRemap { .. }));
2734 }
2735
2736 #[test]
2738 fn relocatable_member_can_be_mapped() {
2739 let manifest = valid_manifest(IdentityMode::Relocatable);
2740 let mapping = RestoreMapping {
2741 members: vec![
2742 RestoreMappingEntry {
2743 source_canister: ROOT.to_string(),
2744 target_canister: ROOT.to_string(),
2745 },
2746 RestoreMappingEntry {
2747 source_canister: CHILD.to_string(),
2748 target_canister: TARGET.to_string(),
2749 },
2750 ],
2751 };
2752
2753 let plan = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
2754 let child = plan
2755 .ordered_members()
2756 .into_iter()
2757 .find(|member| member.source_canister == CHILD)
2758 .expect("child member should be planned");
2759
2760 assert_eq!(plan.identity_summary.fixed_members, 1);
2761 assert_eq!(plan.identity_summary.relocatable_members, 1);
2762 assert_eq!(plan.identity_summary.in_place_members, 1);
2763 assert_eq!(plan.identity_summary.mapped_members, 2);
2764 assert_eq!(plan.identity_summary.remapped_members, 1);
2765 assert_eq!(child.target_canister, TARGET);
2766 assert_eq!(child.parent_target_canister, Some(ROOT.to_string()));
2767 }
2768
2769 #[test]
2771 fn plan_members_include_snapshot_and_verification_metadata() {
2772 let manifest = valid_manifest(IdentityMode::Relocatable);
2773
2774 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2775 let root = plan
2776 .ordered_members()
2777 .into_iter()
2778 .find(|member| member.source_canister == ROOT)
2779 .expect("root member should be planned");
2780
2781 assert_eq!(root.identity_mode, IdentityMode::Fixed);
2782 assert_eq!(root.verification_class, "basic");
2783 assert_eq!(root.verification_checks[0].kind, "call");
2784 assert_eq!(root.source_snapshot.snapshot_id, "snap-root");
2785 assert_eq!(root.source_snapshot.artifact_path, "artifacts/root");
2786 }
2787
2788 #[test]
2790 fn plan_includes_mapping_summary() {
2791 let manifest = valid_manifest(IdentityMode::Relocatable);
2792 let in_place = RestorePlanner::plan(&manifest, None).expect("plan should build");
2793
2794 assert!(!in_place.identity_summary.mapping_supplied);
2795 assert!(!in_place.identity_summary.all_sources_mapped);
2796 assert_eq!(in_place.identity_summary.mapped_members, 0);
2797
2798 let mapping = RestoreMapping {
2799 members: vec![
2800 RestoreMappingEntry {
2801 source_canister: ROOT.to_string(),
2802 target_canister: ROOT.to_string(),
2803 },
2804 RestoreMappingEntry {
2805 source_canister: CHILD.to_string(),
2806 target_canister: TARGET.to_string(),
2807 },
2808 ],
2809 };
2810 let mapped = RestorePlanner::plan(&manifest, Some(&mapping)).expect("plan should build");
2811
2812 assert!(mapped.identity_summary.mapping_supplied);
2813 assert!(mapped.identity_summary.all_sources_mapped);
2814 assert_eq!(mapped.identity_summary.mapped_members, 2);
2815 assert_eq!(mapped.identity_summary.remapped_members, 1);
2816 }
2817
2818 #[test]
2820 fn plan_includes_snapshot_summary() {
2821 let mut manifest = valid_manifest(IdentityMode::Relocatable);
2822 manifest.fleet.members[1].source_snapshot.module_hash = None;
2823 manifest.fleet.members[1].source_snapshot.wasm_hash = None;
2824 manifest.fleet.members[1].source_snapshot.checksum = None;
2825
2826 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2827
2828 assert!(!plan.snapshot_summary.all_members_have_module_hash);
2829 assert!(!plan.snapshot_summary.all_members_have_wasm_hash);
2830 assert!(plan.snapshot_summary.all_members_have_code_version);
2831 assert!(!plan.snapshot_summary.all_members_have_checksum);
2832 assert_eq!(plan.snapshot_summary.members_with_module_hash, 1);
2833 assert_eq!(plan.snapshot_summary.members_with_wasm_hash, 1);
2834 assert_eq!(plan.snapshot_summary.members_with_code_version, 2);
2835 assert_eq!(plan.snapshot_summary.members_with_checksum, 1);
2836 assert!(!plan.readiness_summary.ready);
2837 assert_eq!(
2838 plan.readiness_summary.reasons,
2839 [
2840 "missing-module-hash",
2841 "missing-wasm-hash",
2842 "missing-snapshot-checksum"
2843 ]
2844 );
2845 }
2846
2847 #[test]
2849 fn plan_includes_verification_summary() {
2850 let mut manifest = valid_manifest(IdentityMode::Relocatable);
2851 manifest.verification.fleet_checks.push(VerificationCheck {
2852 kind: "fleet-ready".to_string(),
2853 method: None,
2854 roles: Vec::new(),
2855 });
2856 manifest
2857 .verification
2858 .member_checks
2859 .push(MemberVerificationChecks {
2860 role: "app".to_string(),
2861 checks: vec![VerificationCheck {
2862 kind: "app-ready".to_string(),
2863 method: Some("ready".to_string()),
2864 roles: Vec::new(),
2865 }],
2866 });
2867
2868 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2869
2870 assert!(plan.verification_summary.verification_required);
2871 assert!(plan.verification_summary.all_members_have_checks);
2872 assert_eq!(plan.fleet_verification_checks.len(), 1);
2873 assert_eq!(plan.fleet_verification_checks[0].kind, "fleet-ready");
2874 assert_eq!(plan.verification_summary.fleet_checks, 1);
2875 assert_eq!(plan.verification_summary.member_check_groups, 1);
2876 assert_eq!(plan.verification_summary.member_checks, 3);
2877 assert_eq!(plan.verification_summary.members_with_checks, 2);
2878 assert_eq!(plan.verification_summary.total_checks, 4);
2879 }
2880
2881 #[test]
2883 fn plan_includes_operation_summary() {
2884 let manifest = valid_manifest(IdentityMode::Relocatable);
2885
2886 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2887
2888 assert_eq!(plan.operation_summary.planned_snapshot_loads, 2);
2889 assert_eq!(plan.operation_summary.planned_code_reinstalls, 2);
2890 assert_eq!(plan.operation_summary.planned_verification_checks, 2);
2891 assert_eq!(plan.operation_summary.planned_phases, 1);
2892 }
2893
2894 #[test]
2896 fn restore_plan_defaults_missing_fleet_verification_checks() {
2897 let manifest = valid_manifest(IdentityMode::Relocatable);
2898 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2899 let mut value = serde_json::to_value(&plan).expect("serialize plan");
2900 value
2901 .as_object_mut()
2902 .expect("plan should serialize as an object")
2903 .remove("fleet_verification_checks");
2904
2905 let decoded: RestorePlan = serde_json::from_value(value).expect("decode old plan shape");
2906
2907 assert!(decoded.fleet_verification_checks.is_empty());
2908 assert_eq!(decoded.backup_id, plan.backup_id);
2909 assert_eq!(decoded.member_count, plan.member_count);
2910 }
2911
2912 #[test]
2914 fn restore_status_starts_all_members_as_planned() {
2915 let manifest = valid_manifest(IdentityMode::Relocatable);
2916
2917 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2918 let status = RestoreStatus::from_plan(&plan);
2919
2920 assert_eq!(status.status_version, 1);
2921 assert_eq!(status.backup_id.as_str(), plan.backup_id.as_str());
2922 assert_eq!(
2923 status.source_environment.as_str(),
2924 plan.source_environment.as_str()
2925 );
2926 assert_eq!(
2927 status.source_root_canister.as_str(),
2928 plan.source_root_canister.as_str()
2929 );
2930 assert_eq!(status.topology_hash.as_str(), plan.topology_hash.as_str());
2931 assert!(status.ready);
2932 assert!(status.readiness_reasons.is_empty());
2933 assert!(status.verification_required);
2934 assert_eq!(status.member_count, 2);
2935 assert_eq!(status.phase_count, 1);
2936 assert_eq!(status.planned_snapshot_loads, 2);
2937 assert_eq!(status.planned_code_reinstalls, 2);
2938 assert_eq!(status.planned_verification_checks, 2);
2939 assert_eq!(status.phases.len(), 1);
2940 assert_eq!(status.phases[0].restore_group, 1);
2941 assert_eq!(status.phases[0].members.len(), 2);
2942 assert_eq!(
2943 status.phases[0].members[0].state,
2944 RestoreMemberState::Planned
2945 );
2946 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
2947 assert_eq!(status.phases[0].members[0].target_canister, ROOT);
2948 assert_eq!(status.phases[0].members[0].snapshot_id, "snap-root");
2949 assert_eq!(status.phases[0].members[0].artifact_path, "artifacts/root");
2950 assert_eq!(
2951 status.phases[0].members[1].state,
2952 RestoreMemberState::Planned
2953 );
2954 assert_eq!(status.phases[0].members[1].source_canister, CHILD);
2955 }
2956
2957 #[test]
2959 fn apply_dry_run_renders_ordered_member_operations() {
2960 let manifest = valid_manifest(IdentityMode::Relocatable);
2961
2962 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
2963 let status = RestoreStatus::from_plan(&plan);
2964 let dry_run =
2965 RestoreApplyDryRun::try_from_plan(&plan, Some(&status)).expect("dry-run should build");
2966
2967 assert_eq!(dry_run.dry_run_version, 1);
2968 assert_eq!(dry_run.backup_id.as_str(), "fbk_test_001");
2969 assert!(dry_run.ready);
2970 assert!(dry_run.status_supplied);
2971 assert_eq!(dry_run.member_count, 2);
2972 assert_eq!(dry_run.phase_count, 1);
2973 assert_eq!(dry_run.planned_snapshot_loads, 2);
2974 assert_eq!(dry_run.planned_code_reinstalls, 2);
2975 assert_eq!(dry_run.planned_verification_checks, 2);
2976 assert_eq!(dry_run.rendered_operations, 8);
2977 assert_eq!(dry_run.phases.len(), 1);
2978
2979 let operations = &dry_run.phases[0].operations;
2980 assert_eq!(operations[0].sequence, 0);
2981 assert_eq!(
2982 operations[0].operation,
2983 RestoreApplyOperationKind::UploadSnapshot
2984 );
2985 assert_eq!(operations[0].source_canister, ROOT);
2986 assert_eq!(operations[0].target_canister, ROOT);
2987 assert_eq!(operations[0].snapshot_id, Some("snap-root".to_string()));
2988 assert_eq!(
2989 operations[0].artifact_path,
2990 Some("artifacts/root".to_string())
2991 );
2992 assert_eq!(
2993 operations[1].operation,
2994 RestoreApplyOperationKind::LoadSnapshot
2995 );
2996 assert_eq!(
2997 operations[2].operation,
2998 RestoreApplyOperationKind::ReinstallCode
2999 );
3000 assert_eq!(
3001 operations[3].operation,
3002 RestoreApplyOperationKind::VerifyMember
3003 );
3004 assert_eq!(operations[3].verification_kind, Some("call".to_string()));
3005 assert_eq!(
3006 operations[3].verification_method,
3007 Some("canic_ready".to_string())
3008 );
3009 assert_eq!(operations[4].source_canister, CHILD);
3010 assert_eq!(
3011 operations[7].operation,
3012 RestoreApplyOperationKind::VerifyMember
3013 );
3014 }
3015
3016 #[test]
3018 fn apply_dry_run_renders_fleet_verification_operations() {
3019 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3020 manifest.verification.fleet_checks.push(VerificationCheck {
3021 kind: "fleet-ready".to_string(),
3022 method: Some("canic_fleet_ready".to_string()),
3023 roles: Vec::new(),
3024 });
3025
3026 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3027 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3028
3029 assert_eq!(plan.operation_summary.planned_verification_checks, 3);
3030 assert_eq!(dry_run.rendered_operations, 9);
3031 let operation = dry_run.phases[0]
3032 .operations
3033 .last()
3034 .expect("fleet verification operation should be rendered");
3035 assert_eq!(operation.sequence, 8);
3036 assert_eq!(operation.operation, RestoreApplyOperationKind::VerifyFleet);
3037 assert_eq!(operation.source_canister, ROOT);
3038 assert_eq!(operation.target_canister, ROOT);
3039 assert_eq!(operation.role, "fleet");
3040 assert_eq!(operation.snapshot_id, None);
3041 assert_eq!(operation.artifact_path, None);
3042 assert_eq!(operation.verification_kind, Some("fleet-ready".to_string()));
3043 assert_eq!(
3044 operation.verification_method,
3045 Some("canic_fleet_ready".to_string())
3046 );
3047 }
3048
3049 #[test]
3051 fn apply_dry_run_sequences_operations_across_phases() {
3052 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3053 manifest.fleet.members[0].restore_group = 2;
3054
3055 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3056 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3057
3058 assert_eq!(dry_run.phases.len(), 2);
3059 assert_eq!(dry_run.rendered_operations, 8);
3060 assert_eq!(dry_run.phases[0].operations[0].sequence, 0);
3061 assert_eq!(dry_run.phases[0].operations[3].sequence, 3);
3062 assert_eq!(dry_run.phases[1].operations[0].sequence, 4);
3063 assert_eq!(dry_run.phases[1].operations[3].sequence, 7);
3064 }
3065
3066 #[test]
3068 fn apply_dry_run_validates_artifacts_under_backup_root() {
3069 let root = temp_dir("canic-restore-apply-artifacts-ok");
3070 fs::create_dir_all(&root).expect("create temp root");
3071 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3072 set_member_artifact(
3073 &mut manifest,
3074 CHILD,
3075 &root,
3076 "artifacts/child",
3077 b"child-snapshot",
3078 );
3079 set_member_artifact(
3080 &mut manifest,
3081 ROOT,
3082 &root,
3083 "artifacts/root",
3084 b"root-snapshot",
3085 );
3086
3087 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3088 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3089 .expect("dry-run should validate artifacts");
3090
3091 let validation = dry_run
3092 .artifact_validation
3093 .expect("artifact validation should be present");
3094 assert_eq!(validation.checked_members, 2);
3095 assert!(validation.artifacts_present);
3096 assert!(validation.checksums_verified);
3097 assert_eq!(validation.members_with_expected_checksums, 2);
3098 assert_eq!(validation.checks[0].source_canister, ROOT);
3099 assert!(validation.checks[0].checksum_verified);
3100
3101 fs::remove_dir_all(root).expect("remove temp root");
3102 }
3103
3104 #[test]
3106 fn apply_journal_marks_validated_operations_ready() {
3107 let root = temp_dir("canic-restore-apply-journal-ready");
3108 fs::create_dir_all(&root).expect("create temp root");
3109 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3110 set_member_artifact(
3111 &mut manifest,
3112 CHILD,
3113 &root,
3114 "artifacts/child",
3115 b"child-snapshot",
3116 );
3117 set_member_artifact(
3118 &mut manifest,
3119 ROOT,
3120 &root,
3121 "artifacts/root",
3122 b"root-snapshot",
3123 );
3124
3125 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3126 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3127 .expect("dry-run should validate artifacts");
3128 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3129
3130 fs::remove_dir_all(root).expect("remove temp root");
3131 assert_eq!(journal.journal_version, 1);
3132 assert_eq!(journal.backup_id.as_str(), "fbk_test_001");
3133 assert!(journal.ready);
3134 assert!(journal.blocked_reasons.is_empty());
3135 assert_eq!(journal.operation_count, 8);
3136 assert_eq!(journal.ready_operations, 8);
3137 assert_eq!(journal.blocked_operations, 0);
3138 assert_eq!(journal.operations[0].sequence, 0);
3139 assert_eq!(
3140 journal.operations[0].state,
3141 RestoreApplyOperationState::Ready
3142 );
3143 assert!(journal.operations[0].blocking_reasons.is_empty());
3144 }
3145
3146 #[test]
3148 fn apply_journal_blocks_without_artifact_validation() {
3149 let manifest = valid_manifest(IdentityMode::Relocatable);
3150
3151 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3152 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3153 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3154
3155 assert!(!journal.ready);
3156 assert_eq!(journal.ready_operations, 0);
3157 assert_eq!(journal.blocked_operations, 8);
3158 assert!(
3159 journal
3160 .blocked_reasons
3161 .contains(&"missing-artifact-validation".to_string())
3162 );
3163 assert!(
3164 journal.operations[0]
3165 .blocking_reasons
3166 .contains(&"missing-artifact-validation".to_string())
3167 );
3168 }
3169
3170 #[test]
3172 fn apply_journal_status_reports_next_ready_operation() {
3173 let root = temp_dir("canic-restore-apply-journal-status");
3174 fs::create_dir_all(&root).expect("create temp root");
3175 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3176 set_member_artifact(
3177 &mut manifest,
3178 CHILD,
3179 &root,
3180 "artifacts/child",
3181 b"child-snapshot",
3182 );
3183 set_member_artifact(
3184 &mut manifest,
3185 ROOT,
3186 &root,
3187 "artifacts/root",
3188 b"root-snapshot",
3189 );
3190
3191 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3192 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3193 .expect("dry-run should validate artifacts");
3194 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3195 let status = journal.status();
3196
3197 fs::remove_dir_all(root).expect("remove temp root");
3198 assert_eq!(status.status_version, 1);
3199 assert_eq!(status.backup_id.as_str(), "fbk_test_001");
3200 assert!(status.ready);
3201 assert!(!status.complete);
3202 assert_eq!(status.operation_count, 8);
3203 assert_eq!(status.ready_operations, 8);
3204 assert_eq!(status.next_ready_sequence, Some(0));
3205 assert_eq!(
3206 status.next_ready_operation,
3207 Some(RestoreApplyOperationKind::UploadSnapshot)
3208 );
3209 assert_eq!(status.next_transition_sequence, Some(0));
3210 assert_eq!(
3211 status.next_transition_state,
3212 Some(RestoreApplyOperationState::Ready)
3213 );
3214 assert_eq!(
3215 status.next_transition_operation,
3216 Some(RestoreApplyOperationKind::UploadSnapshot)
3217 );
3218 }
3219
3220 #[test]
3222 fn apply_journal_next_operation_reports_full_ready_row() {
3223 let root = temp_dir("canic-restore-apply-journal-next");
3224 fs::create_dir_all(&root).expect("create temp root");
3225 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3226 set_member_artifact(
3227 &mut manifest,
3228 CHILD,
3229 &root,
3230 "artifacts/child",
3231 b"child-snapshot",
3232 );
3233 set_member_artifact(
3234 &mut manifest,
3235 ROOT,
3236 &root,
3237 "artifacts/root",
3238 b"root-snapshot",
3239 );
3240
3241 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3242 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3243 .expect("dry-run should validate artifacts");
3244 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3245 journal
3246 .mark_operation_completed(0)
3247 .expect("mark operation completed");
3248 let next = journal.next_operation();
3249
3250 fs::remove_dir_all(root).expect("remove temp root");
3251 assert!(next.ready);
3252 assert!(!next.complete);
3253 assert!(next.operation_available);
3254 let operation = next.operation.expect("next operation");
3255 assert_eq!(operation.sequence, 1);
3256 assert_eq!(operation.state, RestoreApplyOperationState::Ready);
3257 assert_eq!(operation.operation, RestoreApplyOperationKind::LoadSnapshot);
3258 assert_eq!(operation.source_canister, ROOT);
3259 }
3260
3261 #[test]
3263 fn apply_journal_next_operation_reports_blocked_state() {
3264 let manifest = valid_manifest(IdentityMode::Relocatable);
3265
3266 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3267 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3268 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3269 let next = journal.next_operation();
3270
3271 assert!(!next.ready);
3272 assert!(!next.operation_available);
3273 assert!(next.operation.is_none());
3274 assert!(
3275 next.blocked_reasons
3276 .contains(&"missing-artifact-validation".to_string())
3277 );
3278 }
3279
3280 #[test]
3282 fn apply_journal_command_preview_reports_upload_command() {
3283 let root = temp_dir("canic-restore-apply-command-upload");
3284 fs::create_dir_all(&root).expect("create temp root");
3285 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3286 set_member_artifact(
3287 &mut manifest,
3288 CHILD,
3289 &root,
3290 "artifacts/child",
3291 b"child-snapshot",
3292 );
3293 set_member_artifact(
3294 &mut manifest,
3295 ROOT,
3296 &root,
3297 "artifacts/root",
3298 b"root-snapshot",
3299 );
3300
3301 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3302 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3303 .expect("dry-run should validate artifacts");
3304 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3305 let preview = journal.next_command_preview();
3306
3307 fs::remove_dir_all(root).expect("remove temp root");
3308 assert!(preview.ready);
3309 assert!(preview.operation_available);
3310 assert!(preview.command_available);
3311 let command = preview.command.expect("command preview");
3312 assert_eq!(command.program, "dfx");
3313 assert_eq!(
3314 command.args,
3315 vec![
3316 "canister".to_string(),
3317 "snapshot".to_string(),
3318 "upload".to_string(),
3319 "--dir".to_string(),
3320 "artifacts/root".to_string(),
3321 ROOT.to_string(),
3322 ]
3323 );
3324 assert!(command.mutates);
3325 assert!(!command.requires_stopped_canister);
3326 }
3327
3328 #[test]
3330 fn apply_journal_command_preview_honors_command_config() {
3331 let root = temp_dir("canic-restore-apply-command-config");
3332 fs::create_dir_all(&root).expect("create temp root");
3333 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3334 set_member_artifact(
3335 &mut manifest,
3336 CHILD,
3337 &root,
3338 "artifacts/child",
3339 b"child-snapshot",
3340 );
3341 set_member_artifact(
3342 &mut manifest,
3343 ROOT,
3344 &root,
3345 "artifacts/root",
3346 b"root-snapshot",
3347 );
3348
3349 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3350 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3351 .expect("dry-run should validate artifacts");
3352 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3353 let preview = journal.next_command_preview_with_config(&RestoreApplyCommandConfig {
3354 program: "/tmp/dfx".to_string(),
3355 network: Some("local".to_string()),
3356 });
3357
3358 fs::remove_dir_all(root).expect("remove temp root");
3359 let command = preview.command.expect("command preview");
3360 assert_eq!(command.program, "/tmp/dfx");
3361 assert_eq!(
3362 command.args,
3363 vec![
3364 "canister".to_string(),
3365 "--network".to_string(),
3366 "local".to_string(),
3367 "snapshot".to_string(),
3368 "upload".to_string(),
3369 "--dir".to_string(),
3370 "artifacts/root".to_string(),
3371 ROOT.to_string(),
3372 ]
3373 );
3374 }
3375
3376 #[test]
3378 fn apply_journal_command_preview_reports_load_command() {
3379 let root = temp_dir("canic-restore-apply-command-load");
3380 fs::create_dir_all(&root).expect("create temp root");
3381 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3382 set_member_artifact(
3383 &mut manifest,
3384 CHILD,
3385 &root,
3386 "artifacts/child",
3387 b"child-snapshot",
3388 );
3389 set_member_artifact(
3390 &mut manifest,
3391 ROOT,
3392 &root,
3393 "artifacts/root",
3394 b"root-snapshot",
3395 );
3396
3397 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3398 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3399 .expect("dry-run should validate artifacts");
3400 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3401 journal
3402 .mark_operation_completed(0)
3403 .expect("mark upload completed");
3404 let preview = journal.next_command_preview();
3405
3406 fs::remove_dir_all(root).expect("remove temp root");
3407 let command = preview.command.expect("command preview");
3408 assert_eq!(
3409 command.args,
3410 vec![
3411 "canister".to_string(),
3412 "snapshot".to_string(),
3413 "load".to_string(),
3414 ROOT.to_string(),
3415 "snap-root".to_string(),
3416 ]
3417 );
3418 assert!(command.mutates);
3419 assert!(command.requires_stopped_canister);
3420 }
3421
3422 #[test]
3424 fn apply_journal_command_preview_reports_reinstall_command() {
3425 let journal = command_preview_journal(RestoreApplyOperationKind::ReinstallCode, None, None);
3426 let preview = journal.next_command_preview_with_config(&RestoreApplyCommandConfig {
3427 program: "dfx".to_string(),
3428 network: Some("local".to_string()),
3429 });
3430
3431 assert!(preview.command_available);
3432 let command = preview.command.expect("command preview");
3433 assert_eq!(
3434 command.args,
3435 vec![
3436 "canister".to_string(),
3437 "--network".to_string(),
3438 "local".to_string(),
3439 "install".to_string(),
3440 "--mode".to_string(),
3441 "reinstall".to_string(),
3442 "--yes".to_string(),
3443 ROOT.to_string(),
3444 ]
3445 );
3446 assert!(command.mutates);
3447 assert!(!command.requires_stopped_canister);
3448 }
3449
3450 #[test]
3452 fn apply_journal_command_preview_reports_status_verification_command() {
3453 let journal = command_preview_journal(
3454 RestoreApplyOperationKind::VerifyMember,
3455 Some("status"),
3456 None,
3457 );
3458 let preview = journal.next_command_preview();
3459
3460 assert!(preview.command_available);
3461 let command = preview.command.expect("command preview");
3462 assert_eq!(
3463 command.args,
3464 vec![
3465 "canister".to_string(),
3466 "status".to_string(),
3467 ROOT.to_string()
3468 ]
3469 );
3470 assert!(!command.mutates);
3471 assert!(!command.requires_stopped_canister);
3472 }
3473
3474 #[test]
3476 fn apply_journal_command_preview_reports_method_verification_command() {
3477 let journal = command_preview_journal(
3478 RestoreApplyOperationKind::VerifyMember,
3479 Some("query"),
3480 Some("health"),
3481 );
3482 let preview = journal.next_command_preview();
3483
3484 assert!(preview.command_available);
3485 let command = preview.command.expect("command preview");
3486 assert_eq!(
3487 command.args,
3488 vec![
3489 "canister".to_string(),
3490 "call".to_string(),
3491 ROOT.to_string(),
3492 "health".to_string(),
3493 ]
3494 );
3495 assert!(!command.mutates);
3496 assert!(!command.requires_stopped_canister);
3497 }
3498
3499 #[test]
3501 fn apply_journal_command_preview_reports_fleet_verification_command() {
3502 let journal = command_preview_journal(
3503 RestoreApplyOperationKind::VerifyFleet,
3504 Some("fleet-ready"),
3505 Some("canic_fleet_ready"),
3506 );
3507 let preview = journal.next_command_preview();
3508
3509 assert!(preview.command_available);
3510 let command = preview.command.expect("command preview");
3511 assert_eq!(
3512 command.args,
3513 vec![
3514 "canister".to_string(),
3515 "call".to_string(),
3516 ROOT.to_string(),
3517 "canic_fleet_ready".to_string(),
3518 ]
3519 );
3520 assert!(!command.mutates);
3521 assert!(!command.requires_stopped_canister);
3522 assert_eq!(command.note, "calls the declared fleet verification method");
3523 }
3524
3525 #[test]
3527 fn apply_journal_command_preview_reports_unavailable_for_unknown_verification() {
3528 let journal =
3529 command_preview_journal(RestoreApplyOperationKind::VerifyMember, Some("query"), None);
3530 let preview = journal.next_command_preview();
3531
3532 assert!(preview.operation_available);
3533 assert!(!preview.command_available);
3534 assert!(preview.command.is_none());
3535 }
3536
3537 #[test]
3539 fn apply_journal_validation_rejects_count_mismatch() {
3540 let manifest = valid_manifest(IdentityMode::Relocatable);
3541
3542 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3543 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3544 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3545 journal.blocked_operations = 0;
3546
3547 let err = journal.validate().expect_err("count mismatch should fail");
3548
3549 assert!(matches!(
3550 err,
3551 RestoreApplyJournalError::CountMismatch {
3552 field: "blocked_operations",
3553 ..
3554 }
3555 ));
3556 }
3557
3558 #[test]
3560 fn apply_journal_validation_rejects_duplicate_sequences() {
3561 let manifest = valid_manifest(IdentityMode::Relocatable);
3562
3563 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3564 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3565 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3566 journal.operations[1].sequence = journal.operations[0].sequence;
3567
3568 let err = journal
3569 .validate()
3570 .expect_err("duplicate sequence should fail");
3571
3572 assert!(matches!(
3573 err,
3574 RestoreApplyJournalError::DuplicateSequence(0)
3575 ));
3576 }
3577
3578 #[test]
3580 fn apply_journal_validation_rejects_failed_without_reason() {
3581 let manifest = valid_manifest(IdentityMode::Relocatable);
3582
3583 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3584 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3585 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3586 journal.operations[0].state = RestoreApplyOperationState::Failed;
3587 journal.operations[0].blocking_reasons = Vec::new();
3588 journal.blocked_operations -= 1;
3589 journal.failed_operations = 1;
3590
3591 let err = journal
3592 .validate()
3593 .expect_err("failed operation without reason should fail");
3594
3595 assert!(matches!(
3596 err,
3597 RestoreApplyJournalError::FailureReasonRequired(0)
3598 ));
3599 }
3600
3601 #[test]
3603 fn apply_journal_mark_next_operation_pending_claims_first_operation() {
3604 let mut journal =
3605 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
3606
3607 journal
3608 .mark_next_operation_pending_at(Some("2026-05-04T12:00:00Z".to_string()))
3609 .expect("mark operation pending");
3610 let status = journal.status();
3611 let next = journal.next_operation();
3612 let preview = journal.next_command_preview();
3613
3614 assert_eq!(journal.pending_operations, 1);
3615 assert_eq!(journal.ready_operations, 0);
3616 assert_eq!(
3617 journal.operations[0].state,
3618 RestoreApplyOperationState::Pending
3619 );
3620 assert_eq!(
3621 journal.operations[0].state_updated_at.as_deref(),
3622 Some("2026-05-04T12:00:00Z")
3623 );
3624 assert_eq!(status.next_ready_sequence, None);
3625 assert_eq!(status.next_transition_sequence, Some(0));
3626 assert_eq!(
3627 status.next_transition_state,
3628 Some(RestoreApplyOperationState::Pending)
3629 );
3630 assert_eq!(
3631 status.next_transition_updated_at.as_deref(),
3632 Some("2026-05-04T12:00:00Z")
3633 );
3634 assert!(next.operation_available);
3635 assert_eq!(
3636 next.operation.expect("next operation").state,
3637 RestoreApplyOperationState::Pending
3638 );
3639 assert!(preview.operation_available);
3640 assert!(preview.command_available);
3641 assert_eq!(
3642 preview.operation.expect("preview operation").state,
3643 RestoreApplyOperationState::Pending
3644 );
3645 }
3646
3647 #[test]
3649 fn apply_journal_mark_next_operation_ready_unclaims_pending_operation() {
3650 let mut journal =
3651 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
3652
3653 journal
3654 .mark_next_operation_pending_at(Some("2026-05-04T12:00:00Z".to_string()))
3655 .expect("mark operation pending");
3656 journal
3657 .mark_next_operation_ready_at(Some("2026-05-04T12:01:00Z".to_string()))
3658 .expect("mark operation ready");
3659 let status = journal.status();
3660 let next = journal.next_operation();
3661
3662 assert_eq!(journal.pending_operations, 0);
3663 assert_eq!(journal.ready_operations, 1);
3664 assert_eq!(
3665 journal.operations[0].state,
3666 RestoreApplyOperationState::Ready
3667 );
3668 assert_eq!(
3669 journal.operations[0].state_updated_at.as_deref(),
3670 Some("2026-05-04T12:01:00Z")
3671 );
3672 assert_eq!(status.next_ready_sequence, Some(0));
3673 assert_eq!(status.next_transition_sequence, Some(0));
3674 assert_eq!(
3675 status.next_transition_state,
3676 Some(RestoreApplyOperationState::Ready)
3677 );
3678 assert_eq!(
3679 status.next_transition_updated_at.as_deref(),
3680 Some("2026-05-04T12:01:00Z")
3681 );
3682 assert_eq!(
3683 next.operation.expect("next operation").state,
3684 RestoreApplyOperationState::Ready
3685 );
3686 }
3687
3688 #[test]
3690 fn apply_journal_validation_rejects_empty_state_updated_at() {
3691 let mut journal =
3692 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
3693
3694 journal.operations[0].state_updated_at = Some(String::new());
3695 let err = journal
3696 .validate()
3697 .expect_err("empty state update marker should fail");
3698
3699 assert!(matches!(
3700 err,
3701 RestoreApplyJournalError::MissingField("operations[].state_updated_at")
3702 ));
3703 }
3704
3705 #[test]
3707 fn apply_journal_mark_next_operation_ready_rejects_without_pending_operation() {
3708 let mut journal =
3709 command_preview_journal(RestoreApplyOperationKind::UploadSnapshot, None, None);
3710
3711 let err = journal
3712 .mark_next_operation_ready()
3713 .expect_err("ready operation should not unclaim");
3714
3715 assert!(matches!(err, RestoreApplyJournalError::NoPendingOperation));
3716 assert_eq!(journal.ready_operations, 1);
3717 assert_eq!(journal.pending_operations, 0);
3718 }
3719
3720 #[test]
3722 fn apply_journal_mark_pending_rejects_out_of_order_operation() {
3723 let root = temp_dir("canic-restore-apply-journal-pending-out-of-order");
3724 fs::create_dir_all(&root).expect("create temp root");
3725 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3726 set_member_artifact(
3727 &mut manifest,
3728 CHILD,
3729 &root,
3730 "artifacts/child",
3731 b"child-snapshot",
3732 );
3733 set_member_artifact(
3734 &mut manifest,
3735 ROOT,
3736 &root,
3737 "artifacts/root",
3738 b"root-snapshot",
3739 );
3740
3741 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3742 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3743 .expect("dry-run should validate artifacts");
3744 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3745
3746 let err = journal
3747 .mark_operation_pending(1)
3748 .expect_err("out-of-order pending claim should fail");
3749
3750 fs::remove_dir_all(root).expect("remove temp root");
3751 assert!(matches!(
3752 err,
3753 RestoreApplyJournalError::OutOfOrderOperationTransition {
3754 requested: 1,
3755 next: 0
3756 }
3757 ));
3758 assert_eq!(journal.pending_operations, 0);
3759 assert_eq!(journal.ready_operations, 8);
3760 }
3761
3762 #[test]
3764 fn apply_journal_mark_completed_advances_next_ready_operation() {
3765 let root = temp_dir("canic-restore-apply-journal-completed");
3766 fs::create_dir_all(&root).expect("create temp root");
3767 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3768 set_member_artifact(
3769 &mut manifest,
3770 CHILD,
3771 &root,
3772 "artifacts/child",
3773 b"child-snapshot",
3774 );
3775 set_member_artifact(
3776 &mut manifest,
3777 ROOT,
3778 &root,
3779 "artifacts/root",
3780 b"root-snapshot",
3781 );
3782
3783 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3784 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3785 .expect("dry-run should validate artifacts");
3786 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3787
3788 journal
3789 .mark_operation_completed(0)
3790 .expect("mark operation completed");
3791 let status = journal.status();
3792
3793 fs::remove_dir_all(root).expect("remove temp root");
3794 assert_eq!(
3795 journal.operations[0].state,
3796 RestoreApplyOperationState::Completed
3797 );
3798 assert_eq!(journal.completed_operations, 1);
3799 assert_eq!(journal.ready_operations, 7);
3800 assert_eq!(status.next_ready_sequence, Some(1));
3801 }
3802
3803 #[test]
3805 fn apply_journal_mark_completed_rejects_out_of_order_operation() {
3806 let root = temp_dir("canic-restore-apply-journal-out-of-order");
3807 fs::create_dir_all(&root).expect("create temp root");
3808 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3809 set_member_artifact(
3810 &mut manifest,
3811 CHILD,
3812 &root,
3813 "artifacts/child",
3814 b"child-snapshot",
3815 );
3816 set_member_artifact(
3817 &mut manifest,
3818 ROOT,
3819 &root,
3820 "artifacts/root",
3821 b"root-snapshot",
3822 );
3823
3824 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3825 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3826 .expect("dry-run should validate artifacts");
3827 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3828
3829 let err = journal
3830 .mark_operation_completed(1)
3831 .expect_err("out-of-order operation should fail");
3832
3833 fs::remove_dir_all(root).expect("remove temp root");
3834 assert!(matches!(
3835 err,
3836 RestoreApplyJournalError::OutOfOrderOperationTransition {
3837 requested: 1,
3838 next: 0
3839 }
3840 ));
3841 assert_eq!(journal.completed_operations, 0);
3842 assert_eq!(journal.ready_operations, 8);
3843 }
3844
3845 #[test]
3847 fn apply_journal_mark_failed_records_reason() {
3848 let root = temp_dir("canic-restore-apply-journal-failed");
3849 fs::create_dir_all(&root).expect("create temp root");
3850 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3851 set_member_artifact(
3852 &mut manifest,
3853 CHILD,
3854 &root,
3855 "artifacts/child",
3856 b"child-snapshot",
3857 );
3858 set_member_artifact(
3859 &mut manifest,
3860 ROOT,
3861 &root,
3862 "artifacts/root",
3863 b"root-snapshot",
3864 );
3865
3866 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3867 let dry_run = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3868 .expect("dry-run should validate artifacts");
3869 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3870
3871 journal
3872 .mark_operation_failed(0, "dfx-load-failed".to_string())
3873 .expect("mark operation failed");
3874
3875 fs::remove_dir_all(root).expect("remove temp root");
3876 assert_eq!(
3877 journal.operations[0].state,
3878 RestoreApplyOperationState::Failed
3879 );
3880 assert_eq!(
3881 journal.operations[0].blocking_reasons,
3882 vec!["dfx-load-failed".to_string()]
3883 );
3884 assert_eq!(journal.failed_operations, 1);
3885 assert_eq!(journal.ready_operations, 7);
3886 }
3887
3888 #[test]
3890 fn apply_journal_rejects_blocked_operation_completion() {
3891 let manifest = valid_manifest(IdentityMode::Relocatable);
3892
3893 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3894 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run should build");
3895 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3896
3897 let err = journal
3898 .mark_operation_completed(0)
3899 .expect_err("blocked operation should not complete");
3900
3901 assert!(matches!(
3902 err,
3903 RestoreApplyJournalError::InvalidOperationTransition { sequence: 0, .. }
3904 ));
3905 }
3906
3907 #[test]
3909 fn apply_dry_run_rejects_missing_artifacts() {
3910 let root = temp_dir("canic-restore-apply-artifacts-missing");
3911 fs::create_dir_all(&root).expect("create temp root");
3912 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3913 manifest.fleet.members[0].source_snapshot.artifact_path = "missing-child".to_string();
3914
3915 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3916 let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3917 .expect_err("missing artifact should fail");
3918
3919 fs::remove_dir_all(root).expect("remove temp root");
3920 assert!(matches!(
3921 err,
3922 RestoreApplyDryRunError::ArtifactMissing { .. }
3923 ));
3924 }
3925
3926 #[test]
3928 fn apply_dry_run_rejects_artifact_path_traversal() {
3929 let root = temp_dir("canic-restore-apply-artifacts-traversal");
3930 fs::create_dir_all(&root).expect("create temp root");
3931 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3932 manifest.fleet.members[1].source_snapshot.artifact_path = "../outside".to_string();
3933
3934 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3935 let err = RestoreApplyDryRun::try_from_plan_with_artifacts(&plan, None, &root)
3936 .expect_err("path traversal should fail");
3937
3938 fs::remove_dir_all(root).expect("remove temp root");
3939 assert!(matches!(
3940 err,
3941 RestoreApplyDryRunError::ArtifactPathEscapesBackup { .. }
3942 ));
3943 }
3944
3945 #[test]
3947 fn apply_dry_run_rejects_mismatched_status() {
3948 let manifest = valid_manifest(IdentityMode::Relocatable);
3949
3950 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3951 let mut status = RestoreStatus::from_plan(&plan);
3952 status.backup_id = "other-backup".to_string();
3953
3954 let err = RestoreApplyDryRun::try_from_plan(&plan, Some(&status))
3955 .expect_err("mismatched status should fail");
3956
3957 assert!(matches!(
3958 err,
3959 RestoreApplyDryRunError::StatusPlanMismatch {
3960 field: "backup_id",
3961 ..
3962 }
3963 ));
3964 }
3965
3966 #[test]
3968 fn plan_expands_role_verification_checks_per_matching_member() {
3969 let mut manifest = valid_manifest(IdentityMode::Relocatable);
3970 manifest.fleet.members.push(fleet_member(
3971 "app",
3972 CHILD_TWO,
3973 Some(ROOT),
3974 IdentityMode::Relocatable,
3975 1,
3976 ));
3977 manifest
3978 .verification
3979 .member_checks
3980 .push(MemberVerificationChecks {
3981 role: "app".to_string(),
3982 checks: vec![VerificationCheck {
3983 kind: "app-ready".to_string(),
3984 method: Some("ready".to_string()),
3985 roles: Vec::new(),
3986 }],
3987 });
3988
3989 let plan = RestorePlanner::plan(&manifest, None).expect("plan should build");
3990
3991 assert_eq!(plan.verification_summary.fleet_checks, 0);
3992 assert_eq!(plan.verification_summary.member_check_groups, 1);
3993 assert_eq!(plan.verification_summary.member_checks, 5);
3994 assert_eq!(plan.verification_summary.members_with_checks, 3);
3995 assert_eq!(plan.verification_summary.total_checks, 5);
3996 }
3997
3998 #[test]
4000 fn mapped_restore_requires_complete_mapping() {
4001 let manifest = valid_manifest(IdentityMode::Relocatable);
4002 let mapping = RestoreMapping {
4003 members: vec![RestoreMappingEntry {
4004 source_canister: ROOT.to_string(),
4005 target_canister: ROOT.to_string(),
4006 }],
4007 };
4008
4009 let err = RestorePlanner::plan(&manifest, Some(&mapping))
4010 .expect_err("incomplete mapping should fail");
4011
4012 assert!(matches!(err, RestorePlanError::MissingMappingSource(_)));
4013 }
4014
4015 #[test]
4017 fn mapped_restore_rejects_unknown_mapping_sources() {
4018 let manifest = valid_manifest(IdentityMode::Relocatable);
4019 let unknown = "rdmx6-jaaaa-aaaaa-aaadq-cai";
4020 let mapping = RestoreMapping {
4021 members: vec![
4022 RestoreMappingEntry {
4023 source_canister: ROOT.to_string(),
4024 target_canister: ROOT.to_string(),
4025 },
4026 RestoreMappingEntry {
4027 source_canister: CHILD.to_string(),
4028 target_canister: TARGET.to_string(),
4029 },
4030 RestoreMappingEntry {
4031 source_canister: unknown.to_string(),
4032 target_canister: unknown.to_string(),
4033 },
4034 ],
4035 };
4036
4037 let err = RestorePlanner::plan(&manifest, Some(&mapping))
4038 .expect_err("unknown mapping source should fail");
4039
4040 assert!(matches!(err, RestorePlanError::UnknownMappingSource(_)));
4041 }
4042
4043 #[test]
4045 fn duplicate_mapping_targets_fail_validation() {
4046 let manifest = valid_manifest(IdentityMode::Relocatable);
4047 let mapping = RestoreMapping {
4048 members: vec![
4049 RestoreMappingEntry {
4050 source_canister: ROOT.to_string(),
4051 target_canister: ROOT.to_string(),
4052 },
4053 RestoreMappingEntry {
4054 source_canister: CHILD.to_string(),
4055 target_canister: ROOT.to_string(),
4056 },
4057 ],
4058 };
4059
4060 let err = RestorePlanner::plan(&manifest, Some(&mapping))
4061 .expect_err("duplicate targets should fail");
4062
4063 assert!(matches!(err, RestorePlanError::DuplicateMappingTarget(_)));
4064 }
4065
4066 fn set_member_artifact(
4068 manifest: &mut FleetBackupManifest,
4069 canister_id: &str,
4070 root: &Path,
4071 artifact_path: &str,
4072 bytes: &[u8],
4073 ) {
4074 let full_path = root.join(artifact_path);
4075 fs::create_dir_all(full_path.parent().expect("artifact parent")).expect("create parent");
4076 fs::write(&full_path, bytes).expect("write artifact");
4077 let checksum = ArtifactChecksum::from_bytes(bytes);
4078 let member = manifest
4079 .fleet
4080 .members
4081 .iter_mut()
4082 .find(|member| member.canister_id == canister_id)
4083 .expect("member should exist");
4084 member.source_snapshot.artifact_path = artifact_path.to_string();
4085 member.source_snapshot.checksum = Some(checksum.hash);
4086 }
4087
4088 fn temp_dir(name: &str) -> PathBuf {
4090 let nanos = SystemTime::now()
4091 .duration_since(UNIX_EPOCH)
4092 .expect("system time should be after epoch")
4093 .as_nanos();
4094 env::temp_dir().join(format!("{name}-{nanos}"))
4095 }
4096}