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