1use canic_backup::{
2 manifest::FleetBackupManifest,
3 persistence::{BackupLayout, PersistenceError},
4 restore::{
5 RestoreApplyCommandConfig, RestoreApplyCommandPreview, RestoreApplyDryRun,
6 RestoreApplyDryRunError, RestoreApplyJournal, RestoreApplyJournalError,
7 RestoreApplyJournalStatus, RestoreApplyNextOperation, RestoreMapping, RestorePlan,
8 RestorePlanError, RestorePlanner, RestoreStatus,
9 },
10};
11use std::{
12 ffi::OsString,
13 fs,
14 io::{self, Write},
15 path::PathBuf,
16};
17use thiserror::Error as ThisError;
18
19#[derive(Debug, ThisError)]
24pub enum RestoreCommandError {
25 #[error("{0}")]
26 Usage(&'static str),
27
28 #[error("missing required option {0}")]
29 MissingOption(&'static str),
30
31 #[error("use either --manifest or --backup-dir, not both")]
32 ConflictingManifestSources,
33
34 #[error("--require-verified requires --backup-dir")]
35 RequireVerifiedNeedsBackupDir,
36
37 #[error("restore apply currently requires --dry-run")]
38 ApplyRequiresDryRun,
39
40 #[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
41 RestoreNotReady {
42 backup_id: String,
43 reasons: Vec<String>,
44 },
45
46 #[error(
47 "restore apply journal for backup {backup_id} has pending operations: pending={pending_operations}, next={next_transition_sequence:?}"
48 )]
49 RestoreApplyPending {
50 backup_id: String,
51 pending_operations: usize,
52 next_transition_sequence: Option<usize>,
53 },
54
55 #[error(
56 "restore apply journal for backup {backup_id} is incomplete: completed={completed_operations}, total={operation_count}"
57 )]
58 RestoreApplyIncomplete {
59 backup_id: String,
60 completed_operations: usize,
61 operation_count: usize,
62 },
63
64 #[error(
65 "restore apply journal for backup {backup_id} has failed operations: failed={failed_operations}"
66 )]
67 RestoreApplyFailed {
68 backup_id: String,
69 failed_operations: usize,
70 },
71
72 #[error("unknown option {0}")]
73 UnknownOption(String),
74
75 #[error("option {0} requires a value")]
76 MissingValue(&'static str),
77
78 #[error("option --sequence requires a non-negative integer value")]
79 InvalidSequence,
80
81 #[error("unsupported apply-mark state {0}; use completed or failed")]
82 InvalidApplyMarkState(String),
83
84 #[error(transparent)]
85 Io(#[from] std::io::Error),
86
87 #[error(transparent)]
88 Json(#[from] serde_json::Error),
89
90 #[error(transparent)]
91 Persistence(#[from] PersistenceError),
92
93 #[error(transparent)]
94 RestorePlan(#[from] RestorePlanError),
95
96 #[error(transparent)]
97 RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
98
99 #[error(transparent)]
100 RestoreApplyJournal(#[from] RestoreApplyJournalError),
101}
102
103#[derive(Clone, Debug, Eq, PartialEq)]
108pub struct RestorePlanOptions {
109 pub manifest: Option<PathBuf>,
110 pub backup_dir: Option<PathBuf>,
111 pub mapping: Option<PathBuf>,
112 pub out: Option<PathBuf>,
113 pub require_verified: bool,
114 pub require_restore_ready: bool,
115}
116
117impl RestorePlanOptions {
118 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
120 where
121 I: IntoIterator<Item = OsString>,
122 {
123 let mut manifest = None;
124 let mut backup_dir = None;
125 let mut mapping = None;
126 let mut out = None;
127 let mut require_verified = false;
128 let mut require_restore_ready = false;
129
130 let mut args = args.into_iter();
131 while let Some(arg) = args.next() {
132 let arg = arg
133 .into_string()
134 .map_err(|_| RestoreCommandError::Usage(usage()))?;
135 match arg.as_str() {
136 "--manifest" => {
137 manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
138 }
139 "--backup-dir" => {
140 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
141 }
142 "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
143 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
144 "--require-verified" => require_verified = true,
145 "--require-restore-ready" => require_restore_ready = true,
146 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
147 _ => return Err(RestoreCommandError::UnknownOption(arg)),
148 }
149 }
150
151 if manifest.is_some() && backup_dir.is_some() {
152 return Err(RestoreCommandError::ConflictingManifestSources);
153 }
154
155 if manifest.is_none() && backup_dir.is_none() {
156 return Err(RestoreCommandError::MissingOption(
157 "--manifest or --backup-dir",
158 ));
159 }
160
161 if require_verified && backup_dir.is_none() {
162 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
163 }
164
165 Ok(Self {
166 manifest,
167 backup_dir,
168 mapping,
169 out,
170 require_verified,
171 require_restore_ready,
172 })
173 }
174}
175
176#[derive(Clone, Debug, Eq, PartialEq)]
181pub struct RestoreStatusOptions {
182 pub plan: PathBuf,
183 pub out: Option<PathBuf>,
184}
185
186impl RestoreStatusOptions {
187 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
189 where
190 I: IntoIterator<Item = OsString>,
191 {
192 let mut plan = None;
193 let mut out = None;
194
195 let mut args = args.into_iter();
196 while let Some(arg) = args.next() {
197 let arg = arg
198 .into_string()
199 .map_err(|_| RestoreCommandError::Usage(usage()))?;
200 match arg.as_str() {
201 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
202 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
203 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
204 _ => return Err(RestoreCommandError::UnknownOption(arg)),
205 }
206 }
207
208 Ok(Self {
209 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
210 out,
211 })
212 }
213}
214
215#[derive(Clone, Debug, Eq, PartialEq)]
220pub struct RestoreApplyOptions {
221 pub plan: PathBuf,
222 pub status: Option<PathBuf>,
223 pub backup_dir: Option<PathBuf>,
224 pub out: Option<PathBuf>,
225 pub journal_out: Option<PathBuf>,
226 pub dry_run: bool,
227}
228
229impl RestoreApplyOptions {
230 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
232 where
233 I: IntoIterator<Item = OsString>,
234 {
235 let mut plan = None;
236 let mut status = None;
237 let mut backup_dir = None;
238 let mut out = None;
239 let mut journal_out = None;
240 let mut dry_run = false;
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 "--status" => status = Some(PathBuf::from(next_value(&mut args, "--status")?)),
250 "--backup-dir" => {
251 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
252 }
253 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
254 "--journal-out" => {
255 journal_out = Some(PathBuf::from(next_value(&mut args, "--journal-out")?));
256 }
257 "--dry-run" => dry_run = true,
258 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
259 _ => return Err(RestoreCommandError::UnknownOption(arg)),
260 }
261 }
262
263 if !dry_run {
264 return Err(RestoreCommandError::ApplyRequiresDryRun);
265 }
266
267 Ok(Self {
268 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
269 status,
270 backup_dir,
271 out,
272 journal_out,
273 dry_run,
274 })
275 }
276}
277
278#[derive(Clone, Debug, Eq, PartialEq)]
283pub struct RestoreApplyStatusOptions {
284 pub journal: PathBuf,
285 pub require_no_pending: bool,
286 pub require_no_failed: bool,
287 pub require_complete: bool,
288 pub out: Option<PathBuf>,
289}
290
291impl RestoreApplyStatusOptions {
292 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
294 where
295 I: IntoIterator<Item = OsString>,
296 {
297 let mut journal = None;
298 let mut require_no_pending = false;
299 let mut require_no_failed = false;
300 let mut require_complete = false;
301 let mut out = None;
302
303 let mut args = args.into_iter();
304 while let Some(arg) = args.next() {
305 let arg = arg
306 .into_string()
307 .map_err(|_| RestoreCommandError::Usage(usage()))?;
308 match arg.as_str() {
309 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
310 "--require-no-pending" => require_no_pending = true,
311 "--require-no-failed" => require_no_failed = true,
312 "--require-complete" => require_complete = true,
313 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
314 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
315 _ => return Err(RestoreCommandError::UnknownOption(arg)),
316 }
317 }
318
319 Ok(Self {
320 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
321 require_no_pending,
322 require_no_failed,
323 require_complete,
324 out,
325 })
326 }
327}
328
329#[derive(Clone, Debug, Eq, PartialEq)]
334pub struct RestoreApplyNextOptions {
335 pub journal: PathBuf,
336 pub out: Option<PathBuf>,
337}
338
339impl RestoreApplyNextOptions {
340 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
342 where
343 I: IntoIterator<Item = OsString>,
344 {
345 let mut journal = None;
346 let mut out = None;
347
348 let mut args = args.into_iter();
349 while let Some(arg) = args.next() {
350 let arg = arg
351 .into_string()
352 .map_err(|_| RestoreCommandError::Usage(usage()))?;
353 match arg.as_str() {
354 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
355 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
356 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
357 _ => return Err(RestoreCommandError::UnknownOption(arg)),
358 }
359 }
360
361 Ok(Self {
362 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
363 out,
364 })
365 }
366}
367
368#[derive(Clone, Debug, Eq, PartialEq)]
373pub struct RestoreApplyCommandOptions {
374 pub journal: PathBuf,
375 pub dfx: String,
376 pub network: Option<String>,
377 pub out: Option<PathBuf>,
378}
379
380impl RestoreApplyCommandOptions {
381 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
383 where
384 I: IntoIterator<Item = OsString>,
385 {
386 let mut journal = None;
387 let mut dfx = "dfx".to_string();
388 let mut network = None;
389 let mut out = None;
390
391 let mut args = args.into_iter();
392 while let Some(arg) = args.next() {
393 let arg = arg
394 .into_string()
395 .map_err(|_| RestoreCommandError::Usage(usage()))?;
396 match arg.as_str() {
397 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
398 "--dfx" => dfx = next_value(&mut args, "--dfx")?,
399 "--network" => network = Some(next_value(&mut args, "--network")?),
400 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
401 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
402 _ => return Err(RestoreCommandError::UnknownOption(arg)),
403 }
404 }
405
406 Ok(Self {
407 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
408 dfx,
409 network,
410 out,
411 })
412 }
413}
414
415#[derive(Clone, Debug, Eq, PartialEq)]
420pub struct RestoreApplyClaimOptions {
421 pub journal: PathBuf,
422 pub updated_at: Option<String>,
423 pub out: Option<PathBuf>,
424}
425
426impl RestoreApplyClaimOptions {
427 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
429 where
430 I: IntoIterator<Item = OsString>,
431 {
432 let mut journal = None;
433 let mut updated_at = None;
434 let mut out = None;
435
436 let mut args = args.into_iter();
437 while let Some(arg) = args.next() {
438 let arg = arg
439 .into_string()
440 .map_err(|_| RestoreCommandError::Usage(usage()))?;
441 match arg.as_str() {
442 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
443 "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
444 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
445 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
446 _ => return Err(RestoreCommandError::UnknownOption(arg)),
447 }
448 }
449
450 Ok(Self {
451 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
452 updated_at,
453 out,
454 })
455 }
456}
457
458#[derive(Clone, Debug, Eq, PartialEq)]
463pub struct RestoreApplyUnclaimOptions {
464 pub journal: PathBuf,
465 pub updated_at: Option<String>,
466 pub out: Option<PathBuf>,
467}
468
469impl RestoreApplyUnclaimOptions {
470 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
472 where
473 I: IntoIterator<Item = OsString>,
474 {
475 let mut journal = None;
476 let mut updated_at = None;
477 let mut out = None;
478
479 let mut args = args.into_iter();
480 while let Some(arg) = args.next() {
481 let arg = arg
482 .into_string()
483 .map_err(|_| RestoreCommandError::Usage(usage()))?;
484 match arg.as_str() {
485 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
486 "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
487 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
488 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
489 _ => return Err(RestoreCommandError::UnknownOption(arg)),
490 }
491 }
492
493 Ok(Self {
494 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
495 updated_at,
496 out,
497 })
498 }
499}
500
501#[derive(Clone, Debug, Eq, PartialEq)]
506pub struct RestoreApplyMarkOptions {
507 pub journal: PathBuf,
508 pub sequence: usize,
509 pub state: RestoreApplyMarkState,
510 pub reason: Option<String>,
511 pub updated_at: Option<String>,
512 pub out: Option<PathBuf>,
513}
514
515impl RestoreApplyMarkOptions {
516 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
518 where
519 I: IntoIterator<Item = OsString>,
520 {
521 let mut journal = None;
522 let mut sequence = None;
523 let mut state = None;
524 let mut reason = None;
525 let mut updated_at = None;
526 let mut out = None;
527
528 let mut args = args.into_iter();
529 while let Some(arg) = args.next() {
530 let arg = arg
531 .into_string()
532 .map_err(|_| RestoreCommandError::Usage(usage()))?;
533 match arg.as_str() {
534 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
535 "--sequence" => {
536 sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
537 }
538 "--state" => {
539 state = Some(RestoreApplyMarkState::parse(next_value(
540 &mut args, "--state",
541 )?)?);
542 }
543 "--reason" => reason = Some(next_value(&mut args, "--reason")?),
544 "--updated-at" => updated_at = Some(next_value(&mut args, "--updated-at")?),
545 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
546 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
547 _ => return Err(RestoreCommandError::UnknownOption(arg)),
548 }
549 }
550
551 Ok(Self {
552 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
553 sequence: sequence.ok_or(RestoreCommandError::MissingOption("--sequence"))?,
554 state: state.ok_or(RestoreCommandError::MissingOption("--state"))?,
555 reason,
556 updated_at,
557 out,
558 })
559 }
560}
561
562#[derive(Clone, Debug, Eq, PartialEq)]
567pub enum RestoreApplyMarkState {
568 Completed,
569 Failed,
570}
571
572impl RestoreApplyMarkState {
573 fn parse(value: String) -> Result<Self, RestoreCommandError> {
575 match value.as_str() {
576 "completed" => Ok(Self::Completed),
577 "failed" => Ok(Self::Failed),
578 _ => Err(RestoreCommandError::InvalidApplyMarkState(value)),
579 }
580 }
581}
582
583pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
585where
586 I: IntoIterator<Item = OsString>,
587{
588 let mut args = args.into_iter();
589 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
590 return Err(RestoreCommandError::Usage(usage()));
591 };
592
593 match command.as_str() {
594 "plan" => {
595 let options = RestorePlanOptions::parse(args)?;
596 let plan = plan_restore(&options)?;
597 write_plan(&options, &plan)?;
598 enforce_restore_plan_requirements(&options, &plan)?;
599 Ok(())
600 }
601 "status" => {
602 let options = RestoreStatusOptions::parse(args)?;
603 let status = restore_status(&options)?;
604 write_status(&options, &status)?;
605 Ok(())
606 }
607 "apply" => {
608 let options = RestoreApplyOptions::parse(args)?;
609 let dry_run = restore_apply_dry_run(&options)?;
610 write_apply_dry_run(&options, &dry_run)?;
611 write_apply_journal_if_requested(&options, &dry_run)?;
612 Ok(())
613 }
614 "apply-status" => {
615 let options = RestoreApplyStatusOptions::parse(args)?;
616 let status = restore_apply_status(&options)?;
617 write_apply_status(&options, &status)?;
618 enforce_apply_status_requirements(&options, &status)?;
619 Ok(())
620 }
621 "apply-next" => {
622 let options = RestoreApplyNextOptions::parse(args)?;
623 let next = restore_apply_next(&options)?;
624 write_apply_next(&options, &next)?;
625 Ok(())
626 }
627 "apply-command" => {
628 let options = RestoreApplyCommandOptions::parse(args)?;
629 let preview = restore_apply_command(&options)?;
630 write_apply_command(&options, &preview)?;
631 Ok(())
632 }
633 "apply-claim" => {
634 let options = RestoreApplyClaimOptions::parse(args)?;
635 let journal = restore_apply_claim(&options)?;
636 write_apply_claim(&options, &journal)?;
637 Ok(())
638 }
639 "apply-unclaim" => {
640 let options = RestoreApplyUnclaimOptions::parse(args)?;
641 let journal = restore_apply_unclaim(&options)?;
642 write_apply_unclaim(&options, &journal)?;
643 Ok(())
644 }
645 "apply-mark" => {
646 let options = RestoreApplyMarkOptions::parse(args)?;
647 let journal = restore_apply_mark(&options)?;
648 write_apply_mark(&options, &journal)?;
649 Ok(())
650 }
651 "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
652 _ => Err(RestoreCommandError::UnknownOption(command)),
653 }
654}
655
656pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
658 verify_backup_layout_if_required(options)?;
659
660 let manifest = read_manifest_source(options)?;
661 let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
662
663 RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
664}
665
666pub fn restore_status(
668 options: &RestoreStatusOptions,
669) -> Result<RestoreStatus, RestoreCommandError> {
670 let plan = read_plan(&options.plan)?;
671 Ok(RestoreStatus::from_plan(&plan))
672}
673
674pub fn restore_apply_dry_run(
676 options: &RestoreApplyOptions,
677) -> Result<RestoreApplyDryRun, RestoreCommandError> {
678 let plan = read_plan(&options.plan)?;
679 let status = options.status.as_ref().map(read_status).transpose()?;
680 if let Some(backup_dir) = &options.backup_dir {
681 return RestoreApplyDryRun::try_from_plan_with_artifacts(
682 &plan,
683 status.as_ref(),
684 backup_dir,
685 )
686 .map_err(RestoreCommandError::from);
687 }
688
689 RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
690}
691
692pub fn restore_apply_status(
694 options: &RestoreApplyStatusOptions,
695) -> Result<RestoreApplyJournalStatus, RestoreCommandError> {
696 let journal = read_apply_journal(&options.journal)?;
697 Ok(journal.status())
698}
699
700fn enforce_apply_status_requirements(
702 options: &RestoreApplyStatusOptions,
703 status: &RestoreApplyJournalStatus,
704) -> Result<(), RestoreCommandError> {
705 if options.require_no_pending && status.pending_operations > 0 {
706 return Err(RestoreCommandError::RestoreApplyPending {
707 backup_id: status.backup_id.clone(),
708 pending_operations: status.pending_operations,
709 next_transition_sequence: status.next_transition_sequence,
710 });
711 }
712
713 if options.require_no_failed && status.failed_operations > 0 {
714 return Err(RestoreCommandError::RestoreApplyFailed {
715 backup_id: status.backup_id.clone(),
716 failed_operations: status.failed_operations,
717 });
718 }
719
720 if options.require_complete && !status.complete {
721 return Err(RestoreCommandError::RestoreApplyIncomplete {
722 backup_id: status.backup_id.clone(),
723 completed_operations: status.completed_operations,
724 operation_count: status.operation_count,
725 });
726 }
727
728 Ok(())
729}
730
731pub fn restore_apply_next(
733 options: &RestoreApplyNextOptions,
734) -> Result<RestoreApplyNextOperation, RestoreCommandError> {
735 let journal = read_apply_journal(&options.journal)?;
736 Ok(journal.next_operation())
737}
738
739pub fn restore_apply_command(
741 options: &RestoreApplyCommandOptions,
742) -> Result<RestoreApplyCommandPreview, RestoreCommandError> {
743 let journal = read_apply_journal(&options.journal)?;
744 Ok(
745 journal.next_command_preview_with_config(&RestoreApplyCommandConfig {
746 program: options.dfx.clone(),
747 network: options.network.clone(),
748 }),
749 )
750}
751
752pub fn restore_apply_claim(
754 options: &RestoreApplyClaimOptions,
755) -> Result<RestoreApplyJournal, RestoreCommandError> {
756 let mut journal = read_apply_journal(&options.journal)?;
757 journal.mark_next_operation_pending_at(Some(state_updated_at(options.updated_at.as_ref())))?;
758 Ok(journal)
759}
760
761pub fn restore_apply_unclaim(
763 options: &RestoreApplyUnclaimOptions,
764) -> Result<RestoreApplyJournal, RestoreCommandError> {
765 let mut journal = read_apply_journal(&options.journal)?;
766 journal.mark_next_operation_ready_at(Some(state_updated_at(options.updated_at.as_ref())))?;
767 Ok(journal)
768}
769
770pub fn restore_apply_mark(
772 options: &RestoreApplyMarkOptions,
773) -> Result<RestoreApplyJournal, RestoreCommandError> {
774 let mut journal = read_apply_journal(&options.journal)?;
775
776 match options.state {
777 RestoreApplyMarkState::Completed => {
778 journal.mark_operation_completed_at(
779 options.sequence,
780 Some(state_updated_at(options.updated_at.as_ref())),
781 )?;
782 }
783 RestoreApplyMarkState::Failed => {
784 let reason =
785 options
786 .reason
787 .clone()
788 .ok_or(RestoreApplyJournalError::FailureReasonRequired(
789 options.sequence,
790 ))?;
791 journal.mark_operation_failed_at(
792 options.sequence,
793 reason,
794 Some(state_updated_at(options.updated_at.as_ref())),
795 )?;
796 }
797 }
798
799 Ok(journal)
800}
801
802fn enforce_restore_plan_requirements(
804 options: &RestorePlanOptions,
805 plan: &RestorePlan,
806) -> Result<(), RestoreCommandError> {
807 if !options.require_restore_ready || plan.readiness_summary.ready {
808 return Ok(());
809 }
810
811 Err(RestoreCommandError::RestoreNotReady {
812 backup_id: plan.backup_id.clone(),
813 reasons: plan.readiness_summary.reasons.clone(),
814 })
815}
816
817fn verify_backup_layout_if_required(
819 options: &RestorePlanOptions,
820) -> Result<(), RestoreCommandError> {
821 if !options.require_verified {
822 return Ok(());
823 }
824
825 let Some(dir) = &options.backup_dir else {
826 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
827 };
828
829 BackupLayout::new(dir.clone()).verify_integrity()?;
830 Ok(())
831}
832
833fn read_manifest_source(
835 options: &RestorePlanOptions,
836) -> Result<FleetBackupManifest, RestoreCommandError> {
837 if let Some(path) = &options.manifest {
838 return read_manifest(path);
839 }
840
841 let Some(dir) = &options.backup_dir else {
842 return Err(RestoreCommandError::MissingOption(
843 "--manifest or --backup-dir",
844 ));
845 };
846
847 BackupLayout::new(dir.clone())
848 .read_manifest()
849 .map_err(RestoreCommandError::from)
850}
851
852fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
854 let data = fs::read_to_string(path)?;
855 serde_json::from_str(&data).map_err(RestoreCommandError::from)
856}
857
858fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
860 let data = fs::read_to_string(path)?;
861 serde_json::from_str(&data).map_err(RestoreCommandError::from)
862}
863
864fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
866 let data = fs::read_to_string(path)?;
867 serde_json::from_str(&data).map_err(RestoreCommandError::from)
868}
869
870fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
872 let data = fs::read_to_string(path)?;
873 serde_json::from_str(&data).map_err(RestoreCommandError::from)
874}
875
876fn read_apply_journal(path: &PathBuf) -> Result<RestoreApplyJournal, RestoreCommandError> {
878 let data = fs::read_to_string(path)?;
879 let journal: RestoreApplyJournal = serde_json::from_str(&data)?;
880 journal.validate()?;
881 Ok(journal)
882}
883
884fn parse_sequence(value: String) -> Result<usize, RestoreCommandError> {
886 value
887 .parse::<usize>()
888 .map_err(|_| RestoreCommandError::InvalidSequence)
889}
890
891fn state_updated_at(updated_at: Option<&String>) -> String {
893 updated_at.cloned().unwrap_or_else(timestamp_placeholder)
894}
895
896fn timestamp_placeholder() -> String {
898 "unknown".to_string()
899}
900
901fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
903 if let Some(path) = &options.out {
904 let data = serde_json::to_vec_pretty(plan)?;
905 fs::write(path, data)?;
906 return Ok(());
907 }
908
909 let stdout = io::stdout();
910 let mut handle = stdout.lock();
911 serde_json::to_writer_pretty(&mut handle, plan)?;
912 writeln!(handle)?;
913 Ok(())
914}
915
916fn write_status(
918 options: &RestoreStatusOptions,
919 status: &RestoreStatus,
920) -> Result<(), RestoreCommandError> {
921 if let Some(path) = &options.out {
922 let data = serde_json::to_vec_pretty(status)?;
923 fs::write(path, data)?;
924 return Ok(());
925 }
926
927 let stdout = io::stdout();
928 let mut handle = stdout.lock();
929 serde_json::to_writer_pretty(&mut handle, status)?;
930 writeln!(handle)?;
931 Ok(())
932}
933
934fn write_apply_dry_run(
936 options: &RestoreApplyOptions,
937 dry_run: &RestoreApplyDryRun,
938) -> Result<(), RestoreCommandError> {
939 if let Some(path) = &options.out {
940 let data = serde_json::to_vec_pretty(dry_run)?;
941 fs::write(path, data)?;
942 return Ok(());
943 }
944
945 let stdout = io::stdout();
946 let mut handle = stdout.lock();
947 serde_json::to_writer_pretty(&mut handle, dry_run)?;
948 writeln!(handle)?;
949 Ok(())
950}
951
952fn write_apply_journal_if_requested(
954 options: &RestoreApplyOptions,
955 dry_run: &RestoreApplyDryRun,
956) -> Result<(), RestoreCommandError> {
957 let Some(path) = &options.journal_out else {
958 return Ok(());
959 };
960
961 let journal = RestoreApplyJournal::from_dry_run(dry_run);
962 let data = serde_json::to_vec_pretty(&journal)?;
963 fs::write(path, data)?;
964 Ok(())
965}
966
967fn write_apply_status(
969 options: &RestoreApplyStatusOptions,
970 status: &RestoreApplyJournalStatus,
971) -> Result<(), RestoreCommandError> {
972 if let Some(path) = &options.out {
973 let data = serde_json::to_vec_pretty(status)?;
974 fs::write(path, data)?;
975 return Ok(());
976 }
977
978 let stdout = io::stdout();
979 let mut handle = stdout.lock();
980 serde_json::to_writer_pretty(&mut handle, status)?;
981 writeln!(handle)?;
982 Ok(())
983}
984
985fn write_apply_next(
987 options: &RestoreApplyNextOptions,
988 next: &RestoreApplyNextOperation,
989) -> Result<(), RestoreCommandError> {
990 if let Some(path) = &options.out {
991 let data = serde_json::to_vec_pretty(next)?;
992 fs::write(path, data)?;
993 return Ok(());
994 }
995
996 let stdout = io::stdout();
997 let mut handle = stdout.lock();
998 serde_json::to_writer_pretty(&mut handle, next)?;
999 writeln!(handle)?;
1000 Ok(())
1001}
1002
1003fn write_apply_command(
1005 options: &RestoreApplyCommandOptions,
1006 preview: &RestoreApplyCommandPreview,
1007) -> Result<(), RestoreCommandError> {
1008 if let Some(path) = &options.out {
1009 let data = serde_json::to_vec_pretty(preview)?;
1010 fs::write(path, data)?;
1011 return Ok(());
1012 }
1013
1014 let stdout = io::stdout();
1015 let mut handle = stdout.lock();
1016 serde_json::to_writer_pretty(&mut handle, preview)?;
1017 writeln!(handle)?;
1018 Ok(())
1019}
1020
1021fn write_apply_claim(
1023 options: &RestoreApplyClaimOptions,
1024 journal: &RestoreApplyJournal,
1025) -> Result<(), RestoreCommandError> {
1026 if let Some(path) = &options.out {
1027 let data = serde_json::to_vec_pretty(journal)?;
1028 fs::write(path, data)?;
1029 return Ok(());
1030 }
1031
1032 let stdout = io::stdout();
1033 let mut handle = stdout.lock();
1034 serde_json::to_writer_pretty(&mut handle, journal)?;
1035 writeln!(handle)?;
1036 Ok(())
1037}
1038
1039fn write_apply_unclaim(
1041 options: &RestoreApplyUnclaimOptions,
1042 journal: &RestoreApplyJournal,
1043) -> Result<(), RestoreCommandError> {
1044 if let Some(path) = &options.out {
1045 let data = serde_json::to_vec_pretty(journal)?;
1046 fs::write(path, data)?;
1047 return Ok(());
1048 }
1049
1050 let stdout = io::stdout();
1051 let mut handle = stdout.lock();
1052 serde_json::to_writer_pretty(&mut handle, journal)?;
1053 writeln!(handle)?;
1054 Ok(())
1055}
1056
1057fn write_apply_mark(
1059 options: &RestoreApplyMarkOptions,
1060 journal: &RestoreApplyJournal,
1061) -> Result<(), RestoreCommandError> {
1062 if let Some(path) = &options.out {
1063 let data = serde_json::to_vec_pretty(journal)?;
1064 fs::write(path, data)?;
1065 return Ok(());
1066 }
1067
1068 let stdout = io::stdout();
1069 let mut handle = stdout.lock();
1070 serde_json::to_writer_pretty(&mut handle, journal)?;
1071 writeln!(handle)?;
1072 Ok(())
1073}
1074
1075fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
1077where
1078 I: Iterator<Item = OsString>,
1079{
1080 args.next()
1081 .and_then(|value| value.into_string().ok())
1082 .ok_or(RestoreCommandError::MissingValue(option))
1083}
1084
1085const fn usage() -> &'static str {
1087 "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-no-pending] [--require-no-failed] [--require-complete]\n canic restore apply-next --journal <file> [--out <file>]\n canic restore apply-command --journal <file> [--dfx <path>] [--network <name>] [--out <file>]\n canic restore apply-claim --journal <file> [--updated-at <text>] [--out <file>]\n canic restore apply-unclaim --journal <file> [--updated-at <text>] [--out <file>]\n canic restore apply-mark --journal <file> --sequence <n> --state completed|failed [--reason <text>] [--updated-at <text>] [--out <file>]"
1088}
1089
1090#[cfg(test)]
1091mod tests {
1092 use super::*;
1093 use canic_backup::restore::RestoreApplyOperationState;
1094 use canic_backup::{
1095 artifacts::ArtifactChecksum,
1096 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
1097 manifest::{
1098 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
1099 FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
1100 VerificationCheck, VerificationPlan,
1101 },
1102 };
1103 use serde_json::json;
1104 use std::{
1105 path::Path,
1106 time::{SystemTime, UNIX_EPOCH},
1107 };
1108
1109 const ROOT: &str = "aaaaa-aa";
1110 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
1111 const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
1112 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1113
1114 #[test]
1116 fn parses_restore_plan_options() {
1117 let options = RestorePlanOptions::parse([
1118 OsString::from("--manifest"),
1119 OsString::from("manifest.json"),
1120 OsString::from("--mapping"),
1121 OsString::from("mapping.json"),
1122 OsString::from("--out"),
1123 OsString::from("plan.json"),
1124 OsString::from("--require-restore-ready"),
1125 ])
1126 .expect("parse options");
1127
1128 assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
1129 assert_eq!(options.backup_dir, None);
1130 assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
1131 assert_eq!(options.out, Some(PathBuf::from("plan.json")));
1132 assert!(!options.require_verified);
1133 assert!(options.require_restore_ready);
1134 }
1135
1136 #[test]
1138 fn parses_verified_restore_plan_options() {
1139 let options = RestorePlanOptions::parse([
1140 OsString::from("--backup-dir"),
1141 OsString::from("backups/run"),
1142 OsString::from("--require-verified"),
1143 ])
1144 .expect("parse verified options");
1145
1146 assert_eq!(options.manifest, None);
1147 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
1148 assert_eq!(options.mapping, None);
1149 assert_eq!(options.out, None);
1150 assert!(options.require_verified);
1151 assert!(!options.require_restore_ready);
1152 }
1153
1154 #[test]
1156 fn parses_restore_status_options() {
1157 let options = RestoreStatusOptions::parse([
1158 OsString::from("--plan"),
1159 OsString::from("restore-plan.json"),
1160 OsString::from("--out"),
1161 OsString::from("restore-status.json"),
1162 ])
1163 .expect("parse status options");
1164
1165 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
1166 assert_eq!(options.out, Some(PathBuf::from("restore-status.json")));
1167 }
1168
1169 #[test]
1171 fn parses_restore_apply_dry_run_options() {
1172 let options = RestoreApplyOptions::parse([
1173 OsString::from("--plan"),
1174 OsString::from("restore-plan.json"),
1175 OsString::from("--status"),
1176 OsString::from("restore-status.json"),
1177 OsString::from("--backup-dir"),
1178 OsString::from("backups/run"),
1179 OsString::from("--dry-run"),
1180 OsString::from("--out"),
1181 OsString::from("restore-apply-dry-run.json"),
1182 OsString::from("--journal-out"),
1183 OsString::from("restore-apply-journal.json"),
1184 ])
1185 .expect("parse apply options");
1186
1187 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
1188 assert_eq!(options.status, Some(PathBuf::from("restore-status.json")));
1189 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
1190 assert_eq!(
1191 options.out,
1192 Some(PathBuf::from("restore-apply-dry-run.json"))
1193 );
1194 assert_eq!(
1195 options.journal_out,
1196 Some(PathBuf::from("restore-apply-journal.json"))
1197 );
1198 assert!(options.dry_run);
1199 }
1200
1201 #[test]
1203 fn parses_restore_apply_status_options() {
1204 let options = RestoreApplyStatusOptions::parse([
1205 OsString::from("--journal"),
1206 OsString::from("restore-apply-journal.json"),
1207 OsString::from("--out"),
1208 OsString::from("restore-apply-status.json"),
1209 OsString::from("--require-no-pending"),
1210 OsString::from("--require-no-failed"),
1211 OsString::from("--require-complete"),
1212 ])
1213 .expect("parse apply-status options");
1214
1215 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1216 assert!(options.require_no_pending);
1217 assert!(options.require_no_failed);
1218 assert!(options.require_complete);
1219 assert_eq!(
1220 options.out,
1221 Some(PathBuf::from("restore-apply-status.json"))
1222 );
1223 }
1224
1225 #[test]
1227 fn parses_restore_apply_next_options() {
1228 let options = RestoreApplyNextOptions::parse([
1229 OsString::from("--journal"),
1230 OsString::from("restore-apply-journal.json"),
1231 OsString::from("--out"),
1232 OsString::from("restore-apply-next.json"),
1233 ])
1234 .expect("parse apply-next options");
1235
1236 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1237 assert_eq!(options.out, Some(PathBuf::from("restore-apply-next.json")));
1238 }
1239
1240 #[test]
1242 fn parses_restore_apply_command_options() {
1243 let options = RestoreApplyCommandOptions::parse([
1244 OsString::from("--journal"),
1245 OsString::from("restore-apply-journal.json"),
1246 OsString::from("--dfx"),
1247 OsString::from("/tmp/dfx"),
1248 OsString::from("--network"),
1249 OsString::from("local"),
1250 OsString::from("--out"),
1251 OsString::from("restore-apply-command.json"),
1252 ])
1253 .expect("parse apply-command options");
1254
1255 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1256 assert_eq!(options.dfx, "/tmp/dfx");
1257 assert_eq!(options.network.as_deref(), Some("local"));
1258 assert_eq!(
1259 options.out,
1260 Some(PathBuf::from("restore-apply-command.json"))
1261 );
1262 }
1263
1264 #[test]
1266 fn parses_restore_apply_claim_options() {
1267 let options = RestoreApplyClaimOptions::parse([
1268 OsString::from("--journal"),
1269 OsString::from("restore-apply-journal.json"),
1270 OsString::from("--updated-at"),
1271 OsString::from("2026-05-04T12:00:00Z"),
1272 OsString::from("--out"),
1273 OsString::from("restore-apply-journal.claimed.json"),
1274 ])
1275 .expect("parse apply-claim options");
1276
1277 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1278 assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:00:00Z"));
1279 assert_eq!(
1280 options.out,
1281 Some(PathBuf::from("restore-apply-journal.claimed.json"))
1282 );
1283 }
1284
1285 #[test]
1287 fn parses_restore_apply_unclaim_options() {
1288 let options = RestoreApplyUnclaimOptions::parse([
1289 OsString::from("--journal"),
1290 OsString::from("restore-apply-journal.json"),
1291 OsString::from("--updated-at"),
1292 OsString::from("2026-05-04T12:01:00Z"),
1293 OsString::from("--out"),
1294 OsString::from("restore-apply-journal.unclaimed.json"),
1295 ])
1296 .expect("parse apply-unclaim options");
1297
1298 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1299 assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:01:00Z"));
1300 assert_eq!(
1301 options.out,
1302 Some(PathBuf::from("restore-apply-journal.unclaimed.json"))
1303 );
1304 }
1305
1306 #[test]
1308 fn parses_restore_apply_mark_options() {
1309 let options = RestoreApplyMarkOptions::parse([
1310 OsString::from("--journal"),
1311 OsString::from("restore-apply-journal.json"),
1312 OsString::from("--sequence"),
1313 OsString::from("4"),
1314 OsString::from("--state"),
1315 OsString::from("failed"),
1316 OsString::from("--reason"),
1317 OsString::from("dfx-load-failed"),
1318 OsString::from("--updated-at"),
1319 OsString::from("2026-05-04T12:02:00Z"),
1320 OsString::from("--out"),
1321 OsString::from("restore-apply-journal.updated.json"),
1322 ])
1323 .expect("parse apply-mark options");
1324
1325 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1326 assert_eq!(options.sequence, 4);
1327 assert_eq!(options.state, RestoreApplyMarkState::Failed);
1328 assert_eq!(options.reason.as_deref(), Some("dfx-load-failed"));
1329 assert_eq!(options.updated_at.as_deref(), Some("2026-05-04T12:02:00Z"));
1330 assert_eq!(
1331 options.out,
1332 Some(PathBuf::from("restore-apply-journal.updated.json"))
1333 );
1334 }
1335
1336 #[test]
1338 fn restore_apply_requires_dry_run() {
1339 let err = RestoreApplyOptions::parse([
1340 OsString::from("--plan"),
1341 OsString::from("restore-plan.json"),
1342 ])
1343 .expect_err("apply without dry-run should fail");
1344
1345 assert!(matches!(err, RestoreCommandError::ApplyRequiresDryRun));
1346 }
1347
1348 #[test]
1350 fn plan_restore_reads_manifest_from_backup_dir() {
1351 let root = temp_dir("canic-cli-restore-plan-layout");
1352 let layout = BackupLayout::new(root.clone());
1353 layout
1354 .write_manifest(&valid_manifest())
1355 .expect("write manifest");
1356
1357 let options = RestorePlanOptions {
1358 manifest: None,
1359 backup_dir: Some(root.clone()),
1360 mapping: None,
1361 out: None,
1362 require_verified: false,
1363 require_restore_ready: false,
1364 };
1365
1366 let plan = plan_restore(&options).expect("plan restore");
1367
1368 fs::remove_dir_all(root).expect("remove temp root");
1369 assert_eq!(plan.backup_id, "backup-test");
1370 assert_eq!(plan.member_count, 2);
1371 }
1372
1373 #[test]
1375 fn parse_rejects_conflicting_manifest_sources() {
1376 let err = RestorePlanOptions::parse([
1377 OsString::from("--manifest"),
1378 OsString::from("manifest.json"),
1379 OsString::from("--backup-dir"),
1380 OsString::from("backups/run"),
1381 ])
1382 .expect_err("conflicting sources should fail");
1383
1384 assert!(matches!(
1385 err,
1386 RestoreCommandError::ConflictingManifestSources
1387 ));
1388 }
1389
1390 #[test]
1392 fn parse_rejects_require_verified_with_manifest_source() {
1393 let err = RestorePlanOptions::parse([
1394 OsString::from("--manifest"),
1395 OsString::from("manifest.json"),
1396 OsString::from("--require-verified"),
1397 ])
1398 .expect_err("verification should require a backup layout");
1399
1400 assert!(matches!(
1401 err,
1402 RestoreCommandError::RequireVerifiedNeedsBackupDir
1403 ));
1404 }
1405
1406 #[test]
1408 fn plan_restore_requires_verified_backup_layout() {
1409 let root = temp_dir("canic-cli-restore-plan-verified");
1410 let layout = BackupLayout::new(root.clone());
1411 let manifest = valid_manifest();
1412 write_verified_layout(&root, &layout, &manifest);
1413
1414 let options = RestorePlanOptions {
1415 manifest: None,
1416 backup_dir: Some(root.clone()),
1417 mapping: None,
1418 out: None,
1419 require_verified: true,
1420 require_restore_ready: false,
1421 };
1422
1423 let plan = plan_restore(&options).expect("plan verified restore");
1424
1425 fs::remove_dir_all(root).expect("remove temp root");
1426 assert_eq!(plan.backup_id, "backup-test");
1427 assert_eq!(plan.member_count, 2);
1428 }
1429
1430 #[test]
1432 fn plan_restore_rejects_unverified_backup_layout() {
1433 let root = temp_dir("canic-cli-restore-plan-unverified");
1434 let layout = BackupLayout::new(root.clone());
1435 layout
1436 .write_manifest(&valid_manifest())
1437 .expect("write manifest");
1438
1439 let options = RestorePlanOptions {
1440 manifest: None,
1441 backup_dir: Some(root.clone()),
1442 mapping: None,
1443 out: None,
1444 require_verified: true,
1445 require_restore_ready: false,
1446 };
1447
1448 let err = plan_restore(&options).expect_err("missing journal should fail");
1449
1450 fs::remove_dir_all(root).expect("remove temp root");
1451 assert!(matches!(err, RestoreCommandError::Persistence(_)));
1452 }
1453
1454 #[test]
1456 fn plan_restore_reads_manifest_and_mapping() {
1457 let root = temp_dir("canic-cli-restore-plan");
1458 fs::create_dir_all(&root).expect("create temp root");
1459 let manifest_path = root.join("manifest.json");
1460 let mapping_path = root.join("mapping.json");
1461
1462 fs::write(
1463 &manifest_path,
1464 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
1465 )
1466 .expect("write manifest");
1467 fs::write(
1468 &mapping_path,
1469 json!({
1470 "members": [
1471 {"source_canister": ROOT, "target_canister": ROOT},
1472 {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
1473 ]
1474 })
1475 .to_string(),
1476 )
1477 .expect("write mapping");
1478
1479 let options = RestorePlanOptions {
1480 manifest: Some(manifest_path),
1481 backup_dir: None,
1482 mapping: Some(mapping_path),
1483 out: None,
1484 require_verified: false,
1485 require_restore_ready: false,
1486 };
1487
1488 let plan = plan_restore(&options).expect("plan restore");
1489
1490 fs::remove_dir_all(root).expect("remove temp root");
1491 let members = plan.ordered_members();
1492 assert_eq!(members.len(), 2);
1493 assert_eq!(members[0].source_canister, ROOT);
1494 assert_eq!(members[1].target_canister, MAPPED_CHILD);
1495 }
1496
1497 #[test]
1499 fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
1500 let root = temp_dir("canic-cli-restore-plan-require-ready");
1501 fs::create_dir_all(&root).expect("create temp root");
1502 let manifest_path = root.join("manifest.json");
1503 let out_path = root.join("plan.json");
1504
1505 fs::write(
1506 &manifest_path,
1507 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
1508 )
1509 .expect("write manifest");
1510
1511 let err = run([
1512 OsString::from("plan"),
1513 OsString::from("--manifest"),
1514 OsString::from(manifest_path.as_os_str()),
1515 OsString::from("--out"),
1516 OsString::from(out_path.as_os_str()),
1517 OsString::from("--require-restore-ready"),
1518 ])
1519 .expect_err("restore readiness should be enforced");
1520
1521 assert!(out_path.exists());
1522 let plan: RestorePlan =
1523 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
1524
1525 fs::remove_dir_all(root).expect("remove temp root");
1526 assert!(!plan.readiness_summary.ready);
1527 assert!(matches!(
1528 err,
1529 RestoreCommandError::RestoreNotReady {
1530 reasons,
1531 ..
1532 } if reasons == [
1533 "missing-module-hash",
1534 "missing-wasm-hash",
1535 "missing-snapshot-checksum"
1536 ]
1537 ));
1538 }
1539
1540 #[test]
1542 fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
1543 let root = temp_dir("canic-cli-restore-plan-ready");
1544 fs::create_dir_all(&root).expect("create temp root");
1545 let manifest_path = root.join("manifest.json");
1546 let out_path = root.join("plan.json");
1547
1548 fs::write(
1549 &manifest_path,
1550 serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
1551 )
1552 .expect("write manifest");
1553
1554 run([
1555 OsString::from("plan"),
1556 OsString::from("--manifest"),
1557 OsString::from(manifest_path.as_os_str()),
1558 OsString::from("--out"),
1559 OsString::from(out_path.as_os_str()),
1560 OsString::from("--require-restore-ready"),
1561 ])
1562 .expect("restore-ready plan should pass");
1563
1564 let plan: RestorePlan =
1565 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
1566
1567 fs::remove_dir_all(root).expect("remove temp root");
1568 assert!(plan.readiness_summary.ready);
1569 assert!(plan.readiness_summary.reasons.is_empty());
1570 }
1571
1572 #[test]
1574 fn run_restore_status_writes_planned_status() {
1575 let root = temp_dir("canic-cli-restore-status");
1576 fs::create_dir_all(&root).expect("create temp root");
1577 let plan_path = root.join("restore-plan.json");
1578 let out_path = root.join("restore-status.json");
1579 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1580
1581 fs::write(
1582 &plan_path,
1583 serde_json::to_vec(&plan).expect("serialize plan"),
1584 )
1585 .expect("write plan");
1586
1587 run([
1588 OsString::from("status"),
1589 OsString::from("--plan"),
1590 OsString::from(plan_path.as_os_str()),
1591 OsString::from("--out"),
1592 OsString::from(out_path.as_os_str()),
1593 ])
1594 .expect("write restore status");
1595
1596 let status: RestoreStatus =
1597 serde_json::from_slice(&fs::read(&out_path).expect("read restore status"))
1598 .expect("decode restore status");
1599 let status_json: serde_json::Value = serde_json::to_value(&status).expect("encode status");
1600
1601 fs::remove_dir_all(root).expect("remove temp root");
1602 assert_eq!(status.status_version, 1);
1603 assert_eq!(status.backup_id.as_str(), "backup-test");
1604 assert!(status.ready);
1605 assert!(status.readiness_reasons.is_empty());
1606 assert_eq!(status.member_count, 2);
1607 assert_eq!(status.phase_count, 1);
1608 assert_eq!(status.planned_snapshot_loads, 2);
1609 assert_eq!(status.planned_code_reinstalls, 2);
1610 assert_eq!(status.planned_verification_checks, 2);
1611 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
1612 assert_eq!(status_json["phases"][0]["members"][0]["state"], "planned");
1613 }
1614
1615 #[test]
1617 fn run_restore_apply_dry_run_writes_operations() {
1618 let root = temp_dir("canic-cli-restore-apply-dry-run");
1619 fs::create_dir_all(&root).expect("create temp root");
1620 let plan_path = root.join("restore-plan.json");
1621 let status_path = root.join("restore-status.json");
1622 let out_path = root.join("restore-apply-dry-run.json");
1623 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1624 let status = RestoreStatus::from_plan(&plan);
1625
1626 fs::write(
1627 &plan_path,
1628 serde_json::to_vec(&plan).expect("serialize plan"),
1629 )
1630 .expect("write plan");
1631 fs::write(
1632 &status_path,
1633 serde_json::to_vec(&status).expect("serialize status"),
1634 )
1635 .expect("write status");
1636
1637 run([
1638 OsString::from("apply"),
1639 OsString::from("--plan"),
1640 OsString::from(plan_path.as_os_str()),
1641 OsString::from("--status"),
1642 OsString::from(status_path.as_os_str()),
1643 OsString::from("--dry-run"),
1644 OsString::from("--out"),
1645 OsString::from(out_path.as_os_str()),
1646 ])
1647 .expect("write apply dry-run");
1648
1649 let dry_run: RestoreApplyDryRun =
1650 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
1651 .expect("decode dry-run");
1652 let dry_run_json: serde_json::Value =
1653 serde_json::to_value(&dry_run).expect("encode dry-run");
1654
1655 fs::remove_dir_all(root).expect("remove temp root");
1656 assert_eq!(dry_run.dry_run_version, 1);
1657 assert_eq!(dry_run.backup_id.as_str(), "backup-test");
1658 assert!(dry_run.ready);
1659 assert!(dry_run.status_supplied);
1660 assert_eq!(dry_run.member_count, 2);
1661 assert_eq!(dry_run.phase_count, 1);
1662 assert_eq!(dry_run.rendered_operations, 8);
1663 assert_eq!(
1664 dry_run_json["phases"][0]["operations"][0]["operation"],
1665 "upload-snapshot"
1666 );
1667 assert_eq!(
1668 dry_run_json["phases"][0]["operations"][3]["operation"],
1669 "verify-member"
1670 );
1671 assert_eq!(
1672 dry_run_json["phases"][0]["operations"][3]["verification_kind"],
1673 "status"
1674 );
1675 assert_eq!(
1676 dry_run_json["phases"][0]["operations"][3]["verification_method"],
1677 serde_json::Value::Null
1678 );
1679 }
1680
1681 #[test]
1683 fn run_restore_apply_dry_run_validates_backup_dir_artifacts() {
1684 let root = temp_dir("canic-cli-restore-apply-artifacts");
1685 fs::create_dir_all(&root).expect("create temp root");
1686 let plan_path = root.join("restore-plan.json");
1687 let out_path = root.join("restore-apply-dry-run.json");
1688 let journal_path = root.join("restore-apply-journal.json");
1689 let status_path = root.join("restore-apply-status.json");
1690 let mut manifest = restore_ready_manifest();
1691 write_manifest_artifacts(&root, &mut manifest);
1692 let plan = RestorePlanner::plan(&manifest, None).expect("build plan");
1693
1694 fs::write(
1695 &plan_path,
1696 serde_json::to_vec(&plan).expect("serialize plan"),
1697 )
1698 .expect("write plan");
1699
1700 run([
1701 OsString::from("apply"),
1702 OsString::from("--plan"),
1703 OsString::from(plan_path.as_os_str()),
1704 OsString::from("--backup-dir"),
1705 OsString::from(root.as_os_str()),
1706 OsString::from("--dry-run"),
1707 OsString::from("--out"),
1708 OsString::from(out_path.as_os_str()),
1709 OsString::from("--journal-out"),
1710 OsString::from(journal_path.as_os_str()),
1711 ])
1712 .expect("write apply dry-run");
1713 run([
1714 OsString::from("apply-status"),
1715 OsString::from("--journal"),
1716 OsString::from(journal_path.as_os_str()),
1717 OsString::from("--out"),
1718 OsString::from(status_path.as_os_str()),
1719 ])
1720 .expect("write apply status");
1721
1722 let dry_run: RestoreApplyDryRun =
1723 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
1724 .expect("decode dry-run");
1725 let validation = dry_run
1726 .artifact_validation
1727 .expect("artifact validation should be present");
1728 let journal_json: serde_json::Value =
1729 serde_json::from_slice(&fs::read(&journal_path).expect("read journal"))
1730 .expect("decode journal");
1731 let status_json: serde_json::Value =
1732 serde_json::from_slice(&fs::read(&status_path).expect("read apply status"))
1733 .expect("decode apply status");
1734
1735 fs::remove_dir_all(root).expect("remove temp root");
1736 assert_eq!(validation.checked_members, 2);
1737 assert!(validation.artifacts_present);
1738 assert!(validation.checksums_verified);
1739 assert_eq!(validation.members_with_expected_checksums, 2);
1740 assert_eq!(journal_json["ready"], true);
1741 assert_eq!(journal_json["operation_count"], 8);
1742 assert_eq!(journal_json["ready_operations"], 8);
1743 assert_eq!(journal_json["blocked_operations"], 0);
1744 assert_eq!(journal_json["operations"][0]["state"], "ready");
1745 assert_eq!(status_json["ready"], true);
1746 assert_eq!(status_json["operation_count"], 8);
1747 assert_eq!(status_json["next_ready_sequence"], 0);
1748 assert_eq!(status_json["next_ready_operation"], "upload-snapshot");
1749 }
1750
1751 #[test]
1753 fn run_restore_apply_status_rejects_invalid_journal() {
1754 let root = temp_dir("canic-cli-restore-apply-status-invalid");
1755 fs::create_dir_all(&root).expect("create temp root");
1756 let journal_path = root.join("restore-apply-journal.json");
1757 let out_path = root.join("restore-apply-status.json");
1758 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1759 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
1760 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
1761 journal.operation_count += 1;
1762
1763 fs::write(
1764 &journal_path,
1765 serde_json::to_vec(&journal).expect("serialize journal"),
1766 )
1767 .expect("write journal");
1768
1769 let err = run([
1770 OsString::from("apply-status"),
1771 OsString::from("--journal"),
1772 OsString::from(journal_path.as_os_str()),
1773 OsString::from("--out"),
1774 OsString::from(out_path.as_os_str()),
1775 ])
1776 .expect_err("invalid journal should fail");
1777
1778 assert!(!out_path.exists());
1779 fs::remove_dir_all(root).expect("remove temp root");
1780 assert!(matches!(
1781 err,
1782 RestoreCommandError::RestoreApplyJournal(RestoreApplyJournalError::CountMismatch {
1783 field: "operation_count",
1784 ..
1785 })
1786 ));
1787 }
1788
1789 #[test]
1791 fn run_restore_apply_status_require_no_pending_writes_status_then_fails() {
1792 let root = temp_dir("canic-cli-restore-apply-status-pending");
1793 fs::create_dir_all(&root).expect("create temp root");
1794 let journal_path = root.join("restore-apply-journal.json");
1795 let out_path = root.join("restore-apply-status.json");
1796 let mut journal = ready_apply_journal();
1797 journal
1798 .mark_next_operation_pending_at(Some("2026-05-04T12:00:00Z".to_string()))
1799 .expect("claim operation");
1800
1801 fs::write(
1802 &journal_path,
1803 serde_json::to_vec(&journal).expect("serialize journal"),
1804 )
1805 .expect("write journal");
1806
1807 let err = run([
1808 OsString::from("apply-status"),
1809 OsString::from("--journal"),
1810 OsString::from(journal_path.as_os_str()),
1811 OsString::from("--out"),
1812 OsString::from(out_path.as_os_str()),
1813 OsString::from("--require-no-pending"),
1814 ])
1815 .expect_err("pending operation should fail requirement");
1816
1817 assert!(out_path.exists());
1818 let status: RestoreApplyJournalStatus =
1819 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
1820 .expect("decode apply status");
1821
1822 fs::remove_dir_all(root).expect("remove temp root");
1823 assert_eq!(status.pending_operations, 1);
1824 assert_eq!(status.next_transition_sequence, Some(0));
1825 assert_eq!(
1826 status.next_transition_updated_at.as_deref(),
1827 Some("2026-05-04T12:00:00Z")
1828 );
1829 assert!(matches!(
1830 err,
1831 RestoreCommandError::RestoreApplyPending {
1832 pending_operations: 1,
1833 next_transition_sequence: Some(0),
1834 ..
1835 }
1836 ));
1837 }
1838
1839 #[test]
1841 fn run_restore_apply_status_require_complete_writes_status_then_fails() {
1842 let root = temp_dir("canic-cli-restore-apply-status-incomplete");
1843 fs::create_dir_all(&root).expect("create temp root");
1844 let journal_path = root.join("restore-apply-journal.json");
1845 let out_path = root.join("restore-apply-status.json");
1846 let journal = ready_apply_journal();
1847
1848 fs::write(
1849 &journal_path,
1850 serde_json::to_vec(&journal).expect("serialize journal"),
1851 )
1852 .expect("write journal");
1853
1854 let err = run([
1855 OsString::from("apply-status"),
1856 OsString::from("--journal"),
1857 OsString::from(journal_path.as_os_str()),
1858 OsString::from("--out"),
1859 OsString::from(out_path.as_os_str()),
1860 OsString::from("--require-complete"),
1861 ])
1862 .expect_err("incomplete journal should fail requirement");
1863
1864 assert!(out_path.exists());
1865 let status: RestoreApplyJournalStatus =
1866 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
1867 .expect("decode apply status");
1868
1869 fs::remove_dir_all(root).expect("remove temp root");
1870 assert!(!status.complete);
1871 assert_eq!(status.completed_operations, 0);
1872 assert_eq!(status.operation_count, 8);
1873 assert!(matches!(
1874 err,
1875 RestoreCommandError::RestoreApplyIncomplete {
1876 completed_operations: 0,
1877 operation_count: 8,
1878 ..
1879 }
1880 ));
1881 }
1882
1883 #[test]
1885 fn run_restore_apply_status_require_no_failed_writes_status_then_fails() {
1886 let root = temp_dir("canic-cli-restore-apply-status-failed");
1887 fs::create_dir_all(&root).expect("create temp root");
1888 let journal_path = root.join("restore-apply-journal.json");
1889 let out_path = root.join("restore-apply-status.json");
1890 let mut journal = ready_apply_journal();
1891 journal
1892 .mark_operation_failed(0, "dfx-load-failed".to_string())
1893 .expect("mark failed operation");
1894
1895 fs::write(
1896 &journal_path,
1897 serde_json::to_vec(&journal).expect("serialize journal"),
1898 )
1899 .expect("write journal");
1900
1901 let err = run([
1902 OsString::from("apply-status"),
1903 OsString::from("--journal"),
1904 OsString::from(journal_path.as_os_str()),
1905 OsString::from("--out"),
1906 OsString::from(out_path.as_os_str()),
1907 OsString::from("--require-no-failed"),
1908 ])
1909 .expect_err("failed operation should fail requirement");
1910
1911 assert!(out_path.exists());
1912 let status: RestoreApplyJournalStatus =
1913 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
1914 .expect("decode apply status");
1915
1916 fs::remove_dir_all(root).expect("remove temp root");
1917 assert_eq!(status.failed_operations, 1);
1918 assert!(matches!(
1919 err,
1920 RestoreCommandError::RestoreApplyFailed {
1921 failed_operations: 1,
1922 ..
1923 }
1924 ));
1925 }
1926
1927 #[test]
1929 fn run_restore_apply_status_require_complete_accepts_complete_journal() {
1930 let root = temp_dir("canic-cli-restore-apply-status-complete");
1931 fs::create_dir_all(&root).expect("create temp root");
1932 let journal_path = root.join("restore-apply-journal.json");
1933 let out_path = root.join("restore-apply-status.json");
1934 let mut journal = ready_apply_journal();
1935 for sequence in 0..journal.operation_count {
1936 journal
1937 .mark_operation_completed(sequence)
1938 .expect("complete operation");
1939 }
1940
1941 fs::write(
1942 &journal_path,
1943 serde_json::to_vec(&journal).expect("serialize journal"),
1944 )
1945 .expect("write journal");
1946
1947 run([
1948 OsString::from("apply-status"),
1949 OsString::from("--journal"),
1950 OsString::from(journal_path.as_os_str()),
1951 OsString::from("--out"),
1952 OsString::from(out_path.as_os_str()),
1953 OsString::from("--require-complete"),
1954 ])
1955 .expect("complete journal should pass requirement");
1956
1957 let status: RestoreApplyJournalStatus =
1958 serde_json::from_slice(&fs::read(&out_path).expect("read apply status"))
1959 .expect("decode apply status");
1960
1961 fs::remove_dir_all(root).expect("remove temp root");
1962 assert!(status.complete);
1963 assert_eq!(status.completed_operations, 8);
1964 assert_eq!(status.operation_count, 8);
1965 }
1966
1967 #[test]
1969 fn run_restore_apply_next_writes_next_ready_operation() {
1970 let root = temp_dir("canic-cli-restore-apply-next");
1971 fs::create_dir_all(&root).expect("create temp root");
1972 let journal_path = root.join("restore-apply-journal.json");
1973 let out_path = root.join("restore-apply-next.json");
1974 let mut journal = ready_apply_journal();
1975 journal
1976 .mark_operation_completed(0)
1977 .expect("mark first operation complete");
1978
1979 fs::write(
1980 &journal_path,
1981 serde_json::to_vec(&journal).expect("serialize journal"),
1982 )
1983 .expect("write journal");
1984
1985 run([
1986 OsString::from("apply-next"),
1987 OsString::from("--journal"),
1988 OsString::from(journal_path.as_os_str()),
1989 OsString::from("--out"),
1990 OsString::from(out_path.as_os_str()),
1991 ])
1992 .expect("write apply next");
1993
1994 let next: RestoreApplyNextOperation =
1995 serde_json::from_slice(&fs::read(&out_path).expect("read next operation"))
1996 .expect("decode next operation");
1997 let operation = next.operation.expect("operation should be available");
1998
1999 fs::remove_dir_all(root).expect("remove temp root");
2000 assert!(next.ready);
2001 assert!(next.operation_available);
2002 assert_eq!(operation.sequence, 1);
2003 assert_eq!(
2004 operation.operation,
2005 canic_backup::restore::RestoreApplyOperationKind::LoadSnapshot
2006 );
2007 }
2008
2009 #[test]
2011 fn run_restore_apply_command_writes_next_command_preview() {
2012 let root = temp_dir("canic-cli-restore-apply-command");
2013 fs::create_dir_all(&root).expect("create temp root");
2014 let journal_path = root.join("restore-apply-journal.json");
2015 let out_path = root.join("restore-apply-command.json");
2016 let journal = ready_apply_journal();
2017
2018 fs::write(
2019 &journal_path,
2020 serde_json::to_vec(&journal).expect("serialize journal"),
2021 )
2022 .expect("write journal");
2023
2024 run([
2025 OsString::from("apply-command"),
2026 OsString::from("--journal"),
2027 OsString::from(journal_path.as_os_str()),
2028 OsString::from("--dfx"),
2029 OsString::from("/tmp/dfx"),
2030 OsString::from("--network"),
2031 OsString::from("local"),
2032 OsString::from("--out"),
2033 OsString::from(out_path.as_os_str()),
2034 ])
2035 .expect("write command preview");
2036
2037 let preview: RestoreApplyCommandPreview =
2038 serde_json::from_slice(&fs::read(&out_path).expect("read command preview"))
2039 .expect("decode command preview");
2040 let command = preview.command.expect("command should be available");
2041
2042 fs::remove_dir_all(root).expect("remove temp root");
2043 assert!(preview.ready);
2044 assert!(preview.command_available);
2045 assert_eq!(command.program, "/tmp/dfx");
2046 assert_eq!(
2047 command.args,
2048 vec![
2049 "canister".to_string(),
2050 "--network".to_string(),
2051 "local".to_string(),
2052 "snapshot".to_string(),
2053 "upload".to_string(),
2054 "--dir".to_string(),
2055 "artifacts/root".to_string(),
2056 ROOT.to_string(),
2057 ]
2058 );
2059 assert!(command.mutates);
2060 }
2061
2062 #[test]
2064 fn run_restore_apply_claim_marks_next_operation_pending() {
2065 let root = temp_dir("canic-cli-restore-apply-claim");
2066 fs::create_dir_all(&root).expect("create temp root");
2067 let journal_path = root.join("restore-apply-journal.json");
2068 let claimed_path = root.join("restore-apply-journal.claimed.json");
2069 let journal = ready_apply_journal();
2070
2071 fs::write(
2072 &journal_path,
2073 serde_json::to_vec(&journal).expect("serialize journal"),
2074 )
2075 .expect("write journal");
2076
2077 run([
2078 OsString::from("apply-claim"),
2079 OsString::from("--journal"),
2080 OsString::from(journal_path.as_os_str()),
2081 OsString::from("--updated-at"),
2082 OsString::from("2026-05-04T12:00:00Z"),
2083 OsString::from("--out"),
2084 OsString::from(claimed_path.as_os_str()),
2085 ])
2086 .expect("claim operation");
2087
2088 let claimed: RestoreApplyJournal =
2089 serde_json::from_slice(&fs::read(&claimed_path).expect("read claimed journal"))
2090 .expect("decode claimed journal");
2091 let status = claimed.status();
2092 let next = claimed.next_operation();
2093
2094 fs::remove_dir_all(root).expect("remove temp root");
2095 assert_eq!(claimed.pending_operations, 1);
2096 assert_eq!(claimed.ready_operations, 7);
2097 assert_eq!(
2098 claimed.operations[0].state,
2099 RestoreApplyOperationState::Pending
2100 );
2101 assert_eq!(
2102 claimed.operations[0].state_updated_at.as_deref(),
2103 Some("2026-05-04T12:00:00Z")
2104 );
2105 assert_eq!(status.next_transition_sequence, Some(0));
2106 assert_eq!(
2107 status.next_transition_state,
2108 Some(RestoreApplyOperationState::Pending)
2109 );
2110 assert_eq!(
2111 status.next_transition_updated_at.as_deref(),
2112 Some("2026-05-04T12:00:00Z")
2113 );
2114 assert_eq!(
2115 next.operation.expect("next operation").state,
2116 RestoreApplyOperationState::Pending
2117 );
2118 }
2119
2120 #[test]
2122 fn run_restore_apply_unclaim_marks_pending_operation_ready() {
2123 let root = temp_dir("canic-cli-restore-apply-unclaim");
2124 fs::create_dir_all(&root).expect("create temp root");
2125 let journal_path = root.join("restore-apply-journal.json");
2126 let unclaimed_path = root.join("restore-apply-journal.unclaimed.json");
2127 let mut journal = ready_apply_journal();
2128 journal
2129 .mark_next_operation_pending()
2130 .expect("claim operation");
2131
2132 fs::write(
2133 &journal_path,
2134 serde_json::to_vec(&journal).expect("serialize journal"),
2135 )
2136 .expect("write journal");
2137
2138 run([
2139 OsString::from("apply-unclaim"),
2140 OsString::from("--journal"),
2141 OsString::from(journal_path.as_os_str()),
2142 OsString::from("--updated-at"),
2143 OsString::from("2026-05-04T12:01:00Z"),
2144 OsString::from("--out"),
2145 OsString::from(unclaimed_path.as_os_str()),
2146 ])
2147 .expect("unclaim operation");
2148
2149 let unclaimed: RestoreApplyJournal =
2150 serde_json::from_slice(&fs::read(&unclaimed_path).expect("read unclaimed journal"))
2151 .expect("decode unclaimed journal");
2152 let status = unclaimed.status();
2153
2154 fs::remove_dir_all(root).expect("remove temp root");
2155 assert_eq!(unclaimed.pending_operations, 0);
2156 assert_eq!(unclaimed.ready_operations, 8);
2157 assert_eq!(
2158 unclaimed.operations[0].state,
2159 RestoreApplyOperationState::Ready
2160 );
2161 assert_eq!(
2162 unclaimed.operations[0].state_updated_at.as_deref(),
2163 Some("2026-05-04T12:01:00Z")
2164 );
2165 assert_eq!(status.next_ready_sequence, Some(0));
2166 assert_eq!(
2167 status.next_transition_state,
2168 Some(RestoreApplyOperationState::Ready)
2169 );
2170 assert_eq!(
2171 status.next_transition_updated_at.as_deref(),
2172 Some("2026-05-04T12:01:00Z")
2173 );
2174 }
2175
2176 #[test]
2178 fn run_restore_apply_mark_completes_operation() {
2179 let root = temp_dir("canic-cli-restore-apply-mark-complete");
2180 fs::create_dir_all(&root).expect("create temp root");
2181 let journal_path = root.join("restore-apply-journal.json");
2182 let updated_path = root.join("restore-apply-journal.updated.json");
2183 let journal = ready_apply_journal();
2184
2185 fs::write(
2186 &journal_path,
2187 serde_json::to_vec(&journal).expect("serialize journal"),
2188 )
2189 .expect("write journal");
2190
2191 run([
2192 OsString::from("apply-mark"),
2193 OsString::from("--journal"),
2194 OsString::from(journal_path.as_os_str()),
2195 OsString::from("--sequence"),
2196 OsString::from("0"),
2197 OsString::from("--state"),
2198 OsString::from("completed"),
2199 OsString::from("--updated-at"),
2200 OsString::from("2026-05-04T12:02:00Z"),
2201 OsString::from("--out"),
2202 OsString::from(updated_path.as_os_str()),
2203 ])
2204 .expect("mark operation completed");
2205
2206 let updated: RestoreApplyJournal =
2207 serde_json::from_slice(&fs::read(&updated_path).expect("read updated journal"))
2208 .expect("decode updated journal");
2209 let status = updated.status();
2210
2211 fs::remove_dir_all(root).expect("remove temp root");
2212 assert_eq!(updated.completed_operations, 1);
2213 assert_eq!(updated.ready_operations, 7);
2214 assert_eq!(
2215 updated.operations[0].state_updated_at.as_deref(),
2216 Some("2026-05-04T12:02:00Z")
2217 );
2218 assert_eq!(status.next_ready_sequence, Some(1));
2219 }
2220
2221 #[test]
2223 fn run_restore_apply_mark_rejects_out_of_order_operation() {
2224 let root = temp_dir("canic-cli-restore-apply-mark-out-of-order");
2225 fs::create_dir_all(&root).expect("create temp root");
2226 let journal_path = root.join("restore-apply-journal.json");
2227 let updated_path = root.join("restore-apply-journal.updated.json");
2228 let journal = ready_apply_journal();
2229
2230 fs::write(
2231 &journal_path,
2232 serde_json::to_vec(&journal).expect("serialize journal"),
2233 )
2234 .expect("write journal");
2235
2236 let err = run([
2237 OsString::from("apply-mark"),
2238 OsString::from("--journal"),
2239 OsString::from(journal_path.as_os_str()),
2240 OsString::from("--sequence"),
2241 OsString::from("1"),
2242 OsString::from("--state"),
2243 OsString::from("completed"),
2244 OsString::from("--out"),
2245 OsString::from(updated_path.as_os_str()),
2246 ])
2247 .expect_err("out-of-order operation should fail");
2248
2249 assert!(!updated_path.exists());
2250 fs::remove_dir_all(root).expect("remove temp root");
2251 assert!(matches!(
2252 err,
2253 RestoreCommandError::RestoreApplyJournal(
2254 RestoreApplyJournalError::OutOfOrderOperationTransition {
2255 requested: 1,
2256 next: 0
2257 }
2258 )
2259 ));
2260 }
2261
2262 #[test]
2264 fn run_restore_apply_mark_failed_requires_reason() {
2265 let root = temp_dir("canic-cli-restore-apply-mark-failed-reason");
2266 fs::create_dir_all(&root).expect("create temp root");
2267 let journal_path = root.join("restore-apply-journal.json");
2268 let journal = ready_apply_journal();
2269
2270 fs::write(
2271 &journal_path,
2272 serde_json::to_vec(&journal).expect("serialize journal"),
2273 )
2274 .expect("write journal");
2275
2276 let err = run([
2277 OsString::from("apply-mark"),
2278 OsString::from("--journal"),
2279 OsString::from(journal_path.as_os_str()),
2280 OsString::from("--sequence"),
2281 OsString::from("0"),
2282 OsString::from("--state"),
2283 OsString::from("failed"),
2284 ])
2285 .expect_err("failed state should require reason");
2286
2287 fs::remove_dir_all(root).expect("remove temp root");
2288 assert!(matches!(
2289 err,
2290 RestoreCommandError::RestoreApplyJournal(
2291 RestoreApplyJournalError::FailureReasonRequired(0)
2292 )
2293 ));
2294 }
2295
2296 #[test]
2298 fn run_restore_apply_dry_run_rejects_mismatched_status() {
2299 let root = temp_dir("canic-cli-restore-apply-dry-run-mismatch");
2300 fs::create_dir_all(&root).expect("create temp root");
2301 let plan_path = root.join("restore-plan.json");
2302 let status_path = root.join("restore-status.json");
2303 let out_path = root.join("restore-apply-dry-run.json");
2304 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
2305 let mut status = RestoreStatus::from_plan(&plan);
2306 status.backup_id = "other-backup".to_string();
2307
2308 fs::write(
2309 &plan_path,
2310 serde_json::to_vec(&plan).expect("serialize plan"),
2311 )
2312 .expect("write plan");
2313 fs::write(
2314 &status_path,
2315 serde_json::to_vec(&status).expect("serialize status"),
2316 )
2317 .expect("write status");
2318
2319 let err = run([
2320 OsString::from("apply"),
2321 OsString::from("--plan"),
2322 OsString::from(plan_path.as_os_str()),
2323 OsString::from("--status"),
2324 OsString::from(status_path.as_os_str()),
2325 OsString::from("--dry-run"),
2326 OsString::from("--out"),
2327 OsString::from(out_path.as_os_str()),
2328 ])
2329 .expect_err("mismatched status should fail");
2330
2331 assert!(!out_path.exists());
2332 fs::remove_dir_all(root).expect("remove temp root");
2333 assert!(matches!(
2334 err,
2335 RestoreCommandError::RestoreApplyDryRun(RestoreApplyDryRunError::StatusPlanMismatch {
2336 field: "backup_id",
2337 ..
2338 })
2339 ));
2340 }
2341
2342 fn ready_apply_journal() -> RestoreApplyJournal {
2344 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
2345 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
2346 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
2347
2348 journal.ready = true;
2349 journal.blocked_reasons = Vec::new();
2350 for operation in &mut journal.operations {
2351 operation.state = canic_backup::restore::RestoreApplyOperationState::Ready;
2352 operation.blocking_reasons = Vec::new();
2353 }
2354 journal.blocked_operations = 0;
2355 journal.ready_operations = journal.operation_count;
2356 journal.validate().expect("journal should validate");
2357 journal
2358 }
2359
2360 fn valid_manifest() -> FleetBackupManifest {
2362 FleetBackupManifest {
2363 manifest_version: 1,
2364 backup_id: "backup-test".to_string(),
2365 created_at: "2026-05-03T00:00:00Z".to_string(),
2366 tool: ToolMetadata {
2367 name: "canic".to_string(),
2368 version: "0.30.1".to_string(),
2369 },
2370 source: SourceMetadata {
2371 environment: "local".to_string(),
2372 root_canister: ROOT.to_string(),
2373 },
2374 consistency: ConsistencySection {
2375 mode: ConsistencyMode::CrashConsistent,
2376 backup_units: vec![BackupUnit {
2377 unit_id: "fleet".to_string(),
2378 kind: BackupUnitKind::SubtreeRooted,
2379 roles: vec!["root".to_string(), "app".to_string()],
2380 consistency_reason: None,
2381 dependency_closure: Vec::new(),
2382 topology_validation: "subtree-closed".to_string(),
2383 quiescence_strategy: None,
2384 }],
2385 },
2386 fleet: FleetSection {
2387 topology_hash_algorithm: "sha256".to_string(),
2388 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
2389 discovery_topology_hash: HASH.to_string(),
2390 pre_snapshot_topology_hash: HASH.to_string(),
2391 topology_hash: HASH.to_string(),
2392 members: vec![
2393 fleet_member("root", ROOT, None, IdentityMode::Fixed),
2394 fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
2395 ],
2396 },
2397 verification: VerificationPlan::default(),
2398 }
2399 }
2400
2401 fn restore_ready_manifest() -> FleetBackupManifest {
2403 let mut manifest = valid_manifest();
2404 for member in &mut manifest.fleet.members {
2405 member.source_snapshot.module_hash = Some(HASH.to_string());
2406 member.source_snapshot.wasm_hash = Some(HASH.to_string());
2407 member.source_snapshot.checksum = Some(HASH.to_string());
2408 }
2409 manifest
2410 }
2411
2412 fn fleet_member(
2414 role: &str,
2415 canister_id: &str,
2416 parent_canister_id: Option<&str>,
2417 identity_mode: IdentityMode,
2418 ) -> FleetMember {
2419 FleetMember {
2420 role: role.to_string(),
2421 canister_id: canister_id.to_string(),
2422 parent_canister_id: parent_canister_id.map(str::to_string),
2423 subnet_canister_id: Some(ROOT.to_string()),
2424 controller_hint: None,
2425 identity_mode,
2426 restore_group: 1,
2427 verification_class: "basic".to_string(),
2428 verification_checks: vec![VerificationCheck {
2429 kind: "status".to_string(),
2430 method: None,
2431 roles: vec![role.to_string()],
2432 }],
2433 source_snapshot: SourceSnapshot {
2434 snapshot_id: format!("{role}-snapshot"),
2435 module_hash: None,
2436 wasm_hash: None,
2437 code_version: Some("v0.30.1".to_string()),
2438 artifact_path: format!("artifacts/{role}"),
2439 checksum_algorithm: "sha256".to_string(),
2440 checksum: None,
2441 },
2442 }
2443 }
2444
2445 fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
2447 layout.write_manifest(manifest).expect("write manifest");
2448
2449 let artifacts = manifest
2450 .fleet
2451 .members
2452 .iter()
2453 .map(|member| {
2454 let bytes = format!("{} artifact", member.role);
2455 let artifact_path = root.join(&member.source_snapshot.artifact_path);
2456 if let Some(parent) = artifact_path.parent() {
2457 fs::create_dir_all(parent).expect("create artifact parent");
2458 }
2459 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
2460 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
2461
2462 ArtifactJournalEntry {
2463 canister_id: member.canister_id.clone(),
2464 snapshot_id: member.source_snapshot.snapshot_id.clone(),
2465 state: ArtifactState::Durable,
2466 temp_path: None,
2467 artifact_path: member.source_snapshot.artifact_path.clone(),
2468 checksum_algorithm: checksum.algorithm,
2469 checksum: Some(checksum.hash),
2470 updated_at: "2026-05-03T00:00:00Z".to_string(),
2471 }
2472 })
2473 .collect();
2474
2475 layout
2476 .write_journal(&DownloadJournal {
2477 journal_version: 1,
2478 backup_id: manifest.backup_id.clone(),
2479 discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
2480 pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
2481 operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
2482 artifacts,
2483 })
2484 .expect("write journal");
2485 }
2486
2487 fn write_manifest_artifacts(root: &Path, manifest: &mut FleetBackupManifest) {
2489 for member in &mut manifest.fleet.members {
2490 let bytes = format!("{} apply artifact", member.role);
2491 let artifact_path = root.join(&member.source_snapshot.artifact_path);
2492 if let Some(parent) = artifact_path.parent() {
2493 fs::create_dir_all(parent).expect("create artifact parent");
2494 }
2495 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
2496 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
2497 member.source_snapshot.checksum = Some(checksum.hash);
2498 }
2499 }
2500
2501 fn temp_dir(prefix: &str) -> PathBuf {
2503 let nanos = SystemTime::now()
2504 .duration_since(UNIX_EPOCH)
2505 .expect("system time after epoch")
2506 .as_nanos();
2507 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
2508 }
2509}