1use canic_backup::{
2 manifest::FleetBackupManifest,
3 persistence::{BackupLayout, PersistenceError},
4 restore::{
5 RestoreApplyCommandConfig, RestoreApplyCommandPreview, RestoreApplyDryRun,
6 RestoreApplyDryRunError, RestoreApplyJournal, RestoreApplyJournalError,
7 RestoreApplyJournalOperation, RestoreApplyJournalReport, RestoreApplyJournalStatus,
8 RestoreApplyNextOperation, RestoreApplyOperationKind, RestoreApplyOperationKindCounts,
9 RestoreApplyOperationState, RestoreApplyReportOperation, RestoreApplyReportOutcome,
10 RestoreApplyRunnerCommand, RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner,
11 RestoreStatus,
12 },
13};
14use serde::Serialize;
15use std::{
16 ffi::OsString,
17 fs,
18 io::{self, Write},
19 path::PathBuf,
20 process::Command,
21};
22use thiserror::Error as ThisError;
23
24#[derive(Debug, ThisError)]
29pub enum RestoreCommandError {
30 #[error("{0}")]
31 Usage(&'static str),
32
33 #[error("missing required option {0}")]
34 MissingOption(&'static str),
35
36 #[error("use either --manifest or --backup-dir, not both")]
37 ConflictingManifestSources,
38
39 #[error("--require-verified requires --backup-dir")]
40 RequireVerifiedNeedsBackupDir,
41
42 #[error("restore apply currently requires --dry-run")]
43 ApplyRequiresDryRun,
44
45 #[error("restore run requires --dry-run, --execute, or --unclaim-pending")]
46 RestoreRunRequiresMode,
47
48 #[error("use only one restore run mode: --dry-run, --execute, or --unclaim-pending")]
49 RestoreRunConflictingModes,
50
51 #[error("restore run command failed for operation {sequence}: status={status}")]
52 RestoreRunCommandFailed { sequence: usize, status: String },
53
54 #[error("restore run for backup {backup_id} used run_mode={actual}, expected {expected}")]
55 RestoreRunModeMismatch {
56 backup_id: String,
57 expected: String,
58 actual: String,
59 },
60
61 #[error(
62 "restore run for backup {backup_id} stopped for {actual}, expected stopped_reason={expected}"
63 )]
64 RestoreRunStoppedReasonMismatch {
65 backup_id: String,
66 expected: String,
67 actual: String,
68 },
69
70 #[error(
71 "restore run for backup {backup_id} reported next_action={actual}, expected {expected}"
72 )]
73 RestoreRunNextActionMismatch {
74 backup_id: String,
75 expected: String,
76 actual: String,
77 },
78
79 #[error("restore run for backup {backup_id} executed {actual} operations, expected {expected}")]
80 RestoreRunExecutedCountMismatch {
81 backup_id: String,
82 expected: usize,
83 actual: usize,
84 },
85
86 #[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
87 RestoreNotReady {
88 backup_id: String,
89 reasons: Vec<String>,
90 },
91
92 #[error(
93 "restore apply journal for backup {backup_id} has pending operations: pending={pending_operations}, next={next_transition_sequence:?}"
94 )]
95 RestoreApplyPending {
96 backup_id: String,
97 pending_operations: usize,
98 next_transition_sequence: Option<usize>,
99 },
100
101 #[error(
102 "restore apply journal for backup {backup_id} is incomplete: completed={completed_operations}, total={operation_count}"
103 )]
104 RestoreApplyIncomplete {
105 backup_id: String,
106 completed_operations: usize,
107 operation_count: usize,
108 },
109
110 #[error(
111 "restore apply journal for backup {backup_id} has failed operations: failed={failed_operations}"
112 )]
113 RestoreApplyFailed {
114 backup_id: String,
115 failed_operations: usize,
116 },
117
118 #[error("restore apply journal for backup {backup_id} is not ready: reasons={reasons:?}")]
119 RestoreApplyNotReady {
120 backup_id: String,
121 reasons: Vec<String>,
122 },
123
124 #[error("restore apply report for backup {backup_id} requires attention: outcome={outcome:?}")]
125 RestoreApplyReportNeedsAttention {
126 backup_id: String,
127 outcome: canic_backup::restore::RestoreApplyReportOutcome,
128 },
129
130 #[error(
131 "restore apply journal for backup {backup_id} has no executable command: operation_available={operation_available}, complete={complete}, blocked_reasons={blocked_reasons:?}"
132 )]
133 RestoreApplyCommandUnavailable {
134 backup_id: String,
135 operation_available: bool,
136 complete: bool,
137 blocked_reasons: Vec<String>,
138 },
139
140 #[error(
141 "restore apply journal operation {sequence} must be pending before apply-mark: state={state:?}"
142 )]
143 RestoreApplyMarkRequiresPending {
144 sequence: usize,
145 state: RestoreApplyOperationState,
146 },
147
148 #[error(
149 "restore apply journal next operation changed before claim: expected={expected}, actual={actual:?}"
150 )]
151 RestoreApplyClaimSequenceMismatch {
152 expected: usize,
153 actual: Option<usize>,
154 },
155
156 #[error(
157 "restore apply journal pending operation changed before unclaim: expected={expected}, actual={actual:?}"
158 )]
159 RestoreApplyUnclaimSequenceMismatch {
160 expected: usize,
161 actual: Option<usize>,
162 },
163
164 #[error("unknown option {0}")]
165 UnknownOption(String),
166
167 #[error("option {0} requires a value")]
168 MissingValue(&'static str),
169
170 #[error("option --sequence requires a non-negative integer value")]
171 InvalidSequence,
172
173 #[error("unsupported apply-mark state {0}; use completed or failed")]
174 InvalidApplyMarkState(String),
175
176 #[error(transparent)]
177 Io(#[from] std::io::Error),
178
179 #[error(transparent)]
180 Json(#[from] serde_json::Error),
181
182 #[error(transparent)]
183 Persistence(#[from] PersistenceError),
184
185 #[error(transparent)]
186 RestorePlan(#[from] RestorePlanError),
187
188 #[error(transparent)]
189 RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
190
191 #[error(transparent)]
192 RestoreApplyJournal(#[from] RestoreApplyJournalError),
193}
194
195#[derive(Clone, Debug, Eq, PartialEq)]
200pub struct RestorePlanOptions {
201 pub manifest: Option<PathBuf>,
202 pub backup_dir: Option<PathBuf>,
203 pub mapping: Option<PathBuf>,
204 pub out: Option<PathBuf>,
205 pub require_verified: bool,
206 pub require_restore_ready: bool,
207}
208
209impl RestorePlanOptions {
210 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
212 where
213 I: IntoIterator<Item = OsString>,
214 {
215 let mut manifest = None;
216 let mut backup_dir = None;
217 let mut mapping = None;
218 let mut out = None;
219 let mut require_verified = false;
220 let mut require_restore_ready = false;
221
222 let mut args = args.into_iter();
223 while let Some(arg) = args.next() {
224 let arg = arg
225 .into_string()
226 .map_err(|_| RestoreCommandError::Usage(usage()))?;
227 match arg.as_str() {
228 "--manifest" => {
229 manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
230 }
231 "--backup-dir" => {
232 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
233 }
234 "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
235 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
236 "--require-verified" => require_verified = true,
237 "--require-restore-ready" => require_restore_ready = true,
238 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
239 _ => return Err(RestoreCommandError::UnknownOption(arg)),
240 }
241 }
242
243 if manifest.is_some() && backup_dir.is_some() {
244 return Err(RestoreCommandError::ConflictingManifestSources);
245 }
246
247 if manifest.is_none() && backup_dir.is_none() {
248 return Err(RestoreCommandError::MissingOption(
249 "--manifest or --backup-dir",
250 ));
251 }
252
253 if require_verified && backup_dir.is_none() {
254 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
255 }
256
257 Ok(Self {
258 manifest,
259 backup_dir,
260 mapping,
261 out,
262 require_verified,
263 require_restore_ready,
264 })
265 }
266}
267
268#[derive(Clone, Debug, Eq, PartialEq)]
273pub struct RestoreStatusOptions {
274 pub plan: PathBuf,
275 pub out: Option<PathBuf>,
276}
277
278impl RestoreStatusOptions {
279 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
281 where
282 I: IntoIterator<Item = OsString>,
283 {
284 let mut plan = None;
285 let mut out = None;
286
287 let mut args = args.into_iter();
288 while let Some(arg) = args.next() {
289 let arg = arg
290 .into_string()
291 .map_err(|_| RestoreCommandError::Usage(usage()))?;
292 match arg.as_str() {
293 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
294 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
295 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
296 _ => return Err(RestoreCommandError::UnknownOption(arg)),
297 }
298 }
299
300 Ok(Self {
301 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
302 out,
303 })
304 }
305}
306
307#[derive(Clone, Debug, Eq, PartialEq)]
312pub struct RestoreApplyOptions {
313 pub plan: PathBuf,
314 pub status: Option<PathBuf>,
315 pub backup_dir: Option<PathBuf>,
316 pub out: Option<PathBuf>,
317 pub journal_out: Option<PathBuf>,
318 pub dry_run: bool,
319}
320
321impl RestoreApplyOptions {
322 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
324 where
325 I: IntoIterator<Item = OsString>,
326 {
327 let mut plan = None;
328 let mut status = None;
329 let mut backup_dir = None;
330 let mut out = None;
331 let mut journal_out = None;
332 let mut dry_run = false;
333
334 let mut args = args.into_iter();
335 while let Some(arg) = args.next() {
336 let arg = arg
337 .into_string()
338 .map_err(|_| RestoreCommandError::Usage(usage()))?;
339 match arg.as_str() {
340 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
341 "--status" => status = Some(PathBuf::from(next_value(&mut args, "--status")?)),
342 "--backup-dir" => {
343 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
344 }
345 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
346 "--journal-out" => {
347 journal_out = Some(PathBuf::from(next_value(&mut args, "--journal-out")?));
348 }
349 "--dry-run" => dry_run = true,
350 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
351 _ => return Err(RestoreCommandError::UnknownOption(arg)),
352 }
353 }
354
355 if !dry_run {
356 return Err(RestoreCommandError::ApplyRequiresDryRun);
357 }
358
359 Ok(Self {
360 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
361 status,
362 backup_dir,
363 out,
364 journal_out,
365 dry_run,
366 })
367 }
368}
369
370#[derive(Clone, Debug, Eq, PartialEq)]
375#[expect(
376 clippy::struct_excessive_bools,
377 reason = "CLI status options mirror independent fail-closed guard flags"
378)]
379pub struct RestoreApplyStatusOptions {
380 pub journal: PathBuf,
381 pub require_ready: bool,
382 pub require_no_pending: bool,
383 pub require_no_failed: bool,
384 pub require_complete: bool,
385 pub out: Option<PathBuf>,
386}
387
388impl RestoreApplyStatusOptions {
389 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
391 where
392 I: IntoIterator<Item = OsString>,
393 {
394 let mut journal = None;
395 let mut require_ready = false;
396 let mut require_no_pending = false;
397 let mut require_no_failed = false;
398 let mut require_complete = false;
399 let mut out = None;
400
401 let mut args = args.into_iter();
402 while let Some(arg) = args.next() {
403 let arg = arg
404 .into_string()
405 .map_err(|_| RestoreCommandError::Usage(usage()))?;
406 match arg.as_str() {
407 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
408 "--require-ready" => require_ready = true,
409 "--require-no-pending" => require_no_pending = true,
410 "--require-no-failed" => require_no_failed = true,
411 "--require-complete" => require_complete = true,
412 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
413 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
414 _ => return Err(RestoreCommandError::UnknownOption(arg)),
415 }
416 }
417
418 Ok(Self {
419 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
420 require_ready,
421 require_no_pending,
422 require_no_failed,
423 require_complete,
424 out,
425 })
426 }
427}
428
429#[derive(Clone, Debug, Eq, PartialEq)]
434pub struct RestoreApplyReportOptions {
435 pub journal: PathBuf,
436 pub require_no_attention: bool,
437 pub out: Option<PathBuf>,
438}
439
440impl RestoreApplyReportOptions {
441 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
443 where
444 I: IntoIterator<Item = OsString>,
445 {
446 let mut journal = None;
447 let mut require_no_attention = false;
448 let mut out = None;
449
450 let mut args = args.into_iter();
451 while let Some(arg) = args.next() {
452 let arg = arg
453 .into_string()
454 .map_err(|_| RestoreCommandError::Usage(usage()))?;
455 match arg.as_str() {
456 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
457 "--require-no-attention" => require_no_attention = true,
458 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
459 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
460 _ => return Err(RestoreCommandError::UnknownOption(arg)),
461 }
462 }
463
464 Ok(Self {
465 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
466 require_no_attention,
467 out,
468 })
469 }
470}
471
472#[derive(Clone, Debug, Eq, PartialEq)]
477#[expect(
478 clippy::struct_excessive_bools,
479 reason = "CLI runner options mirror independent mode and fail-closed guard flags"
480)]
481pub struct RestoreRunOptions {
482 pub journal: PathBuf,
483 pub dfx: String,
484 pub network: Option<String>,
485 pub out: Option<PathBuf>,
486 pub dry_run: bool,
487 pub execute: bool,
488 pub unclaim_pending: bool,
489 pub max_steps: Option<usize>,
490 pub require_complete: bool,
491 pub require_no_attention: bool,
492 pub require_run_mode: Option<String>,
493 pub require_stopped_reason: Option<String>,
494 pub require_next_action: Option<String>,
495 pub require_executed_count: Option<usize>,
496}
497
498impl RestoreRunOptions {
499 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
501 where
502 I: IntoIterator<Item = OsString>,
503 {
504 let mut journal = None;
505 let mut dfx = "dfx".to_string();
506 let mut network = None;
507 let mut out = None;
508 let mut dry_run = false;
509 let mut execute = false;
510 let mut unclaim_pending = false;
511 let mut max_steps = None;
512 let mut require_complete = false;
513 let mut require_no_attention = false;
514 let mut require_run_mode = None;
515 let mut require_stopped_reason = None;
516 let mut require_next_action = None;
517 let mut require_executed_count = None;
518
519 let mut args = args.into_iter();
520 while let Some(arg) = args.next() {
521 let arg = arg
522 .into_string()
523 .map_err(|_| RestoreCommandError::Usage(usage()))?;
524 match arg.as_str() {
525 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
526 "--dfx" => dfx = next_value(&mut args, "--dfx")?,
527 "--network" => network = Some(next_value(&mut args, "--network")?),
528 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
529 "--dry-run" => dry_run = true,
530 "--execute" => execute = true,
531 "--unclaim-pending" => unclaim_pending = true,
532 "--max-steps" => {
533 max_steps = Some(parse_sequence(next_value(&mut args, "--max-steps")?)?);
534 }
535 "--require-complete" => require_complete = true,
536 "--require-no-attention" => require_no_attention = true,
537 "--require-run-mode" => {
538 require_run_mode = Some(next_value(&mut args, "--require-run-mode")?);
539 }
540 "--require-stopped-reason" => {
541 require_stopped_reason =
542 Some(next_value(&mut args, "--require-stopped-reason")?);
543 }
544 "--require-next-action" => {
545 require_next_action = Some(next_value(&mut args, "--require-next-action")?);
546 }
547 "--require-executed-count" => {
548 require_executed_count = Some(parse_sequence(next_value(
549 &mut args,
550 "--require-executed-count",
551 )?)?);
552 }
553 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
554 _ => return Err(RestoreCommandError::UnknownOption(arg)),
555 }
556 }
557
558 let mode_count = [dry_run, execute, unclaim_pending]
559 .into_iter()
560 .filter(|enabled| *enabled)
561 .count();
562 if mode_count > 1 {
563 return Err(RestoreCommandError::RestoreRunConflictingModes);
564 }
565
566 if mode_count == 0 {
567 return Err(RestoreCommandError::RestoreRunRequiresMode);
568 }
569
570 Ok(Self {
571 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
572 dfx,
573 network,
574 out,
575 dry_run,
576 execute,
577 unclaim_pending,
578 max_steps,
579 require_complete,
580 require_no_attention,
581 require_run_mode,
582 require_stopped_reason,
583 require_next_action,
584 require_executed_count,
585 })
586 }
587}
588
589struct RestoreRunResult {
594 response: RestoreRunResponse,
595 error: Option<RestoreCommandError>,
596}
597
598impl RestoreRunResult {
599 const fn ok(response: RestoreRunResponse) -> Self {
601 Self {
602 response,
603 error: None,
604 }
605 }
606}
607
608const RESTORE_RUN_MODE_DRY_RUN: &str = "dry-run";
609const RESTORE_RUN_MODE_EXECUTE: &str = "execute";
610const RESTORE_RUN_MODE_UNCLAIM_PENDING: &str = "unclaim-pending";
611
612const RESTORE_RUN_STOPPED_BLOCKED: &str = "blocked";
613const RESTORE_RUN_STOPPED_COMMAND_FAILED: &str = "command-failed";
614const RESTORE_RUN_STOPPED_COMPLETE: &str = "complete";
615const RESTORE_RUN_STOPPED_MAX_STEPS: &str = "max-steps-reached";
616const RESTORE_RUN_STOPPED_PENDING: &str = "pending";
617const RESTORE_RUN_STOPPED_PREVIEW: &str = "preview";
618const RESTORE_RUN_STOPPED_READY: &str = "ready";
619const RESTORE_RUN_STOPPED_RECOVERED_PENDING: &str = "recovered-pending";
620
621const RESTORE_RUN_ACTION_DONE: &str = "done";
622const RESTORE_RUN_ACTION_FIX_BLOCKED: &str = "fix-blocked-journal";
623const RESTORE_RUN_ACTION_INSPECT_FAILED: &str = "inspect-failed-operation";
624const RESTORE_RUN_ACTION_RERUN: &str = "rerun";
625const RESTORE_RUN_ACTION_UNCLAIM_PENDING: &str = "unclaim-pending";
626
627const RESTORE_RUN_EXECUTED_COMPLETED: &str = "completed";
628const RESTORE_RUN_EXECUTED_FAILED: &str = "failed";
629const RESTORE_RUN_COMMAND_EXIT_PREFIX: &str = "runner-command-exit";
630const RESTORE_RUN_RESPONSE_VERSION: u16 = 1;
631
632#[derive(Clone, Debug, Serialize)]
637#[expect(
638 clippy::struct_excessive_bools,
639 reason = "Runner response exposes stable JSON status flags for operators and CI"
640)]
641pub struct RestoreRunResponse {
642 run_version: u16,
643 backup_id: String,
644 run_mode: &'static str,
645 dry_run: bool,
646 execute: bool,
647 unclaim_pending: bool,
648 stopped_reason: &'static str,
649 next_action: &'static str,
650 #[serde(skip_serializing_if = "Option::is_none")]
651 max_steps_reached: Option<bool>,
652 #[serde(default, skip_serializing_if = "Vec::is_empty")]
653 executed_operations: Vec<RestoreRunExecutedOperation>,
654 #[serde(skip_serializing_if = "Option::is_none")]
655 executed_operation_count: Option<usize>,
656 #[serde(skip_serializing_if = "Option::is_none")]
657 recovered_operation: Option<RestoreApplyJournalOperation>,
658 ready: bool,
659 complete: bool,
660 attention_required: bool,
661 outcome: RestoreApplyReportOutcome,
662 operation_count: usize,
663 operation_counts: RestoreApplyOperationKindCounts,
664 operation_counts_supplied: bool,
665 progress: canic_backup::restore::RestoreApplyProgressSummary,
666 pending_operations: usize,
667 ready_operations: usize,
668 blocked_operations: usize,
669 completed_operations: usize,
670 failed_operations: usize,
671 blocked_reasons: Vec<String>,
672 next_transition: Option<RestoreApplyReportOperation>,
673 #[serde(skip_serializing_if = "Option::is_none")]
674 operation_available: Option<bool>,
675 #[serde(skip_serializing_if = "Option::is_none")]
676 command_available: Option<bool>,
677 #[serde(skip_serializing_if = "Option::is_none")]
678 command: Option<RestoreApplyRunnerCommand>,
679}
680
681impl RestoreRunResponse {
682 fn from_report(
684 backup_id: String,
685 report: RestoreApplyJournalReport,
686 mode: RestoreRunResponseMode,
687 ) -> Self {
688 Self {
689 run_version: RESTORE_RUN_RESPONSE_VERSION,
690 backup_id,
691 run_mode: mode.run_mode,
692 dry_run: mode.dry_run,
693 execute: mode.execute,
694 unclaim_pending: mode.unclaim_pending,
695 stopped_reason: mode.stopped_reason,
696 next_action: mode.next_action,
697 max_steps_reached: None,
698 executed_operations: Vec::new(),
699 executed_operation_count: None,
700 recovered_operation: None,
701 ready: report.ready,
702 complete: report.complete,
703 attention_required: report.attention_required,
704 outcome: report.outcome,
705 operation_count: report.operation_count,
706 operation_counts: report.operation_counts,
707 operation_counts_supplied: report.operation_counts_supplied,
708 progress: report.progress,
709 pending_operations: report.pending_operations,
710 ready_operations: report.ready_operations,
711 blocked_operations: report.blocked_operations,
712 completed_operations: report.completed_operations,
713 failed_operations: report.failed_operations,
714 blocked_reasons: report.blocked_reasons,
715 next_transition: report.next_transition,
716 operation_available: None,
717 command_available: None,
718 command: None,
719 }
720 }
721}
722
723#[derive(Clone, Debug, Serialize)]
728struct RestoreRunExecutedOperation {
729 sequence: usize,
730 operation: RestoreApplyOperationKind,
731 target_canister: String,
732 command: RestoreApplyRunnerCommand,
733 status: String,
734 state: &'static str,
735}
736
737impl RestoreRunExecutedOperation {
738 fn completed(
740 operation: RestoreApplyJournalOperation,
741 command: RestoreApplyRunnerCommand,
742 status: String,
743 ) -> Self {
744 Self::from_operation(operation, command, status, RESTORE_RUN_EXECUTED_COMPLETED)
745 }
746
747 fn failed(
749 operation: RestoreApplyJournalOperation,
750 command: RestoreApplyRunnerCommand,
751 status: String,
752 ) -> Self {
753 Self::from_operation(operation, command, status, RESTORE_RUN_EXECUTED_FAILED)
754 }
755
756 fn from_operation(
758 operation: RestoreApplyJournalOperation,
759 command: RestoreApplyRunnerCommand,
760 status: String,
761 state: &'static str,
762 ) -> Self {
763 Self {
764 sequence: operation.sequence,
765 operation: operation.operation,
766 target_canister: operation.target_canister,
767 command,
768 status,
769 state,
770 }
771 }
772}
773
774struct RestoreRunResponseMode {
779 run_mode: &'static str,
780 dry_run: bool,
781 execute: bool,
782 unclaim_pending: bool,
783 stopped_reason: &'static str,
784 next_action: &'static str,
785}
786
787impl RestoreRunResponseMode {
788 const fn new(
790 run_mode: &'static str,
791 dry_run: bool,
792 execute: bool,
793 unclaim_pending: bool,
794 stopped_reason: &'static str,
795 next_action: &'static str,
796 ) -> Self {
797 Self {
798 run_mode,
799 dry_run,
800 execute,
801 unclaim_pending,
802 stopped_reason,
803 next_action,
804 }
805 }
806
807 const fn dry_run(stopped_reason: &'static str, next_action: &'static str) -> Self {
809 Self::new(
810 RESTORE_RUN_MODE_DRY_RUN,
811 true,
812 false,
813 false,
814 stopped_reason,
815 next_action,
816 )
817 }
818
819 const fn execute(stopped_reason: &'static str, next_action: &'static str) -> Self {
821 Self::new(
822 RESTORE_RUN_MODE_EXECUTE,
823 false,
824 true,
825 false,
826 stopped_reason,
827 next_action,
828 )
829 }
830
831 const fn unclaim_pending(next_action: &'static str) -> Self {
833 Self::new(
834 RESTORE_RUN_MODE_UNCLAIM_PENDING,
835 false,
836 false,
837 true,
838 RESTORE_RUN_STOPPED_RECOVERED_PENDING,
839 next_action,
840 )
841 }
842}
843
844#[derive(Clone, Debug, Eq, PartialEq)]
849pub struct RestoreApplyNextOptions {
850 pub journal: PathBuf,
851 pub out: Option<PathBuf>,
852}
853
854impl RestoreApplyNextOptions {
855 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
857 where
858 I: IntoIterator<Item = OsString>,
859 {
860 let mut journal = None;
861 let mut out = None;
862
863 let mut args = args.into_iter();
864 while let Some(arg) = args.next() {
865 let arg = arg
866 .into_string()
867 .map_err(|_| RestoreCommandError::Usage(usage()))?;
868 match arg.as_str() {
869 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
870 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
871 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
872 _ => return Err(RestoreCommandError::UnknownOption(arg)),
873 }
874 }
875
876 Ok(Self {
877 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
878 out,
879 })
880 }
881}
882
883#[derive(Clone, Debug, Eq, PartialEq)]
888pub struct RestoreApplyCommandOptions {
889 pub journal: PathBuf,
890 pub dfx: String,
891 pub network: Option<String>,
892 pub out: Option<PathBuf>,
893 pub require_command: bool,
894}
895
896impl RestoreApplyCommandOptions {
897 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
899 where
900 I: IntoIterator<Item = OsString>,
901 {
902 let mut journal = None;
903 let mut dfx = "dfx".to_string();
904 let mut network = None;
905 let mut out = None;
906 let mut require_command = false;
907
908 let mut args = args.into_iter();
909 while let Some(arg) = args.next() {
910 let arg = arg
911 .into_string()
912 .map_err(|_| RestoreCommandError::Usage(usage()))?;
913 match arg.as_str() {
914 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
915 "--dfx" => dfx = next_value(&mut args, "--dfx")?,
916 "--network" => network = Some(next_value(&mut args, "--network")?),
917 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
918 "--require-command" => require_command = true,
919 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
920 _ => return Err(RestoreCommandError::UnknownOption(arg)),
921 }
922 }
923
924 Ok(Self {
925 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
926 dfx,
927 network,
928 out,
929 require_command,
930 })
931 }
932}
933
934#[derive(Clone, Debug, Eq, PartialEq)]
939pub struct RestoreApplyClaimOptions {
940 pub journal: PathBuf,
941 pub sequence: Option<usize>,
942 pub updated_at: Option<String>,
943 pub out: Option<PathBuf>,
944}
945
946impl RestoreApplyClaimOptions {
947 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
949 where
950 I: IntoIterator<Item = OsString>,
951 {
952 let mut journal = None;
953 let mut sequence = None;
954 let mut updated_at = None;
955 let mut out = None;
956
957 let mut args = args.into_iter();
958 while let Some(arg) = args.next() {
959 let arg = arg
960 .into_string()
961 .map_err(|_| RestoreCommandError::Usage(usage()))?;
962 match arg.as_str() {
963 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
964 "--sequence" => {
965 sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
966 }
967 "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
968 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
969 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
970 _ => return Err(RestoreCommandError::UnknownOption(arg)),
971 }
972 }
973
974 Ok(Self {
975 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
976 sequence,
977 updated_at,
978 out,
979 })
980 }
981}
982
983#[derive(Clone, Debug, Eq, PartialEq)]
988pub struct RestoreApplyUnclaimOptions {
989 pub journal: PathBuf,
990 pub sequence: Option<usize>,
991 pub updated_at: Option<String>,
992 pub out: Option<PathBuf>,
993}
994
995impl RestoreApplyUnclaimOptions {
996 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
998 where
999 I: IntoIterator<Item = OsString>,
1000 {
1001 let mut journal = None;
1002 let mut sequence = None;
1003 let mut updated_at = None;
1004 let mut out = None;
1005
1006 let mut args = args.into_iter();
1007 while let Some(arg) = args.next() {
1008 let arg = arg
1009 .into_string()
1010 .map_err(|_| RestoreCommandError::Usage(usage()))?;
1011 match arg.as_str() {
1012 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
1013 "--sequence" => {
1014 sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
1015 }
1016 "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
1017 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
1018 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
1019 _ => return Err(RestoreCommandError::UnknownOption(arg)),
1020 }
1021 }
1022
1023 Ok(Self {
1024 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
1025 sequence,
1026 updated_at,
1027 out,
1028 })
1029 }
1030}
1031
1032#[derive(Clone, Debug, Eq, PartialEq)]
1037pub struct RestoreApplyMarkOptions {
1038 pub journal: PathBuf,
1039 pub sequence: usize,
1040 pub state: RestoreApplyMarkState,
1041 pub reason: Option<String>,
1042 pub updated_at: Option<String>,
1043 pub out: Option<PathBuf>,
1044 pub require_pending: bool,
1045}
1046
1047impl RestoreApplyMarkOptions {
1048 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
1050 where
1051 I: IntoIterator<Item = OsString>,
1052 {
1053 let mut journal = None;
1054 let mut sequence = None;
1055 let mut state = None;
1056 let mut reason = None;
1057 let mut updated_at = None;
1058 let mut out = None;
1059 let mut require_pending = false;
1060
1061 let mut args = args.into_iter();
1062 while let Some(arg) = args.next() {
1063 let arg = arg
1064 .into_string()
1065 .map_err(|_| RestoreCommandError::Usage(usage()))?;
1066 match arg.as_str() {
1067 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
1068 "--sequence" => {
1069 sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
1070 }
1071 "--state" => {
1072 state = Some(RestoreApplyMarkState::parse(next_value(
1073 &mut args, "--state",
1074 )?)?);
1075 }
1076 "--reason" => reason = Some(next_value(&mut args, "--reason")?),
1077 "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
1078 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
1079 "--require-pending" => require_pending = true,
1080 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
1081 _ => return Err(RestoreCommandError::UnknownOption(arg)),
1082 }
1083 }
1084
1085 Ok(Self {
1086 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
1087 sequence: sequence.ok_or(RestoreCommandError::MissingOption("--sequence"))?,
1088 state: state.ok_or(RestoreCommandError::MissingOption("--state"))?,
1089 reason,
1090 updated_at,
1091 out,
1092 require_pending,
1093 })
1094 }
1095}
1096
1097#[derive(Clone, Debug, Eq, PartialEq)]
1102pub enum RestoreApplyMarkState {
1103 Completed,
1104 Failed,
1105}
1106
1107impl RestoreApplyMarkState {
1108 fn parse(value: String) -> Result<Self, RestoreCommandError> {
1110 match value.as_str() {
1111 "completed" => Ok(Self::Completed),
1112 "failed" => Ok(Self::Failed),
1113 _ => Err(RestoreCommandError::InvalidApplyMarkState(value)),
1114 }
1115 }
1116}
1117
1118pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
1120where
1121 I: IntoIterator<Item = OsString>,
1122{
1123 let mut args = args.into_iter();
1124 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
1125 return Err(RestoreCommandError::Usage(usage()));
1126 };
1127
1128 match command.as_str() {
1129 "plan" => {
1130 let options = RestorePlanOptions::parse(args)?;
1131 let plan = plan_restore(&options)?;
1132 write_plan(&options, &plan)?;
1133 enforce_restore_plan_requirements(&options, &plan)?;
1134 Ok(())
1135 }
1136 "status" => {
1137 let options = RestoreStatusOptions::parse(args)?;
1138 let status = restore_status(&options)?;
1139 write_status(&options, &status)?;
1140 Ok(())
1141 }
1142 "apply" => {
1143 let options = RestoreApplyOptions::parse(args)?;
1144 let dry_run = restore_apply_dry_run(&options)?;
1145 write_apply_dry_run(&options, &dry_run)?;
1146 write_apply_journal_if_requested(&options, &dry_run)?;
1147 Ok(())
1148 }
1149 "apply-status" => {
1150 let options = RestoreApplyStatusOptions::parse(args)?;
1151 let status = restore_apply_status(&options)?;
1152 write_apply_status(&options, &status)?;
1153 enforce_apply_status_requirements(&options, &status)?;
1154 Ok(())
1155 }
1156 "apply-report" => {
1157 let options = RestoreApplyReportOptions::parse(args)?;
1158 let report = restore_apply_report(&options)?;
1159 write_apply_report(&options, &report)?;
1160 enforce_apply_report_requirements(&options, &report)?;
1161 Ok(())
1162 }
1163 "run" => {
1164 let options = RestoreRunOptions::parse(args)?;
1165 let run = if options.execute {
1166 restore_run_execute_result(&options)?
1167 } else if options.unclaim_pending {
1168 RestoreRunResult::ok(restore_run_unclaim_pending(&options)?)
1169 } else {
1170 RestoreRunResult::ok(restore_run_dry_run(&options)?)
1171 };
1172 write_restore_run(&options, &run.response)?;
1173 if let Some(error) = run.error {
1174 return Err(error);
1175 }
1176 enforce_restore_run_requirements(&options, &run.response)?;
1177 Ok(())
1178 }
1179 "apply-next" => {
1180 let options = RestoreApplyNextOptions::parse(args)?;
1181 let next = restore_apply_next(&options)?;
1182 write_apply_next(&options, &next)?;
1183 Ok(())
1184 }
1185 "apply-command" => {
1186 let options = RestoreApplyCommandOptions::parse(args)?;
1187 let preview = restore_apply_command(&options)?;
1188 write_apply_command(&options, &preview)?;
1189 enforce_apply_command_requirements(&options, &preview)?;
1190 Ok(())
1191 }
1192 "apply-claim" => {
1193 let options = RestoreApplyClaimOptions::parse(args)?;
1194 let journal = restore_apply_claim(&options)?;
1195 write_apply_claim(&options, &journal)?;
1196 Ok(())
1197 }
1198 "apply-unclaim" => {
1199 let options = RestoreApplyUnclaimOptions::parse(args)?;
1200 let journal = restore_apply_unclaim(&options)?;
1201 write_apply_unclaim(&options, &journal)?;
1202 Ok(())
1203 }
1204 "apply-mark" => {
1205 let options = RestoreApplyMarkOptions::parse(args)?;
1206 let journal = restore_apply_mark(&options)?;
1207 write_apply_mark(&options, &journal)?;
1208 Ok(())
1209 }
1210 "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
1211 _ => Err(RestoreCommandError::UnknownOption(command)),
1212 }
1213}
1214
1215pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
1217 verify_backup_layout_if_required(options)?;
1218
1219 let manifest = read_manifest_source(options)?;
1220 let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
1221
1222 RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
1223}
1224
1225pub fn restore_status(
1227 options: &RestoreStatusOptions,
1228) -> Result<RestoreStatus, RestoreCommandError> {
1229 let plan = read_plan(&options.plan)?;
1230 Ok(RestoreStatus::from_plan(&plan))
1231}
1232
1233pub fn restore_apply_dry_run(
1235 options: &RestoreApplyOptions,
1236) -> Result<RestoreApplyDryRun, RestoreCommandError> {
1237 let plan = read_plan(&options.plan)?;
1238 let status = options.status.as_ref().map(read_status).transpose()?;
1239 if let Some(backup_dir) = &options.backup_dir {
1240 return RestoreApplyDryRun::try_from_plan_with_artifacts(
1241 &plan,
1242 status.as_ref(),
1243 backup_dir,
1244 )
1245 .map_err(RestoreCommandError::from);
1246 }
1247
1248 RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
1249}
1250
1251pub fn restore_apply_status(
1253 options: &RestoreApplyStatusOptions,
1254) -> Result<RestoreApplyJournalStatus, RestoreCommandError> {
1255 let journal = read_apply_journal(&options.journal)?;
1256 Ok(journal.status())
1257}
1258
1259pub fn restore_apply_report(
1261 options: &RestoreApplyReportOptions,
1262) -> Result<RestoreApplyJournalReport, RestoreCommandError> {
1263 let journal = read_apply_journal(&options.journal)?;
1264 Ok(journal.report())
1265}
1266
1267pub fn restore_run_dry_run(
1269 options: &RestoreRunOptions,
1270) -> Result<RestoreRunResponse, RestoreCommandError> {
1271 let journal = read_apply_journal(&options.journal)?;
1272 let report = journal.report();
1273 let preview = journal.next_command_preview_with_config(&restore_run_command_config(options));
1274 let stopped_reason = restore_run_stopped_reason(&report, false, false);
1275 let next_action = restore_run_next_action(&report, false);
1276
1277 let mut response = RestoreRunResponse::from_report(
1278 journal.backup_id,
1279 report,
1280 RestoreRunResponseMode::dry_run(stopped_reason, next_action),
1281 );
1282 response.operation_available = Some(preview.operation_available);
1283 response.command_available = Some(preview.command_available);
1284 response.command = preview.command;
1285 Ok(response)
1286}
1287
1288pub fn restore_run_unclaim_pending(
1290 options: &RestoreRunOptions,
1291) -> Result<RestoreRunResponse, RestoreCommandError> {
1292 let mut journal = read_apply_journal(&options.journal)?;
1293 let recovered_operation = journal
1294 .next_transition_operation()
1295 .filter(|operation| operation.state == RestoreApplyOperationState::Pending)
1296 .cloned()
1297 .ok_or(RestoreApplyJournalError::NoPendingOperation)?;
1298
1299 journal.mark_next_operation_ready_at(Some(timestamp_placeholder()))?;
1300 write_apply_journal_file(&options.journal, &journal)?;
1301
1302 let report = journal.report();
1303 let next_action = restore_run_next_action(&report, true);
1304 let mut response = RestoreRunResponse::from_report(
1305 journal.backup_id,
1306 report,
1307 RestoreRunResponseMode::unclaim_pending(next_action),
1308 );
1309 response.recovered_operation = Some(recovered_operation);
1310 Ok(response)
1311}
1312
1313pub fn restore_run_execute(
1315 options: &RestoreRunOptions,
1316) -> Result<RestoreRunResponse, RestoreCommandError> {
1317 let run = restore_run_execute_result(options)?;
1318 if let Some(error) = run.error {
1319 return Err(error);
1320 }
1321
1322 Ok(run.response)
1323}
1324
1325fn restore_run_execute_result(
1327 options: &RestoreRunOptions,
1328) -> Result<RestoreRunResult, RestoreCommandError> {
1329 let mut journal = read_apply_journal(&options.journal)?;
1330 let mut executed_operations = Vec::new();
1331 let config = restore_run_command_config(options);
1332
1333 loop {
1334 let report = journal.report();
1335 let max_steps_reached =
1336 restore_run_max_steps_reached(options, executed_operations.len(), &report);
1337 if report.complete || max_steps_reached {
1338 return Ok(RestoreRunResult::ok(restore_run_execute_summary(
1339 &journal,
1340 executed_operations,
1341 max_steps_reached,
1342 )));
1343 }
1344
1345 enforce_restore_run_executable(&journal, &report)?;
1346 let preview = journal.next_command_preview_with_config(&config);
1347 enforce_restore_run_command_available(&preview)?;
1348
1349 let operation = preview
1350 .operation
1351 .clone()
1352 .ok_or_else(|| restore_command_unavailable_error(&preview))?;
1353 let command = preview
1354 .command
1355 .clone()
1356 .ok_or_else(|| restore_command_unavailable_error(&preview))?;
1357 let sequence = operation.sequence;
1358
1359 enforce_apply_claim_sequence(sequence, &journal)?;
1360 journal.mark_operation_pending_at(sequence, Some(timestamp_placeholder()))?;
1361 write_apply_journal_file(&options.journal, &journal)?;
1362
1363 let status = Command::new(&command.program)
1364 .args(&command.args)
1365 .status()?;
1366 let status_label = exit_status_label(status);
1367 if status.success() {
1368 journal.mark_operation_completed_at(sequence, Some(timestamp_placeholder()))?;
1369 write_apply_journal_file(&options.journal, &journal)?;
1370 executed_operations.push(RestoreRunExecutedOperation::completed(
1371 operation,
1372 command,
1373 status_label,
1374 ));
1375 continue;
1376 }
1377
1378 journal.mark_operation_failed_at(
1379 sequence,
1380 format!("{RESTORE_RUN_COMMAND_EXIT_PREFIX}-{status_label}"),
1381 Some(timestamp_placeholder()),
1382 )?;
1383 write_apply_journal_file(&options.journal, &journal)?;
1384 executed_operations.push(RestoreRunExecutedOperation::failed(
1385 operation,
1386 command,
1387 status_label.clone(),
1388 ));
1389 let response = restore_run_execute_summary(&journal, executed_operations, false);
1390 return Ok(RestoreRunResult {
1391 response,
1392 error: Some(RestoreCommandError::RestoreRunCommandFailed {
1393 sequence,
1394 status: status_label,
1395 }),
1396 });
1397 }
1398}
1399
1400fn restore_run_command_config(options: &RestoreRunOptions) -> RestoreApplyCommandConfig {
1402 restore_command_config(&options.dfx, options.network.as_deref())
1403}
1404
1405fn restore_apply_command_config(options: &RestoreApplyCommandOptions) -> RestoreApplyCommandConfig {
1407 restore_command_config(&options.dfx, options.network.as_deref())
1408}
1409
1410fn restore_command_config(program: &str, network: Option<&str>) -> RestoreApplyCommandConfig {
1412 RestoreApplyCommandConfig {
1413 program: program.to_string(),
1414 network: network.map(str::to_string),
1415 }
1416}
1417
1418fn restore_run_max_steps_reached(
1420 options: &RestoreRunOptions,
1421 executed_operation_count: usize,
1422 report: &RestoreApplyJournalReport,
1423) -> bool {
1424 options.max_steps == Some(executed_operation_count) && !report.complete
1425}
1426
1427fn restore_run_execute_summary(
1429 journal: &RestoreApplyJournal,
1430 executed_operations: Vec<RestoreRunExecutedOperation>,
1431 max_steps_reached: bool,
1432) -> RestoreRunResponse {
1433 let report = journal.report();
1434 let executed_operation_count = executed_operations.len();
1435 let stopped_reason = restore_run_stopped_reason(&report, max_steps_reached, true);
1436 let next_action = restore_run_next_action(&report, false);
1437
1438 let mut response = RestoreRunResponse::from_report(
1439 journal.backup_id.clone(),
1440 report,
1441 RestoreRunResponseMode::execute(stopped_reason, next_action),
1442 );
1443 response.max_steps_reached = Some(max_steps_reached);
1444 response.executed_operation_count = Some(executed_operation_count);
1445 response.executed_operations = executed_operations;
1446 response
1447}
1448
1449const fn restore_run_stopped_reason(
1451 report: &RestoreApplyJournalReport,
1452 max_steps_reached: bool,
1453 executed: bool,
1454) -> &'static str {
1455 if report.complete {
1456 return RESTORE_RUN_STOPPED_COMPLETE;
1457 }
1458 if report.failed_operations > 0 {
1459 return RESTORE_RUN_STOPPED_COMMAND_FAILED;
1460 }
1461 if report.pending_operations > 0 {
1462 return RESTORE_RUN_STOPPED_PENDING;
1463 }
1464 if !report.ready || report.blocked_operations > 0 {
1465 return RESTORE_RUN_STOPPED_BLOCKED;
1466 }
1467 if max_steps_reached {
1468 return RESTORE_RUN_STOPPED_MAX_STEPS;
1469 }
1470 if executed {
1471 return RESTORE_RUN_STOPPED_READY;
1472 }
1473 RESTORE_RUN_STOPPED_PREVIEW
1474}
1475
1476const fn restore_run_next_action(
1478 report: &RestoreApplyJournalReport,
1479 recovered_pending: bool,
1480) -> &'static str {
1481 if report.complete {
1482 return RESTORE_RUN_ACTION_DONE;
1483 }
1484 if report.failed_operations > 0 {
1485 return RESTORE_RUN_ACTION_INSPECT_FAILED;
1486 }
1487 if report.pending_operations > 0 {
1488 return RESTORE_RUN_ACTION_UNCLAIM_PENDING;
1489 }
1490 if !report.ready || report.blocked_operations > 0 {
1491 return RESTORE_RUN_ACTION_FIX_BLOCKED;
1492 }
1493 if recovered_pending {
1494 return RESTORE_RUN_ACTION_RERUN;
1495 }
1496 RESTORE_RUN_ACTION_RERUN
1497}
1498
1499fn enforce_restore_run_executable(
1501 journal: &RestoreApplyJournal,
1502 report: &RestoreApplyJournalReport,
1503) -> Result<(), RestoreCommandError> {
1504 if report.pending_operations > 0 {
1505 return Err(RestoreCommandError::RestoreApplyPending {
1506 backup_id: report.backup_id.clone(),
1507 pending_operations: report.pending_operations,
1508 next_transition_sequence: report
1509 .next_transition
1510 .as_ref()
1511 .map(|operation| operation.sequence),
1512 });
1513 }
1514
1515 if report.failed_operations > 0 {
1516 return Err(RestoreCommandError::RestoreApplyFailed {
1517 backup_id: report.backup_id.clone(),
1518 failed_operations: report.failed_operations,
1519 });
1520 }
1521
1522 if report.ready {
1523 return Ok(());
1524 }
1525
1526 Err(RestoreCommandError::RestoreApplyNotReady {
1527 backup_id: journal.backup_id.clone(),
1528 reasons: report.blocked_reasons.clone(),
1529 })
1530}
1531
1532fn enforce_restore_run_command_available(
1534 preview: &RestoreApplyCommandPreview,
1535) -> Result<(), RestoreCommandError> {
1536 if preview.command_available {
1537 return Ok(());
1538 }
1539
1540 Err(restore_command_unavailable_error(preview))
1541}
1542
1543fn restore_command_unavailable_error(preview: &RestoreApplyCommandPreview) -> RestoreCommandError {
1545 RestoreCommandError::RestoreApplyCommandUnavailable {
1546 backup_id: preview.backup_id.clone(),
1547 operation_available: preview.operation_available,
1548 complete: preview.complete,
1549 blocked_reasons: preview.blocked_reasons.clone(),
1550 }
1551}
1552
1553fn exit_status_label(status: std::process::ExitStatus) -> String {
1555 status
1556 .code()
1557 .map_or_else(|| "signal".to_string(), |code| code.to_string())
1558}
1559
1560fn enforce_restore_run_requirements(
1562 options: &RestoreRunOptions,
1563 run: &RestoreRunResponse,
1564) -> Result<(), RestoreCommandError> {
1565 if options.require_complete && !run.complete {
1566 return Err(RestoreCommandError::RestoreApplyIncomplete {
1567 backup_id: run.backup_id.clone(),
1568 completed_operations: run.completed_operations,
1569 operation_count: run.operation_count,
1570 });
1571 }
1572
1573 if options.require_no_attention && run.attention_required {
1574 return Err(RestoreCommandError::RestoreApplyReportNeedsAttention {
1575 backup_id: run.backup_id.clone(),
1576 outcome: run.outcome.clone(),
1577 });
1578 }
1579
1580 if let Some(expected) = &options.require_run_mode
1581 && run.run_mode != expected
1582 {
1583 return Err(RestoreCommandError::RestoreRunModeMismatch {
1584 backup_id: run.backup_id.clone(),
1585 expected: expected.clone(),
1586 actual: run.run_mode.to_string(),
1587 });
1588 }
1589
1590 if let Some(expected) = &options.require_stopped_reason
1591 && run.stopped_reason != expected
1592 {
1593 return Err(RestoreCommandError::RestoreRunStoppedReasonMismatch {
1594 backup_id: run.backup_id.clone(),
1595 expected: expected.clone(),
1596 actual: run.stopped_reason.to_string(),
1597 });
1598 }
1599
1600 if let Some(expected) = &options.require_next_action
1601 && run.next_action != expected
1602 {
1603 return Err(RestoreCommandError::RestoreRunNextActionMismatch {
1604 backup_id: run.backup_id.clone(),
1605 expected: expected.clone(),
1606 actual: run.next_action.to_string(),
1607 });
1608 }
1609
1610 if let Some(expected) = options.require_executed_count {
1611 let actual = run.executed_operation_count.unwrap_or(0);
1612 if actual != expected {
1613 return Err(RestoreCommandError::RestoreRunExecutedCountMismatch {
1614 backup_id: run.backup_id.clone(),
1615 expected,
1616 actual,
1617 });
1618 }
1619 }
1620
1621 Ok(())
1622}
1623
1624fn enforce_apply_report_requirements(
1626 options: &RestoreApplyReportOptions,
1627 report: &RestoreApplyJournalReport,
1628) -> Result<(), RestoreCommandError> {
1629 if !options.require_no_attention || !report.attention_required {
1630 return Ok(());
1631 }
1632
1633 Err(RestoreCommandError::RestoreApplyReportNeedsAttention {
1634 backup_id: report.backup_id.clone(),
1635 outcome: report.outcome.clone(),
1636 })
1637}
1638
1639fn enforce_apply_status_requirements(
1641 options: &RestoreApplyStatusOptions,
1642 status: &RestoreApplyJournalStatus,
1643) -> Result<(), RestoreCommandError> {
1644 if options.require_ready && !status.ready {
1645 return Err(RestoreCommandError::RestoreApplyNotReady {
1646 backup_id: status.backup_id.clone(),
1647 reasons: status.blocked_reasons.clone(),
1648 });
1649 }
1650
1651 if options.require_no_pending && status.pending_operations > 0 {
1652 return Err(RestoreCommandError::RestoreApplyPending {
1653 backup_id: status.backup_id.clone(),
1654 pending_operations: status.pending_operations,
1655 next_transition_sequence: status.next_transition_sequence,
1656 });
1657 }
1658
1659 if options.require_no_failed && status.failed_operations > 0 {
1660 return Err(RestoreCommandError::RestoreApplyFailed {
1661 backup_id: status.backup_id.clone(),
1662 failed_operations: status.failed_operations,
1663 });
1664 }
1665
1666 if options.require_complete && !status.complete {
1667 return Err(RestoreCommandError::RestoreApplyIncomplete {
1668 backup_id: status.backup_id.clone(),
1669 completed_operations: status.completed_operations,
1670 operation_count: status.operation_count,
1671 });
1672 }
1673
1674 Ok(())
1675}
1676
1677pub fn restore_apply_next(
1679 options: &RestoreApplyNextOptions,
1680) -> Result<RestoreApplyNextOperation, RestoreCommandError> {
1681 let journal = read_apply_journal(&options.journal)?;
1682 Ok(journal.next_operation())
1683}
1684
1685pub fn restore_apply_command(
1687 options: &RestoreApplyCommandOptions,
1688) -> Result<RestoreApplyCommandPreview, RestoreCommandError> {
1689 let journal = read_apply_journal(&options.journal)?;
1690 Ok(journal.next_command_preview_with_config(&restore_apply_command_config(options)))
1691}
1692
1693fn enforce_apply_command_requirements(
1695 options: &RestoreApplyCommandOptions,
1696 preview: &RestoreApplyCommandPreview,
1697) -> Result<(), RestoreCommandError> {
1698 if !options.require_command || preview.command_available {
1699 return Ok(());
1700 }
1701
1702 Err(restore_command_unavailable_error(preview))
1703}
1704
1705pub fn restore_apply_claim(
1707 options: &RestoreApplyClaimOptions,
1708) -> Result<RestoreApplyJournal, RestoreCommandError> {
1709 let mut journal = read_apply_journal(&options.journal)?;
1710 let updated_at = Some(state_updated_at(options.updated_at.as_ref()));
1711
1712 if let Some(sequence) = options.sequence {
1713 enforce_apply_claim_sequence(sequence, &journal)?;
1714 journal.mark_operation_pending_at(sequence, updated_at)?;
1715 return Ok(journal);
1716 }
1717
1718 journal.mark_next_operation_pending_at(updated_at)?;
1719 Ok(journal)
1720}
1721
1722fn enforce_apply_claim_sequence(
1724 expected: usize,
1725 journal: &RestoreApplyJournal,
1726) -> Result<(), RestoreCommandError> {
1727 let actual = journal
1728 .next_transition_operation()
1729 .map(|operation| operation.sequence);
1730
1731 if actual == Some(expected) {
1732 return Ok(());
1733 }
1734
1735 Err(RestoreCommandError::RestoreApplyClaimSequenceMismatch { expected, actual })
1736}
1737
1738pub fn restore_apply_unclaim(
1740 options: &RestoreApplyUnclaimOptions,
1741) -> Result<RestoreApplyJournal, RestoreCommandError> {
1742 let mut journal = read_apply_journal(&options.journal)?;
1743 if let Some(sequence) = options.sequence {
1744 enforce_apply_unclaim_sequence(sequence, &journal)?;
1745 }
1746
1747 journal.mark_next_operation_ready_at(Some(state_updated_at(options.updated_at.as_ref())))?;
1748 Ok(journal)
1749}
1750
1751fn enforce_apply_unclaim_sequence(
1753 expected: usize,
1754 journal: &RestoreApplyJournal,
1755) -> Result<(), RestoreCommandError> {
1756 let actual = journal
1757 .next_transition_operation()
1758 .map(|operation| operation.sequence);
1759
1760 if actual == Some(expected) {
1761 return Ok(());
1762 }
1763
1764 Err(RestoreCommandError::RestoreApplyUnclaimSequenceMismatch { expected, actual })
1765}
1766
1767pub fn restore_apply_mark(
1769 options: &RestoreApplyMarkOptions,
1770) -> Result<RestoreApplyJournal, RestoreCommandError> {
1771 let mut journal = read_apply_journal(&options.journal)?;
1772 enforce_apply_mark_pending_requirement(options, &journal)?;
1773
1774 match options.state {
1775 RestoreApplyMarkState::Completed => {
1776 journal.mark_operation_completed_at(
1777 options.sequence,
1778 Some(state_updated_at(options.updated_at.as_ref())),
1779 )?;
1780 }
1781 RestoreApplyMarkState::Failed => {
1782 let reason =
1783 options
1784 .reason
1785 .clone()
1786 .ok_or(RestoreApplyJournalError::FailureReasonRequired(
1787 options.sequence,
1788 ))?;
1789 journal.mark_operation_failed_at(
1790 options.sequence,
1791 reason,
1792 Some(state_updated_at(options.updated_at.as_ref())),
1793 )?;
1794 }
1795 }
1796
1797 Ok(journal)
1798}
1799
1800fn enforce_apply_mark_pending_requirement(
1802 options: &RestoreApplyMarkOptions,
1803 journal: &RestoreApplyJournal,
1804) -> Result<(), RestoreCommandError> {
1805 if !options.require_pending {
1806 return Ok(());
1807 }
1808
1809 let state = journal
1810 .operations
1811 .iter()
1812 .find(|operation| operation.sequence == options.sequence)
1813 .map(|operation| operation.state.clone())
1814 .ok_or(RestoreApplyJournalError::OperationNotFound(
1815 options.sequence,
1816 ))?;
1817
1818 if state == RestoreApplyOperationState::Pending {
1819 return Ok(());
1820 }
1821
1822 Err(RestoreCommandError::RestoreApplyMarkRequiresPending {
1823 sequence: options.sequence,
1824 state,
1825 })
1826}
1827
1828fn enforce_restore_plan_requirements(
1830 options: &RestorePlanOptions,
1831 plan: &RestorePlan,
1832) -> Result<(), RestoreCommandError> {
1833 if !options.require_restore_ready || plan.readiness_summary.ready {
1834 return Ok(());
1835 }
1836
1837 Err(RestoreCommandError::RestoreNotReady {
1838 backup_id: plan.backup_id.clone(),
1839 reasons: plan.readiness_summary.reasons.clone(),
1840 })
1841}
1842
1843fn verify_backup_layout_if_required(
1845 options: &RestorePlanOptions,
1846) -> Result<(), RestoreCommandError> {
1847 if !options.require_verified {
1848 return Ok(());
1849 }
1850
1851 let Some(dir) = &options.backup_dir else {
1852 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
1853 };
1854
1855 BackupLayout::new(dir.clone()).verify_integrity()?;
1856 Ok(())
1857}
1858
1859fn read_manifest_source(
1861 options: &RestorePlanOptions,
1862) -> Result<FleetBackupManifest, RestoreCommandError> {
1863 if let Some(path) = &options.manifest {
1864 return read_manifest(path);
1865 }
1866
1867 let Some(dir) = &options.backup_dir else {
1868 return Err(RestoreCommandError::MissingOption(
1869 "--manifest or --backup-dir",
1870 ));
1871 };
1872
1873 BackupLayout::new(dir.clone())
1874 .read_manifest()
1875 .map_err(RestoreCommandError::from)
1876}
1877
1878fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
1880 let data = fs::read_to_string(path)?;
1881 serde_json::from_str(&data).map_err(RestoreCommandError::from)
1882}
1883
1884fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
1886 let data = fs::read_to_string(path)?;
1887 serde_json::from_str(&data).map_err(RestoreCommandError::from)
1888}
1889
1890fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
1892 let data = fs::read_to_string(path)?;
1893 serde_json::from_str(&data).map_err(RestoreCommandError::from)
1894}
1895
1896fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
1898 let data = fs::read_to_string(path)?;
1899 serde_json::from_str(&data).map_err(RestoreCommandError::from)
1900}
1901
1902fn read_apply_journal(path: &PathBuf) -> Result<RestoreApplyJournal, RestoreCommandError> {
1904 let data = fs::read_to_string(path)?;
1905 let journal: RestoreApplyJournal = serde_json::from_str(&data)?;
1906 journal.validate()?;
1907 Ok(journal)
1908}
1909
1910fn parse_sequence(value: String) -> Result<usize, RestoreCommandError> {
1912 value
1913 .parse::<usize>()
1914 .map_err(|_| RestoreCommandError::InvalidSequence)
1915}
1916
1917fn state_updated_at(updated_at: Option<&String>) -> String {
1919 updated_at.cloned().unwrap_or_else(timestamp_placeholder)
1920}
1921
1922fn timestamp_placeholder() -> String {
1924 "unknown".to_string()
1925}
1926
1927fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
1929 if let Some(path) = &options.out {
1930 let data = serde_json::to_vec_pretty(plan)?;
1931 fs::write(path, data)?;
1932 return Ok(());
1933 }
1934
1935 let stdout = io::stdout();
1936 let mut handle = stdout.lock();
1937 serde_json::to_writer_pretty(&mut handle, plan)?;
1938 writeln!(handle)?;
1939 Ok(())
1940}
1941
1942fn write_status(
1944 options: &RestoreStatusOptions,
1945 status: &RestoreStatus,
1946) -> Result<(), RestoreCommandError> {
1947 if let Some(path) = &options.out {
1948 let data = serde_json::to_vec_pretty(status)?;
1949 fs::write(path, data)?;
1950 return Ok(());
1951 }
1952
1953 let stdout = io::stdout();
1954 let mut handle = stdout.lock();
1955 serde_json::to_writer_pretty(&mut handle, status)?;
1956 writeln!(handle)?;
1957 Ok(())
1958}
1959
1960fn write_apply_dry_run(
1962 options: &RestoreApplyOptions,
1963 dry_run: &RestoreApplyDryRun,
1964) -> Result<(), RestoreCommandError> {
1965 if let Some(path) = &options.out {
1966 let data = serde_json::to_vec_pretty(dry_run)?;
1967 fs::write(path, data)?;
1968 return Ok(());
1969 }
1970
1971 let stdout = io::stdout();
1972 let mut handle = stdout.lock();
1973 serde_json::to_writer_pretty(&mut handle, dry_run)?;
1974 writeln!(handle)?;
1975 Ok(())
1976}
1977
1978fn write_apply_journal_if_requested(
1980 options: &RestoreApplyOptions,
1981 dry_run: &RestoreApplyDryRun,
1982) -> Result<(), RestoreCommandError> {
1983 let Some(path) = &options.journal_out else {
1984 return Ok(());
1985 };
1986
1987 let journal = RestoreApplyJournal::from_dry_run(dry_run);
1988 let data = serde_json::to_vec_pretty(&journal)?;
1989 fs::write(path, data)?;
1990 Ok(())
1991}
1992
1993fn write_apply_status(
1995 options: &RestoreApplyStatusOptions,
1996 status: &RestoreApplyJournalStatus,
1997) -> Result<(), RestoreCommandError> {
1998 if let Some(path) = &options.out {
1999 let data = serde_json::to_vec_pretty(status)?;
2000 fs::write(path, data)?;
2001 return Ok(());
2002 }
2003
2004 let stdout = io::stdout();
2005 let mut handle = stdout.lock();
2006 serde_json::to_writer_pretty(&mut handle, status)?;
2007 writeln!(handle)?;
2008 Ok(())
2009}
2010
2011fn write_apply_report(
2013 options: &RestoreApplyReportOptions,
2014 report: &RestoreApplyJournalReport,
2015) -> Result<(), RestoreCommandError> {
2016 if let Some(path) = &options.out {
2017 let data = serde_json::to_vec_pretty(report)?;
2018 fs::write(path, data)?;
2019 return Ok(());
2020 }
2021
2022 let stdout = io::stdout();
2023 let mut handle = stdout.lock();
2024 serde_json::to_writer_pretty(&mut handle, report)?;
2025 writeln!(handle)?;
2026 Ok(())
2027}
2028
2029fn write_restore_run(
2031 options: &RestoreRunOptions,
2032 run: &RestoreRunResponse,
2033) -> Result<(), RestoreCommandError> {
2034 if let Some(path) = &options.out {
2035 let data = serde_json::to_vec_pretty(run)?;
2036 fs::write(path, data)?;
2037 return Ok(());
2038 }
2039
2040 let stdout = io::stdout();
2041 let mut handle = stdout.lock();
2042 serde_json::to_writer_pretty(&mut handle, run)?;
2043 writeln!(handle)?;
2044 Ok(())
2045}
2046
2047fn write_apply_journal_file(
2049 path: &PathBuf,
2050 journal: &RestoreApplyJournal,
2051) -> Result<(), RestoreCommandError> {
2052 let data = serde_json::to_vec_pretty(journal)?;
2053 fs::write(path, data)?;
2054 Ok(())
2055}
2056
2057fn write_apply_next(
2059 options: &RestoreApplyNextOptions,
2060 next: &RestoreApplyNextOperation,
2061) -> Result<(), RestoreCommandError> {
2062 if let Some(path) = &options.out {
2063 let data = serde_json::to_vec_pretty(next)?;
2064 fs::write(path, data)?;
2065 return Ok(());
2066 }
2067
2068 let stdout = io::stdout();
2069 let mut handle = stdout.lock();
2070 serde_json::to_writer_pretty(&mut handle, next)?;
2071 writeln!(handle)?;
2072 Ok(())
2073}
2074
2075fn write_apply_command(
2077 options: &RestoreApplyCommandOptions,
2078 preview: &RestoreApplyCommandPreview,
2079) -> Result<(), RestoreCommandError> {
2080 if let Some(path) = &options.out {
2081 let data = serde_json::to_vec_pretty(preview)?;
2082 fs::write(path, data)?;
2083 return Ok(());
2084 }
2085
2086 let stdout = io::stdout();
2087 let mut handle = stdout.lock();
2088 serde_json::to_writer_pretty(&mut handle, preview)?;
2089 writeln!(handle)?;
2090 Ok(())
2091}
2092
2093fn write_apply_claim(
2095 options: &RestoreApplyClaimOptions,
2096 journal: &RestoreApplyJournal,
2097) -> Result<(), RestoreCommandError> {
2098 if let Some(path) = &options.out {
2099 let data = serde_json::to_vec_pretty(journal)?;
2100 fs::write(path, data)?;
2101 return Ok(());
2102 }
2103
2104 let stdout = io::stdout();
2105 let mut handle = stdout.lock();
2106 serde_json::to_writer_pretty(&mut handle, journal)?;
2107 writeln!(handle)?;
2108 Ok(())
2109}
2110
2111fn write_apply_unclaim(
2113 options: &RestoreApplyUnclaimOptions,
2114 journal: &RestoreApplyJournal,
2115) -> Result<(), RestoreCommandError> {
2116 if let Some(path) = &options.out {
2117 let data = serde_json::to_vec_pretty(journal)?;
2118 fs::write(path, data)?;
2119 return Ok(());
2120 }
2121
2122 let stdout = io::stdout();
2123 let mut handle = stdout.lock();
2124 serde_json::to_writer_pretty(&mut handle, journal)?;
2125 writeln!(handle)?;
2126 Ok(())
2127}
2128
2129fn write_apply_mark(
2131 options: &RestoreApplyMarkOptions,
2132 journal: &RestoreApplyJournal,
2133) -> Result<(), RestoreCommandError> {
2134 if let Some(path) = &options.out {
2135 let data = serde_json::to_vec_pretty(journal)?;
2136 fs::write(path, data)?;
2137 return Ok(());
2138 }
2139
2140 let stdout = io::stdout();
2141 let mut handle = stdout.lock();
2142 serde_json::to_writer_pretty(&mut handle, journal)?;
2143 writeln!(handle)?;
2144 Ok(())
2145}
2146
2147fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
2149where
2150 I: Iterator<Item = OsString>,
2151{
2152 args.next()
2153 .and_then(|value| value.into_string().ok())
2154 .ok_or(RestoreCommandError::MissingValue(option))
2155}
2156
2157const fn usage() -> &'static str {
2159 "usage: canic restore plan (--manifest <file> | --backup-dir <dir>) [--mapping <file>] [--out <file>] [--require-verified] [--require-restore-ready]\n canic restore status --plan <file> [--out <file>]\n canic restore apply --plan <file> [--status <file>] [--backup-dir <dir>] --dry-run [--out <file>] [--journal-out <file>]\n canic restore apply-status --journal <file> [--out <file>] [--require-ready] [--require-no-pending] [--require-no-failed] [--require-complete]\n canic restore apply-report --journal <file> [--out <file>] [--require-no-attention]\n canic restore run --journal <file> (--dry-run | --execute | --unclaim-pending) [--dfx <path>] [--network <name>] [--max-steps <n>] [--out <file>] [--require-complete] [--require-no-attention] [--require-run-mode <text>] [--require-stopped-reason <text>] [--require-next-action <text>] [--require-executed-count <n>]\n canic restore apply-next --journal <file> [--out <file>]\n canic restore apply-command --journal <file> [--dfx <path>] [--network <name>] [--out <file>] [--require-command]\n canic restore apply-claim --journal <file> [--sequence <n>] [--updated-at <text>] [--out <file>]\n canic restore apply-unclaim --journal <file> [--sequence <n>] [--updated-at <text>] [--out <file>]\n canic restore apply-mark --journal <file> --sequence <n> --state completed|failed [--reason <text>] [--updated-at <text>] [--out <file>] [--require-pending]"
2160}
2161
2162#[cfg(test)]
2163mod tests {
2164 use super::*;
2165 use canic_backup::restore::RestoreApplyOperationState;
2166 use canic_backup::{
2167 artifacts::ArtifactChecksum,
2168 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
2169 manifest::{
2170 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
2171 FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
2172 VerificationCheck, VerificationPlan,
2173 },
2174 };
2175 use serde_json::json;
2176 use std::{
2177 path::Path,
2178 time::{SystemTime, UNIX_EPOCH},
2179 };
2180
2181 const ROOT: &str = "aaaaa-aa";
2182 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
2183 const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
2184 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2185
2186 #[test]
2188 fn parses_restore_plan_options() {
2189 let options = RestorePlanOptions::parse([
2190 OsString::from("--manifest"),
2191 OsString::from("manifest.json"),
2192 OsString::from("--mapping"),
2193 OsString::from("mapping.json"),
2194 OsString::from("--out"),
2195 OsString::from("plan.json"),
2196 OsString::from("--require-restore-ready"),
2197 ])
2198 .expect("parse options");
2199
2200 assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
2201 assert_eq!(options.backup_dir, None);
2202 assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
2203 assert_eq!(options.out, Some(PathBuf::from("plan.json")));
2204 assert!(!options.require_verified);
2205 assert!(options.require_restore_ready);
2206 }
2207
2208 #[test]
2210 fn parses_verified_restore_plan_options() {
2211 let options = RestorePlanOptions::parse([
2212 OsString::from("--backup-dir"),
2213 OsString::from("backups/run"),
2214 OsString::from("--require-verified"),
2215 ])
2216 .expect("parse verified options");
2217
2218 assert_eq!(options.manifest, None);
2219 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
2220 assert_eq!(options.mapping, None);
2221 assert_eq!(options.out, None);
2222 assert!(options.require_verified);
2223 assert!(!options.require_restore_ready);
2224 }
2225
2226 #[test]
2228 fn parses_restore_status_options() {
2229 let options = RestoreStatusOptions::parse([
2230 OsString::from("--plan"),
2231 OsString::from("restore-plan.json"),
2232 OsString::from("--out"),
2233 OsString::from("restore-status.json"),
2234 ])
2235 .expect("parse status options");
2236
2237 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
2238 assert_eq!(options.out, Some(PathBuf::from("restore-status.json")));
2239 }
2240
2241 #[test]
2243 fn parses_restore_apply_dry_run_options() {
2244 let options = RestoreApplyOptions::parse([
2245 OsString::from("--plan"),
2246 OsString::from("restore-plan.json"),
2247 OsString::from("--status"),
2248 OsString::from("restore-status.json"),
2249 OsString::from("--backup-dir"),
2250 OsString::from("backups/run"),
2251 OsString::from("--dry-run"),
2252 OsString::from("--out"),
2253 OsString::from("restore-apply-dry-run.json"),
2254 OsString::from("--journal-out"),
2255 OsString::from("restore-apply-journal.json"),
2256 ])
2257 .expect("parse apply options");
2258
2259 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
2260 assert_eq!(options.status, Some(PathBuf::from("restore-status.json")));
2261 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
2262 assert_eq!(
2263 options.out,
2264 Some(PathBuf::from("restore-apply-dry-run.json"))
2265 );
2266 assert_eq!(
2267 options.journal_out,
2268 Some(PathBuf::from("restore-apply-journal.json"))
2269 );
2270 assert!(options.dry_run);
2271 }
2272
2273 #[test]
2275 fn parses_restore_apply_status_options() {
2276 let options = RestoreApplyStatusOptions::parse([
2277 OsString::from("--journal"),
2278 OsString::from("restore-apply-journal.json"),
2279 OsString::from("--out"),
2280 OsString::from("restore-apply-status.json"),
2281 OsString::from("--require-ready"),
2282 OsString::from("--require-no-pending"),
2283 OsString::from("--require-no-failed"),
2284 OsString::from("--require-complete"),
2285 ])
2286 .expect("parse apply-status options");
2287
2288 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2289 assert!(options.require_ready);
2290 assert!(options.require_no_pending);
2291 assert!(options.require_no_failed);
2292 assert!(options.require_complete);
2293 assert_eq!(
2294 options.out,
2295 Some(PathBuf::from("restore-apply-status.json"))
2296 );
2297 }
2298
2299 #[test]
2301 fn parses_restore_apply_report_options() {
2302 let options = RestoreApplyReportOptions::parse([
2303 OsString::from("--journal"),
2304 OsString::from("restore-apply-journal.json"),
2305 OsString::from("--out"),
2306 OsString::from("restore-apply-report.json"),
2307 OsString::from("--require-no-attention"),
2308 ])
2309 .expect("parse apply-report options");
2310
2311 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2312 assert!(options.require_no_attention);
2313 assert_eq!(
2314 options.out,
2315 Some(PathBuf::from("restore-apply-report.json"))
2316 );
2317 }
2318
2319 #[test]
2321 fn parses_restore_run_dry_run_options() {
2322 let options = RestoreRunOptions::parse([
2323 OsString::from("--journal"),
2324 OsString::from("restore-apply-journal.json"),
2325 OsString::from("--dry-run"),
2326 OsString::from("--dfx"),
2327 OsString::from("/tmp/dfx"),
2328 OsString::from("--network"),
2329 OsString::from("local"),
2330 OsString::from("--out"),
2331 OsString::from("restore-run-dry-run.json"),
2332 OsString::from("--max-steps"),
2333 OsString::from("1"),
2334 OsString::from("--require-complete"),
2335 OsString::from("--require-no-attention"),
2336 OsString::from("--require-run-mode"),
2337 OsString::from("dry-run"),
2338 OsString::from("--require-stopped-reason"),
2339 OsString::from("preview"),
2340 OsString::from("--require-next-action"),
2341 OsString::from("rerun"),
2342 OsString::from("--require-executed-count"),
2343 OsString::from("0"),
2344 ])
2345 .expect("parse restore run options");
2346
2347 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2348 assert_eq!(options.dfx, "/tmp/dfx");
2349 assert_eq!(options.network.as_deref(), Some("local"));
2350 assert_eq!(options.out, Some(PathBuf::from("restore-run-dry-run.json")));
2351 assert!(options.dry_run);
2352 assert!(!options.execute);
2353 assert!(!options.unclaim_pending);
2354 assert_eq!(options.max_steps, Some(1));
2355 assert!(options.require_complete);
2356 assert!(options.require_no_attention);
2357 assert_eq!(options.require_run_mode.as_deref(), Some("dry-run"));
2358 assert_eq!(options.require_stopped_reason.as_deref(), Some("preview"));
2359 assert_eq!(options.require_next_action.as_deref(), Some("rerun"));
2360 assert_eq!(options.require_executed_count, Some(0));
2361 }
2362
2363 #[test]
2365 fn parses_restore_run_execute_options() {
2366 let options = RestoreRunOptions::parse([
2367 OsString::from("--journal"),
2368 OsString::from("restore-apply-journal.json"),
2369 OsString::from("--execute"),
2370 OsString::from("--dfx"),
2371 OsString::from("/bin/true"),
2372 OsString::from("--max-steps"),
2373 OsString::from("4"),
2374 ])
2375 .expect("parse restore run execute options");
2376
2377 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2378 assert_eq!(options.dfx, "/bin/true");
2379 assert_eq!(options.network, None);
2380 assert_eq!(options.out, None);
2381 assert!(!options.dry_run);
2382 assert!(options.execute);
2383 assert!(!options.unclaim_pending);
2384 assert_eq!(options.max_steps, Some(4));
2385 assert!(!options.require_complete);
2386 assert!(!options.require_no_attention);
2387 assert_eq!(options.require_run_mode, None);
2388 assert_eq!(options.require_stopped_reason, None);
2389 assert_eq!(options.require_next_action, None);
2390 assert_eq!(options.require_executed_count, None);
2391 }
2392
2393 #[test]
2395 fn parses_restore_run_unclaim_pending_options() {
2396 let options = RestoreRunOptions::parse([
2397 OsString::from("--journal"),
2398 OsString::from("restore-apply-journal.json"),
2399 OsString::from("--unclaim-pending"),
2400 OsString::from("--out"),
2401 OsString::from("restore-run.json"),
2402 ])
2403 .expect("parse restore run unclaim options");
2404
2405 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2406 assert_eq!(options.out, Some(PathBuf::from("restore-run.json")));
2407 assert!(!options.dry_run);
2408 assert!(!options.execute);
2409 assert!(options.unclaim_pending);
2410 }
2411
2412 #[test]
2414 fn parses_restore_apply_next_options() {
2415 let options = RestoreApplyNextOptions::parse([
2416 OsString::from("--journal"),
2417 OsString::from("restore-apply-journal.json"),
2418 OsString::from("--out"),
2419 OsString::from("restore-apply-next.json"),
2420 ])
2421 .expect("parse apply-next options");
2422
2423 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2424 assert_eq!(options.out, Some(PathBuf::from("restore-apply-next.json")));
2425 }
2426
2427 #[test]
2429 fn parses_restore_apply_command_options() {
2430 let options = RestoreApplyCommandOptions::parse([
2431 OsString::from("--journal"),
2432 OsString::from("restore-apply-journal.json"),
2433 OsString::from("--dfx"),
2434 OsString::from("/tmp/dfx"),
2435 OsString::from("--network"),
2436 OsString::from("local"),
2437 OsString::from("--out"),
2438 OsString::from("restore-apply-command.json"),
2439 OsString::from("--require-command"),
2440 ])
2441 .expect("parse apply-command options");
2442
2443 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2444 assert_eq!(options.dfx, "/tmp/dfx");
2445 assert_eq!(options.network.as_deref(), Some("local"));
2446 assert!(options.require_command);
2447 assert_eq!(
2448 options.out,
2449 Some(PathBuf::from("restore-apply-command.json"))
2450 );
2451 }
2452
2453 #[test]
2455 fn parses_restore_apply_claim_options() {
2456 let options = RestoreApplyClaimOptions::parse([
2457 OsString::from("--journal"),
2458 OsString::from("restore-apply-journal.json"),
2459 OsString::from("--sequence"),
2460 OsString::from("0"),
2461 OsString::from("--updated-at"),
2462 OsString::from("2026-05-04T12:00:00Z"),
2463 OsString::from("--out"),
2464 OsString::from("restore-apply-journal.claimed.json"),
2465 ])
2466 .expect("parse apply-claim options");
2467
2468 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2469 assert_eq!(options.sequence, Some(0));
2470 assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:00:00Z"));
2471 assert_eq!(
2472 options.out,
2473 Some(PathBuf::from("restore-apply-journal.claimed.json"))
2474 );
2475 }
2476
2477 #[test]
2479 fn parses_restore_apply_unclaim_options() {
2480 let options = RestoreApplyUnclaimOptions::parse([
2481 OsString::from("--journal"),
2482 OsString::from("restore-apply-journal.json"),
2483 OsString::from("--sequence"),
2484 OsString::from("0"),
2485 OsString::from("--updated-at"),
2486 OsString::from("2026-05-04T12:01:00Z"),
2487 OsString::from("--out"),
2488 OsString::from("restore-apply-journal.unclaimed.json"),
2489 ])
2490 .expect("parse apply-unclaim options");
2491
2492 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2493 assert_eq!(options.sequence, Some(0));
2494 assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:01:00Z"));
2495 assert_eq!(
2496 options.out,
2497 Some(PathBuf::from("restore-apply-journal.unclaimed.json"))
2498 );
2499 }
2500
2501 #[test]
2503 fn parses_restore_apply_mark_options() {
2504 let options = RestoreApplyMarkOptions::parse([
2505 OsString::from("--journal"),
2506 OsString::from("restore-apply-journal.json"),
2507 OsString::from("--sequence"),
2508 OsString::from("4"),
2509 OsString::from("--state"),
2510 OsString::from("failed"),
2511 OsString::from("--reason"),
2512 OsString::from("dfx-load-failed"),
2513 OsString::from("--updated-at"),
2514 OsString::from("2026-05-04T12:02:00Z"),
2515 OsString::from("--out"),
2516 OsString::from("restore-apply-journal.updated.json"),
2517 OsString::from("--require-pending"),
2518 ])
2519 .expect("parse apply-mark options");
2520
2521 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
2522 assert_eq!(options.sequence, 4);
2523 assert_eq!(options.state, RestoreApplyMarkState::Failed);
2524 assert_eq!(options.reason.as_deref(), Some("dfx-load-failed"));
2525 assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:02:00Z"));
2526 assert!(options.require_pending);
2527 assert_eq!(
2528 options.out,
2529 Some(PathBuf::from("restore-apply-journal.updated.json"))
2530 );
2531 }
2532
2533 #[test]
2535 fn restore_apply_requires_dry_run() {
2536 let err = RestoreApplyOptions::parse([
2537 OsString::from("--plan"),
2538 OsString::from("restore-plan.json"),
2539 ])
2540 .expect_err("apply without dry-run should fail");
2541
2542 assert!(matches!(err, RestoreCommandError::ApplyRequiresDryRun));
2543 }
2544
2545 #[test]
2547 fn restore_run_requires_mode() {
2548 let err = RestoreRunOptions::parse([
2549 OsString::from("--journal"),
2550 OsString::from("restore-apply-journal.json"),
2551 ])
2552 .expect_err("restore run without dry-run should fail");
2553
2554 assert!(matches!(err, RestoreCommandError::RestoreRunRequiresMode));
2555 }
2556
2557 #[test]
2559 fn restore_run_rejects_conflicting_modes() {
2560 let err = RestoreRunOptions::parse([
2561 OsString::from("--journal"),
2562 OsString::from("restore-apply-journal.json"),
2563 OsString::from("--dry-run"),
2564 OsString::from("--execute"),
2565 OsString::from("--unclaim-pending"),
2566 ])
2567 .expect_err("restore run should reject conflicting modes");
2568
2569 assert!(matches!(
2570 err,
2571 RestoreCommandError::RestoreRunConflictingModes
2572 ));
2573 }
2574
2575 #[test]
2577 fn plan_restore_reads_manifest_from_backup_dir() {
2578 let root = temp_dir("canic-cli-restore-plan-layout");
2579 let layout = BackupLayout::new(root.clone());
2580 layout
2581 .write_manifest(&valid_manifest())
2582 .expect("write manifest");
2583
2584 let options = RestorePlanOptions {
2585 manifest: None,
2586 backup_dir: Some(root.clone()),
2587 mapping: None,
2588 out: None,
2589 require_verified: false,
2590 require_restore_ready: false,
2591 };
2592
2593 let plan = plan_restore(&options).expect("plan restore");
2594
2595 fs::remove_dir_all(root).expect("remove temp root");
2596 assert_eq!(plan.backup_id, "backup-test");
2597 assert_eq!(plan.member_count, 2);
2598 }
2599
2600 #[test]
2602 fn parse_rejects_conflicting_manifest_sources() {
2603 let err = RestorePlanOptions::parse([
2604 OsString::from("--manifest"),
2605 OsString::from("manifest.json"),
2606 OsString::from("--backup-dir"),
2607 OsString::from("backups/run"),
2608 ])
2609 .expect_err("conflicting sources should fail");
2610
2611 assert!(matches!(
2612 err,
2613 RestoreCommandError::ConflictingManifestSources
2614 ));
2615 }
2616
2617 #[test]
2619 fn parse_rejects_require_verified_with_manifest_source() {
2620 let err = RestorePlanOptions::parse([
2621 OsString::from("--manifest"),
2622 OsString::from("manifest.json"),
2623 OsString::from("--require-verified"),
2624 ])
2625 .expect_err("verification should require a backup layout");
2626
2627 assert!(matches!(
2628 err,
2629 RestoreCommandError::RequireVerifiedNeedsBackupDir
2630 ));
2631 }
2632
2633 #[test]
2635 fn plan_restore_requires_verified_backup_layout() {
2636 let root = temp_dir("canic-cli-restore-plan-verified");
2637 let layout = BackupLayout::new(root.clone());
2638 let manifest = valid_manifest();
2639 write_verified_layout(&root, &layout, &manifest);
2640
2641 let options = RestorePlanOptions {
2642 manifest: None,
2643 backup_dir: Some(root.clone()),
2644 mapping: None,
2645 out: None,
2646 require_verified: true,
2647 require_restore_ready: false,
2648 };
2649
2650 let plan = plan_restore(&options).expect("plan verified restore");
2651
2652 fs::remove_dir_all(root).expect("remove temp root");
2653 assert_eq!(plan.backup_id, "backup-test");
2654 assert_eq!(plan.member_count, 2);
2655 }
2656
2657 #[test]
2659 fn plan_restore_rejects_unverified_backup_layout() {
2660 let root = temp_dir("canic-cli-restore-plan-unverified");
2661 let layout = BackupLayout::new(root.clone());
2662 layout
2663 .write_manifest(&valid_manifest())
2664 .expect("write manifest");
2665
2666 let options = RestorePlanOptions {
2667 manifest: None,
2668 backup_dir: Some(root.clone()),
2669 mapping: None,
2670 out: None,
2671 require_verified: true,
2672 require_restore_ready: false,
2673 };
2674
2675 let err = plan_restore(&options).expect_err("missing journal should fail");
2676
2677 fs::remove_dir_all(root).expect("remove temp root");
2678 assert!(matches!(err, RestoreCommandError::Persistence(_)));
2679 }
2680
2681 #[test]
2683 fn plan_restore_reads_manifest_and_mapping() {
2684 let root = temp_dir("canic-cli-restore-plan");
2685 fs::create_dir_all(&root).expect("create temp root");
2686 let manifest_path = root.join("manifest.json");
2687 let mapping_path = root.join("mapping.json");
2688
2689 fs::write(
2690 &manifest_path,
2691 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
2692 )
2693 .expect("write manifest");
2694 fs::write(
2695 &mapping_path,
2696 json!({
2697 "members": [
2698 {"source_canister": ROOT, "target_canister": ROOT},
2699 {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
2700 ]
2701 })
2702 .to_string(),
2703 )
2704 .expect("write mapping");
2705
2706 let options = RestorePlanOptions {
2707 manifest: Some(manifest_path),
2708 backup_dir: None,
2709 mapping: Some(mapping_path),
2710 out: None,
2711 require_verified: false,
2712 require_restore_ready: false,
2713 };
2714
2715 let plan = plan_restore(&options).expect("plan restore");
2716
2717 fs::remove_dir_all(root).expect("remove temp root");
2718 let members = plan.ordered_members();
2719 assert_eq!(members.len(), 2);
2720 assert_eq!(members[0].source_canister, ROOT);
2721 assert_eq!(members[1].target_canister, MAPPED_CHILD);
2722 }
2723
2724 #[test]
2726 fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
2727 let root = temp_dir("canic-cli-restore-plan-require-ready");
2728 fs::create_dir_all(&root).expect("create temp root");
2729 let manifest_path = root.join("manifest.json");
2730 let out_path = root.join("plan.json");
2731
2732 fs::write(
2733 &manifest_path,
2734 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
2735 )
2736 .expect("write manifest");
2737
2738 let err = run([
2739 OsString::from("plan"),
2740 OsString::from("--manifest"),
2741 OsString::from(manifest_path.as_os_str()),
2742 OsString::from("--out"),
2743 OsString::from(out_path.as_os_str()),
2744 OsString::from("--require-restore-ready"),
2745 ])
2746 .expect_err("restore readiness should be enforced");
2747
2748 assert!(out_path.exists());
2749 let plan: RestorePlan =
2750 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
2751
2752 fs::remove_dir_all(root).expect("remove temp root");
2753 assert!(!plan.readiness_summary.ready);
2754 assert!(matches!(
2755 err,
2756 RestoreCommandError::RestoreNotReady {
2757 reasons,
2758 ..
2759 } if reasons == [
2760 "missing-module-hash",
2761 "missing-wasm-hash",
2762 "missing-snapshot-checksum"
2763 ]
2764 ));
2765 }
2766
2767 #[test]
2769 fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
2770 let root = temp_dir("canic-cli-restore-plan-ready");
2771 fs::create_dir_all(&root).expect("create temp root");
2772 let manifest_path = root.join("manifest.json");
2773 let out_path = root.join("plan.json");
2774
2775 fs::write(
2776 &manifest_path,
2777 serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
2778 )
2779 .expect("write manifest");
2780
2781 run([
2782 OsString::from("plan"),
2783 OsString::from("--manifest"),
2784 OsString::from(manifest_path.as_os_str()),
2785 OsString::from("--out"),
2786 OsString::from(out_path.as_os_str()),
2787 OsString::from("--require-restore-ready"),
2788 ])
2789 .expect("restore-ready plan should pass");
2790
2791 let plan: RestorePlan =
2792 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
2793
2794 fs::remove_dir_all(root).expect("remove temp root");
2795 assert!(plan.readiness_summary.ready);
2796 assert!(plan.readiness_summary.reasons.is_empty());
2797 }
2798
2799 #[test]
2801 fn run_restore_status_writes_planned_status() {
2802 let root = temp_dir("canic-cli-restore-status");
2803 fs::create_dir_all(&root).expect("create temp root");
2804 let plan_path = root.join("restore-plan.json");
2805 let out_path = root.join("restore-status.json");
2806 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
2807
2808 fs::write(
2809 &plan_path,
2810 serde_json::to_vec(&plan).expect("serialize plan"),
2811 )
2812 .expect("write plan");
2813
2814 run([
2815 OsString::from("status"),
2816 OsString::from("--plan"),
2817 OsString::from(plan_path.as_os_str()),
2818 OsString::from("--out"),
2819 OsString::from(out_path.as_os_str()),
2820 ])
2821 .expect("write restore status");
2822
2823 let status: RestoreStatus =
2824 serde_json::from_slice(&fs::read(&out_path).expect("read restore status"))
2825 .expect("decode restore status");
2826 let status_json: serde_json::Value = serde_json::to_value(&status).expect("encode status");
2827
2828 fs::remove_dir_all(root).expect("remove temp root");
2829 assert_eq!(status.status_version, 1);
2830 assert_eq!(status.backup_id.as_str(), "backup-test");
2831 assert!(status.ready);
2832 assert!(status.readiness_reasons.is_empty());
2833 assert_eq!(status.member_count, 2);
2834 assert_eq!(status.phase_count, 1);
2835 assert_eq!(status.planned_snapshot_uploads, 2);
2836 assert_eq!(status.planned_snapshot_loads, 2);
2837 assert_eq!(status.planned_code_reinstalls, 2);
2838 assert_eq!(status.planned_verification_checks, 2);
2839 assert_eq!(status.planned_operations, 8);
2840 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
2841 assert_eq!(status_json["phases"][0]["members"][0]["state"], "planned");
2842 }
2843
2844 #[test]
2846 fn run_restore_apply_dry_run_writes_operations() {
2847 let root = temp_dir("canic-cli-restore-apply-dry-run");
2848 fs::create_dir_all(&root).expect("create temp root");
2849 let plan_path = root.join("restore-plan.json");
2850 let status_path = root.join("restore-status.json");
2851 let out_path = root.join("restore-apply-dry-run.json");
2852 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
2853 let status = RestoreStatus::from_plan(&plan);
2854
2855 fs::write(
2856 &plan_path,
2857 serde_json::to_vec(&plan).expect("serialize plan"),
2858 )
2859 .expect("write plan");
2860 fs::write(
2861 &status_path,
2862 serde_json::to_vec(&status).expect("serialize status"),
2863 )
2864 .expect("write status");
2865
2866 run([
2867 OsString::from("apply"),
2868 OsString::from("--plan"),
2869 OsString::from(plan_path.as_os_str()),
2870 OsString::from("--status"),
2871 OsString::from(status_path.as_os_str()),
2872 OsString::from("--dry-run"),
2873 OsString::from("--out"),
2874 OsString::from(out_path.as_os_str()),
2875 ])
2876 .expect("write apply dry-run");
2877
2878 let dry_run: RestoreApplyDryRun =
2879 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
2880 .expect("decode dry-run");
2881 let dry_run_json: serde_json::Value =
2882 serde_json::to_value(&dry_run).expect("encode dry-run");
2883
2884 fs::remove_dir_all(root).expect("remove temp root");
2885 assert_eq!(dry_run.dry_run_version, 1);
2886 assert_eq!(dry_run.backup_id.as_str(), "backup-test");
2887 assert!(dry_run.ready);
2888 assert!(dry_run.status_supplied);
2889 assert_eq!(dry_run.member_count, 2);
2890 assert_eq!(dry_run.phase_count, 1);
2891 assert_eq!(dry_run.planned_snapshot_uploads, 2);
2892 assert_eq!(dry_run.planned_operations, 8);
2893 assert_eq!(dry_run.rendered_operations, 8);
2894 assert_eq!(dry_run_json["operation_counts"]["snapshot_uploads"], 2);
2895 assert_eq!(dry_run_json["operation_counts"]["snapshot_loads"], 2);
2896 assert_eq!(dry_run_json["operation_counts"]["code_reinstalls"], 2);
2897 assert_eq!(dry_run_json["operation_counts"]["member_verifications"], 2);
2898 assert_eq!(dry_run_json["operation_counts"]["fleet_verifications"], 0);
2899 assert_eq!(
2900 dry_run_json["operation_counts"]["verification_operations"],
2901 2
2902 );
2903 assert_eq!(
2904 dry_run_json["phases"][0]["operations"][0]["operation"],
2905 "upload-snapshot"
2906 );
2907 assert_eq!(
2908 dry_run_json["phases"][0]["operations"][3]["operation"],
2909 "verify-member"
2910 );
2911 assert_eq!(
2912 dry_run_json["phases"][0]["operations"][3]["verification_kind"],
2913 "status"
2914 );
2915 assert_eq!(
2916 dry_run_json["phases"][0]["operations"][3]["verification_method"],
2917 serde_json::Value::Null
2918 );
2919 }
2920
2921 #[test]
2923 fn run_restore_apply_dry_run_validates_backup_dir_artifacts() {
2924 let root = temp_dir("canic-cli-restore-apply-artifacts");
2925 fs::create_dir_all(&root).expect("create temp root");
2926 let plan_path = root.join("restore-plan.json");
2927 let out_path = root.join("restore-apply-dry-run.json");
2928 let journal_path = root.join("restore-apply-journal.json");
2929 let status_path = root.join("restore-apply-status.json");
2930 let mut manifest = restore_ready_manifest();
2931 write_manifest_artifacts(&root, &mut manifest);
2932 let plan = RestorePlanner::plan(&manifest, None).expect("build plan");
2933
2934 fs::write(
2935 &plan_path,
2936 serde_json::to_vec(&plan).expect("serialize plan"),
2937 )
2938 .expect("write plan");
2939
2940 run([
2941 OsString::from("apply"),
2942 OsString::from("--plan"),
2943 OsString::from(plan_path.as_os_str()),
2944 OsString::from("--backup-dir"),
2945 OsString::from(root.as_os_str()),
2946 OsString::from("--dry-run"),
2947 OsString::from("--out"),
2948 OsString::from(out_path.as_os_str()),
2949 OsString::from("--journal-out"),
2950 OsString::from(journal_path.as_os_str()),
2951 ])
2952 .expect("write apply dry-run");
2953 run([
2954 OsString::from("apply-status"),
2955 OsString::from("--journal"),
2956 OsString::from(journal_path.as_os_str()),
2957 OsString::from("--out"),
2958 OsString::from(status_path.as_os_str()),
2959 ])
2960 .expect("write apply status");
2961
2962 let dry_run: RestoreApplyDryRun =
2963 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
2964 .expect("decode dry-run");
2965 let validation = dry_run
2966 .artifact_validation
2967 .expect("artifact validation should be present");
2968 let journal_json: serde_json::Value =
2969 serde_json::from_slice(&fs::read(&journal_path).expect("read journal"))
2970 .expect("decode journal");
2971 let status_json: serde_json::Value =
2972 serde_json::from_slice(&fs::read(&status_path).expect("read apply status"))
2973 .expect("decode apply status");
2974
2975 fs::remove_dir_all(root).expect("remove temp root");
2976 assert_eq!(validation.checked_members, 2);
2977 assert!(validation.artifacts_present);
2978 assert!(validation.checksums_verified);
2979 assert_eq!(validation.members_with_expected_checksums, 2);
2980 assert_eq!(journal_json["ready"], true);
2981 assert_eq!(journal_json["operation_count"], 8);
2982 assert_eq!(journal_json["operation_counts"]["snapshot_uploads"], 2);
2983 assert_eq!(journal_json["operation_counts"]["snapshot_loads"], 2);
2984 assert_eq!(journal_json["operation_counts"]["code_reinstalls"], 2);
2985 assert_eq!(journal_json["operation_counts"]["member_verifications"], 2);
2986 assert_eq!(journal_json["operation_counts"]["fleet_verifications"], 0);
2987 assert_eq!(
2988 journal_json["operation_counts"]["verification_operations"],
2989 2
2990 );
2991 assert_eq!(journal_json["ready_operations"], 8);
2992 assert_eq!(journal_json["blocked_operations"], 0);
2993 assert_eq!(journal_json["operations"][0]["state"], "ready");
2994 assert_eq!(status_json["ready"], true);
2995 assert_eq!(status_json["operation_count"], 8);
2996 assert_eq!(status_json["operation_counts"]["snapshot_uploads"], 2);
2997 assert_eq!(status_json["operation_counts"]["snapshot_loads"], 2);
2998 assert_eq!(status_json["operation_counts"]["code_reinstalls"], 2);
2999 assert_eq!(status_json["operation_counts"]["member_verifications"], 2);
3000 assert_eq!(status_json["operation_counts"]["fleet_verifications"], 0);
3001 assert_eq!(
3002 status_json["operation_counts"]["verification_operations"],
3003 2
3004 );
3005 assert_eq!(status_json["operation_counts_supplied"], true);
3006 assert_eq!(status_json["progress"]["operation_count"], 8);
3007 assert_eq!(status_json["progress"]["completed_operations"], 0);
3008 assert_eq!(status_json["progress"]["remaining_operations"], 8);
3009 assert_eq!(status_json["progress"]["transitionable_operations"], 8);
3010 assert_eq!(status_json["progress"]["attention_operations"], 0);
3011 assert_eq!(status_json["progress"]["completion_basis_points"], 0);
3012 assert_eq!(status_json["next_ready_sequence"], 0);
3013 assert_eq!(status_json["next_ready_operation"], "upload-snapshot");
3014 }
3015
3016 #[test]
3018 fn run_restore_apply_status_rejects_invalid_journal() {
3019 let root = temp_dir("canic-cli-restore-apply-status-invalid");
3020 fs::create_dir_all(&root).expect("create temp root");
3021 let journal_path = root.join("restore-apply-journal.json");
3022 let out_path = root.join("restore-apply-status.json");
3023 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
3024 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
3025 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
3026 journal.operation_count += 1;
3027
3028 fs::write(
3029 &journal_path,
3030 serde_json::to_vec(&journal).expect("serialize journal"),
3031 )
3032 .expect("write journal");
3033
3034 let err = run([
3035 OsString::from("apply-status"),
3036 OsString::from("--journal"),
3037 OsString::from(journal_path.as_os_str()),
3038 OsString::from("--out"),
3039 OsString::from(out_path.as_os_str()),
3040 ])
3041 .expect_err("invalid journal should fail");
3042
3043 assert!(!out_path.exists());
3044 fs::remove_dir_all(root).expect("remove temp root");
3045 assert!(matches!(
3046 err,
3047 RestoreCommandError::RestoreApplyJournal(RestoreApplyJournalError::CountMismatch {
3048 field: "operation_count",
3049 ..
3050 })
3051 ));
3052 }
3053
3054 #[test]
3056 fn run_restore_apply_status_require_no_pending_writes_status_then_fails() {
3057 let root = temp_dir("canic-cli-restore-apply-status-pending");
3058 fs::create_dir_all(&root).expect("create temp root");
3059 let journal_path = root.join("restore-apply-journal.json");
3060 let out_path = root.join("restore-apply-status.json");
3061 let mut journal = ready_apply_journal();
3062 journal
3063 .mark_next_operation_pending_at(Some("2026-05-04T12:00:00Z".to_string()))
3064 .expect("claim operation");
3065
3066 fs::write(
3067 &journal_path,
3068 serde_json::to_vec(&journal).expect("serialize journal"),
3069 )
3070 .expect("write journal");
3071
3072 let err = run([
3073 OsString::from("apply-status"),
3074 OsString::from("--journal"),
3075 OsString::from(journal_path.as_os_str()),
3076 OsString::from("--out"),
3077 OsString::from(out_path.as_os_str()),
3078 OsString::from("--require-no-pending"),
3079 ])
3080 .expect_err("pending operation should fail requirement");
3081
3082 assert!(out_path.exists());
3083 let status: RestoreApplyJournalStatus =
3084 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
3085 .expect("decode apply status");
3086
3087 fs::remove_dir_all(root).expect("remove temp root");
3088 assert_eq!(status.pending_operations, 1);
3089 assert_eq!(status.next_transition_sequence, Some(0));
3090 assert_eq!(
3091 status.next_transition_updated_at.as_deref(),
3092 Some("2026-05-04T12:00:00Z")
3093 );
3094 assert!(matches!(
3095 err,
3096 RestoreCommandError::RestoreApplyPending {
3097 pending_operations: 1,
3098 next_transition_sequence: Some(0),
3099 ..
3100 }
3101 ));
3102 }
3103
3104 #[test]
3106 fn run_restore_apply_status_require_ready_writes_status_then_fails() {
3107 let root = temp_dir("canic-cli-restore-apply-status-ready");
3108 fs::create_dir_all(&root).expect("create temp root");
3109 let journal_path = root.join("restore-apply-journal.json");
3110 let out_path = root.join("restore-apply-status.json");
3111 let plan = RestorePlanner::plan(&valid_manifest(), None).expect("build plan");
3112 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
3113 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
3114
3115 fs::write(
3116 &journal_path,
3117 serde_json::to_vec(&journal).expect("serialize journal"),
3118 )
3119 .expect("write journal");
3120
3121 let err = run([
3122 OsString::from("apply-status"),
3123 OsString::from("--journal"),
3124 OsString::from(journal_path.as_os_str()),
3125 OsString::from("--out"),
3126 OsString::from(out_path.as_os_str()),
3127 OsString::from("--require-ready"),
3128 ])
3129 .expect_err("unready journal should fail requirement");
3130
3131 let status: RestoreApplyJournalStatus =
3132 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
3133 .expect("decode apply status");
3134
3135 fs::remove_dir_all(root).expect("remove temp root");
3136 assert!(!status.ready);
3137 assert_eq!(status.blocked_operations, status.operation_count);
3138 assert!(
3139 status
3140 .blocked_reasons
3141 .contains(&"missing-snapshot-checksum".to_string())
3142 );
3143 assert!(matches!(
3144 err,
3145 RestoreCommandError::RestoreApplyNotReady { reasons, .. }
3146 if reasons.contains(&"missing-snapshot-checksum".to_string())
3147 ));
3148 }
3149
3150 #[test]
3152 fn run_restore_apply_report_writes_attention_summary() {
3153 let root = temp_dir("canic-cli-restore-apply-report");
3154 fs::create_dir_all(&root).expect("create temp root");
3155 let journal_path = root.join("restore-apply-journal.json");
3156 let out_path = root.join("restore-apply-report.json");
3157 let mut journal = ready_apply_journal();
3158 journal
3159 .mark_operation_failed_at(
3160 0,
3161 "dfx-upload-failed".to_string(),
3162 Some("2026-05-05T12:00:00Z".to_string()),
3163 )
3164 .expect("mark failed operation");
3165 journal
3166 .mark_next_operation_pending_at(Some("2026-05-05T12:01:00Z".to_string()))
3167 .expect("mark pending operation");
3168
3169 fs::write(
3170 &journal_path,
3171 serde_json::to_vec(&journal).expect("serialize journal"),
3172 )
3173 .expect("write journal");
3174
3175 run([
3176 OsString::from("apply-report"),
3177 OsString::from("--journal"),
3178 OsString::from(journal_path.as_os_str()),
3179 OsString::from("--out"),
3180 OsString::from(out_path.as_os_str()),
3181 ])
3182 .expect("write apply report");
3183
3184 let report: RestoreApplyJournalReport =
3185 serde_json::from_slice(&fs::read(&out_path).expect("read apply report"))
3186 .expect("decode apply report");
3187 let report_json: serde_json::Value =
3188 serde_json::to_value(&report).expect("encode apply report");
3189
3190 fs::remove_dir_all(root).expect("remove temp root");
3191 assert_eq!(report.backup_id, "backup-test");
3192 assert!(report.attention_required);
3193 assert_eq!(report.failed_operations, 1);
3194 assert_eq!(report.pending_operations, 1);
3195 assert_eq!(report.operation_counts.snapshot_uploads, 2);
3196 assert_eq!(report.operation_counts.snapshot_loads, 2);
3197 assert_eq!(report.operation_counts.code_reinstalls, 2);
3198 assert_eq!(report.operation_counts.member_verifications, 2);
3199 assert_eq!(report.operation_counts.fleet_verifications, 0);
3200 assert_eq!(report.operation_counts.verification_operations, 2);
3201 assert!(report.operation_counts_supplied);
3202 assert_eq!(report.progress.operation_count, 8);
3203 assert_eq!(report.progress.completed_operations, 0);
3204 assert_eq!(report.progress.remaining_operations, 8);
3205 assert_eq!(report.progress.transitionable_operations, 7);
3206 assert_eq!(report.progress.attention_operations, 2);
3207 assert_eq!(report.progress.completion_basis_points, 0);
3208 assert_eq!(report.failed.len(), 1);
3209 assert_eq!(report.pending.len(), 1);
3210 assert_eq!(report.failed[0].sequence, 0);
3211 assert_eq!(report.pending[0].sequence, 1);
3212 assert_eq!(
3213 report.next_transition.as_ref().map(|op| op.sequence),
3214 Some(1)
3215 );
3216 assert_eq!(report_json["outcome"], "failed");
3217 assert_eq!(report_json["failed"][0]["reasons"][0], "dfx-upload-failed");
3218 }
3219
3220 #[test]
3222 fn run_restore_run_dry_run_writes_native_runner_preview() {
3223 let root = temp_dir("canic-cli-restore-run-dry-run");
3224 fs::create_dir_all(&root).expect("create temp root");
3225 let journal_path = root.join("restore-apply-journal.json");
3226 let out_path = root.join("restore-run-dry-run.json");
3227 let journal = ready_apply_journal();
3228
3229 fs::write(
3230 &journal_path,
3231 serde_json::to_vec(&journal).expect("serialize journal"),
3232 )
3233 .expect("write journal");
3234
3235 run([
3236 OsString::from("run"),
3237 OsString::from("--journal"),
3238 OsString::from(journal_path.as_os_str()),
3239 OsString::from("--dry-run"),
3240 OsString::from("--dfx"),
3241 OsString::from("/tmp/dfx"),
3242 OsString::from("--network"),
3243 OsString::from("local"),
3244 OsString::from("--out"),
3245 OsString::from(out_path.as_os_str()),
3246 ])
3247 .expect("write restore run dry-run");
3248
3249 let dry_run: serde_json::Value =
3250 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
3251 .expect("decode dry-run");
3252
3253 fs::remove_dir_all(root).expect("remove temp root");
3254 assert_eq!(dry_run["run_version"], 1);
3255 assert_eq!(dry_run["backup_id"], "backup-test");
3256 assert_eq!(dry_run["run_mode"], "dry-run");
3257 assert_eq!(dry_run["dry_run"], true);
3258 assert_eq!(dry_run["ready"], true);
3259 assert_eq!(dry_run["complete"], false);
3260 assert_eq!(dry_run["attention_required"], false);
3261 assert_eq!(dry_run["operation_counts"]["snapshot_uploads"], 2);
3262 assert_eq!(dry_run["operation_counts"]["snapshot_loads"], 2);
3263 assert_eq!(dry_run["operation_counts"]["code_reinstalls"], 2);
3264 assert_eq!(dry_run["operation_counts"]["member_verifications"], 2);
3265 assert_eq!(dry_run["operation_counts"]["fleet_verifications"], 0);
3266 assert_eq!(dry_run["operation_counts"]["verification_operations"], 2);
3267 assert_eq!(dry_run["operation_counts_supplied"], true);
3268 assert_eq!(dry_run["progress"]["operation_count"], 8);
3269 assert_eq!(dry_run["progress"]["completed_operations"], 0);
3270 assert_eq!(dry_run["progress"]["remaining_operations"], 8);
3271 assert_eq!(dry_run["progress"]["transitionable_operations"], 8);
3272 assert_eq!(dry_run["progress"]["attention_operations"], 0);
3273 assert_eq!(dry_run["progress"]["completion_basis_points"], 0);
3274 assert_eq!(dry_run["stopped_reason"], "preview");
3275 assert_eq!(dry_run["next_action"], "rerun");
3276 assert_eq!(dry_run["operation_available"], true);
3277 assert_eq!(dry_run["command_available"], true);
3278 assert_eq!(dry_run["next_transition"]["sequence"], 0);
3279 assert_eq!(dry_run["command"]["program"], "/tmp/dfx");
3280 assert_eq!(
3281 dry_run["command"]["args"],
3282 json!([
3283 "canister",
3284 "--network",
3285 "local",
3286 "snapshot",
3287 "upload",
3288 "--dir",
3289 "artifacts/root",
3290 ROOT
3291 ])
3292 );
3293 assert_eq!(dry_run["command"]["mutates"], true);
3294 }
3295
3296 #[test]
3298 fn run_restore_run_unclaim_pending_marks_operation_ready() {
3299 let root = temp_dir("canic-cli-restore-run-unclaim-pending");
3300 fs::create_dir_all(&root).expect("create temp root");
3301 let journal_path = root.join("restore-apply-journal.json");
3302 let out_path = root.join("restore-run.json");
3303 let mut journal = ready_apply_journal();
3304 journal
3305 .mark_next_operation_pending_at(Some("2026-05-05T12:01:00Z".to_string()))
3306 .expect("mark pending operation");
3307
3308 fs::write(
3309 &journal_path,
3310 serde_json::to_vec(&journal).expect("serialize journal"),
3311 )
3312 .expect("write journal");
3313
3314 run([
3315 OsString::from("run"),
3316 OsString::from("--journal"),
3317 OsString::from(journal_path.as_os_str()),
3318 OsString::from("--unclaim-pending"),
3319 OsString::from("--out"),
3320 OsString::from(out_path.as_os_str()),
3321 ])
3322 .expect("unclaim pending operation");
3323
3324 let run_summary: serde_json::Value =
3325 serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3326 .expect("decode run summary");
3327 let updated: RestoreApplyJournal =
3328 serde_json::from_slice(&fs::read(&journal_path).expect("read updated journal"))
3329 .expect("decode updated journal");
3330
3331 fs::remove_dir_all(root).expect("remove temp root");
3332 assert_eq!(run_summary["run_mode"], "unclaim-pending");
3333 assert_eq!(run_summary["unclaim_pending"], true);
3334 assert_eq!(run_summary["stopped_reason"], "recovered-pending");
3335 assert_eq!(run_summary["next_action"], "rerun");
3336 assert_eq!(run_summary["recovered_operation"]["sequence"], 0);
3337 assert_eq!(run_summary["recovered_operation"]["state"], "pending");
3338 assert_eq!(run_summary["pending_operations"], 0);
3339 assert_eq!(run_summary["ready_operations"], 8);
3340 assert_eq!(run_summary["attention_required"], false);
3341 assert_eq!(updated.pending_operations, 0);
3342 assert_eq!(updated.ready_operations, 8);
3343 assert_eq!(
3344 updated.operations[0].state,
3345 RestoreApplyOperationState::Ready
3346 );
3347 }
3348
3349 #[test]
3351 fn run_restore_run_execute_marks_completed_operation() {
3352 let root = temp_dir("canic-cli-restore-run-execute");
3353 fs::create_dir_all(&root).expect("create temp root");
3354 let journal_path = root.join("restore-apply-journal.json");
3355 let out_path = root.join("restore-run.json");
3356 let journal = ready_apply_journal();
3357
3358 fs::write(
3359 &journal_path,
3360 serde_json::to_vec(&journal).expect("serialize journal"),
3361 )
3362 .expect("write journal");
3363
3364 run([
3365 OsString::from("run"),
3366 OsString::from("--journal"),
3367 OsString::from(journal_path.as_os_str()),
3368 OsString::from("--execute"),
3369 OsString::from("--dfx"),
3370 OsString::from("/bin/true"),
3371 OsString::from("--max-steps"),
3372 OsString::from("1"),
3373 OsString::from("--out"),
3374 OsString::from(out_path.as_os_str()),
3375 ])
3376 .expect("execute one restore run step");
3377
3378 let run_summary: serde_json::Value =
3379 serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3380 .expect("decode run summary");
3381 let updated: RestoreApplyJournal =
3382 serde_json::from_slice(&fs::read(&journal_path).expect("read updated journal"))
3383 .expect("decode updated journal");
3384
3385 fs::remove_dir_all(root).expect("remove temp root");
3386 assert_eq!(run_summary["run_mode"], "execute");
3387 assert_eq!(run_summary["execute"], true);
3388 assert_eq!(run_summary["dry_run"], false);
3389 assert_eq!(run_summary["max_steps_reached"], true);
3390 assert_eq!(run_summary["stopped_reason"], "max-steps-reached");
3391 assert_eq!(run_summary["next_action"], "rerun");
3392 assert_eq!(run_summary["executed_operation_count"], 1);
3393 assert_eq!(run_summary["executed_operations"][0]["sequence"], 0);
3394 assert_eq!(
3395 run_summary["executed_operations"][0]["command"]["program"],
3396 "/bin/true"
3397 );
3398 assert_eq!(updated.completed_operations, 1);
3399 assert_eq!(updated.pending_operations, 0);
3400 assert_eq!(updated.failed_operations, 0);
3401 assert_eq!(
3402 updated.operations[0].state,
3403 RestoreApplyOperationState::Completed
3404 );
3405 }
3406
3407 #[test]
3409 fn run_restore_run_require_complete_writes_summary_then_fails() {
3410 let root = temp_dir("canic-cli-restore-run-require-complete");
3411 fs::create_dir_all(&root).expect("create temp root");
3412 let journal_path = root.join("restore-apply-journal.json");
3413 let out_path = root.join("restore-run.json");
3414 let journal = ready_apply_journal();
3415
3416 fs::write(
3417 &journal_path,
3418 serde_json::to_vec(&journal).expect("serialize journal"),
3419 )
3420 .expect("write journal");
3421
3422 let err = run([
3423 OsString::from("run"),
3424 OsString::from("--journal"),
3425 OsString::from(journal_path.as_os_str()),
3426 OsString::from("--execute"),
3427 OsString::from("--dfx"),
3428 OsString::from("/bin/true"),
3429 OsString::from("--max-steps"),
3430 OsString::from("1"),
3431 OsString::from("--out"),
3432 OsString::from(out_path.as_os_str()),
3433 OsString::from("--require-complete"),
3434 ])
3435 .expect_err("incomplete run should fail requirement");
3436
3437 let run_summary: serde_json::Value =
3438 serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3439 .expect("decode run summary");
3440
3441 fs::remove_dir_all(root).expect("remove temp root");
3442 assert_eq!(run_summary["executed_operation_count"], 1);
3443 assert_eq!(run_summary["complete"], false);
3444 assert!(matches!(
3445 err,
3446 RestoreCommandError::RestoreApplyIncomplete {
3447 completed_operations: 1,
3448 operation_count: 8,
3449 ..
3450 }
3451 ));
3452 }
3453
3454 #[test]
3456 fn run_restore_run_execute_marks_failed_operation() {
3457 let root = temp_dir("canic-cli-restore-run-execute-failed");
3458 fs::create_dir_all(&root).expect("create temp root");
3459 let journal_path = root.join("restore-apply-journal.json");
3460 let out_path = root.join("restore-run.json");
3461 let journal = ready_apply_journal();
3462
3463 fs::write(
3464 &journal_path,
3465 serde_json::to_vec(&journal).expect("serialize journal"),
3466 )
3467 .expect("write journal");
3468
3469 let err = run([
3470 OsString::from("run"),
3471 OsString::from("--journal"),
3472 OsString::from(journal_path.as_os_str()),
3473 OsString::from("--execute"),
3474 OsString::from("--dfx"),
3475 OsString::from("/bin/false"),
3476 OsString::from("--max-steps"),
3477 OsString::from("1"),
3478 OsString::from("--out"),
3479 OsString::from(out_path.as_os_str()),
3480 ])
3481 .expect_err("failing runner command should fail");
3482
3483 let run_summary: serde_json::Value =
3484 serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3485 .expect("decode run summary");
3486 let updated: RestoreApplyJournal =
3487 serde_json::from_slice(&fs::read(&journal_path).expect("read updated journal"))
3488 .expect("decode updated journal");
3489
3490 fs::remove_dir_all(root).expect("remove temp root");
3491 assert!(matches!(
3492 err,
3493 RestoreCommandError::RestoreRunCommandFailed {
3494 sequence: 0,
3495 status,
3496 } if status == "1"
3497 ));
3498 assert_eq!(updated.failed_operations, 1);
3499 assert_eq!(updated.pending_operations, 0);
3500 assert_eq!(
3501 updated.operations[0].state,
3502 RestoreApplyOperationState::Failed
3503 );
3504 assert_eq!(run_summary["execute"], true);
3505 assert_eq!(run_summary["attention_required"], true);
3506 assert_eq!(run_summary["outcome"], "failed");
3507 assert_eq!(run_summary["stopped_reason"], "command-failed");
3508 assert_eq!(run_summary["next_action"], "inspect-failed-operation");
3509 assert_eq!(run_summary["executed_operation_count"], 1);
3510 assert_eq!(run_summary["executed_operations"][0]["state"], "failed");
3511 assert_eq!(run_summary["executed_operations"][0]["status"], "1");
3512 assert_eq!(
3513 updated.operations[0].blocking_reasons,
3514 vec!["runner-command-exit-1".to_string()]
3515 );
3516 }
3517
3518 #[test]
3520 fn run_restore_run_require_no_attention_writes_summary_then_fails() {
3521 let root = temp_dir("canic-cli-restore-run-require-attention");
3522 fs::create_dir_all(&root).expect("create temp root");
3523 let journal_path = root.join("restore-apply-journal.json");
3524 let out_path = root.join("restore-run.json");
3525 let mut journal = ready_apply_journal();
3526 journal
3527 .mark_next_operation_pending_at(Some("2026-05-05T12:01:00Z".to_string()))
3528 .expect("mark pending operation");
3529
3530 fs::write(
3531 &journal_path,
3532 serde_json::to_vec(&journal).expect("serialize journal"),
3533 )
3534 .expect("write journal");
3535
3536 let err = run([
3537 OsString::from("run"),
3538 OsString::from("--journal"),
3539 OsString::from(journal_path.as_os_str()),
3540 OsString::from("--dry-run"),
3541 OsString::from("--out"),
3542 OsString::from(out_path.as_os_str()),
3543 OsString::from("--require-no-attention"),
3544 ])
3545 .expect_err("attention run should fail requirement");
3546
3547 let run_summary: serde_json::Value =
3548 serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3549 .expect("decode run summary");
3550
3551 fs::remove_dir_all(root).expect("remove temp root");
3552 assert_eq!(run_summary["attention_required"], true);
3553 assert_eq!(run_summary["outcome"], "pending");
3554 assert_eq!(run_summary["stopped_reason"], "pending");
3555 assert_eq!(run_summary["next_action"], "unclaim-pending");
3556 assert!(matches!(
3557 err,
3558 RestoreCommandError::RestoreApplyReportNeedsAttention {
3559 outcome: canic_backup::restore::RestoreApplyReportOutcome::Pending,
3560 ..
3561 }
3562 ));
3563 }
3564
3565 #[test]
3567 fn run_restore_run_require_run_mode_writes_summary_then_fails() {
3568 let root = temp_dir("canic-cli-restore-run-require-run-mode");
3569 fs::create_dir_all(&root).expect("create temp root");
3570 let journal_path = root.join("restore-apply-journal.json");
3571 let out_path = root.join("restore-run.json");
3572 let journal = ready_apply_journal();
3573
3574 fs::write(
3575 &journal_path,
3576 serde_json::to_vec(&journal).expect("serialize journal"),
3577 )
3578 .expect("write journal");
3579
3580 let err = run([
3581 OsString::from("run"),
3582 OsString::from("--journal"),
3583 OsString::from(journal_path.as_os_str()),
3584 OsString::from("--dry-run"),
3585 OsString::from("--out"),
3586 OsString::from(out_path.as_os_str()),
3587 OsString::from("--require-run-mode"),
3588 OsString::from("execute"),
3589 ])
3590 .expect_err("run mode mismatch should fail requirement");
3591
3592 let run_summary: serde_json::Value =
3593 serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3594 .expect("decode run summary");
3595
3596 fs::remove_dir_all(root).expect("remove temp root");
3597 assert_eq!(run_summary["run_mode"], "dry-run");
3598 assert!(matches!(
3599 err,
3600 RestoreCommandError::RestoreRunModeMismatch {
3601 expected,
3602 actual,
3603 ..
3604 } if expected == "execute" && actual == "dry-run"
3605 ));
3606 }
3607
3608 #[test]
3610 fn run_restore_run_require_executed_count_writes_summary_then_fails() {
3611 let root = temp_dir("canic-cli-restore-run-require-executed-count");
3612 fs::create_dir_all(&root).expect("create temp root");
3613 let journal_path = root.join("restore-apply-journal.json");
3614 let out_path = root.join("restore-run.json");
3615 let journal = ready_apply_journal();
3616
3617 fs::write(
3618 &journal_path,
3619 serde_json::to_vec(&journal).expect("serialize journal"),
3620 )
3621 .expect("write journal");
3622
3623 let err = run([
3624 OsString::from("run"),
3625 OsString::from("--journal"),
3626 OsString::from(journal_path.as_os_str()),
3627 OsString::from("--execute"),
3628 OsString::from("--dfx"),
3629 OsString::from("/bin/true"),
3630 OsString::from("--max-steps"),
3631 OsString::from("1"),
3632 OsString::from("--out"),
3633 OsString::from(out_path.as_os_str()),
3634 OsString::from("--require-executed-count"),
3635 OsString::from("2"),
3636 ])
3637 .expect_err("executed count mismatch should fail requirement");
3638
3639 let run_summary: serde_json::Value =
3640 serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3641 .expect("decode run summary");
3642
3643 fs::remove_dir_all(root).expect("remove temp root");
3644 assert_eq!(run_summary["executed_operation_count"], 1);
3645 assert!(matches!(
3646 err,
3647 RestoreCommandError::RestoreRunExecutedCountMismatch {
3648 expected: 2,
3649 actual: 1,
3650 ..
3651 }
3652 ));
3653 }
3654
3655 #[test]
3657 fn run_restore_run_require_stopped_reason_writes_summary_then_fails() {
3658 let root = temp_dir("canic-cli-restore-run-require-stopped-reason");
3659 fs::create_dir_all(&root).expect("create temp root");
3660 let journal_path = root.join("restore-apply-journal.json");
3661 let out_path = root.join("restore-run.json");
3662 let journal = ready_apply_journal();
3663
3664 fs::write(
3665 &journal_path,
3666 serde_json::to_vec(&journal).expect("serialize journal"),
3667 )
3668 .expect("write journal");
3669
3670 let err = run([
3671 OsString::from("run"),
3672 OsString::from("--journal"),
3673 OsString::from(journal_path.as_os_str()),
3674 OsString::from("--dry-run"),
3675 OsString::from("--out"),
3676 OsString::from(out_path.as_os_str()),
3677 OsString::from("--require-stopped-reason"),
3678 OsString::from("complete"),
3679 ])
3680 .expect_err("stopped reason mismatch should fail requirement");
3681
3682 let run_summary: serde_json::Value =
3683 serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3684 .expect("decode run summary");
3685
3686 fs::remove_dir_all(root).expect("remove temp root");
3687 assert_eq!(run_summary["stopped_reason"], "preview");
3688 assert!(matches!(
3689 err,
3690 RestoreCommandError::RestoreRunStoppedReasonMismatch {
3691 expected,
3692 actual,
3693 ..
3694 } if expected == "complete" && actual == "preview"
3695 ));
3696 }
3697
3698 #[test]
3700 fn run_restore_run_require_next_action_writes_summary_then_fails() {
3701 let root = temp_dir("canic-cli-restore-run-require-next-action");
3702 fs::create_dir_all(&root).expect("create temp root");
3703 let journal_path = root.join("restore-apply-journal.json");
3704 let out_path = root.join("restore-run.json");
3705 let journal = ready_apply_journal();
3706
3707 fs::write(
3708 &journal_path,
3709 serde_json::to_vec(&journal).expect("serialize journal"),
3710 )
3711 .expect("write journal");
3712
3713 let err = run([
3714 OsString::from("run"),
3715 OsString::from("--journal"),
3716 OsString::from(journal_path.as_os_str()),
3717 OsString::from("--dry-run"),
3718 OsString::from("--out"),
3719 OsString::from(out_path.as_os_str()),
3720 OsString::from("--require-next-action"),
3721 OsString::from("done"),
3722 ])
3723 .expect_err("next action mismatch should fail requirement");
3724
3725 let run_summary: serde_json::Value =
3726 serde_json::from_slice(&fs::read(&out_path).expect("read run summary"))
3727 .expect("decode run summary");
3728
3729 fs::remove_dir_all(root).expect("remove temp root");
3730 assert_eq!(run_summary["next_action"], "rerun");
3731 assert!(matches!(
3732 err,
3733 RestoreCommandError::RestoreRunNextActionMismatch {
3734 expected,
3735 actual,
3736 ..
3737 } if expected == "done" && actual == "rerun"
3738 ));
3739 }
3740
3741 #[test]
3743 fn run_restore_apply_report_require_no_attention_writes_report_then_fails() {
3744 let root = temp_dir("canic-cli-restore-apply-report-attention");
3745 fs::create_dir_all(&root).expect("create temp root");
3746 let journal_path = root.join("restore-apply-journal.json");
3747 let out_path = root.join("restore-apply-report.json");
3748 let mut journal = ready_apply_journal();
3749 journal
3750 .mark_next_operation_pending_at(Some("2026-05-05T12:01:00Z".to_string()))
3751 .expect("mark pending operation");
3752
3753 fs::write(
3754 &journal_path,
3755 serde_json::to_vec(&journal).expect("serialize journal"),
3756 )
3757 .expect("write journal");
3758
3759 let err = run([
3760 OsString::from("apply-report"),
3761 OsString::from("--journal"),
3762 OsString::from(journal_path.as_os_str()),
3763 OsString::from("--out"),
3764 OsString::from(out_path.as_os_str()),
3765 OsString::from("--require-no-attention"),
3766 ])
3767 .expect_err("attention report should fail requirement");
3768
3769 let report: RestoreApplyJournalReport =
3770 serde_json::from_slice(&fs::read(&out_path).expect("read apply report"))
3771 .expect("decode apply report");
3772
3773 fs::remove_dir_all(root).expect("remove temp root");
3774 assert!(report.attention_required);
3775 assert_eq!(report.pending_operations, 1);
3776 assert!(matches!(
3777 err,
3778 RestoreCommandError::RestoreApplyReportNeedsAttention {
3779 outcome: canic_backup::restore::RestoreApplyReportOutcome::Pending,
3780 ..
3781 }
3782 ));
3783 }
3784
3785 #[test]
3787 fn run_restore_apply_status_require_complete_writes_status_then_fails() {
3788 let root = temp_dir("canic-cli-restore-apply-status-incomplete");
3789 fs::create_dir_all(&root).expect("create temp root");
3790 let journal_path = root.join("restore-apply-journal.json");
3791 let out_path = root.join("restore-apply-status.json");
3792 let journal = ready_apply_journal();
3793
3794 fs::write(
3795 &journal_path,
3796 serde_json::to_vec(&journal).expect("serialize journal"),
3797 )
3798 .expect("write journal");
3799
3800 let err = run([
3801 OsString::from("apply-status"),
3802 OsString::from("--journal"),
3803 OsString::from(journal_path.as_os_str()),
3804 OsString::from("--out"),
3805 OsString::from(out_path.as_os_str()),
3806 OsString::from("--require-complete"),
3807 ])
3808 .expect_err("incomplete journal should fail requirement");
3809
3810 assert!(out_path.exists());
3811 let status: RestoreApplyJournalStatus =
3812 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
3813 .expect("decode apply status");
3814
3815 fs::remove_dir_all(root).expect("remove temp root");
3816 assert!(!status.complete);
3817 assert_eq!(status.completed_operations, 0);
3818 assert_eq!(status.operation_count, 8);
3819 assert!(matches!(
3820 err,
3821 RestoreCommandError::RestoreApplyIncomplete {
3822 completed_operations: 0,
3823 operation_count: 8,
3824 ..
3825 }
3826 ));
3827 }
3828
3829 #[test]
3831 fn run_restore_apply_status_require_no_failed_writes_status_then_fails() {
3832 let root = temp_dir("canic-cli-restore-apply-status-failed");
3833 fs::create_dir_all(&root).expect("create temp root");
3834 let journal_path = root.join("restore-apply-journal.json");
3835 let out_path = root.join("restore-apply-status.json");
3836 let mut journal = ready_apply_journal();
3837 journal
3838 .mark_operation_failed(0, "dfx-load-failed".to_string())
3839 .expect("mark failed operation");
3840
3841 fs::write(
3842 &journal_path,
3843 serde_json::to_vec(&journal).expect("serialize journal"),
3844 )
3845 .expect("write journal");
3846
3847 let err = run([
3848 OsString::from("apply-status"),
3849 OsString::from("--journal"),
3850 OsString::from(journal_path.as_os_str()),
3851 OsString::from("--out"),
3852 OsString::from(out_path.as_os_str()),
3853 OsString::from("--require-no-failed"),
3854 ])
3855 .expect_err("failed operation should fail requirement");
3856
3857 assert!(out_path.exists());
3858 let status: RestoreApplyJournalStatus =
3859 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
3860 .expect("decode apply status");
3861
3862 fs::remove_dir_all(root).expect("remove temp root");
3863 assert_eq!(status.failed_operations, 1);
3864 assert!(matches!(
3865 err,
3866 RestoreCommandError::RestoreApplyFailed {
3867 failed_operations: 1,
3868 ..
3869 }
3870 ));
3871 }
3872
3873 #[test]
3875 fn run_restore_apply_status_require_complete_accepts_complete_journal() {
3876 let root = temp_dir("canic-cli-restore-apply-status-complete");
3877 fs::create_dir_all(&root).expect("create temp root");
3878 let journal_path = root.join("restore-apply-journal.json");
3879 let out_path = root.join("restore-apply-status.json");
3880 let mut journal = ready_apply_journal();
3881 for sequence in 0..journal.operation_count {
3882 journal
3883 .mark_operation_completed(sequence)
3884 .expect("complete operation");
3885 }
3886
3887 fs::write(
3888 &journal_path,
3889 serde_json::to_vec(&journal).expect("serialize journal"),
3890 )
3891 .expect("write journal");
3892
3893 run([
3894 OsString::from("apply-status"),
3895 OsString::from("--journal"),
3896 OsString::from(journal_path.as_os_str()),
3897 OsString::from("--out"),
3898 OsString::from(out_path.as_os_str()),
3899 OsString::from("--require-complete"),
3900 ])
3901 .expect("complete journal should pass requirement");
3902
3903 let status: RestoreApplyJournalStatus =
3904 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
3905 .expect("decode apply status");
3906
3907 fs::remove_dir_all(root).expect("remove temp root");
3908 assert!(status.complete);
3909 assert_eq!(status.completed_operations, 8);
3910 assert_eq!(status.operation_count, 8);
3911 }
3912
3913 #[test]
3915 fn run_restore_apply_next_writes_next_ready_operation() {
3916 let root = temp_dir("canic-cli-restore-apply-next");
3917 fs::create_dir_all(&root).expect("create temp root");
3918 let journal_path = root.join("restore-apply-journal.json");
3919 let out_path = root.join("restore-apply-next.json");
3920 let mut journal = ready_apply_journal();
3921 journal
3922 .mark_operation_completed(0)
3923 .expect("mark first operation complete");
3924
3925 fs::write(
3926 &journal_path,
3927 serde_json::to_vec(&journal).expect("serialize journal"),
3928 )
3929 .expect("write journal");
3930
3931 run([
3932 OsString::from("apply-next"),
3933 OsString::from("--journal"),
3934 OsString::from(journal_path.as_os_str()),
3935 OsString::from("--out"),
3936 OsString::from(out_path.as_os_str()),
3937 ])
3938 .expect("write apply next");
3939
3940 let next: RestoreApplyNextOperation =
3941 serde_json::from_slice(&fs::read(&out_path).expect("read next operation"))
3942 .expect("decode next operation");
3943 let operation = next.operation.expect("operation should be available");
3944
3945 fs::remove_dir_all(root).expect("remove temp root");
3946 assert!(next.ready);
3947 assert!(next.operation_available);
3948 assert_eq!(operation.sequence, 1);
3949 assert_eq!(
3950 operation.operation,
3951 canic_backup::restore::RestoreApplyOperationKind::LoadSnapshot
3952 );
3953 }
3954
3955 #[test]
3957 fn run_restore_apply_command_writes_next_command_preview() {
3958 let root = temp_dir("canic-cli-restore-apply-command");
3959 fs::create_dir_all(&root).expect("create temp root");
3960 let journal_path = root.join("restore-apply-journal.json");
3961 let out_path = root.join("restore-apply-command.json");
3962 let journal = ready_apply_journal();
3963
3964 fs::write(
3965 &journal_path,
3966 serde_json::to_vec(&journal).expect("serialize journal"),
3967 )
3968 .expect("write journal");
3969
3970 run([
3971 OsString::from("apply-command"),
3972 OsString::from("--journal"),
3973 OsString::from(journal_path.as_os_str()),
3974 OsString::from("--dfx"),
3975 OsString::from("/tmp/dfx"),
3976 OsString::from("--network"),
3977 OsString::from("local"),
3978 OsString::from("--out"),
3979 OsString::from(out_path.as_os_str()),
3980 ])
3981 .expect("write command preview");
3982
3983 let preview: RestoreApplyCommandPreview =
3984 serde_json::from_slice(&fs::read(&out_path).expect("read command preview"))
3985 .expect("decode command preview");
3986 let command = preview.command.expect("command should be available");
3987
3988 fs::remove_dir_all(root).expect("remove temp root");
3989 assert!(preview.ready);
3990 assert!(preview.command_available);
3991 assert_eq!(command.program, "/tmp/dfx");
3992 assert_eq!(
3993 command.args,
3994 vec![
3995 "canister".to_string(),
3996 "--network".to_string(),
3997 "local".to_string(),
3998 "snapshot".to_string(),
3999 "upload".to_string(),
4000 "--dir".to_string(),
4001 "artifacts/root".to_string(),
4002 ROOT.to_string(),
4003 ]
4004 );
4005 assert!(command.mutates);
4006 }
4007
4008 #[test]
4010 fn run_restore_apply_command_require_command_writes_preview_then_fails() {
4011 let root = temp_dir("canic-cli-restore-apply-command-require");
4012 fs::create_dir_all(&root).expect("create temp root");
4013 let journal_path = root.join("restore-apply-journal.json");
4014 let out_path = root.join("restore-apply-command.json");
4015 let mut journal = ready_apply_journal();
4016
4017 for sequence in 0..journal.operation_count {
4018 journal
4019 .mark_operation_completed(sequence)
4020 .expect("mark operation completed");
4021 }
4022
4023 fs::write(
4024 &journal_path,
4025 serde_json::to_vec(&journal).expect("serialize journal"),
4026 )
4027 .expect("write journal");
4028
4029 let err = run([
4030 OsString::from("apply-command"),
4031 OsString::from("--journal"),
4032 OsString::from(journal_path.as_os_str()),
4033 OsString::from("--out"),
4034 OsString::from(out_path.as_os_str()),
4035 OsString::from("--require-command"),
4036 ])
4037 .expect_err("missing command should fail");
4038
4039 let preview: RestoreApplyCommandPreview =
4040 serde_json::from_slice(&fs::read(&out_path).expect("read command preview"))
4041 .expect("decode command preview");
4042
4043 fs::remove_dir_all(root).expect("remove temp root");
4044 assert!(preview.complete);
4045 assert!(!preview.operation_available);
4046 assert!(!preview.command_available);
4047 assert!(matches!(
4048 err,
4049 RestoreCommandError::RestoreApplyCommandUnavailable {
4050 operation_available: false,
4051 complete: true,
4052 ..
4053 }
4054 ));
4055 }
4056
4057 #[test]
4059 fn run_restore_apply_claim_marks_next_operation_pending() {
4060 let root = temp_dir("canic-cli-restore-apply-claim");
4061 fs::create_dir_all(&root).expect("create temp root");
4062 let journal_path = root.join("restore-apply-journal.json");
4063 let claimed_path = root.join("restore-apply-journal.claimed.json");
4064 let journal = ready_apply_journal();
4065
4066 fs::write(
4067 &journal_path,
4068 serde_json::to_vec(&journal).expect("serialize journal"),
4069 )
4070 .expect("write journal");
4071
4072 run([
4073 OsString::from("apply-claim"),
4074 OsString::from("--journal"),
4075 OsString::from(journal_path.as_os_str()),
4076 OsString::from("--sequence"),
4077 OsString::from("0"),
4078 OsString::from("--updated-at"),
4079 OsString::from("2026-05-04T12:00:00Z"),
4080 OsString::from("--out"),
4081 OsString::from(claimed_path.as_os_str()),
4082 ])
4083 .expect("claim operation");
4084
4085 let claimed: RestoreApplyJournal =
4086 serde_json::from_slice(&fs::read(&claimed_path).expect("read claimed journal"))
4087 .expect("decode claimed journal");
4088 let status = claimed.status();
4089 let next = claimed.next_operation();
4090
4091 fs::remove_dir_all(root).expect("remove temp root");
4092 assert_eq!(claimed.pending_operations, 1);
4093 assert_eq!(claimed.ready_operations, 7);
4094 assert_eq!(
4095 claimed.operations[0].state,
4096 RestoreApplyOperationState::Pending
4097 );
4098 assert_eq!(
4099 claimed.operations[0].state_updated_at.as_deref(),
4100 Some("2026-05-04T12:00:00Z")
4101 );
4102 assert_eq!(status.next_transition_sequence, Some(0));
4103 assert_eq!(
4104 status.next_transition_state,
4105 Some(RestoreApplyOperationState::Pending)
4106 );
4107 assert_eq!(
4108 status.next_transition_updated_at.as_deref(),
4109 Some("2026-05-04T12:00:00Z")
4110 );
4111 assert_eq!(
4112 next.operation.expect("next operation").state,
4113 RestoreApplyOperationState::Pending
4114 );
4115 }
4116
4117 #[test]
4119 fn run_restore_apply_claim_rejects_sequence_mismatch() {
4120 let root = temp_dir("canic-cli-restore-apply-claim-sequence");
4121 fs::create_dir_all(&root).expect("create temp root");
4122 let journal_path = root.join("restore-apply-journal.json");
4123 let claimed_path = root.join("restore-apply-journal.claimed.json");
4124 let journal = ready_apply_journal();
4125
4126 fs::write(
4127 &journal_path,
4128 serde_json::to_vec(&journal).expect("serialize journal"),
4129 )
4130 .expect("write journal");
4131
4132 let err = run([
4133 OsString::from("apply-claim"),
4134 OsString::from("--journal"),
4135 OsString::from(journal_path.as_os_str()),
4136 OsString::from("--sequence"),
4137 OsString::from("1"),
4138 OsString::from("--out"),
4139 OsString::from(claimed_path.as_os_str()),
4140 ])
4141 .expect_err("stale sequence should fail claim");
4142
4143 assert!(!claimed_path.exists());
4144 fs::remove_dir_all(root).expect("remove temp root");
4145 assert!(matches!(
4146 err,
4147 RestoreCommandError::RestoreApplyClaimSequenceMismatch {
4148 expected: 1,
4149 actual: Some(0),
4150 }
4151 ));
4152 }
4153
4154 #[test]
4156 fn run_restore_apply_unclaim_marks_pending_operation_ready() {
4157 let root = temp_dir("canic-cli-restore-apply-unclaim");
4158 fs::create_dir_all(&root).expect("create temp root");
4159 let journal_path = root.join("restore-apply-journal.json");
4160 let unclaimed_path = root.join("restore-apply-journal.unclaimed.json");
4161 let mut journal = ready_apply_journal();
4162 journal
4163 .mark_next_operation_pending()
4164 .expect("claim operation");
4165
4166 fs::write(
4167 &journal_path,
4168 serde_json::to_vec(&journal).expect("serialize journal"),
4169 )
4170 .expect("write journal");
4171
4172 run([
4173 OsString::from("apply-unclaim"),
4174 OsString::from("--journal"),
4175 OsString::from(journal_path.as_os_str()),
4176 OsString::from("--sequence"),
4177 OsString::from("0"),
4178 OsString::from("--updated-at"),
4179 OsString::from("2026-05-04T12:01:00Z"),
4180 OsString::from("--out"),
4181 OsString::from(unclaimed_path.as_os_str()),
4182 ])
4183 .expect("unclaim operation");
4184
4185 let unclaimed: RestoreApplyJournal =
4186 serde_json::from_slice(&fs::read(&unclaimed_path).expect("read unclaimed journal"))
4187 .expect("decode unclaimed journal");
4188 let status = unclaimed.status();
4189
4190 fs::remove_dir_all(root).expect("remove temp root");
4191 assert_eq!(unclaimed.pending_operations, 0);
4192 assert_eq!(unclaimed.ready_operations, 8);
4193 assert_eq!(
4194 unclaimed.operations[0].state,
4195 RestoreApplyOperationState::Ready
4196 );
4197 assert_eq!(
4198 unclaimed.operations[0].state_updated_at.as_deref(),
4199 Some("2026-05-04T12:01:00Z")
4200 );
4201 assert_eq!(status.next_ready_sequence, Some(0));
4202 assert_eq!(
4203 status.next_transition_state,
4204 Some(RestoreApplyOperationState::Ready)
4205 );
4206 assert_eq!(
4207 status.next_transition_updated_at.as_deref(),
4208 Some("2026-05-04T12:01:00Z")
4209 );
4210 }
4211
4212 #[test]
4214 fn run_restore_apply_unclaim_rejects_sequence_mismatch() {
4215 let root = temp_dir("canic-cli-restore-apply-unclaim-sequence");
4216 fs::create_dir_all(&root).expect("create temp root");
4217 let journal_path = root.join("restore-apply-journal.json");
4218 let unclaimed_path = root.join("restore-apply-journal.unclaimed.json");
4219 let mut journal = ready_apply_journal();
4220 journal
4221 .mark_next_operation_pending()
4222 .expect("claim operation");
4223
4224 fs::write(
4225 &journal_path,
4226 serde_json::to_vec(&journal).expect("serialize journal"),
4227 )
4228 .expect("write journal");
4229
4230 let err = run([
4231 OsString::from("apply-unclaim"),
4232 OsString::from("--journal"),
4233 OsString::from(journal_path.as_os_str()),
4234 OsString::from("--sequence"),
4235 OsString::from("1"),
4236 OsString::from("--out"),
4237 OsString::from(unclaimed_path.as_os_str()),
4238 ])
4239 .expect_err("stale sequence should fail unclaim");
4240
4241 assert!(!unclaimed_path.exists());
4242 fs::remove_dir_all(root).expect("remove temp root");
4243 assert!(matches!(
4244 err,
4245 RestoreCommandError::RestoreApplyUnclaimSequenceMismatch {
4246 expected: 1,
4247 actual: Some(0),
4248 }
4249 ));
4250 }
4251
4252 #[test]
4254 fn run_restore_apply_mark_completes_operation() {
4255 let root = temp_dir("canic-cli-restore-apply-mark-complete");
4256 fs::create_dir_all(&root).expect("create temp root");
4257 let journal_path = root.join("restore-apply-journal.json");
4258 let updated_path = root.join("restore-apply-journal.updated.json");
4259 let journal = ready_apply_journal();
4260
4261 fs::write(
4262 &journal_path,
4263 serde_json::to_vec(&journal).expect("serialize journal"),
4264 )
4265 .expect("write journal");
4266
4267 run([
4268 OsString::from("apply-mark"),
4269 OsString::from("--journal"),
4270 OsString::from(journal_path.as_os_str()),
4271 OsString::from("--sequence"),
4272 OsString::from("0"),
4273 OsString::from("--state"),
4274 OsString::from("completed"),
4275 OsString::from("--updated-at"),
4276 OsString::from("2026-05-04T12:02:00Z"),
4277 OsString::from("--out"),
4278 OsString::from(updated_path.as_os_str()),
4279 ])
4280 .expect("mark operation completed");
4281
4282 let updated: RestoreApplyJournal =
4283 serde_json::from_slice(&fs::read(&updated_path).expect("read updated journal"))
4284 .expect("decode updated journal");
4285 let status = updated.status();
4286
4287 fs::remove_dir_all(root).expect("remove temp root");
4288 assert_eq!(updated.completed_operations, 1);
4289 assert_eq!(updated.ready_operations, 7);
4290 assert_eq!(
4291 updated.operations[0].state_updated_at.as_deref(),
4292 Some("2026-05-04T12:02:00Z")
4293 );
4294 assert_eq!(status.next_ready_sequence, Some(1));
4295 }
4296
4297 #[test]
4299 fn run_restore_apply_mark_require_pending_rejects_ready_operation() {
4300 let root = temp_dir("canic-cli-restore-apply-mark-require-pending");
4301 fs::create_dir_all(&root).expect("create temp root");
4302 let journal_path = root.join("restore-apply-journal.json");
4303 let updated_path = root.join("restore-apply-journal.updated.json");
4304 let journal = ready_apply_journal();
4305
4306 fs::write(
4307 &journal_path,
4308 serde_json::to_vec(&journal).expect("serialize journal"),
4309 )
4310 .expect("write journal");
4311
4312 let err = run([
4313 OsString::from("apply-mark"),
4314 OsString::from("--journal"),
4315 OsString::from(journal_path.as_os_str()),
4316 OsString::from("--sequence"),
4317 OsString::from("0"),
4318 OsString::from("--state"),
4319 OsString::from("completed"),
4320 OsString::from("--out"),
4321 OsString::from(updated_path.as_os_str()),
4322 OsString::from("--require-pending"),
4323 ])
4324 .expect_err("ready operation should fail pending requirement");
4325
4326 assert!(!updated_path.exists());
4327 fs::remove_dir_all(root).expect("remove temp root");
4328 assert!(matches!(
4329 err,
4330 RestoreCommandError::RestoreApplyMarkRequiresPending {
4331 sequence: 0,
4332 state: RestoreApplyOperationState::Ready,
4333 }
4334 ));
4335 }
4336
4337 #[test]
4339 fn run_restore_apply_mark_rejects_out_of_order_operation() {
4340 let root = temp_dir("canic-cli-restore-apply-mark-out-of-order");
4341 fs::create_dir_all(&root).expect("create temp root");
4342 let journal_path = root.join("restore-apply-journal.json");
4343 let updated_path = root.join("restore-apply-journal.updated.json");
4344 let journal = ready_apply_journal();
4345
4346 fs::write(
4347 &journal_path,
4348 serde_json::to_vec(&journal).expect("serialize journal"),
4349 )
4350 .expect("write journal");
4351
4352 let err = run([
4353 OsString::from("apply-mark"),
4354 OsString::from("--journal"),
4355 OsString::from(journal_path.as_os_str()),
4356 OsString::from("--sequence"),
4357 OsString::from("1"),
4358 OsString::from("--state"),
4359 OsString::from("completed"),
4360 OsString::from("--out"),
4361 OsString::from(updated_path.as_os_str()),
4362 ])
4363 .expect_err("out-of-order operation should fail");
4364
4365 assert!(!updated_path.exists());
4366 fs::remove_dir_all(root).expect("remove temp root");
4367 assert!(matches!(
4368 err,
4369 RestoreCommandError::RestoreApplyJournal(
4370 RestoreApplyJournalError::OutOfOrderOperationTransition {
4371 requested: 1,
4372 next: 0
4373 }
4374 )
4375 ));
4376 }
4377
4378 #[test]
4380 fn run_restore_apply_mark_failed_requires_reason() {
4381 let root = temp_dir("canic-cli-restore-apply-mark-failed-reason");
4382 fs::create_dir_all(&root).expect("create temp root");
4383 let journal_path = root.join("restore-apply-journal.json");
4384 let journal = ready_apply_journal();
4385
4386 fs::write(
4387 &journal_path,
4388 serde_json::to_vec(&journal).expect("serialize journal"),
4389 )
4390 .expect("write journal");
4391
4392 let err = run([
4393 OsString::from("apply-mark"),
4394 OsString::from("--journal"),
4395 OsString::from(journal_path.as_os_str()),
4396 OsString::from("--sequence"),
4397 OsString::from("0"),
4398 OsString::from("--state"),
4399 OsString::from("failed"),
4400 ])
4401 .expect_err("failed state should require reason");
4402
4403 fs::remove_dir_all(root).expect("remove temp root");
4404 assert!(matches!(
4405 err,
4406 RestoreCommandError::RestoreApplyJournal(
4407 RestoreApplyJournalError::FailureReasonRequired(0)
4408 )
4409 ));
4410 }
4411
4412 #[test]
4414 fn run_restore_apply_dry_run_rejects_mismatched_status() {
4415 let root = temp_dir("canic-cli-restore-apply-dry-run-mismatch");
4416 fs::create_dir_all(&root).expect("create temp root");
4417 let plan_path = root.join("restore-plan.json");
4418 let status_path = root.join("restore-status.json");
4419 let out_path = root.join("restore-apply-dry-run.json");
4420 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
4421 let mut status = RestoreStatus::from_plan(&plan);
4422 status.backup_id = "other-backup".to_string();
4423
4424 fs::write(
4425 &plan_path,
4426 serde_json::to_vec(&plan).expect("serialize plan"),
4427 )
4428 .expect("write plan");
4429 fs::write(
4430 &status_path,
4431 serde_json::to_vec(&status).expect("serialize status"),
4432 )
4433 .expect("write status");
4434
4435 let err = run([
4436 OsString::from("apply"),
4437 OsString::from("--plan"),
4438 OsString::from(plan_path.as_os_str()),
4439 OsString::from("--status"),
4440 OsString::from(status_path.as_os_str()),
4441 OsString::from("--dry-run"),
4442 OsString::from("--out"),
4443 OsString::from(out_path.as_os_str()),
4444 ])
4445 .expect_err("mismatched status should fail");
4446
4447 assert!(!out_path.exists());
4448 fs::remove_dir_all(root).expect("remove temp root");
4449 assert!(matches!(
4450 err,
4451 RestoreCommandError::RestoreApplyDryRun(RestoreApplyDryRunError::StatusPlanMismatch {
4452 field: "backup_id",
4453 ..
4454 })
4455 ));
4456 }
4457
4458 fn ready_apply_journal() -> RestoreApplyJournal {
4460 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
4461 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
4462 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
4463
4464 journal.ready = true;
4465 journal.blocked_reasons = Vec::new();
4466 for operation in &mut journal.operations {
4467 operation.state = canic_backup::restore::RestoreApplyOperationState::Ready;
4468 operation.blocking_reasons = Vec::new();
4469 }
4470 journal.blocked_operations = 0;
4471 journal.ready_operations = journal.operation_count;
4472 journal.validate().expect("journal should validate");
4473 journal
4474 }
4475
4476 fn valid_manifest() -> FleetBackupManifest {
4478 FleetBackupManifest {
4479 manifest_version: 1,
4480 backup_id: "backup-test".to_string(),
4481 created_at: "2026-05-03T00:00:00Z".to_string(),
4482 tool: ToolMetadata {
4483 name: "canic".to_string(),
4484 version: "0.30.1".to_string(),
4485 },
4486 source: SourceMetadata {
4487 environment: "local".to_string(),
4488 root_canister: ROOT.to_string(),
4489 },
4490 consistency: ConsistencySection {
4491 mode: ConsistencyMode::CrashConsistent,
4492 backup_units: vec![BackupUnit {
4493 unit_id: "fleet".to_string(),
4494 kind: BackupUnitKind::SubtreeRooted,
4495 roles: vec!["root".to_string(), "app".to_string()],
4496 consistency_reason: None,
4497 dependency_closure: Vec::new(),
4498 topology_validation: "subtree-closed".to_string(),
4499 quiescence_strategy: None,
4500 }],
4501 },
4502 fleet: FleetSection {
4503 topology_hash_algorithm: "sha256".to_string(),
4504 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
4505 discovery_topology_hash: HASH.to_string(),
4506 pre_snapshot_topology_hash: HASH.to_string(),
4507 topology_hash: HASH.to_string(),
4508 members: vec![
4509 fleet_member("root", ROOT, None, IdentityMode::Fixed),
4510 fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
4511 ],
4512 },
4513 verification: VerificationPlan::default(),
4514 }
4515 }
4516
4517 fn restore_ready_manifest() -> FleetBackupManifest {
4519 let mut manifest = valid_manifest();
4520 for member in &mut manifest.fleet.members {
4521 member.source_snapshot.module_hash = Some(HASH.to_string());
4522 member.source_snapshot.wasm_hash = Some(HASH.to_string());
4523 member.source_snapshot.checksum = Some(HASH.to_string());
4524 }
4525 manifest
4526 }
4527
4528 fn fleet_member(
4530 role: &str,
4531 canister_id: &str,
4532 parent_canister_id: Option<&str>,
4533 identity_mode: IdentityMode,
4534 ) -> FleetMember {
4535 FleetMember {
4536 role: role.to_string(),
4537 canister_id: canister_id.to_string(),
4538 parent_canister_id: parent_canister_id.map(str::to_string),
4539 subnet_canister_id: Some(ROOT.to_string()),
4540 controller_hint: None,
4541 identity_mode,
4542 restore_group: 1,
4543 verification_class: "basic".to_string(),
4544 verification_checks: vec![VerificationCheck {
4545 kind: "status".to_string(),
4546 method: None,
4547 roles: vec![role.to_string()],
4548 }],
4549 source_snapshot: SourceSnapshot {
4550 snapshot_id: format!("{role}-snapshot"),
4551 module_hash: None,
4552 wasm_hash: None,
4553 code_version: Some("v0.30.1".to_string()),
4554 artifact_path: format!("artifacts/{role}"),
4555 checksum_algorithm: "sha256".to_string(),
4556 checksum: None,
4557 },
4558 }
4559 }
4560
4561 fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
4563 layout.write_manifest(manifest).expect("write manifest");
4564
4565 let artifacts = manifest
4566 .fleet
4567 .members
4568 .iter()
4569 .map(|member| {
4570 let bytes = format!("{} artifact", member.role);
4571 let artifact_path = root.join(&member.source_snapshot.artifact_path);
4572 if let Some(parent) = artifact_path.parent() {
4573 fs::create_dir_all(parent).expect("create artifact parent");
4574 }
4575 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
4576 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
4577
4578 ArtifactJournalEntry {
4579 canister_id: member.canister_id.clone(),
4580 snapshot_id: member.source_snapshot.snapshot_id.clone(),
4581 state: ArtifactState::Durable,
4582 temp_path: None,
4583 artifact_path: member.source_snapshot.artifact_path.clone(),
4584 checksum_algorithm: checksum.algorithm,
4585 checksum: Some(checksum.hash),
4586 updated_at: "2026-05-03T00:00:00Z".to_string(),
4587 }
4588 })
4589 .collect();
4590
4591 layout
4592 .write_journal(&DownloadJournal {
4593 journal_version: 1,
4594 backup_id: manifest.backup_id.clone(),
4595 discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
4596 pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
4597 operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
4598 artifacts,
4599 })
4600 .expect("write journal");
4601 }
4602
4603 fn write_manifest_artifacts(root: &Path, manifest: &mut FleetBackupManifest) {
4605 for member in &mut manifest.fleet.members {
4606 let bytes = format!("{} apply artifact", member.role);
4607 let artifact_path = root.join(&member.source_snapshot.artifact_path);
4608 if let Some(parent) = artifact_path.parent() {
4609 fs::create_dir_all(parent).expect("create artifact parent");
4610 }
4611 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
4612 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
4613 member.source_snapshot.checksum = Some(checksum.hash);
4614 }
4615 }
4616
4617 fn temp_dir(prefix: &str) -> PathBuf {
4619 let nanos = SystemTime::now()
4620 .duration_since(UNIX_EPOCH)
4621 .expect("system time after epoch")
4622 .as_nanos();
4623 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
4624 }
4625}