1use canic_backup::{
2 manifest::FleetBackupManifest,
3 persistence::{BackupLayout, PersistenceError},
4 restore::{
5 RestoreApplyCommandConfig, RestoreApplyCommandPreview, RestoreApplyDryRun,
6 RestoreApplyDryRunError, RestoreApplyJournal, RestoreApplyJournalError,
7 RestoreApplyJournalReport, RestoreApplyJournalStatus, RestoreApplyNextOperation,
8 RestoreApplyOperationState, RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner,
9 RestoreStatus,
10 },
11};
12use std::{
13 ffi::OsString,
14 fs,
15 io::{self, Write},
16 path::PathBuf,
17};
18use thiserror::Error as ThisError;
19
20#[derive(Debug, ThisError)]
25pub enum RestoreCommandError {
26 #[error("{0}")]
27 Usage(&'static str),
28
29 #[error("missing required option {0}")]
30 MissingOption(&'static str),
31
32 #[error("use either --manifest or --backup-dir, not both")]
33 ConflictingManifestSources,
34
35 #[error("--require-verified requires --backup-dir")]
36 RequireVerifiedNeedsBackupDir,
37
38 #[error("restore apply currently requires --dry-run")]
39 ApplyRequiresDryRun,
40
41 #[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
42 RestoreNotReady {
43 backup_id: String,
44 reasons: Vec<String>,
45 },
46
47 #[error(
48 "restore apply journal for backup {backup_id} has pending operations: pending={pending_operations}, next={next_transition_sequence:?}"
49 )]
50 RestoreApplyPending {
51 backup_id: String,
52 pending_operations: usize,
53 next_transition_sequence: Option<usize>,
54 },
55
56 #[error(
57 "restore apply journal for backup {backup_id} is incomplete: completed={completed_operations}, total={operation_count}"
58 )]
59 RestoreApplyIncomplete {
60 backup_id: String,
61 completed_operations: usize,
62 operation_count: usize,
63 },
64
65 #[error(
66 "restore apply journal for backup {backup_id} has failed operations: failed={failed_operations}"
67 )]
68 RestoreApplyFailed {
69 backup_id: String,
70 failed_operations: usize,
71 },
72
73 #[error("restore apply journal for backup {backup_id} is not ready: reasons={reasons:?}")]
74 RestoreApplyNotReady {
75 backup_id: String,
76 reasons: Vec<String>,
77 },
78
79 #[error("restore apply report for backup {backup_id} requires attention: outcome={outcome:?}")]
80 RestoreApplyReportNeedsAttention {
81 backup_id: String,
82 outcome: canic_backup::restore::RestoreApplyReportOutcome,
83 },
84
85 #[error(
86 "restore apply journal for backup {backup_id} has no executable command: operation_available={operation_available}, complete={complete}, blocked_reasons={blocked_reasons:?}"
87 )]
88 RestoreApplyCommandUnavailable {
89 backup_id: String,
90 operation_available: bool,
91 complete: bool,
92 blocked_reasons: Vec<String>,
93 },
94
95 #[error(
96 "restore apply journal operation {sequence} must be pending before apply-mark: state={state:?}"
97 )]
98 RestoreApplyMarkRequiresPending {
99 sequence: usize,
100 state: RestoreApplyOperationState,
101 },
102
103 #[error(
104 "restore apply journal next operation changed before claim: expected={expected}, actual={actual:?}"
105 )]
106 RestoreApplyClaimSequenceMismatch {
107 expected: usize,
108 actual: Option<usize>,
109 },
110
111 #[error(
112 "restore apply journal pending operation changed before unclaim: expected={expected}, actual={actual:?}"
113 )]
114 RestoreApplyUnclaimSequenceMismatch {
115 expected: usize,
116 actual: Option<usize>,
117 },
118
119 #[error("unknown option {0}")]
120 UnknownOption(String),
121
122 #[error("option {0} requires a value")]
123 MissingValue(&'static str),
124
125 #[error("option --sequence requires a non-negative integer value")]
126 InvalidSequence,
127
128 #[error("unsupported apply-mark state {0}; use completed or failed")]
129 InvalidApplyMarkState(String),
130
131 #[error(transparent)]
132 Io(#[from] std::io::Error),
133
134 #[error(transparent)]
135 Json(#[from] serde_json::Error),
136
137 #[error(transparent)]
138 Persistence(#[from] PersistenceError),
139
140 #[error(transparent)]
141 RestorePlan(#[from] RestorePlanError),
142
143 #[error(transparent)]
144 RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
145
146 #[error(transparent)]
147 RestoreApplyJournal(#[from] RestoreApplyJournalError),
148}
149
150#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct RestorePlanOptions {
156 pub manifest: Option<PathBuf>,
157 pub backup_dir: Option<PathBuf>,
158 pub mapping: Option<PathBuf>,
159 pub out: Option<PathBuf>,
160 pub require_verified: bool,
161 pub require_restore_ready: bool,
162}
163
164impl RestorePlanOptions {
165 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
167 where
168 I: IntoIterator<Item = OsString>,
169 {
170 let mut manifest = None;
171 let mut backup_dir = None;
172 let mut mapping = None;
173 let mut out = None;
174 let mut require_verified = false;
175 let mut require_restore_ready = false;
176
177 let mut args = args.into_iter();
178 while let Some(arg) = args.next() {
179 let arg = arg
180 .into_string()
181 .map_err(|_| RestoreCommandError::Usage(usage()))?;
182 match arg.as_str() {
183 "--manifest" => {
184 manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
185 }
186 "--backup-dir" => {
187 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
188 }
189 "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
190 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
191 "--require-verified" => require_verified = true,
192 "--require-restore-ready" => require_restore_ready = true,
193 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
194 _ => return Err(RestoreCommandError::UnknownOption(arg)),
195 }
196 }
197
198 if manifest.is_some() && backup_dir.is_some() {
199 return Err(RestoreCommandError::ConflictingManifestSources);
200 }
201
202 if manifest.is_none() && backup_dir.is_none() {
203 return Err(RestoreCommandError::MissingOption(
204 "--manifest or --backup-dir",
205 ));
206 }
207
208 if require_verified && backup_dir.is_none() {
209 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
210 }
211
212 Ok(Self {
213 manifest,
214 backup_dir,
215 mapping,
216 out,
217 require_verified,
218 require_restore_ready,
219 })
220 }
221}
222
223#[derive(Clone, Debug, Eq, PartialEq)]
228pub struct RestoreStatusOptions {
229 pub plan: PathBuf,
230 pub out: Option<PathBuf>,
231}
232
233impl RestoreStatusOptions {
234 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
236 where
237 I: IntoIterator<Item = OsString>,
238 {
239 let mut plan = None;
240 let mut out = None;
241
242 let mut args = args.into_iter();
243 while let Some(arg) = args.next() {
244 let arg = arg
245 .into_string()
246 .map_err(|_| RestoreCommandError::Usage(usage()))?;
247 match arg.as_str() {
248 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
249 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
250 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
251 _ => return Err(RestoreCommandError::UnknownOption(arg)),
252 }
253 }
254
255 Ok(Self {
256 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
257 out,
258 })
259 }
260}
261
262#[derive(Clone, Debug, Eq, PartialEq)]
267pub struct RestoreApplyOptions {
268 pub plan: PathBuf,
269 pub status: Option<PathBuf>,
270 pub backup_dir: Option<PathBuf>,
271 pub out: Option<PathBuf>,
272 pub journal_out: Option<PathBuf>,
273 pub dry_run: bool,
274}
275
276impl RestoreApplyOptions {
277 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
279 where
280 I: IntoIterator<Item = OsString>,
281 {
282 let mut plan = None;
283 let mut status = None;
284 let mut backup_dir = None;
285 let mut out = None;
286 let mut journal_out = None;
287 let mut dry_run = false;
288
289 let mut args = args.into_iter();
290 while let Some(arg) = args.next() {
291 let arg = arg
292 .into_string()
293 .map_err(|_| RestoreCommandError::Usage(usage()))?;
294 match arg.as_str() {
295 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
296 "--status" => status = Some(PathBuf::from(next_value(&mut args, "--status")?)),
297 "--backup-dir" => {
298 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
299 }
300 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
301 "--journal-out" => {
302 journal_out = Some(PathBuf::from(next_value(&mut args, "--journal-out")?));
303 }
304 "--dry-run" => dry_run = true,
305 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
306 _ => return Err(RestoreCommandError::UnknownOption(arg)),
307 }
308 }
309
310 if !dry_run {
311 return Err(RestoreCommandError::ApplyRequiresDryRun);
312 }
313
314 Ok(Self {
315 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
316 status,
317 backup_dir,
318 out,
319 journal_out,
320 dry_run,
321 })
322 }
323}
324
325#[derive(Clone, Debug, Eq, PartialEq)]
330#[expect(
331 clippy::struct_excessive_bools,
332 reason = "CLI status options mirror independent fail-closed guard flags"
333)]
334pub struct RestoreApplyStatusOptions {
335 pub journal: PathBuf,
336 pub require_ready: bool,
337 pub require_no_pending: bool,
338 pub require_no_failed: bool,
339 pub require_complete: bool,
340 pub out: Option<PathBuf>,
341}
342
343impl RestoreApplyStatusOptions {
344 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
346 where
347 I: IntoIterator<Item = OsString>,
348 {
349 let mut journal = None;
350 let mut require_ready = false;
351 let mut require_no_pending = false;
352 let mut require_no_failed = false;
353 let mut require_complete = false;
354 let mut out = None;
355
356 let mut args = args.into_iter();
357 while let Some(arg) = args.next() {
358 let arg = arg
359 .into_string()
360 .map_err(|_| RestoreCommandError::Usage(usage()))?;
361 match arg.as_str() {
362 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
363 "--require-ready" => require_ready = true,
364 "--require-no-pending" => require_no_pending = true,
365 "--require-no-failed" => require_no_failed = true,
366 "--require-complete" => require_complete = true,
367 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
368 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
369 _ => return Err(RestoreCommandError::UnknownOption(arg)),
370 }
371 }
372
373 Ok(Self {
374 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
375 require_ready,
376 require_no_pending,
377 require_no_failed,
378 require_complete,
379 out,
380 })
381 }
382}
383
384#[derive(Clone, Debug, Eq, PartialEq)]
389pub struct RestoreApplyReportOptions {
390 pub journal: PathBuf,
391 pub require_no_attention: bool,
392 pub out: Option<PathBuf>,
393}
394
395impl RestoreApplyReportOptions {
396 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
398 where
399 I: IntoIterator<Item = OsString>,
400 {
401 let mut journal = None;
402 let mut require_no_attention = false;
403 let mut out = None;
404
405 let mut args = args.into_iter();
406 while let Some(arg) = args.next() {
407 let arg = arg
408 .into_string()
409 .map_err(|_| RestoreCommandError::Usage(usage()))?;
410 match arg.as_str() {
411 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
412 "--require-no-attention" => require_no_attention = true,
413 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
414 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
415 _ => return Err(RestoreCommandError::UnknownOption(arg)),
416 }
417 }
418
419 Ok(Self {
420 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
421 require_no_attention,
422 out,
423 })
424 }
425}
426
427#[derive(Clone, Debug, Eq, PartialEq)]
432pub struct RestoreApplyNextOptions {
433 pub journal: PathBuf,
434 pub out: Option<PathBuf>,
435}
436
437impl RestoreApplyNextOptions {
438 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
440 where
441 I: IntoIterator<Item = OsString>,
442 {
443 let mut journal = None;
444 let mut out = None;
445
446 let mut args = args.into_iter();
447 while let Some(arg) = args.next() {
448 let arg = arg
449 .into_string()
450 .map_err(|_| RestoreCommandError::Usage(usage()))?;
451 match arg.as_str() {
452 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
453 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
454 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
455 _ => return Err(RestoreCommandError::UnknownOption(arg)),
456 }
457 }
458
459 Ok(Self {
460 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
461 out,
462 })
463 }
464}
465
466#[derive(Clone, Debug, Eq, PartialEq)]
471pub struct RestoreApplyCommandOptions {
472 pub journal: PathBuf,
473 pub dfx: String,
474 pub network: Option<String>,
475 pub out: Option<PathBuf>,
476 pub require_command: bool,
477}
478
479impl RestoreApplyCommandOptions {
480 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
482 where
483 I: IntoIterator<Item = OsString>,
484 {
485 let mut journal = None;
486 let mut dfx = "dfx".to_string();
487 let mut network = None;
488 let mut out = None;
489 let mut require_command = false;
490
491 let mut args = args.into_iter();
492 while let Some(arg) = args.next() {
493 let arg = arg
494 .into_string()
495 .map_err(|_| RestoreCommandError::Usage(usage()))?;
496 match arg.as_str() {
497 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
498 "--dfx" => dfx = next_value(&mut args, "--dfx")?,
499 "--network" => network = Some(next_value(&mut args, "--network")?),
500 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
501 "--require-command" => require_command = true,
502 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
503 _ => return Err(RestoreCommandError::UnknownOption(arg)),
504 }
505 }
506
507 Ok(Self {
508 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
509 dfx,
510 network,
511 out,
512 require_command,
513 })
514 }
515}
516
517#[derive(Clone, Debug, Eq, PartialEq)]
522pub struct RestoreApplyClaimOptions {
523 pub journal: PathBuf,
524 pub sequence: Option<usize>,
525 pub updated_at: Option<String>,
526 pub out: Option<PathBuf>,
527}
528
529impl RestoreApplyClaimOptions {
530 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
532 where
533 I: IntoIterator<Item = OsString>,
534 {
535 let mut journal = None;
536 let mut sequence = None;
537 let mut updated_at = None;
538 let mut out = None;
539
540 let mut args = args.into_iter();
541 while let Some(arg) = args.next() {
542 let arg = arg
543 .into_string()
544 .map_err(|_| RestoreCommandError::Usage(usage()))?;
545 match arg.as_str() {
546 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
547 "--sequence" => {
548 sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
549 }
550 "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
551 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
552 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
553 _ => return Err(RestoreCommandError::UnknownOption(arg)),
554 }
555 }
556
557 Ok(Self {
558 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
559 sequence,
560 updated_at,
561 out,
562 })
563 }
564}
565
566#[derive(Clone, Debug, Eq, PartialEq)]
571pub struct RestoreApplyUnclaimOptions {
572 pub journal: PathBuf,
573 pub sequence: Option<usize>,
574 pub updated_at: Option<String>,
575 pub out: Option<PathBuf>,
576}
577
578impl RestoreApplyUnclaimOptions {
579 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
581 where
582 I: IntoIterator<Item = OsString>,
583 {
584 let mut journal = None;
585 let mut sequence = None;
586 let mut updated_at = None;
587 let mut out = None;
588
589 let mut args = args.into_iter();
590 while let Some(arg) = args.next() {
591 let arg = arg
592 .into_string()
593 .map_err(|_| RestoreCommandError::Usage(usage()))?;
594 match arg.as_str() {
595 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
596 "--sequence" => {
597 sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
598 }
599 "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
600 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
601 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
602 _ => return Err(RestoreCommandError::UnknownOption(arg)),
603 }
604 }
605
606 Ok(Self {
607 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
608 sequence,
609 updated_at,
610 out,
611 })
612 }
613}
614
615#[derive(Clone, Debug, Eq, PartialEq)]
620pub struct RestoreApplyMarkOptions {
621 pub journal: PathBuf,
622 pub sequence: usize,
623 pub state: RestoreApplyMarkState,
624 pub reason: Option<String>,
625 pub updated_at: Option<String>,
626 pub out: Option<PathBuf>,
627 pub require_pending: bool,
628}
629
630impl RestoreApplyMarkOptions {
631 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
633 where
634 I: IntoIterator<Item = OsString>,
635 {
636 let mut journal = None;
637 let mut sequence = None;
638 let mut state = None;
639 let mut reason = None;
640 let mut updated_at = None;
641 let mut out = None;
642 let mut require_pending = false;
643
644 let mut args = args.into_iter();
645 while let Some(arg) = args.next() {
646 let arg = arg
647 .into_string()
648 .map_err(|_| RestoreCommandError::Usage(usage()))?;
649 match arg.as_str() {
650 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
651 "--sequence" => {
652 sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
653 }
654 "--state" => {
655 state = Some(RestoreApplyMarkState::parse(next_value(
656 &mut args, "--state",
657 )?)?);
658 }
659 "--reason" => reason = Some(next_value(&mut args, "--reason")?),
660 "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
661 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
662 "--require-pending" => require_pending = true,
663 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
664 _ => return Err(RestoreCommandError::UnknownOption(arg)),
665 }
666 }
667
668 Ok(Self {
669 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
670 sequence: sequence.ok_or(RestoreCommandError::MissingOption("--sequence"))?,
671 state: state.ok_or(RestoreCommandError::MissingOption("--state"))?,
672 reason,
673 updated_at,
674 out,
675 require_pending,
676 })
677 }
678}
679
680#[derive(Clone, Debug, Eq, PartialEq)]
685pub enum RestoreApplyMarkState {
686 Completed,
687 Failed,
688}
689
690impl RestoreApplyMarkState {
691 fn parse(value: String) -> Result<Self, RestoreCommandError> {
693 match value.as_str() {
694 "completed" => Ok(Self::Completed),
695 "failed" => Ok(Self::Failed),
696 _ => Err(RestoreCommandError::InvalidApplyMarkState(value)),
697 }
698 }
699}
700
701pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
703where
704 I: IntoIterator<Item = OsString>,
705{
706 let mut args = args.into_iter();
707 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
708 return Err(RestoreCommandError::Usage(usage()));
709 };
710
711 match command.as_str() {
712 "plan" => {
713 let options = RestorePlanOptions::parse(args)?;
714 let plan = plan_restore(&options)?;
715 write_plan(&options, &plan)?;
716 enforce_restore_plan_requirements(&options, &plan)?;
717 Ok(())
718 }
719 "status" => {
720 let options = RestoreStatusOptions::parse(args)?;
721 let status = restore_status(&options)?;
722 write_status(&options, &status)?;
723 Ok(())
724 }
725 "apply" => {
726 let options = RestoreApplyOptions::parse(args)?;
727 let dry_run = restore_apply_dry_run(&options)?;
728 write_apply_dry_run(&options, &dry_run)?;
729 write_apply_journal_if_requested(&options, &dry_run)?;
730 Ok(())
731 }
732 "apply-status" => {
733 let options = RestoreApplyStatusOptions::parse(args)?;
734 let status = restore_apply_status(&options)?;
735 write_apply_status(&options, &status)?;
736 enforce_apply_status_requirements(&options, &status)?;
737 Ok(())
738 }
739 "apply-report" => {
740 let options = RestoreApplyReportOptions::parse(args)?;
741 let report = restore_apply_report(&options)?;
742 write_apply_report(&options, &report)?;
743 enforce_apply_report_requirements(&options, &report)?;
744 Ok(())
745 }
746 "apply-next" => {
747 let options = RestoreApplyNextOptions::parse(args)?;
748 let next = restore_apply_next(&options)?;
749 write_apply_next(&options, &next)?;
750 Ok(())
751 }
752 "apply-command" => {
753 let options = RestoreApplyCommandOptions::parse(args)?;
754 let preview = restore_apply_command(&options)?;
755 write_apply_command(&options, &preview)?;
756 enforce_apply_command_requirements(&options, &preview)?;
757 Ok(())
758 }
759 "apply-claim" => {
760 let options = RestoreApplyClaimOptions::parse(args)?;
761 let journal = restore_apply_claim(&options)?;
762 write_apply_claim(&options, &journal)?;
763 Ok(())
764 }
765 "apply-unclaim" => {
766 let options = RestoreApplyUnclaimOptions::parse(args)?;
767 let journal = restore_apply_unclaim(&options)?;
768 write_apply_unclaim(&options, &journal)?;
769 Ok(())
770 }
771 "apply-mark" => {
772 let options = RestoreApplyMarkOptions::parse(args)?;
773 let journal = restore_apply_mark(&options)?;
774 write_apply_mark(&options, &journal)?;
775 Ok(())
776 }
777 "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
778 _ => Err(RestoreCommandError::UnknownOption(command)),
779 }
780}
781
782pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
784 verify_backup_layout_if_required(options)?;
785
786 let manifest = read_manifest_source(options)?;
787 let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
788
789 RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
790}
791
792pub fn restore_status(
794 options: &RestoreStatusOptions,
795) -> Result<RestoreStatus, RestoreCommandError> {
796 let plan = read_plan(&options.plan)?;
797 Ok(RestoreStatus::from_plan(&plan))
798}
799
800pub fn restore_apply_dry_run(
802 options: &RestoreApplyOptions,
803) -> Result<RestoreApplyDryRun, RestoreCommandError> {
804 let plan = read_plan(&options.plan)?;
805 let status = options.status.as_ref().map(read_status).transpose()?;
806 if let Some(backup_dir) = &options.backup_dir {
807 return RestoreApplyDryRun::try_from_plan_with_artifacts(
808 &plan,
809 status.as_ref(),
810 backup_dir,
811 )
812 .map_err(RestoreCommandError::from);
813 }
814
815 RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
816}
817
818pub fn restore_apply_status(
820 options: &RestoreApplyStatusOptions,
821) -> Result<RestoreApplyJournalStatus, RestoreCommandError> {
822 let journal = read_apply_journal(&options.journal)?;
823 Ok(journal.status())
824}
825
826pub fn restore_apply_report(
828 options: &RestoreApplyReportOptions,
829) -> Result<RestoreApplyJournalReport, RestoreCommandError> {
830 let journal = read_apply_journal(&options.journal)?;
831 Ok(journal.report())
832}
833
834fn enforce_apply_report_requirements(
836 options: &RestoreApplyReportOptions,
837 report: &RestoreApplyJournalReport,
838) -> Result<(), RestoreCommandError> {
839 if !options.require_no_attention || !report.attention_required {
840 return Ok(());
841 }
842
843 Err(RestoreCommandError::RestoreApplyReportNeedsAttention {
844 backup_id: report.backup_id.clone(),
845 outcome: report.outcome.clone(),
846 })
847}
848
849fn enforce_apply_status_requirements(
851 options: &RestoreApplyStatusOptions,
852 status: &RestoreApplyJournalStatus,
853) -> Result<(), RestoreCommandError> {
854 if options.require_ready && !status.ready {
855 return Err(RestoreCommandError::RestoreApplyNotReady {
856 backup_id: status.backup_id.clone(),
857 reasons: status.blocked_reasons.clone(),
858 });
859 }
860
861 if options.require_no_pending && status.pending_operations > 0 {
862 return Err(RestoreCommandError::RestoreApplyPending {
863 backup_id: status.backup_id.clone(),
864 pending_operations: status.pending_operations,
865 next_transition_sequence: status.next_transition_sequence,
866 });
867 }
868
869 if options.require_no_failed && status.failed_operations > 0 {
870 return Err(RestoreCommandError::RestoreApplyFailed {
871 backup_id: status.backup_id.clone(),
872 failed_operations: status.failed_operations,
873 });
874 }
875
876 if options.require_complete && !status.complete {
877 return Err(RestoreCommandError::RestoreApplyIncomplete {
878 backup_id: status.backup_id.clone(),
879 completed_operations: status.completed_operations,
880 operation_count: status.operation_count,
881 });
882 }
883
884 Ok(())
885}
886
887pub fn restore_apply_next(
889 options: &RestoreApplyNextOptions,
890) -> Result<RestoreApplyNextOperation, RestoreCommandError> {
891 let journal = read_apply_journal(&options.journal)?;
892 Ok(journal.next_operation())
893}
894
895pub fn restore_apply_command(
897 options: &RestoreApplyCommandOptions,
898) -> Result<RestoreApplyCommandPreview, RestoreCommandError> {
899 let journal = read_apply_journal(&options.journal)?;
900 Ok(
901 journal.next_command_preview_with_config(&RestoreApplyCommandConfig {
902 program: options.dfx.clone(),
903 network: options.network.clone(),
904 }),
905 )
906}
907
908fn enforce_apply_command_requirements(
910 options: &RestoreApplyCommandOptions,
911 preview: &RestoreApplyCommandPreview,
912) -> Result<(), RestoreCommandError> {
913 if !options.require_command || preview.command_available {
914 return Ok(());
915 }
916
917 Err(RestoreCommandError::RestoreApplyCommandUnavailable {
918 backup_id: preview.backup_id.clone(),
919 operation_available: preview.operation_available,
920 complete: preview.complete,
921 blocked_reasons: preview.blocked_reasons.clone(),
922 })
923}
924
925pub fn restore_apply_claim(
927 options: &RestoreApplyClaimOptions,
928) -> Result<RestoreApplyJournal, RestoreCommandError> {
929 let mut journal = read_apply_journal(&options.journal)?;
930 let updated_at = Some(state_updated_at(options.updated_at.as_ref()));
931
932 if let Some(sequence) = options.sequence {
933 enforce_apply_claim_sequence(sequence, &journal)?;
934 journal.mark_operation_pending_at(sequence, updated_at)?;
935 return Ok(journal);
936 }
937
938 journal.mark_next_operation_pending_at(updated_at)?;
939 Ok(journal)
940}
941
942fn enforce_apply_claim_sequence(
944 expected: usize,
945 journal: &RestoreApplyJournal,
946) -> Result<(), RestoreCommandError> {
947 let actual = journal
948 .next_transition_operation()
949 .map(|operation| operation.sequence);
950
951 if actual == Some(expected) {
952 return Ok(());
953 }
954
955 Err(RestoreCommandError::RestoreApplyClaimSequenceMismatch { expected, actual })
956}
957
958pub fn restore_apply_unclaim(
960 options: &RestoreApplyUnclaimOptions,
961) -> Result<RestoreApplyJournal, RestoreCommandError> {
962 let mut journal = read_apply_journal(&options.journal)?;
963 if let Some(sequence) = options.sequence {
964 enforce_apply_unclaim_sequence(sequence, &journal)?;
965 }
966
967 journal.mark_next_operation_ready_at(Some(state_updated_at(options.updated_at.as_ref())))?;
968 Ok(journal)
969}
970
971fn enforce_apply_unclaim_sequence(
973 expected: usize,
974 journal: &RestoreApplyJournal,
975) -> Result<(), RestoreCommandError> {
976 let actual = journal
977 .next_transition_operation()
978 .map(|operation| operation.sequence);
979
980 if actual == Some(expected) {
981 return Ok(());
982 }
983
984 Err(RestoreCommandError::RestoreApplyUnclaimSequenceMismatch { expected, actual })
985}
986
987pub fn restore_apply_mark(
989 options: &RestoreApplyMarkOptions,
990) -> Result<RestoreApplyJournal, RestoreCommandError> {
991 let mut journal = read_apply_journal(&options.journal)?;
992 enforce_apply_mark_pending_requirement(options, &journal)?;
993
994 match options.state {
995 RestoreApplyMarkState::Completed => {
996 journal.mark_operation_completed_at(
997 options.sequence,
998 Some(state_updated_at(options.updated_at.as_ref())),
999 )?;
1000 }
1001 RestoreApplyMarkState::Failed => {
1002 let reason =
1003 options
1004 .reason
1005 .clone()
1006 .ok_or(RestoreApplyJournalError::FailureReasonRequired(
1007 options.sequence,
1008 ))?;
1009 journal.mark_operation_failed_at(
1010 options.sequence,
1011 reason,
1012 Some(state_updated_at(options.updated_at.as_ref())),
1013 )?;
1014 }
1015 }
1016
1017 Ok(journal)
1018}
1019
1020fn enforce_apply_mark_pending_requirement(
1022 options: &RestoreApplyMarkOptions,
1023 journal: &RestoreApplyJournal,
1024) -> Result<(), RestoreCommandError> {
1025 if !options.require_pending {
1026 return Ok(());
1027 }
1028
1029 let state = journal
1030 .operations
1031 .iter()
1032 .find(|operation| operation.sequence == options.sequence)
1033 .map(|operation| operation.state.clone())
1034 .ok_or(RestoreApplyJournalError::OperationNotFound(
1035 options.sequence,
1036 ))?;
1037
1038 if state == RestoreApplyOperationState::Pending {
1039 return Ok(());
1040 }
1041
1042 Err(RestoreCommandError::RestoreApplyMarkRequiresPending {
1043 sequence: options.sequence,
1044 state,
1045 })
1046}
1047
1048fn enforce_restore_plan_requirements(
1050 options: &RestorePlanOptions,
1051 plan: &RestorePlan,
1052) -> Result<(), RestoreCommandError> {
1053 if !options.require_restore_ready || plan.readiness_summary.ready {
1054 return Ok(());
1055 }
1056
1057 Err(RestoreCommandError::RestoreNotReady {
1058 backup_id: plan.backup_id.clone(),
1059 reasons: plan.readiness_summary.reasons.clone(),
1060 })
1061}
1062
1063fn verify_backup_layout_if_required(
1065 options: &RestorePlanOptions,
1066) -> Result<(), RestoreCommandError> {
1067 if !options.require_verified {
1068 return Ok(());
1069 }
1070
1071 let Some(dir) = &options.backup_dir else {
1072 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
1073 };
1074
1075 BackupLayout::new(dir.clone()).verify_integrity()?;
1076 Ok(())
1077}
1078
1079fn read_manifest_source(
1081 options: &RestorePlanOptions,
1082) -> Result<FleetBackupManifest, RestoreCommandError> {
1083 if let Some(path) = &options.manifest {
1084 return read_manifest(path);
1085 }
1086
1087 let Some(dir) = &options.backup_dir else {
1088 return Err(RestoreCommandError::MissingOption(
1089 "--manifest or --backup-dir",
1090 ));
1091 };
1092
1093 BackupLayout::new(dir.clone())
1094 .read_manifest()
1095 .map_err(RestoreCommandError::from)
1096}
1097
1098fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
1100 let data = fs::read_to_string(path)?;
1101 serde_json::from_str(&data).map_err(RestoreCommandError::from)
1102}
1103
1104fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
1106 let data = fs::read_to_string(path)?;
1107 serde_json::from_str(&data).map_err(RestoreCommandError::from)
1108}
1109
1110fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
1112 let data = fs::read_to_string(path)?;
1113 serde_json::from_str(&data).map_err(RestoreCommandError::from)
1114}
1115
1116fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
1118 let data = fs::read_to_string(path)?;
1119 serde_json::from_str(&data).map_err(RestoreCommandError::from)
1120}
1121
1122fn read_apply_journal(path: &PathBuf) -> Result<RestoreApplyJournal, RestoreCommandError> {
1124 let data = fs::read_to_string(path)?;
1125 let journal: RestoreApplyJournal = serde_json::from_str(&data)?;
1126 journal.validate()?;
1127 Ok(journal)
1128}
1129
1130fn parse_sequence(value: String) -> Result<usize, RestoreCommandError> {
1132 value
1133 .parse::<usize>()
1134 .map_err(|_| RestoreCommandError::InvalidSequence)
1135}
1136
1137fn state_updated_at(updated_at: Option<&String>) -> String {
1139 updated_at.cloned().unwrap_or_else(timestamp_placeholder)
1140}
1141
1142fn timestamp_placeholder() -> String {
1144 "unknown".to_string()
1145}
1146
1147fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
1149 if let Some(path) = &options.out {
1150 let data = serde_json::to_vec_pretty(plan)?;
1151 fs::write(path, data)?;
1152 return Ok(());
1153 }
1154
1155 let stdout = io::stdout();
1156 let mut handle = stdout.lock();
1157 serde_json::to_writer_pretty(&mut handle, plan)?;
1158 writeln!(handle)?;
1159 Ok(())
1160}
1161
1162fn write_status(
1164 options: &RestoreStatusOptions,
1165 status: &RestoreStatus,
1166) -> Result<(), RestoreCommandError> {
1167 if let Some(path) = &options.out {
1168 let data = serde_json::to_vec_pretty(status)?;
1169 fs::write(path, data)?;
1170 return Ok(());
1171 }
1172
1173 let stdout = io::stdout();
1174 let mut handle = stdout.lock();
1175 serde_json::to_writer_pretty(&mut handle, status)?;
1176 writeln!(handle)?;
1177 Ok(())
1178}
1179
1180fn write_apply_dry_run(
1182 options: &RestoreApplyOptions,
1183 dry_run: &RestoreApplyDryRun,
1184) -> Result<(), RestoreCommandError> {
1185 if let Some(path) = &options.out {
1186 let data = serde_json::to_vec_pretty(dry_run)?;
1187 fs::write(path, data)?;
1188 return Ok(());
1189 }
1190
1191 let stdout = io::stdout();
1192 let mut handle = stdout.lock();
1193 serde_json::to_writer_pretty(&mut handle, dry_run)?;
1194 writeln!(handle)?;
1195 Ok(())
1196}
1197
1198fn write_apply_journal_if_requested(
1200 options: &RestoreApplyOptions,
1201 dry_run: &RestoreApplyDryRun,
1202) -> Result<(), RestoreCommandError> {
1203 let Some(path) = &options.journal_out else {
1204 return Ok(());
1205 };
1206
1207 let journal = RestoreApplyJournal::from_dry_run(dry_run);
1208 let data = serde_json::to_vec_pretty(&journal)?;
1209 fs::write(path, data)?;
1210 Ok(())
1211}
1212
1213fn write_apply_status(
1215 options: &RestoreApplyStatusOptions,
1216 status: &RestoreApplyJournalStatus,
1217) -> Result<(), RestoreCommandError> {
1218 if let Some(path) = &options.out {
1219 let data = serde_json::to_vec_pretty(status)?;
1220 fs::write(path, data)?;
1221 return Ok(());
1222 }
1223
1224 let stdout = io::stdout();
1225 let mut handle = stdout.lock();
1226 serde_json::to_writer_pretty(&mut handle, status)?;
1227 writeln!(handle)?;
1228 Ok(())
1229}
1230
1231fn write_apply_report(
1233 options: &RestoreApplyReportOptions,
1234 report: &RestoreApplyJournalReport,
1235) -> Result<(), RestoreCommandError> {
1236 if let Some(path) = &options.out {
1237 let data = serde_json::to_vec_pretty(report)?;
1238 fs::write(path, data)?;
1239 return Ok(());
1240 }
1241
1242 let stdout = io::stdout();
1243 let mut handle = stdout.lock();
1244 serde_json::to_writer_pretty(&mut handle, report)?;
1245 writeln!(handle)?;
1246 Ok(())
1247}
1248
1249fn write_apply_next(
1251 options: &RestoreApplyNextOptions,
1252 next: &RestoreApplyNextOperation,
1253) -> Result<(), RestoreCommandError> {
1254 if let Some(path) = &options.out {
1255 let data = serde_json::to_vec_pretty(next)?;
1256 fs::write(path, data)?;
1257 return Ok(());
1258 }
1259
1260 let stdout = io::stdout();
1261 let mut handle = stdout.lock();
1262 serde_json::to_writer_pretty(&mut handle, next)?;
1263 writeln!(handle)?;
1264 Ok(())
1265}
1266
1267fn write_apply_command(
1269 options: &RestoreApplyCommandOptions,
1270 preview: &RestoreApplyCommandPreview,
1271) -> Result<(), RestoreCommandError> {
1272 if let Some(path) = &options.out {
1273 let data = serde_json::to_vec_pretty(preview)?;
1274 fs::write(path, data)?;
1275 return Ok(());
1276 }
1277
1278 let stdout = io::stdout();
1279 let mut handle = stdout.lock();
1280 serde_json::to_writer_pretty(&mut handle, preview)?;
1281 writeln!(handle)?;
1282 Ok(())
1283}
1284
1285fn write_apply_claim(
1287 options: &RestoreApplyClaimOptions,
1288 journal: &RestoreApplyJournal,
1289) -> Result<(), RestoreCommandError> {
1290 if let Some(path) = &options.out {
1291 let data = serde_json::to_vec_pretty(journal)?;
1292 fs::write(path, data)?;
1293 return Ok(());
1294 }
1295
1296 let stdout = io::stdout();
1297 let mut handle = stdout.lock();
1298 serde_json::to_writer_pretty(&mut handle, journal)?;
1299 writeln!(handle)?;
1300 Ok(())
1301}
1302
1303fn write_apply_unclaim(
1305 options: &RestoreApplyUnclaimOptions,
1306 journal: &RestoreApplyJournal,
1307) -> Result<(), RestoreCommandError> {
1308 if let Some(path) = &options.out {
1309 let data = serde_json::to_vec_pretty(journal)?;
1310 fs::write(path, data)?;
1311 return Ok(());
1312 }
1313
1314 let stdout = io::stdout();
1315 let mut handle = stdout.lock();
1316 serde_json::to_writer_pretty(&mut handle, journal)?;
1317 writeln!(handle)?;
1318 Ok(())
1319}
1320
1321fn write_apply_mark(
1323 options: &RestoreApplyMarkOptions,
1324 journal: &RestoreApplyJournal,
1325) -> Result<(), RestoreCommandError> {
1326 if let Some(path) = &options.out {
1327 let data = serde_json::to_vec_pretty(journal)?;
1328 fs::write(path, data)?;
1329 return Ok(());
1330 }
1331
1332 let stdout = io::stdout();
1333 let mut handle = stdout.lock();
1334 serde_json::to_writer_pretty(&mut handle, journal)?;
1335 writeln!(handle)?;
1336 Ok(())
1337}
1338
1339fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
1341where
1342 I: Iterator<Item = OsString>,
1343{
1344 args.next()
1345 .and_then(|value| value.into_string().ok())
1346 .ok_or(RestoreCommandError::MissingValue(option))
1347}
1348
1349const fn usage() -> &'static str {
1351 "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 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]"
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356 use super::*;
1357 use canic_backup::restore::RestoreApplyOperationState;
1358 use canic_backup::{
1359 artifacts::ArtifactChecksum,
1360 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
1361 manifest::{
1362 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
1363 FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
1364 VerificationCheck, VerificationPlan,
1365 },
1366 };
1367 use serde_json::json;
1368 use std::{
1369 path::Path,
1370 time::{SystemTime, UNIX_EPOCH},
1371 };
1372
1373 const ROOT: &str = "aaaaa-aa";
1374 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
1375 const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
1376 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1377
1378 #[test]
1380 fn parses_restore_plan_options() {
1381 let options = RestorePlanOptions::parse([
1382 OsString::from("--manifest"),
1383 OsString::from("manifest.json"),
1384 OsString::from("--mapping"),
1385 OsString::from("mapping.json"),
1386 OsString::from("--out"),
1387 OsString::from("plan.json"),
1388 OsString::from("--require-restore-ready"),
1389 ])
1390 .expect("parse options");
1391
1392 assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
1393 assert_eq!(options.backup_dir, None);
1394 assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
1395 assert_eq!(options.out, Some(PathBuf::from("plan.json")));
1396 assert!(!options.require_verified);
1397 assert!(options.require_restore_ready);
1398 }
1399
1400 #[test]
1402 fn parses_verified_restore_plan_options() {
1403 let options = RestorePlanOptions::parse([
1404 OsString::from("--backup-dir"),
1405 OsString::from("backups/run"),
1406 OsString::from("--require-verified"),
1407 ])
1408 .expect("parse verified options");
1409
1410 assert_eq!(options.manifest, None);
1411 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
1412 assert_eq!(options.mapping, None);
1413 assert_eq!(options.out, None);
1414 assert!(options.require_verified);
1415 assert!(!options.require_restore_ready);
1416 }
1417
1418 #[test]
1420 fn parses_restore_status_options() {
1421 let options = RestoreStatusOptions::parse([
1422 OsString::from("--plan"),
1423 OsString::from("restore-plan.json"),
1424 OsString::from("--out"),
1425 OsString::from("restore-status.json"),
1426 ])
1427 .expect("parse status options");
1428
1429 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
1430 assert_eq!(options.out, Some(PathBuf::from("restore-status.json")));
1431 }
1432
1433 #[test]
1435 fn parses_restore_apply_dry_run_options() {
1436 let options = RestoreApplyOptions::parse([
1437 OsString::from("--plan"),
1438 OsString::from("restore-plan.json"),
1439 OsString::from("--status"),
1440 OsString::from("restore-status.json"),
1441 OsString::from("--backup-dir"),
1442 OsString::from("backups/run"),
1443 OsString::from("--dry-run"),
1444 OsString::from("--out"),
1445 OsString::from("restore-apply-dry-run.json"),
1446 OsString::from("--journal-out"),
1447 OsString::from("restore-apply-journal.json"),
1448 ])
1449 .expect("parse apply options");
1450
1451 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
1452 assert_eq!(options.status, Some(PathBuf::from("restore-status.json")));
1453 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
1454 assert_eq!(
1455 options.out,
1456 Some(PathBuf::from("restore-apply-dry-run.json"))
1457 );
1458 assert_eq!(
1459 options.journal_out,
1460 Some(PathBuf::from("restore-apply-journal.json"))
1461 );
1462 assert!(options.dry_run);
1463 }
1464
1465 #[test]
1467 fn parses_restore_apply_status_options() {
1468 let options = RestoreApplyStatusOptions::parse([
1469 OsString::from("--journal"),
1470 OsString::from("restore-apply-journal.json"),
1471 OsString::from("--out"),
1472 OsString::from("restore-apply-status.json"),
1473 OsString::from("--require-ready"),
1474 OsString::from("--require-no-pending"),
1475 OsString::from("--require-no-failed"),
1476 OsString::from("--require-complete"),
1477 ])
1478 .expect("parse apply-status options");
1479
1480 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1481 assert!(options.require_ready);
1482 assert!(options.require_no_pending);
1483 assert!(options.require_no_failed);
1484 assert!(options.require_complete);
1485 assert_eq!(
1486 options.out,
1487 Some(PathBuf::from("restore-apply-status.json"))
1488 );
1489 }
1490
1491 #[test]
1493 fn parses_restore_apply_report_options() {
1494 let options = RestoreApplyReportOptions::parse([
1495 OsString::from("--journal"),
1496 OsString::from("restore-apply-journal.json"),
1497 OsString::from("--out"),
1498 OsString::from("restore-apply-report.json"),
1499 OsString::from("--require-no-attention"),
1500 ])
1501 .expect("parse apply-report options");
1502
1503 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1504 assert!(options.require_no_attention);
1505 assert_eq!(
1506 options.out,
1507 Some(PathBuf::from("restore-apply-report.json"))
1508 );
1509 }
1510
1511 #[test]
1513 fn parses_restore_apply_next_options() {
1514 let options = RestoreApplyNextOptions::parse([
1515 OsString::from("--journal"),
1516 OsString::from("restore-apply-journal.json"),
1517 OsString::from("--out"),
1518 OsString::from("restore-apply-next.json"),
1519 ])
1520 .expect("parse apply-next options");
1521
1522 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1523 assert_eq!(options.out, Some(PathBuf::from("restore-apply-next.json")));
1524 }
1525
1526 #[test]
1528 fn parses_restore_apply_command_options() {
1529 let options = RestoreApplyCommandOptions::parse([
1530 OsString::from("--journal"),
1531 OsString::from("restore-apply-journal.json"),
1532 OsString::from("--dfx"),
1533 OsString::from("/tmp/dfx"),
1534 OsString::from("--network"),
1535 OsString::from("local"),
1536 OsString::from("--out"),
1537 OsString::from("restore-apply-command.json"),
1538 OsString::from("--require-command"),
1539 ])
1540 .expect("parse apply-command options");
1541
1542 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1543 assert_eq!(options.dfx, "/tmp/dfx");
1544 assert_eq!(options.network.as_deref(), Some("local"));
1545 assert!(options.require_command);
1546 assert_eq!(
1547 options.out,
1548 Some(PathBuf::from("restore-apply-command.json"))
1549 );
1550 }
1551
1552 #[test]
1554 fn parses_restore_apply_claim_options() {
1555 let options = RestoreApplyClaimOptions::parse([
1556 OsString::from("--journal"),
1557 OsString::from("restore-apply-journal.json"),
1558 OsString::from("--sequence"),
1559 OsString::from("0"),
1560 OsString::from("--updated-at"),
1561 OsString::from("2026-05-04T12:00:00Z"),
1562 OsString::from("--out"),
1563 OsString::from("restore-apply-journal.claimed.json"),
1564 ])
1565 .expect("parse apply-claim options");
1566
1567 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1568 assert_eq!(options.sequence, Some(0));
1569 assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:00:00Z"));
1570 assert_eq!(
1571 options.out,
1572 Some(PathBuf::from("restore-apply-journal.claimed.json"))
1573 );
1574 }
1575
1576 #[test]
1578 fn parses_restore_apply_unclaim_options() {
1579 let options = RestoreApplyUnclaimOptions::parse([
1580 OsString::from("--journal"),
1581 OsString::from("restore-apply-journal.json"),
1582 OsString::from("--sequence"),
1583 OsString::from("0"),
1584 OsString::from("--updated-at"),
1585 OsString::from("2026-05-04T12:01:00Z"),
1586 OsString::from("--out"),
1587 OsString::from("restore-apply-journal.unclaimed.json"),
1588 ])
1589 .expect("parse apply-unclaim options");
1590
1591 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1592 assert_eq!(options.sequence, Some(0));
1593 assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:01:00Z"));
1594 assert_eq!(
1595 options.out,
1596 Some(PathBuf::from("restore-apply-journal.unclaimed.json"))
1597 );
1598 }
1599
1600 #[test]
1602 fn parses_restore_apply_mark_options() {
1603 let options = RestoreApplyMarkOptions::parse([
1604 OsString::from("--journal"),
1605 OsString::from("restore-apply-journal.json"),
1606 OsString::from("--sequence"),
1607 OsString::from("4"),
1608 OsString::from("--state"),
1609 OsString::from("failed"),
1610 OsString::from("--reason"),
1611 OsString::from("dfx-load-failed"),
1612 OsString::from("--updated-at"),
1613 OsString::from("2026-05-04T12:02:00Z"),
1614 OsString::from("--out"),
1615 OsString::from("restore-apply-journal.updated.json"),
1616 OsString::from("--require-pending"),
1617 ])
1618 .expect("parse apply-mark options");
1619
1620 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1621 assert_eq!(options.sequence, 4);
1622 assert_eq!(options.state, RestoreApplyMarkState::Failed);
1623 assert_eq!(options.reason.as_deref(), Some("dfx-load-failed"));
1624 assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:02:00Z"));
1625 assert!(options.require_pending);
1626 assert_eq!(
1627 options.out,
1628 Some(PathBuf::from("restore-apply-journal.updated.json"))
1629 );
1630 }
1631
1632 #[test]
1634 fn restore_apply_requires_dry_run() {
1635 let err = RestoreApplyOptions::parse([
1636 OsString::from("--plan"),
1637 OsString::from("restore-plan.json"),
1638 ])
1639 .expect_err("apply without dry-run should fail");
1640
1641 assert!(matches!(err, RestoreCommandError::ApplyRequiresDryRun));
1642 }
1643
1644 #[test]
1646 fn plan_restore_reads_manifest_from_backup_dir() {
1647 let root = temp_dir("canic-cli-restore-plan-layout");
1648 let layout = BackupLayout::new(root.clone());
1649 layout
1650 .write_manifest(&valid_manifest())
1651 .expect("write manifest");
1652
1653 let options = RestorePlanOptions {
1654 manifest: None,
1655 backup_dir: Some(root.clone()),
1656 mapping: None,
1657 out: None,
1658 require_verified: false,
1659 require_restore_ready: false,
1660 };
1661
1662 let plan = plan_restore(&options).expect("plan restore");
1663
1664 fs::remove_dir_all(root).expect("remove temp root");
1665 assert_eq!(plan.backup_id, "backup-test");
1666 assert_eq!(plan.member_count, 2);
1667 }
1668
1669 #[test]
1671 fn parse_rejects_conflicting_manifest_sources() {
1672 let err = RestorePlanOptions::parse([
1673 OsString::from("--manifest"),
1674 OsString::from("manifest.json"),
1675 OsString::from("--backup-dir"),
1676 OsString::from("backups/run"),
1677 ])
1678 .expect_err("conflicting sources should fail");
1679
1680 assert!(matches!(
1681 err,
1682 RestoreCommandError::ConflictingManifestSources
1683 ));
1684 }
1685
1686 #[test]
1688 fn parse_rejects_require_verified_with_manifest_source() {
1689 let err = RestorePlanOptions::parse([
1690 OsString::from("--manifest"),
1691 OsString::from("manifest.json"),
1692 OsString::from("--require-verified"),
1693 ])
1694 .expect_err("verification should require a backup layout");
1695
1696 assert!(matches!(
1697 err,
1698 RestoreCommandError::RequireVerifiedNeedsBackupDir
1699 ));
1700 }
1701
1702 #[test]
1704 fn plan_restore_requires_verified_backup_layout() {
1705 let root = temp_dir("canic-cli-restore-plan-verified");
1706 let layout = BackupLayout::new(root.clone());
1707 let manifest = valid_manifest();
1708 write_verified_layout(&root, &layout, &manifest);
1709
1710 let options = RestorePlanOptions {
1711 manifest: None,
1712 backup_dir: Some(root.clone()),
1713 mapping: None,
1714 out: None,
1715 require_verified: true,
1716 require_restore_ready: false,
1717 };
1718
1719 let plan = plan_restore(&options).expect("plan verified restore");
1720
1721 fs::remove_dir_all(root).expect("remove temp root");
1722 assert_eq!(plan.backup_id, "backup-test");
1723 assert_eq!(plan.member_count, 2);
1724 }
1725
1726 #[test]
1728 fn plan_restore_rejects_unverified_backup_layout() {
1729 let root = temp_dir("canic-cli-restore-plan-unverified");
1730 let layout = BackupLayout::new(root.clone());
1731 layout
1732 .write_manifest(&valid_manifest())
1733 .expect("write manifest");
1734
1735 let options = RestorePlanOptions {
1736 manifest: None,
1737 backup_dir: Some(root.clone()),
1738 mapping: None,
1739 out: None,
1740 require_verified: true,
1741 require_restore_ready: false,
1742 };
1743
1744 let err = plan_restore(&options).expect_err("missing journal should fail");
1745
1746 fs::remove_dir_all(root).expect("remove temp root");
1747 assert!(matches!(err, RestoreCommandError::Persistence(_)));
1748 }
1749
1750 #[test]
1752 fn plan_restore_reads_manifest_and_mapping() {
1753 let root = temp_dir("canic-cli-restore-plan");
1754 fs::create_dir_all(&root).expect("create temp root");
1755 let manifest_path = root.join("manifest.json");
1756 let mapping_path = root.join("mapping.json");
1757
1758 fs::write(
1759 &manifest_path,
1760 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
1761 )
1762 .expect("write manifest");
1763 fs::write(
1764 &mapping_path,
1765 json!({
1766 "members": [
1767 {"source_canister": ROOT, "target_canister": ROOT},
1768 {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
1769 ]
1770 })
1771 .to_string(),
1772 )
1773 .expect("write mapping");
1774
1775 let options = RestorePlanOptions {
1776 manifest: Some(manifest_path),
1777 backup_dir: None,
1778 mapping: Some(mapping_path),
1779 out: None,
1780 require_verified: false,
1781 require_restore_ready: false,
1782 };
1783
1784 let plan = plan_restore(&options).expect("plan restore");
1785
1786 fs::remove_dir_all(root).expect("remove temp root");
1787 let members = plan.ordered_members();
1788 assert_eq!(members.len(), 2);
1789 assert_eq!(members[0].source_canister, ROOT);
1790 assert_eq!(members[1].target_canister, MAPPED_CHILD);
1791 }
1792
1793 #[test]
1795 fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
1796 let root = temp_dir("canic-cli-restore-plan-require-ready");
1797 fs::create_dir_all(&root).expect("create temp root");
1798 let manifest_path = root.join("manifest.json");
1799 let out_path = root.join("plan.json");
1800
1801 fs::write(
1802 &manifest_path,
1803 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
1804 )
1805 .expect("write manifest");
1806
1807 let err = run([
1808 OsString::from("plan"),
1809 OsString::from("--manifest"),
1810 OsString::from(manifest_path.as_os_str()),
1811 OsString::from("--out"),
1812 OsString::from(out_path.as_os_str()),
1813 OsString::from("--require-restore-ready"),
1814 ])
1815 .expect_err("restore readiness should be enforced");
1816
1817 assert!(out_path.exists());
1818 let plan: RestorePlan =
1819 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
1820
1821 fs::remove_dir_all(root).expect("remove temp root");
1822 assert!(!plan.readiness_summary.ready);
1823 assert!(matches!(
1824 err,
1825 RestoreCommandError::RestoreNotReady {
1826 reasons,
1827 ..
1828 } if reasons == [
1829 "missing-module-hash",
1830 "missing-wasm-hash",
1831 "missing-snapshot-checksum"
1832 ]
1833 ));
1834 }
1835
1836 #[test]
1838 fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
1839 let root = temp_dir("canic-cli-restore-plan-ready");
1840 fs::create_dir_all(&root).expect("create temp root");
1841 let manifest_path = root.join("manifest.json");
1842 let out_path = root.join("plan.json");
1843
1844 fs::write(
1845 &manifest_path,
1846 serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
1847 )
1848 .expect("write manifest");
1849
1850 run([
1851 OsString::from("plan"),
1852 OsString::from("--manifest"),
1853 OsString::from(manifest_path.as_os_str()),
1854 OsString::from("--out"),
1855 OsString::from(out_path.as_os_str()),
1856 OsString::from("--require-restore-ready"),
1857 ])
1858 .expect("restore-ready plan should pass");
1859
1860 let plan: RestorePlan =
1861 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
1862
1863 fs::remove_dir_all(root).expect("remove temp root");
1864 assert!(plan.readiness_summary.ready);
1865 assert!(plan.readiness_summary.reasons.is_empty());
1866 }
1867
1868 #[test]
1870 fn run_restore_status_writes_planned_status() {
1871 let root = temp_dir("canic-cli-restore-status");
1872 fs::create_dir_all(&root).expect("create temp root");
1873 let plan_path = root.join("restore-plan.json");
1874 let out_path = root.join("restore-status.json");
1875 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1876
1877 fs::write(
1878 &plan_path,
1879 serde_json::to_vec(&plan).expect("serialize plan"),
1880 )
1881 .expect("write plan");
1882
1883 run([
1884 OsString::from("status"),
1885 OsString::from("--plan"),
1886 OsString::from(plan_path.as_os_str()),
1887 OsString::from("--out"),
1888 OsString::from(out_path.as_os_str()),
1889 ])
1890 .expect("write restore status");
1891
1892 let status: RestoreStatus =
1893 serde_json::from_slice(&fs::read(&out_path).expect("read restore status"))
1894 .expect("decode restore status");
1895 let status_json: serde_json::Value = serde_json::to_value(&status).expect("encode status");
1896
1897 fs::remove_dir_all(root).expect("remove temp root");
1898 assert_eq!(status.status_version, 1);
1899 assert_eq!(status.backup_id.as_str(), "backup-test");
1900 assert!(status.ready);
1901 assert!(status.readiness_reasons.is_empty());
1902 assert_eq!(status.member_count, 2);
1903 assert_eq!(status.phase_count, 1);
1904 assert_eq!(status.planned_snapshot_loads, 2);
1905 assert_eq!(status.planned_code_reinstalls, 2);
1906 assert_eq!(status.planned_verification_checks, 2);
1907 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
1908 assert_eq!(status_json["phases"][0]["members"][0]["state"], "planned");
1909 }
1910
1911 #[test]
1913 fn run_restore_apply_dry_run_writes_operations() {
1914 let root = temp_dir("canic-cli-restore-apply-dry-run");
1915 fs::create_dir_all(&root).expect("create temp root");
1916 let plan_path = root.join("restore-plan.json");
1917 let status_path = root.join("restore-status.json");
1918 let out_path = root.join("restore-apply-dry-run.json");
1919 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1920 let status = RestoreStatus::from_plan(&plan);
1921
1922 fs::write(
1923 &plan_path,
1924 serde_json::to_vec(&plan).expect("serialize plan"),
1925 )
1926 .expect("write plan");
1927 fs::write(
1928 &status_path,
1929 serde_json::to_vec(&status).expect("serialize status"),
1930 )
1931 .expect("write status");
1932
1933 run([
1934 OsString::from("apply"),
1935 OsString::from("--plan"),
1936 OsString::from(plan_path.as_os_str()),
1937 OsString::from("--status"),
1938 OsString::from(status_path.as_os_str()),
1939 OsString::from("--dry-run"),
1940 OsString::from("--out"),
1941 OsString::from(out_path.as_os_str()),
1942 ])
1943 .expect("write apply dry-run");
1944
1945 let dry_run: RestoreApplyDryRun =
1946 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
1947 .expect("decode dry-run");
1948 let dry_run_json: serde_json::Value =
1949 serde_json::to_value(&dry_run).expect("encode dry-run");
1950
1951 fs::remove_dir_all(root).expect("remove temp root");
1952 assert_eq!(dry_run.dry_run_version, 1);
1953 assert_eq!(dry_run.backup_id.as_str(), "backup-test");
1954 assert!(dry_run.ready);
1955 assert!(dry_run.status_supplied);
1956 assert_eq!(dry_run.member_count, 2);
1957 assert_eq!(dry_run.phase_count, 1);
1958 assert_eq!(dry_run.rendered_operations, 8);
1959 assert_eq!(
1960 dry_run_json["phases"][0]["operations"][0]["operation"],
1961 "upload-snapshot"
1962 );
1963 assert_eq!(
1964 dry_run_json["phases"][0]["operations"][3]["operation"],
1965 "verify-member"
1966 );
1967 assert_eq!(
1968 dry_run_json["phases"][0]["operations"][3]["verification_kind"],
1969 "status"
1970 );
1971 assert_eq!(
1972 dry_run_json["phases"][0]["operations"][3]["verification_method"],
1973 serde_json::Value::Null
1974 );
1975 }
1976
1977 #[test]
1979 fn run_restore_apply_dry_run_validates_backup_dir_artifacts() {
1980 let root = temp_dir("canic-cli-restore-apply-artifacts");
1981 fs::create_dir_all(&root).expect("create temp root");
1982 let plan_path = root.join("restore-plan.json");
1983 let out_path = root.join("restore-apply-dry-run.json");
1984 let journal_path = root.join("restore-apply-journal.json");
1985 let status_path = root.join("restore-apply-status.json");
1986 let mut manifest = restore_ready_manifest();
1987 write_manifest_artifacts(&root, &mut manifest);
1988 let plan = RestorePlanner::plan(&manifest, None).expect("build plan");
1989
1990 fs::write(
1991 &plan_path,
1992 serde_json::to_vec(&plan).expect("serialize plan"),
1993 )
1994 .expect("write plan");
1995
1996 run([
1997 OsString::from("apply"),
1998 OsString::from("--plan"),
1999 OsString::from(plan_path.as_os_str()),
2000 OsString::from("--backup-dir"),
2001 OsString::from(root.as_os_str()),
2002 OsString::from("--dry-run"),
2003 OsString::from("--out"),
2004 OsString::from(out_path.as_os_str()),
2005 OsString::from("--journal-out"),
2006 OsString::from(journal_path.as_os_str()),
2007 ])
2008 .expect("write apply dry-run");
2009 run([
2010 OsString::from("apply-status"),
2011 OsString::from("--journal"),
2012 OsString::from(journal_path.as_os_str()),
2013 OsString::from("--out"),
2014 OsString::from(status_path.as_os_str()),
2015 ])
2016 .expect("write apply status");
2017
2018 let dry_run: RestoreApplyDryRun =
2019 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
2020 .expect("decode dry-run");
2021 let validation = dry_run
2022 .artifact_validation
2023 .expect("artifact validation should be present");
2024 let journal_json: serde_json::Value =
2025 serde_json::from_slice(&fs::read(&journal_path).expect("read journal"))
2026 .expect("decode journal");
2027 let status_json: serde_json::Value =
2028 serde_json::from_slice(&fs::read(&status_path).expect("read apply status"))
2029 .expect("decode apply status");
2030
2031 fs::remove_dir_all(root).expect("remove temp root");
2032 assert_eq!(validation.checked_members, 2);
2033 assert!(validation.artifacts_present);
2034 assert!(validation.checksums_verified);
2035 assert_eq!(validation.members_with_expected_checksums, 2);
2036 assert_eq!(journal_json["ready"], true);
2037 assert_eq!(journal_json["operation_count"], 8);
2038 assert_eq!(journal_json["ready_operations"], 8);
2039 assert_eq!(journal_json["blocked_operations"], 0);
2040 assert_eq!(journal_json["operations"][0]["state"], "ready");
2041 assert_eq!(status_json["ready"], true);
2042 assert_eq!(status_json["operation_count"], 8);
2043 assert_eq!(status_json["next_ready_sequence"], 0);
2044 assert_eq!(status_json["next_ready_operation"], "upload-snapshot");
2045 }
2046
2047 #[test]
2049 fn run_restore_apply_status_rejects_invalid_journal() {
2050 let root = temp_dir("canic-cli-restore-apply-status-invalid");
2051 fs::create_dir_all(&root).expect("create temp root");
2052 let journal_path = root.join("restore-apply-journal.json");
2053 let out_path = root.join("restore-apply-status.json");
2054 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
2055 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
2056 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2057 journal.operation_count += 1;
2058
2059 fs::write(
2060 &journal_path,
2061 serde_json::to_vec(&journal).expect("serialize journal"),
2062 )
2063 .expect("write journal");
2064
2065 let err = run([
2066 OsString::from("apply-status"),
2067 OsString::from("--journal"),
2068 OsString::from(journal_path.as_os_str()),
2069 OsString::from("--out"),
2070 OsString::from(out_path.as_os_str()),
2071 ])
2072 .expect_err("invalid journal should fail");
2073
2074 assert!(!out_path.exists());
2075 fs::remove_dir_all(root).expect("remove temp root");
2076 assert!(matches!(
2077 err,
2078 RestoreCommandError::RestoreApplyJournal(RestoreApplyJournalError::CountMismatch {
2079 field: "operation_count",
2080 ..
2081 })
2082 ));
2083 }
2084
2085 #[test]
2087 fn run_restore_apply_status_require_no_pending_writes_status_then_fails() {
2088 let root = temp_dir("canic-cli-restore-apply-status-pending");
2089 fs::create_dir_all(&root).expect("create temp root");
2090 let journal_path = root.join("restore-apply-journal.json");
2091 let out_path = root.join("restore-apply-status.json");
2092 let mut journal = ready_apply_journal();
2093 journal
2094 .mark_next_operation_pending_at(Some("2026-05-04T12:00:00Z".to_string()))
2095 .expect("claim operation");
2096
2097 fs::write(
2098 &journal_path,
2099 serde_json::to_vec(&journal).expect("serialize journal"),
2100 )
2101 .expect("write journal");
2102
2103 let err = run([
2104 OsString::from("apply-status"),
2105 OsString::from("--journal"),
2106 OsString::from(journal_path.as_os_str()),
2107 OsString::from("--out"),
2108 OsString::from(out_path.as_os_str()),
2109 OsString::from("--require-no-pending"),
2110 ])
2111 .expect_err("pending operation should fail requirement");
2112
2113 assert!(out_path.exists());
2114 let status: RestoreApplyJournalStatus =
2115 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
2116 .expect("decode apply status");
2117
2118 fs::remove_dir_all(root).expect("remove temp root");
2119 assert_eq!(status.pending_operations, 1);
2120 assert_eq!(status.next_transition_sequence, Some(0));
2121 assert_eq!(
2122 status.next_transition_updated_at.as_deref(),
2123 Some("2026-05-04T12:00:00Z")
2124 );
2125 assert!(matches!(
2126 err,
2127 RestoreCommandError::RestoreApplyPending {
2128 pending_operations: 1,
2129 next_transition_sequence: Some(0),
2130 ..
2131 }
2132 ));
2133 }
2134
2135 #[test]
2137 fn run_restore_apply_status_require_ready_writes_status_then_fails() {
2138 let root = temp_dir("canic-cli-restore-apply-status-ready");
2139 fs::create_dir_all(&root).expect("create temp root");
2140 let journal_path = root.join("restore-apply-journal.json");
2141 let out_path = root.join("restore-apply-status.json");
2142 let plan = RestorePlanner::plan(&valid_manifest(), None).expect("build plan");
2143 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
2144 let journal = RestoreApplyJournal::from_dry_run(&dry_run);
2145
2146 fs::write(
2147 &journal_path,
2148 serde_json::to_vec(&journal).expect("serialize journal"),
2149 )
2150 .expect("write journal");
2151
2152 let err = run([
2153 OsString::from("apply-status"),
2154 OsString::from("--journal"),
2155 OsString::from(journal_path.as_os_str()),
2156 OsString::from("--out"),
2157 OsString::from(out_path.as_os_str()),
2158 OsString::from("--require-ready"),
2159 ])
2160 .expect_err("unready journal should fail requirement");
2161
2162 let status: RestoreApplyJournalStatus =
2163 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
2164 .expect("decode apply status");
2165
2166 fs::remove_dir_all(root).expect("remove temp root");
2167 assert!(!status.ready);
2168 assert_eq!(status.blocked_operations, status.operation_count);
2169 assert!(
2170 status
2171 .blocked_reasons
2172 .contains(&"missing-snapshot-checksum".to_string())
2173 );
2174 assert!(matches!(
2175 err,
2176 RestoreCommandError::RestoreApplyNotReady { reasons, .. }
2177 if reasons.contains(&"missing-snapshot-checksum".to_string())
2178 ));
2179 }
2180
2181 #[test]
2183 fn run_restore_apply_report_writes_attention_summary() {
2184 let root = temp_dir("canic-cli-restore-apply-report");
2185 fs::create_dir_all(&root).expect("create temp root");
2186 let journal_path = root.join("restore-apply-journal.json");
2187 let out_path = root.join("restore-apply-report.json");
2188 let mut journal = ready_apply_journal();
2189 journal
2190 .mark_operation_failed_at(
2191 0,
2192 "dfx-upload-failed".to_string(),
2193 Some("2026-05-05T12:00:00Z".to_string()),
2194 )
2195 .expect("mark failed operation");
2196 journal
2197 .mark_next_operation_pending_at(Some("2026-05-05T12:01:00Z".to_string()))
2198 .expect("mark pending operation");
2199
2200 fs::write(
2201 &journal_path,
2202 serde_json::to_vec(&journal).expect("serialize journal"),
2203 )
2204 .expect("write journal");
2205
2206 run([
2207 OsString::from("apply-report"),
2208 OsString::from("--journal"),
2209 OsString::from(journal_path.as_os_str()),
2210 OsString::from("--out"),
2211 OsString::from(out_path.as_os_str()),
2212 ])
2213 .expect("write apply report");
2214
2215 let report: RestoreApplyJournalReport =
2216 serde_json::from_slice(&fs::read(&out_path).expect("read apply report"))
2217 .expect("decode apply report");
2218 let report_json: serde_json::Value =
2219 serde_json::to_value(&report).expect("encode apply report");
2220
2221 fs::remove_dir_all(root).expect("remove temp root");
2222 assert_eq!(report.backup_id, "backup-test");
2223 assert!(report.attention_required);
2224 assert_eq!(report.failed_operations, 1);
2225 assert_eq!(report.pending_operations, 1);
2226 assert_eq!(report.failed.len(), 1);
2227 assert_eq!(report.pending.len(), 1);
2228 assert_eq!(report.failed[0].sequence, 0);
2229 assert_eq!(report.pending[0].sequence, 1);
2230 assert_eq!(
2231 report.next_transition.as_ref().map(|op| op.sequence),
2232 Some(1)
2233 );
2234 assert_eq!(report_json["outcome"], "failed");
2235 assert_eq!(report_json["failed"][0]["reasons"][0], "dfx-upload-failed");
2236 }
2237
2238 #[test]
2240 fn run_restore_apply_report_require_no_attention_writes_report_then_fails() {
2241 let root = temp_dir("canic-cli-restore-apply-report-attention");
2242 fs::create_dir_all(&root).expect("create temp root");
2243 let journal_path = root.join("restore-apply-journal.json");
2244 let out_path = root.join("restore-apply-report.json");
2245 let mut journal = ready_apply_journal();
2246 journal
2247 .mark_next_operation_pending_at(Some("2026-05-05T12:01:00Z".to_string()))
2248 .expect("mark pending operation");
2249
2250 fs::write(
2251 &journal_path,
2252 serde_json::to_vec(&journal).expect("serialize journal"),
2253 )
2254 .expect("write journal");
2255
2256 let err = run([
2257 OsString::from("apply-report"),
2258 OsString::from("--journal"),
2259 OsString::from(journal_path.as_os_str()),
2260 OsString::from("--out"),
2261 OsString::from(out_path.as_os_str()),
2262 OsString::from("--require-no-attention"),
2263 ])
2264 .expect_err("attention report should fail requirement");
2265
2266 let report: RestoreApplyJournalReport =
2267 serde_json::from_slice(&fs::read(&out_path).expect("read apply report"))
2268 .expect("decode apply report");
2269
2270 fs::remove_dir_all(root).expect("remove temp root");
2271 assert!(report.attention_required);
2272 assert_eq!(report.pending_operations, 1);
2273 assert!(matches!(
2274 err,
2275 RestoreCommandError::RestoreApplyReportNeedsAttention {
2276 outcome: canic_backup::restore::RestoreApplyReportOutcome::Pending,
2277 ..
2278 }
2279 ));
2280 }
2281
2282 #[test]
2284 fn run_restore_apply_status_require_complete_writes_status_then_fails() {
2285 let root = temp_dir("canic-cli-restore-apply-status-incomplete");
2286 fs::create_dir_all(&root).expect("create temp root");
2287 let journal_path = root.join("restore-apply-journal.json");
2288 let out_path = root.join("restore-apply-status.json");
2289 let journal = ready_apply_journal();
2290
2291 fs::write(
2292 &journal_path,
2293 serde_json::to_vec(&journal).expect("serialize journal"),
2294 )
2295 .expect("write journal");
2296
2297 let err = run([
2298 OsString::from("apply-status"),
2299 OsString::from("--journal"),
2300 OsString::from(journal_path.as_os_str()),
2301 OsString::from("--out"),
2302 OsString::from(out_path.as_os_str()),
2303 OsString::from("--require-complete"),
2304 ])
2305 .expect_err("incomplete journal should fail requirement");
2306
2307 assert!(out_path.exists());
2308 let status: RestoreApplyJournalStatus =
2309 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
2310 .expect("decode apply status");
2311
2312 fs::remove_dir_all(root).expect("remove temp root");
2313 assert!(!status.complete);
2314 assert_eq!(status.completed_operations, 0);
2315 assert_eq!(status.operation_count, 8);
2316 assert!(matches!(
2317 err,
2318 RestoreCommandError::RestoreApplyIncomplete {
2319 completed_operations: 0,
2320 operation_count: 8,
2321 ..
2322 }
2323 ));
2324 }
2325
2326 #[test]
2328 fn run_restore_apply_status_require_no_failed_writes_status_then_fails() {
2329 let root = temp_dir("canic-cli-restore-apply-status-failed");
2330 fs::create_dir_all(&root).expect("create temp root");
2331 let journal_path = root.join("restore-apply-journal.json");
2332 let out_path = root.join("restore-apply-status.json");
2333 let mut journal = ready_apply_journal();
2334 journal
2335 .mark_operation_failed(0, "dfx-load-failed".to_string())
2336 .expect("mark failed operation");
2337
2338 fs::write(
2339 &journal_path,
2340 serde_json::to_vec(&journal).expect("serialize journal"),
2341 )
2342 .expect("write journal");
2343
2344 let err = run([
2345 OsString::from("apply-status"),
2346 OsString::from("--journal"),
2347 OsString::from(journal_path.as_os_str()),
2348 OsString::from("--out"),
2349 OsString::from(out_path.as_os_str()),
2350 OsString::from("--require-no-failed"),
2351 ])
2352 .expect_err("failed operation should fail requirement");
2353
2354 assert!(out_path.exists());
2355 let status: RestoreApplyJournalStatus =
2356 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
2357 .expect("decode apply status");
2358
2359 fs::remove_dir_all(root).expect("remove temp root");
2360 assert_eq!(status.failed_operations, 1);
2361 assert!(matches!(
2362 err,
2363 RestoreCommandError::RestoreApplyFailed {
2364 failed_operations: 1,
2365 ..
2366 }
2367 ));
2368 }
2369
2370 #[test]
2372 fn run_restore_apply_status_require_complete_accepts_complete_journal() {
2373 let root = temp_dir("canic-cli-restore-apply-status-complete");
2374 fs::create_dir_all(&root).expect("create temp root");
2375 let journal_path = root.join("restore-apply-journal.json");
2376 let out_path = root.join("restore-apply-status.json");
2377 let mut journal = ready_apply_journal();
2378 for sequence in 0..journal.operation_count {
2379 journal
2380 .mark_operation_completed(sequence)
2381 .expect("complete operation");
2382 }
2383
2384 fs::write(
2385 &journal_path,
2386 serde_json::to_vec(&journal).expect("serialize journal"),
2387 )
2388 .expect("write journal");
2389
2390 run([
2391 OsString::from("apply-status"),
2392 OsString::from("--journal"),
2393 OsString::from(journal_path.as_os_str()),
2394 OsString::from("--out"),
2395 OsString::from(out_path.as_os_str()),
2396 OsString::from("--require-complete"),
2397 ])
2398 .expect("complete journal should pass requirement");
2399
2400 let status: RestoreApplyJournalStatus =
2401 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
2402 .expect("decode apply status");
2403
2404 fs::remove_dir_all(root).expect("remove temp root");
2405 assert!(status.complete);
2406 assert_eq!(status.completed_operations, 8);
2407 assert_eq!(status.operation_count, 8);
2408 }
2409
2410 #[test]
2412 fn run_restore_apply_next_writes_next_ready_operation() {
2413 let root = temp_dir("canic-cli-restore-apply-next");
2414 fs::create_dir_all(&root).expect("create temp root");
2415 let journal_path = root.join("restore-apply-journal.json");
2416 let out_path = root.join("restore-apply-next.json");
2417 let mut journal = ready_apply_journal();
2418 journal
2419 .mark_operation_completed(0)
2420 .expect("mark first operation complete");
2421
2422 fs::write(
2423 &journal_path,
2424 serde_json::to_vec(&journal).expect("serialize journal"),
2425 )
2426 .expect("write journal");
2427
2428 run([
2429 OsString::from("apply-next"),
2430 OsString::from("--journal"),
2431 OsString::from(journal_path.as_os_str()),
2432 OsString::from("--out"),
2433 OsString::from(out_path.as_os_str()),
2434 ])
2435 .expect("write apply next");
2436
2437 let next: RestoreApplyNextOperation =
2438 serde_json::from_slice(&fs::read(&out_path).expect("read next operation"))
2439 .expect("decode next operation");
2440 let operation = next.operation.expect("operation should be available");
2441
2442 fs::remove_dir_all(root).expect("remove temp root");
2443 assert!(next.ready);
2444 assert!(next.operation_available);
2445 assert_eq!(operation.sequence, 1);
2446 assert_eq!(
2447 operation.operation,
2448 canic_backup::restore::RestoreApplyOperationKind::LoadSnapshot
2449 );
2450 }
2451
2452 #[test]
2454 fn run_restore_apply_command_writes_next_command_preview() {
2455 let root = temp_dir("canic-cli-restore-apply-command");
2456 fs::create_dir_all(&root).expect("create temp root");
2457 let journal_path = root.join("restore-apply-journal.json");
2458 let out_path = root.join("restore-apply-command.json");
2459 let journal = ready_apply_journal();
2460
2461 fs::write(
2462 &journal_path,
2463 serde_json::to_vec(&journal).expect("serialize journal"),
2464 )
2465 .expect("write journal");
2466
2467 run([
2468 OsString::from("apply-command"),
2469 OsString::from("--journal"),
2470 OsString::from(journal_path.as_os_str()),
2471 OsString::from("--dfx"),
2472 OsString::from("/tmp/dfx"),
2473 OsString::from("--network"),
2474 OsString::from("local"),
2475 OsString::from("--out"),
2476 OsString::from(out_path.as_os_str()),
2477 ])
2478 .expect("write command preview");
2479
2480 let preview: RestoreApplyCommandPreview =
2481 serde_json::from_slice(&fs::read(&out_path).expect("read command preview"))
2482 .expect("decode command preview");
2483 let command = preview.command.expect("command should be available");
2484
2485 fs::remove_dir_all(root).expect("remove temp root");
2486 assert!(preview.ready);
2487 assert!(preview.command_available);
2488 assert_eq!(command.program, "/tmp/dfx");
2489 assert_eq!(
2490 command.args,
2491 vec![
2492 "canister".to_string(),
2493 "--network".to_string(),
2494 "local".to_string(),
2495 "snapshot".to_string(),
2496 "upload".to_string(),
2497 "--dir".to_string(),
2498 "artifacts/root".to_string(),
2499 ROOT.to_string(),
2500 ]
2501 );
2502 assert!(command.mutates);
2503 }
2504
2505 #[test]
2507 fn run_restore_apply_command_require_command_writes_preview_then_fails() {
2508 let root = temp_dir("canic-cli-restore-apply-command-require");
2509 fs::create_dir_all(&root).expect("create temp root");
2510 let journal_path = root.join("restore-apply-journal.json");
2511 let out_path = root.join("restore-apply-command.json");
2512 let mut journal = ready_apply_journal();
2513
2514 for sequence in 0..journal.operation_count {
2515 journal
2516 .mark_operation_completed(sequence)
2517 .expect("mark operation completed");
2518 }
2519
2520 fs::write(
2521 &journal_path,
2522 serde_json::to_vec(&journal).expect("serialize journal"),
2523 )
2524 .expect("write journal");
2525
2526 let err = run([
2527 OsString::from("apply-command"),
2528 OsString::from("--journal"),
2529 OsString::from(journal_path.as_os_str()),
2530 OsString::from("--out"),
2531 OsString::from(out_path.as_os_str()),
2532 OsString::from("--require-command"),
2533 ])
2534 .expect_err("missing command should fail");
2535
2536 let preview: RestoreApplyCommandPreview =
2537 serde_json::from_slice(&fs::read(&out_path).expect("read command preview"))
2538 .expect("decode command preview");
2539
2540 fs::remove_dir_all(root).expect("remove temp root");
2541 assert!(preview.complete);
2542 assert!(!preview.operation_available);
2543 assert!(!preview.command_available);
2544 assert!(matches!(
2545 err,
2546 RestoreCommandError::RestoreApplyCommandUnavailable {
2547 operation_available: false,
2548 complete: true,
2549 ..
2550 }
2551 ));
2552 }
2553
2554 #[test]
2556 fn run_restore_apply_claim_marks_next_operation_pending() {
2557 let root = temp_dir("canic-cli-restore-apply-claim");
2558 fs::create_dir_all(&root).expect("create temp root");
2559 let journal_path = root.join("restore-apply-journal.json");
2560 let claimed_path = root.join("restore-apply-journal.claimed.json");
2561 let journal = ready_apply_journal();
2562
2563 fs::write(
2564 &journal_path,
2565 serde_json::to_vec(&journal).expect("serialize journal"),
2566 )
2567 .expect("write journal");
2568
2569 run([
2570 OsString::from("apply-claim"),
2571 OsString::from("--journal"),
2572 OsString::from(journal_path.as_os_str()),
2573 OsString::from("--sequence"),
2574 OsString::from("0"),
2575 OsString::from("--updated-at"),
2576 OsString::from("2026-05-04T12:00:00Z"),
2577 OsString::from("--out"),
2578 OsString::from(claimed_path.as_os_str()),
2579 ])
2580 .expect("claim operation");
2581
2582 let claimed: RestoreApplyJournal =
2583 serde_json::from_slice(&fs::read(&claimed_path).expect("read claimed journal"))
2584 .expect("decode claimed journal");
2585 let status = claimed.status();
2586 let next = claimed.next_operation();
2587
2588 fs::remove_dir_all(root).expect("remove temp root");
2589 assert_eq!(claimed.pending_operations, 1);
2590 assert_eq!(claimed.ready_operations, 7);
2591 assert_eq!(
2592 claimed.operations[0].state,
2593 RestoreApplyOperationState::Pending
2594 );
2595 assert_eq!(
2596 claimed.operations[0].state_updated_at.as_deref(),
2597 Some("2026-05-04T12:00:00Z")
2598 );
2599 assert_eq!(status.next_transition_sequence, Some(0));
2600 assert_eq!(
2601 status.next_transition_state,
2602 Some(RestoreApplyOperationState::Pending)
2603 );
2604 assert_eq!(
2605 status.next_transition_updated_at.as_deref(),
2606 Some("2026-05-04T12:00:00Z")
2607 );
2608 assert_eq!(
2609 next.operation.expect("next operation").state,
2610 RestoreApplyOperationState::Pending
2611 );
2612 }
2613
2614 #[test]
2616 fn run_restore_apply_claim_rejects_sequence_mismatch() {
2617 let root = temp_dir("canic-cli-restore-apply-claim-sequence");
2618 fs::create_dir_all(&root).expect("create temp root");
2619 let journal_path = root.join("restore-apply-journal.json");
2620 let claimed_path = root.join("restore-apply-journal.claimed.json");
2621 let journal = ready_apply_journal();
2622
2623 fs::write(
2624 &journal_path,
2625 serde_json::to_vec(&journal).expect("serialize journal"),
2626 )
2627 .expect("write journal");
2628
2629 let err = run([
2630 OsString::from("apply-claim"),
2631 OsString::from("--journal"),
2632 OsString::from(journal_path.as_os_str()),
2633 OsString::from("--sequence"),
2634 OsString::from("1"),
2635 OsString::from("--out"),
2636 OsString::from(claimed_path.as_os_str()),
2637 ])
2638 .expect_err("stale sequence should fail claim");
2639
2640 assert!(!claimed_path.exists());
2641 fs::remove_dir_all(root).expect("remove temp root");
2642 assert!(matches!(
2643 err,
2644 RestoreCommandError::RestoreApplyClaimSequenceMismatch {
2645 expected: 1,
2646 actual: Some(0),
2647 }
2648 ));
2649 }
2650
2651 #[test]
2653 fn run_restore_apply_unclaim_marks_pending_operation_ready() {
2654 let root = temp_dir("canic-cli-restore-apply-unclaim");
2655 fs::create_dir_all(&root).expect("create temp root");
2656 let journal_path = root.join("restore-apply-journal.json");
2657 let unclaimed_path = root.join("restore-apply-journal.unclaimed.json");
2658 let mut journal = ready_apply_journal();
2659 journal
2660 .mark_next_operation_pending()
2661 .expect("claim operation");
2662
2663 fs::write(
2664 &journal_path,
2665 serde_json::to_vec(&journal).expect("serialize journal"),
2666 )
2667 .expect("write journal");
2668
2669 run([
2670 OsString::from("apply-unclaim"),
2671 OsString::from("--journal"),
2672 OsString::from(journal_path.as_os_str()),
2673 OsString::from("--sequence"),
2674 OsString::from("0"),
2675 OsString::from("--updated-at"),
2676 OsString::from("2026-05-04T12:01:00Z"),
2677 OsString::from("--out"),
2678 OsString::from(unclaimed_path.as_os_str()),
2679 ])
2680 .expect("unclaim operation");
2681
2682 let unclaimed: RestoreApplyJournal =
2683 serde_json::from_slice(&fs::read(&unclaimed_path).expect("read unclaimed journal"))
2684 .expect("decode unclaimed journal");
2685 let status = unclaimed.status();
2686
2687 fs::remove_dir_all(root).expect("remove temp root");
2688 assert_eq!(unclaimed.pending_operations, 0);
2689 assert_eq!(unclaimed.ready_operations, 8);
2690 assert_eq!(
2691 unclaimed.operations[0].state,
2692 RestoreApplyOperationState::Ready
2693 );
2694 assert_eq!(
2695 unclaimed.operations[0].state_updated_at.as_deref(),
2696 Some("2026-05-04T12:01:00Z")
2697 );
2698 assert_eq!(status.next_ready_sequence, Some(0));
2699 assert_eq!(
2700 status.next_transition_state,
2701 Some(RestoreApplyOperationState::Ready)
2702 );
2703 assert_eq!(
2704 status.next_transition_updated_at.as_deref(),
2705 Some("2026-05-04T12:01:00Z")
2706 );
2707 }
2708
2709 #[test]
2711 fn run_restore_apply_unclaim_rejects_sequence_mismatch() {
2712 let root = temp_dir("canic-cli-restore-apply-unclaim-sequence");
2713 fs::create_dir_all(&root).expect("create temp root");
2714 let journal_path = root.join("restore-apply-journal.json");
2715 let unclaimed_path = root.join("restore-apply-journal.unclaimed.json");
2716 let mut journal = ready_apply_journal();
2717 journal
2718 .mark_next_operation_pending()
2719 .expect("claim operation");
2720
2721 fs::write(
2722 &journal_path,
2723 serde_json::to_vec(&journal).expect("serialize journal"),
2724 )
2725 .expect("write journal");
2726
2727 let err = run([
2728 OsString::from("apply-unclaim"),
2729 OsString::from("--journal"),
2730 OsString::from(journal_path.as_os_str()),
2731 OsString::from("--sequence"),
2732 OsString::from("1"),
2733 OsString::from("--out"),
2734 OsString::from(unclaimed_path.as_os_str()),
2735 ])
2736 .expect_err("stale sequence should fail unclaim");
2737
2738 assert!(!unclaimed_path.exists());
2739 fs::remove_dir_all(root).expect("remove temp root");
2740 assert!(matches!(
2741 err,
2742 RestoreCommandError::RestoreApplyUnclaimSequenceMismatch {
2743 expected: 1,
2744 actual: Some(0),
2745 }
2746 ));
2747 }
2748
2749 #[test]
2751 fn run_restore_apply_mark_completes_operation() {
2752 let root = temp_dir("canic-cli-restore-apply-mark-complete");
2753 fs::create_dir_all(&root).expect("create temp root");
2754 let journal_path = root.join("restore-apply-journal.json");
2755 let updated_path = root.join("restore-apply-journal.updated.json");
2756 let journal = ready_apply_journal();
2757
2758 fs::write(
2759 &journal_path,
2760 serde_json::to_vec(&journal).expect("serialize journal"),
2761 )
2762 .expect("write journal");
2763
2764 run([
2765 OsString::from("apply-mark"),
2766 OsString::from("--journal"),
2767 OsString::from(journal_path.as_os_str()),
2768 OsString::from("--sequence"),
2769 OsString::from("0"),
2770 OsString::from("--state"),
2771 OsString::from("completed"),
2772 OsString::from("--updated-at"),
2773 OsString::from("2026-05-04T12:02:00Z"),
2774 OsString::from("--out"),
2775 OsString::from(updated_path.as_os_str()),
2776 ])
2777 .expect("mark operation completed");
2778
2779 let updated: RestoreApplyJournal =
2780 serde_json::from_slice(&fs::read(&updated_path).expect("read updated journal"))
2781 .expect("decode updated journal");
2782 let status = updated.status();
2783
2784 fs::remove_dir_all(root).expect("remove temp root");
2785 assert_eq!(updated.completed_operations, 1);
2786 assert_eq!(updated.ready_operations, 7);
2787 assert_eq!(
2788 updated.operations[0].state_updated_at.as_deref(),
2789 Some("2026-05-04T12:02:00Z")
2790 );
2791 assert_eq!(status.next_ready_sequence, Some(1));
2792 }
2793
2794 #[test]
2796 fn run_restore_apply_mark_require_pending_rejects_ready_operation() {
2797 let root = temp_dir("canic-cli-restore-apply-mark-require-pending");
2798 fs::create_dir_all(&root).expect("create temp root");
2799 let journal_path = root.join("restore-apply-journal.json");
2800 let updated_path = root.join("restore-apply-journal.updated.json");
2801 let journal = ready_apply_journal();
2802
2803 fs::write(
2804 &journal_path,
2805 serde_json::to_vec(&journal).expect("serialize journal"),
2806 )
2807 .expect("write journal");
2808
2809 let err = run([
2810 OsString::from("apply-mark"),
2811 OsString::from("--journal"),
2812 OsString::from(journal_path.as_os_str()),
2813 OsString::from("--sequence"),
2814 OsString::from("0"),
2815 OsString::from("--state"),
2816 OsString::from("completed"),
2817 OsString::from("--out"),
2818 OsString::from(updated_path.as_os_str()),
2819 OsString::from("--require-pending"),
2820 ])
2821 .expect_err("ready operation should fail pending requirement");
2822
2823 assert!(!updated_path.exists());
2824 fs::remove_dir_all(root).expect("remove temp root");
2825 assert!(matches!(
2826 err,
2827 RestoreCommandError::RestoreApplyMarkRequiresPending {
2828 sequence: 0,
2829 state: RestoreApplyOperationState::Ready,
2830 }
2831 ));
2832 }
2833
2834 #[test]
2836 fn run_restore_apply_mark_rejects_out_of_order_operation() {
2837 let root = temp_dir("canic-cli-restore-apply-mark-out-of-order");
2838 fs::create_dir_all(&root).expect("create temp root");
2839 let journal_path = root.join("restore-apply-journal.json");
2840 let updated_path = root.join("restore-apply-journal.updated.json");
2841 let journal = ready_apply_journal();
2842
2843 fs::write(
2844 &journal_path,
2845 serde_json::to_vec(&journal).expect("serialize journal"),
2846 )
2847 .expect("write journal");
2848
2849 let err = run([
2850 OsString::from("apply-mark"),
2851 OsString::from("--journal"),
2852 OsString::from(journal_path.as_os_str()),
2853 OsString::from("--sequence"),
2854 OsString::from("1"),
2855 OsString::from("--state"),
2856 OsString::from("completed"),
2857 OsString::from("--out"),
2858 OsString::from(updated_path.as_os_str()),
2859 ])
2860 .expect_err("out-of-order operation should fail");
2861
2862 assert!(!updated_path.exists());
2863 fs::remove_dir_all(root).expect("remove temp root");
2864 assert!(matches!(
2865 err,
2866 RestoreCommandError::RestoreApplyJournal(
2867 RestoreApplyJournalError::OutOfOrderOperationTransition {
2868 requested: 1,
2869 next: 0
2870 }
2871 )
2872 ));
2873 }
2874
2875 #[test]
2877 fn run_restore_apply_mark_failed_requires_reason() {
2878 let root = temp_dir("canic-cli-restore-apply-mark-failed-reason");
2879 fs::create_dir_all(&root).expect("create temp root");
2880 let journal_path = root.join("restore-apply-journal.json");
2881 let journal = ready_apply_journal();
2882
2883 fs::write(
2884 &journal_path,
2885 serde_json::to_vec(&journal).expect("serialize journal"),
2886 )
2887 .expect("write journal");
2888
2889 let err = run([
2890 OsString::from("apply-mark"),
2891 OsString::from("--journal"),
2892 OsString::from(journal_path.as_os_str()),
2893 OsString::from("--sequence"),
2894 OsString::from("0"),
2895 OsString::from("--state"),
2896 OsString::from("failed"),
2897 ])
2898 .expect_err("failed state should require reason");
2899
2900 fs::remove_dir_all(root).expect("remove temp root");
2901 assert!(matches!(
2902 err,
2903 RestoreCommandError::RestoreApplyJournal(
2904 RestoreApplyJournalError::FailureReasonRequired(0)
2905 )
2906 ));
2907 }
2908
2909 #[test]
2911 fn run_restore_apply_dry_run_rejects_mismatched_status() {
2912 let root = temp_dir("canic-cli-restore-apply-dry-run-mismatch");
2913 fs::create_dir_all(&root).expect("create temp root");
2914 let plan_path = root.join("restore-plan.json");
2915 let status_path = root.join("restore-status.json");
2916 let out_path = root.join("restore-apply-dry-run.json");
2917 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
2918 let mut status = RestoreStatus::from_plan(&plan);
2919 status.backup_id = "other-backup".to_string();
2920
2921 fs::write(
2922 &plan_path,
2923 serde_json::to_vec(&plan).expect("serialize plan"),
2924 )
2925 .expect("write plan");
2926 fs::write(
2927 &status_path,
2928 serde_json::to_vec(&status).expect("serialize status"),
2929 )
2930 .expect("write status");
2931
2932 let err = run([
2933 OsString::from("apply"),
2934 OsString::from("--plan"),
2935 OsString::from(plan_path.as_os_str()),
2936 OsString::from("--status"),
2937 OsString::from(status_path.as_os_str()),
2938 OsString::from("--dry-run"),
2939 OsString::from("--out"),
2940 OsString::from(out_path.as_os_str()),
2941 ])
2942 .expect_err("mismatched status should fail");
2943
2944 assert!(!out_path.exists());
2945 fs::remove_dir_all(root).expect("remove temp root");
2946 assert!(matches!(
2947 err,
2948 RestoreCommandError::RestoreApplyDryRun(RestoreApplyDryRunError::StatusPlanMismatch {
2949 field: "backup_id",
2950 ..
2951 })
2952 ));
2953 }
2954
2955 fn ready_apply_journal() -> RestoreApplyJournal {
2957 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
2958 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
2959 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2960
2961 journal.ready = true;
2962 journal.blocked_reasons = Vec::new();
2963 for operation in &mut journal.operations {
2964 operation.state = canic_backup::restore::RestoreApplyOperationState::Ready;
2965 operation.blocking_reasons = Vec::new();
2966 }
2967 journal.blocked_operations = 0;
2968 journal.ready_operations = journal.operation_count;
2969 journal.validate().expect("journal should validate");
2970 journal
2971 }
2972
2973 fn valid_manifest() -> FleetBackupManifest {
2975 FleetBackupManifest {
2976 manifest_version: 1,
2977 backup_id: "backup-test".to_string(),
2978 created_at: "2026-05-03T00:00:00Z".to_string(),
2979 tool: ToolMetadata {
2980 name: "canic".to_string(),
2981 version: "0.30.1".to_string(),
2982 },
2983 source: SourceMetadata {
2984 environment: "local".to_string(),
2985 root_canister: ROOT.to_string(),
2986 },
2987 consistency: ConsistencySection {
2988 mode: ConsistencyMode::CrashConsistent,
2989 backup_units: vec![BackupUnit {
2990 unit_id: "fleet".to_string(),
2991 kind: BackupUnitKind::SubtreeRooted,
2992 roles: vec!["root".to_string(), "app".to_string()],
2993 consistency_reason: None,
2994 dependency_closure: Vec::new(),
2995 topology_validation: "subtree-closed".to_string(),
2996 quiescence_strategy: None,
2997 }],
2998 },
2999 fleet: FleetSection {
3000 topology_hash_algorithm: "sha256".to_string(),
3001 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
3002 discovery_topology_hash: HASH.to_string(),
3003 pre_snapshot_topology_hash: HASH.to_string(),
3004 topology_hash: HASH.to_string(),
3005 members: vec![
3006 fleet_member("root", ROOT, None, IdentityMode::Fixed),
3007 fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
3008 ],
3009 },
3010 verification: VerificationPlan::default(),
3011 }
3012 }
3013
3014 fn restore_ready_manifest() -> FleetBackupManifest {
3016 let mut manifest = valid_manifest();
3017 for member in &mut manifest.fleet.members {
3018 member.source_snapshot.module_hash = Some(HASH.to_string());
3019 member.source_snapshot.wasm_hash = Some(HASH.to_string());
3020 member.source_snapshot.checksum = Some(HASH.to_string());
3021 }
3022 manifest
3023 }
3024
3025 fn fleet_member(
3027 role: &str,
3028 canister_id: &str,
3029 parent_canister_id: Option<&str>,
3030 identity_mode: IdentityMode,
3031 ) -> FleetMember {
3032 FleetMember {
3033 role: role.to_string(),
3034 canister_id: canister_id.to_string(),
3035 parent_canister_id: parent_canister_id.map(str::to_string),
3036 subnet_canister_id: Some(ROOT.to_string()),
3037 controller_hint: None,
3038 identity_mode,
3039 restore_group: 1,
3040 verification_class: "basic".to_string(),
3041 verification_checks: vec![VerificationCheck {
3042 kind: "status".to_string(),
3043 method: None,
3044 roles: vec![role.to_string()],
3045 }],
3046 source_snapshot: SourceSnapshot {
3047 snapshot_id: format!("{role}-snapshot"),
3048 module_hash: None,
3049 wasm_hash: None,
3050 code_version: Some("v0.30.1".to_string()),
3051 artifact_path: format!("artifacts/{role}"),
3052 checksum_algorithm: "sha256".to_string(),
3053 checksum: None,
3054 },
3055 }
3056 }
3057
3058 fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
3060 layout.write_manifest(manifest).expect("write manifest");
3061
3062 let artifacts = manifest
3063 .fleet
3064 .members
3065 .iter()
3066 .map(|member| {
3067 let bytes = format!("{} artifact", member.role);
3068 let artifact_path = root.join(&member.source_snapshot.artifact_path);
3069 if let Some(parent) = artifact_path.parent() {
3070 fs::create_dir_all(parent).expect("create artifact parent");
3071 }
3072 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
3073 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
3074
3075 ArtifactJournalEntry {
3076 canister_id: member.canister_id.clone(),
3077 snapshot_id: member.source_snapshot.snapshot_id.clone(),
3078 state: ArtifactState::Durable,
3079 temp_path: None,
3080 artifact_path: member.source_snapshot.artifact_path.clone(),
3081 checksum_algorithm: checksum.algorithm,
3082 checksum: Some(checksum.hash),
3083 updated_at: "2026-05-03T00:00:00Z".to_string(),
3084 }
3085 })
3086 .collect();
3087
3088 layout
3089 .write_journal(&DownloadJournal {
3090 journal_version: 1,
3091 backup_id: manifest.backup_id.clone(),
3092 discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
3093 pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
3094 operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
3095 artifacts,
3096 })
3097 .expect("write journal");
3098 }
3099
3100 fn write_manifest_artifacts(root: &Path, manifest: &mut FleetBackupManifest) {
3102 for member in &mut manifest.fleet.members {
3103 let bytes = format!("{} apply artifact", member.role);
3104 let artifact_path = root.join(&member.source_snapshot.artifact_path);
3105 if let Some(parent) = artifact_path.parent() {
3106 fs::create_dir_all(parent).expect("create artifact parent");
3107 }
3108 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
3109 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
3110 member.source_snapshot.checksum = Some(checksum.hash);
3111 }
3112 }
3113
3114 fn temp_dir(prefix: &str) -> PathBuf {
3116 let nanos = SystemTime::now()
3117 .duration_since(UNIX_EPOCH)
3118 .expect("system time after epoch")
3119 .as_nanos();
3120 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
3121 }
3122}