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("unknown option {0}")]
47 UnknownOption(String),
48
49 #[error("option {0} requires a value")]
50 MissingValue(&'static str),
51
52 #[error("option --sequence requires a non-negative integer value")]
53 InvalidSequence,
54
55 #[error("unsupported apply-mark state {0}; use completed or failed")]
56 InvalidApplyMarkState(String),
57
58 #[error(transparent)]
59 Io(#[from] std::io::Error),
60
61 #[error(transparent)]
62 Json(#[from] serde_json::Error),
63
64 #[error(transparent)]
65 Persistence(#[from] PersistenceError),
66
67 #[error(transparent)]
68 RestorePlan(#[from] RestorePlanError),
69
70 #[error(transparent)]
71 RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
72
73 #[error(transparent)]
74 RestoreApplyJournal(#[from] RestoreApplyJournalError),
75}
76
77#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct RestorePlanOptions {
83 pub manifest: Option<PathBuf>,
84 pub backup_dir: Option<PathBuf>,
85 pub mapping: Option<PathBuf>,
86 pub out: Option<PathBuf>,
87 pub require_verified: bool,
88 pub require_restore_ready: bool,
89}
90
91impl RestorePlanOptions {
92 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
94 where
95 I: IntoIterator<Item = OsString>,
96 {
97 let mut manifest = None;
98 let mut backup_dir = None;
99 let mut mapping = None;
100 let mut out = None;
101 let mut require_verified = false;
102 let mut require_restore_ready = false;
103
104 let mut args = args.into_iter();
105 while let Some(arg) = args.next() {
106 let arg = arg
107 .into_string()
108 .map_err(|_| RestoreCommandError::Usage(usage()))?;
109 match arg.as_str() {
110 "--manifest" => {
111 manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
112 }
113 "--backup-dir" => {
114 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
115 }
116 "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
117 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
118 "--require-verified" => require_verified = true,
119 "--require-restore-ready" => require_restore_ready = true,
120 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
121 _ => return Err(RestoreCommandError::UnknownOption(arg)),
122 }
123 }
124
125 if manifest.is_some() && backup_dir.is_some() {
126 return Err(RestoreCommandError::ConflictingManifestSources);
127 }
128
129 if manifest.is_none() && backup_dir.is_none() {
130 return Err(RestoreCommandError::MissingOption(
131 "--manifest or --backup-dir",
132 ));
133 }
134
135 if require_verified && backup_dir.is_none() {
136 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
137 }
138
139 Ok(Self {
140 manifest,
141 backup_dir,
142 mapping,
143 out,
144 require_verified,
145 require_restore_ready,
146 })
147 }
148}
149
150#[derive(Clone, Debug, Eq, PartialEq)]
155pub struct RestoreStatusOptions {
156 pub plan: PathBuf,
157 pub out: Option<PathBuf>,
158}
159
160impl RestoreStatusOptions {
161 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
163 where
164 I: IntoIterator<Item = OsString>,
165 {
166 let mut plan = None;
167 let mut out = None;
168
169 let mut args = args.into_iter();
170 while let Some(arg) = args.next() {
171 let arg = arg
172 .into_string()
173 .map_err(|_| RestoreCommandError::Usage(usage()))?;
174 match arg.as_str() {
175 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
176 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
177 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
178 _ => return Err(RestoreCommandError::UnknownOption(arg)),
179 }
180 }
181
182 Ok(Self {
183 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
184 out,
185 })
186 }
187}
188
189#[derive(Clone, Debug, Eq, PartialEq)]
194pub struct RestoreApplyOptions {
195 pub plan: PathBuf,
196 pub status: Option<PathBuf>,
197 pub backup_dir: Option<PathBuf>,
198 pub out: Option<PathBuf>,
199 pub journal_out: Option<PathBuf>,
200 pub dry_run: bool,
201}
202
203impl RestoreApplyOptions {
204 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
206 where
207 I: IntoIterator<Item = OsString>,
208 {
209 let mut plan = None;
210 let mut status = None;
211 let mut backup_dir = None;
212 let mut out = None;
213 let mut journal_out = None;
214 let mut dry_run = false;
215
216 let mut args = args.into_iter();
217 while let Some(arg) = args.next() {
218 let arg = arg
219 .into_string()
220 .map_err(|_| RestoreCommandError::Usage(usage()))?;
221 match arg.as_str() {
222 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
223 "--status" => status = Some(PathBuf::from(next_value(&mut args, "--status")?)),
224 "--backup-dir" => {
225 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
226 }
227 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
228 "--journal-out" => {
229 journal_out = Some(PathBuf::from(next_value(&mut args, "--journal-out")?));
230 }
231 "--dry-run" => dry_run = true,
232 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
233 _ => return Err(RestoreCommandError::UnknownOption(arg)),
234 }
235 }
236
237 if !dry_run {
238 return Err(RestoreCommandError::ApplyRequiresDryRun);
239 }
240
241 Ok(Self {
242 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
243 status,
244 backup_dir,
245 out,
246 journal_out,
247 dry_run,
248 })
249 }
250}
251
252#[derive(Clone, Debug, Eq, PartialEq)]
257pub struct RestoreApplyStatusOptions {
258 pub journal: PathBuf,
259 pub out: Option<PathBuf>,
260}
261
262impl RestoreApplyStatusOptions {
263 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
265 where
266 I: IntoIterator<Item = OsString>,
267 {
268 let mut journal = None;
269 let mut out = None;
270
271 let mut args = args.into_iter();
272 while let Some(arg) = args.next() {
273 let arg = arg
274 .into_string()
275 .map_err(|_| RestoreCommandError::Usage(usage()))?;
276 match arg.as_str() {
277 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
278 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
279 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
280 _ => return Err(RestoreCommandError::UnknownOption(arg)),
281 }
282 }
283
284 Ok(Self {
285 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
286 out,
287 })
288 }
289}
290
291#[derive(Clone, Debug, Eq, PartialEq)]
296pub struct RestoreApplyNextOptions {
297 pub journal: PathBuf,
298 pub out: Option<PathBuf>,
299}
300
301impl RestoreApplyNextOptions {
302 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
304 where
305 I: IntoIterator<Item = OsString>,
306 {
307 let mut journal = None;
308 let mut out = None;
309
310 let mut args = args.into_iter();
311 while let Some(arg) = args.next() {
312 let arg = arg
313 .into_string()
314 .map_err(|_| RestoreCommandError::Usage(usage()))?;
315 match arg.as_str() {
316 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
317 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
318 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
319 _ => return Err(RestoreCommandError::UnknownOption(arg)),
320 }
321 }
322
323 Ok(Self {
324 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
325 out,
326 })
327 }
328}
329
330#[derive(Clone, Debug, Eq, PartialEq)]
335pub struct RestoreApplyCommandOptions {
336 pub journal: PathBuf,
337 pub dfx: String,
338 pub network: Option<String>,
339 pub out: Option<PathBuf>,
340}
341
342impl RestoreApplyCommandOptions {
343 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
345 where
346 I: IntoIterator<Item = OsString>,
347 {
348 let mut journal = None;
349 let mut dfx = "dfx".to_string();
350 let mut network = None;
351 let mut out = None;
352
353 let mut args = args.into_iter();
354 while let Some(arg) = args.next() {
355 let arg = arg
356 .into_string()
357 .map_err(|_| RestoreCommandError::Usage(usage()))?;
358 match arg.as_str() {
359 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
360 "--dfx" => dfx = next_value(&mut args, "--dfx")?,
361 "--network" => network = Some(next_value(&mut args, "--network")?),
362 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
363 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
364 _ => return Err(RestoreCommandError::UnknownOption(arg)),
365 }
366 }
367
368 Ok(Self {
369 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
370 dfx,
371 network,
372 out,
373 })
374 }
375}
376
377#[derive(Clone, Debug, Eq, PartialEq)]
382pub struct RestoreApplyMarkOptions {
383 pub journal: PathBuf,
384 pub sequence: usize,
385 pub state: RestoreApplyMarkState,
386 pub reason: Option<String>,
387 pub out: Option<PathBuf>,
388}
389
390impl RestoreApplyMarkOptions {
391 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
393 where
394 I: IntoIterator<Item = OsString>,
395 {
396 let mut journal = None;
397 let mut sequence = None;
398 let mut state = None;
399 let mut reason = None;
400 let mut out = None;
401
402 let mut args = args.into_iter();
403 while let Some(arg) = args.next() {
404 let arg = arg
405 .into_string()
406 .map_err(|_| RestoreCommandError::Usage(usage()))?;
407 match arg.as_str() {
408 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
409 "--sequence" => {
410 sequence = Some(parse_sequence(next_value(&mut args, "--sequence")?)?);
411 }
412 "--state" => {
413 state = Some(RestoreApplyMarkState::parse(next_value(
414 &mut args, "--state",
415 )?)?);
416 }
417 "--reason" => reason = Some(next_value(&mut args, "--reason")?),
418 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
419 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
420 _ => return Err(RestoreCommandError::UnknownOption(arg)),
421 }
422 }
423
424 Ok(Self {
425 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
426 sequence: sequence.ok_or(RestoreCommandError::MissingOption("--sequence"))?,
427 state: state.ok_or(RestoreCommandError::MissingOption("--state"))?,
428 reason,
429 out,
430 })
431 }
432}
433
434#[derive(Clone, Debug, Eq, PartialEq)]
439pub enum RestoreApplyMarkState {
440 Completed,
441 Failed,
442}
443
444impl RestoreApplyMarkState {
445 fn parse(value: String) -> Result<Self, RestoreCommandError> {
447 match value.as_str() {
448 "completed" => Ok(Self::Completed),
449 "failed" => Ok(Self::Failed),
450 _ => Err(RestoreCommandError::InvalidApplyMarkState(value)),
451 }
452 }
453}
454
455pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
457where
458 I: IntoIterator<Item = OsString>,
459{
460 let mut args = args.into_iter();
461 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
462 return Err(RestoreCommandError::Usage(usage()));
463 };
464
465 match command.as_str() {
466 "plan" => {
467 let options = RestorePlanOptions::parse(args)?;
468 let plan = plan_restore(&options)?;
469 write_plan(&options, &plan)?;
470 enforce_restore_plan_requirements(&options, &plan)?;
471 Ok(())
472 }
473 "status" => {
474 let options = RestoreStatusOptions::parse(args)?;
475 let status = restore_status(&options)?;
476 write_status(&options, &status)?;
477 Ok(())
478 }
479 "apply" => {
480 let options = RestoreApplyOptions::parse(args)?;
481 let dry_run = restore_apply_dry_run(&options)?;
482 write_apply_dry_run(&options, &dry_run)?;
483 write_apply_journal_if_requested(&options, &dry_run)?;
484 Ok(())
485 }
486 "apply-status" => {
487 let options = RestoreApplyStatusOptions::parse(args)?;
488 let status = restore_apply_status(&options)?;
489 write_apply_status(&options, &status)?;
490 Ok(())
491 }
492 "apply-next" => {
493 let options = RestoreApplyNextOptions::parse(args)?;
494 let next = restore_apply_next(&options)?;
495 write_apply_next(&options, &next)?;
496 Ok(())
497 }
498 "apply-command" => {
499 let options = RestoreApplyCommandOptions::parse(args)?;
500 let preview = restore_apply_command(&options)?;
501 write_apply_command(&options, &preview)?;
502 Ok(())
503 }
504 "apply-mark" => {
505 let options = RestoreApplyMarkOptions::parse(args)?;
506 let journal = restore_apply_mark(&options)?;
507 write_apply_mark(&options, &journal)?;
508 Ok(())
509 }
510 "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
511 _ => Err(RestoreCommandError::UnknownOption(command)),
512 }
513}
514
515pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
517 verify_backup_layout_if_required(options)?;
518
519 let manifest = read_manifest_source(options)?;
520 let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
521
522 RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
523}
524
525pub fn restore_status(
527 options: &RestoreStatusOptions,
528) -> Result<RestoreStatus, RestoreCommandError> {
529 let plan = read_plan(&options.plan)?;
530 Ok(RestoreStatus::from_plan(&plan))
531}
532
533pub fn restore_apply_dry_run(
535 options: &RestoreApplyOptions,
536) -> Result<RestoreApplyDryRun, RestoreCommandError> {
537 let plan = read_plan(&options.plan)?;
538 let status = options.status.as_ref().map(read_status).transpose()?;
539 if let Some(backup_dir) = &options.backup_dir {
540 return RestoreApplyDryRun::try_from_plan_with_artifacts(
541 &plan,
542 status.as_ref(),
543 backup_dir,
544 )
545 .map_err(RestoreCommandError::from);
546 }
547
548 RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
549}
550
551pub fn restore_apply_status(
553 options: &RestoreApplyStatusOptions,
554) -> Result<RestoreApplyJournalStatus, RestoreCommandError> {
555 let journal = read_apply_journal(&options.journal)?;
556 Ok(journal.status())
557}
558
559pub fn restore_apply_next(
561 options: &RestoreApplyNextOptions,
562) -> Result<RestoreApplyNextOperation, RestoreCommandError> {
563 let journal = read_apply_journal(&options.journal)?;
564 Ok(journal.next_operation())
565}
566
567pub fn restore_apply_command(
569 options: &RestoreApplyCommandOptions,
570) -> Result<RestoreApplyCommandPreview, RestoreCommandError> {
571 let journal = read_apply_journal(&options.journal)?;
572 Ok(
573 journal.next_command_preview_with_config(&RestoreApplyCommandConfig {
574 program: options.dfx.clone(),
575 network: options.network.clone(),
576 }),
577 )
578}
579
580pub fn restore_apply_mark(
582 options: &RestoreApplyMarkOptions,
583) -> Result<RestoreApplyJournal, RestoreCommandError> {
584 let mut journal = read_apply_journal(&options.journal)?;
585
586 match options.state {
587 RestoreApplyMarkState::Completed => {
588 journal.mark_operation_completed(options.sequence)?;
589 }
590 RestoreApplyMarkState::Failed => {
591 let reason =
592 options
593 .reason
594 .clone()
595 .ok_or(RestoreApplyJournalError::FailureReasonRequired(
596 options.sequence,
597 ))?;
598 journal.mark_operation_failed(options.sequence, reason)?;
599 }
600 }
601
602 Ok(journal)
603}
604
605fn enforce_restore_plan_requirements(
607 options: &RestorePlanOptions,
608 plan: &RestorePlan,
609) -> Result<(), RestoreCommandError> {
610 if !options.require_restore_ready || plan.readiness_summary.ready {
611 return Ok(());
612 }
613
614 Err(RestoreCommandError::RestoreNotReady {
615 backup_id: plan.backup_id.clone(),
616 reasons: plan.readiness_summary.reasons.clone(),
617 })
618}
619
620fn verify_backup_layout_if_required(
622 options: &RestorePlanOptions,
623) -> Result<(), RestoreCommandError> {
624 if !options.require_verified {
625 return Ok(());
626 }
627
628 let Some(dir) = &options.backup_dir else {
629 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
630 };
631
632 BackupLayout::new(dir.clone()).verify_integrity()?;
633 Ok(())
634}
635
636fn read_manifest_source(
638 options: &RestorePlanOptions,
639) -> Result<FleetBackupManifest, RestoreCommandError> {
640 if let Some(path) = &options.manifest {
641 return read_manifest(path);
642 }
643
644 let Some(dir) = &options.backup_dir else {
645 return Err(RestoreCommandError::MissingOption(
646 "--manifest or --backup-dir",
647 ));
648 };
649
650 BackupLayout::new(dir.clone())
651 .read_manifest()
652 .map_err(RestoreCommandError::from)
653}
654
655fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
657 let data = fs::read_to_string(path)?;
658 serde_json::from_str(&data).map_err(RestoreCommandError::from)
659}
660
661fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
663 let data = fs::read_to_string(path)?;
664 serde_json::from_str(&data).map_err(RestoreCommandError::from)
665}
666
667fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
669 let data = fs::read_to_string(path)?;
670 serde_json::from_str(&data).map_err(RestoreCommandError::from)
671}
672
673fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
675 let data = fs::read_to_string(path)?;
676 serde_json::from_str(&data).map_err(RestoreCommandError::from)
677}
678
679fn read_apply_journal(path: &PathBuf) -> Result<RestoreApplyJournal, RestoreCommandError> {
681 let data = fs::read_to_string(path)?;
682 let journal: RestoreApplyJournal = serde_json::from_str(&data)?;
683 journal.validate()?;
684 Ok(journal)
685}
686
687fn parse_sequence(value: String) -> Result<usize, RestoreCommandError> {
689 value
690 .parse::<usize>()
691 .map_err(|_| RestoreCommandError::InvalidSequence)
692}
693
694fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
696 if let Some(path) = &options.out {
697 let data = serde_json::to_vec_pretty(plan)?;
698 fs::write(path, data)?;
699 return Ok(());
700 }
701
702 let stdout = io::stdout();
703 let mut handle = stdout.lock();
704 serde_json::to_writer_pretty(&mut handle, plan)?;
705 writeln!(handle)?;
706 Ok(())
707}
708
709fn write_status(
711 options: &RestoreStatusOptions,
712 status: &RestoreStatus,
713) -> Result<(), RestoreCommandError> {
714 if let Some(path) = &options.out {
715 let data = serde_json::to_vec_pretty(status)?;
716 fs::write(path, data)?;
717 return Ok(());
718 }
719
720 let stdout = io::stdout();
721 let mut handle = stdout.lock();
722 serde_json::to_writer_pretty(&mut handle, status)?;
723 writeln!(handle)?;
724 Ok(())
725}
726
727fn write_apply_dry_run(
729 options: &RestoreApplyOptions,
730 dry_run: &RestoreApplyDryRun,
731) -> Result<(), RestoreCommandError> {
732 if let Some(path) = &options.out {
733 let data = serde_json::to_vec_pretty(dry_run)?;
734 fs::write(path, data)?;
735 return Ok(());
736 }
737
738 let stdout = io::stdout();
739 let mut handle = stdout.lock();
740 serde_json::to_writer_pretty(&mut handle, dry_run)?;
741 writeln!(handle)?;
742 Ok(())
743}
744
745fn write_apply_journal_if_requested(
747 options: &RestoreApplyOptions,
748 dry_run: &RestoreApplyDryRun,
749) -> Result<(), RestoreCommandError> {
750 let Some(path) = &options.journal_out else {
751 return Ok(());
752 };
753
754 let journal = RestoreApplyJournal::from_dry_run(dry_run);
755 let data = serde_json::to_vec_pretty(&journal)?;
756 fs::write(path, data)?;
757 Ok(())
758}
759
760fn write_apply_status(
762 options: &RestoreApplyStatusOptions,
763 status: &RestoreApplyJournalStatus,
764) -> Result<(), RestoreCommandError> {
765 if let Some(path) = &options.out {
766 let data = serde_json::to_vec_pretty(status)?;
767 fs::write(path, data)?;
768 return Ok(());
769 }
770
771 let stdout = io::stdout();
772 let mut handle = stdout.lock();
773 serde_json::to_writer_pretty(&mut handle, status)?;
774 writeln!(handle)?;
775 Ok(())
776}
777
778fn write_apply_next(
780 options: &RestoreApplyNextOptions,
781 next: &RestoreApplyNextOperation,
782) -> Result<(), RestoreCommandError> {
783 if let Some(path) = &options.out {
784 let data = serde_json::to_vec_pretty(next)?;
785 fs::write(path, data)?;
786 return Ok(());
787 }
788
789 let stdout = io::stdout();
790 let mut handle = stdout.lock();
791 serde_json::to_writer_pretty(&mut handle, next)?;
792 writeln!(handle)?;
793 Ok(())
794}
795
796fn write_apply_command(
798 options: &RestoreApplyCommandOptions,
799 preview: &RestoreApplyCommandPreview,
800) -> Result<(), RestoreCommandError> {
801 if let Some(path) = &options.out {
802 let data = serde_json::to_vec_pretty(preview)?;
803 fs::write(path, data)?;
804 return Ok(());
805 }
806
807 let stdout = io::stdout();
808 let mut handle = stdout.lock();
809 serde_json::to_writer_pretty(&mut handle, preview)?;
810 writeln!(handle)?;
811 Ok(())
812}
813
814fn write_apply_mark(
816 options: &RestoreApplyMarkOptions,
817 journal: &RestoreApplyJournal,
818) -> Result<(), RestoreCommandError> {
819 if let Some(path) = &options.out {
820 let data = serde_json::to_vec_pretty(journal)?;
821 fs::write(path, data)?;
822 return Ok(());
823 }
824
825 let stdout = io::stdout();
826 let mut handle = stdout.lock();
827 serde_json::to_writer_pretty(&mut handle, journal)?;
828 writeln!(handle)?;
829 Ok(())
830}
831
832fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
834where
835 I: Iterator<Item = OsString>,
836{
837 args.next()
838 .and_then(|value| value.into_string().ok())
839 .ok_or(RestoreCommandError::MissingValue(option))
840}
841
842const fn usage() -> &'static str {
844 "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>]\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-mark --journal <file> --sequence <n> --state completed|failed [--reason <text>] [--out <file>]"
845}
846
847#[cfg(test)]
848mod tests {
849 use super::*;
850 use canic_backup::{
851 artifacts::ArtifactChecksum,
852 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
853 manifest::{
854 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
855 FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
856 VerificationCheck, VerificationPlan,
857 },
858 };
859 use serde_json::json;
860 use std::{
861 path::Path,
862 time::{SystemTime, UNIX_EPOCH},
863 };
864
865 const ROOT: &str = "aaaaa-aa";
866 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
867 const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
868 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
869
870 #[test]
872 fn parses_restore_plan_options() {
873 let options = RestorePlanOptions::parse([
874 OsString::from("--manifest"),
875 OsString::from("manifest.json"),
876 OsString::from("--mapping"),
877 OsString::from("mapping.json"),
878 OsString::from("--out"),
879 OsString::from("plan.json"),
880 OsString::from("--require-restore-ready"),
881 ])
882 .expect("parse options");
883
884 assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
885 assert_eq!(options.backup_dir, None);
886 assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
887 assert_eq!(options.out, Some(PathBuf::from("plan.json")));
888 assert!(!options.require_verified);
889 assert!(options.require_restore_ready);
890 }
891
892 #[test]
894 fn parses_verified_restore_plan_options() {
895 let options = RestorePlanOptions::parse([
896 OsString::from("--backup-dir"),
897 OsString::from("backups/run"),
898 OsString::from("--require-verified"),
899 ])
900 .expect("parse verified options");
901
902 assert_eq!(options.manifest, None);
903 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
904 assert_eq!(options.mapping, None);
905 assert_eq!(options.out, None);
906 assert!(options.require_verified);
907 assert!(!options.require_restore_ready);
908 }
909
910 #[test]
912 fn parses_restore_status_options() {
913 let options = RestoreStatusOptions::parse([
914 OsString::from("--plan"),
915 OsString::from("restore-plan.json"),
916 OsString::from("--out"),
917 OsString::from("restore-status.json"),
918 ])
919 .expect("parse status options");
920
921 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
922 assert_eq!(options.out, Some(PathBuf::from("restore-status.json")));
923 }
924
925 #[test]
927 fn parses_restore_apply_dry_run_options() {
928 let options = RestoreApplyOptions::parse([
929 OsString::from("--plan"),
930 OsString::from("restore-plan.json"),
931 OsString::from("--status"),
932 OsString::from("restore-status.json"),
933 OsString::from("--backup-dir"),
934 OsString::from("backups/run"),
935 OsString::from("--dry-run"),
936 OsString::from("--out"),
937 OsString::from("restore-apply-dry-run.json"),
938 OsString::from("--journal-out"),
939 OsString::from("restore-apply-journal.json"),
940 ])
941 .expect("parse apply options");
942
943 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
944 assert_eq!(options.status, Some(PathBuf::from("restore-status.json")));
945 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
946 assert_eq!(
947 options.out,
948 Some(PathBuf::from("restore-apply-dry-run.json"))
949 );
950 assert_eq!(
951 options.journal_out,
952 Some(PathBuf::from("restore-apply-journal.json"))
953 );
954 assert!(options.dry_run);
955 }
956
957 #[test]
959 fn parses_restore_apply_status_options() {
960 let options = RestoreApplyStatusOptions::parse([
961 OsString::from("--journal"),
962 OsString::from("restore-apply-journal.json"),
963 OsString::from("--out"),
964 OsString::from("restore-apply-status.json"),
965 ])
966 .expect("parse apply-status options");
967
968 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
969 assert_eq!(
970 options.out,
971 Some(PathBuf::from("restore-apply-status.json"))
972 );
973 }
974
975 #[test]
977 fn parses_restore_apply_next_options() {
978 let options = RestoreApplyNextOptions::parse([
979 OsString::from("--journal"),
980 OsString::from("restore-apply-journal.json"),
981 OsString::from("--out"),
982 OsString::from("restore-apply-next.json"),
983 ])
984 .expect("parse apply-next options");
985
986 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
987 assert_eq!(options.out, Some(PathBuf::from("restore-apply-next.json")));
988 }
989
990 #[test]
992 fn parses_restore_apply_command_options() {
993 let options = RestoreApplyCommandOptions::parse([
994 OsString::from("--journal"),
995 OsString::from("restore-apply-journal.json"),
996 OsString::from("--dfx"),
997 OsString::from("/tmp/dfx"),
998 OsString::from("--network"),
999 OsString::from("local"),
1000 OsString::from("--out"),
1001 OsString::from("restore-apply-command.json"),
1002 ])
1003 .expect("parse apply-command options");
1004
1005 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1006 assert_eq!(options.dfx, "/tmp/dfx");
1007 assert_eq!(options.network.as_deref(), Some("local"));
1008 assert_eq!(
1009 options.out,
1010 Some(PathBuf::from("restore-apply-command.json"))
1011 );
1012 }
1013
1014 #[test]
1016 fn parses_restore_apply_mark_options() {
1017 let options = RestoreApplyMarkOptions::parse([
1018 OsString::from("--journal"),
1019 OsString::from("restore-apply-journal.json"),
1020 OsString::from("--sequence"),
1021 OsString::from("4"),
1022 OsString::from("--state"),
1023 OsString::from("failed"),
1024 OsString::from("--reason"),
1025 OsString::from("dfx-load-failed"),
1026 OsString::from("--out"),
1027 OsString::from("restore-apply-journal.updated.json"),
1028 ])
1029 .expect("parse apply-mark options");
1030
1031 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
1032 assert_eq!(options.sequence, 4);
1033 assert_eq!(options.state, RestoreApplyMarkState::Failed);
1034 assert_eq!(options.reason.as_deref(), Some("dfx-load-failed"));
1035 assert_eq!(
1036 options.out,
1037 Some(PathBuf::from("restore-apply-journal.updated.json"))
1038 );
1039 }
1040
1041 #[test]
1043 fn restore_apply_requires_dry_run() {
1044 let err = RestoreApplyOptions::parse([
1045 OsString::from("--plan"),
1046 OsString::from("restore-plan.json"),
1047 ])
1048 .expect_err("apply without dry-run should fail");
1049
1050 assert!(matches!(err, RestoreCommandError::ApplyRequiresDryRun));
1051 }
1052
1053 #[test]
1055 fn plan_restore_reads_manifest_from_backup_dir() {
1056 let root = temp_dir("canic-cli-restore-plan-layout");
1057 let layout = BackupLayout::new(root.clone());
1058 layout
1059 .write_manifest(&valid_manifest())
1060 .expect("write manifest");
1061
1062 let options = RestorePlanOptions {
1063 manifest: None,
1064 backup_dir: Some(root.clone()),
1065 mapping: None,
1066 out: None,
1067 require_verified: false,
1068 require_restore_ready: false,
1069 };
1070
1071 let plan = plan_restore(&options).expect("plan restore");
1072
1073 fs::remove_dir_all(root).expect("remove temp root");
1074 assert_eq!(plan.backup_id, "backup-test");
1075 assert_eq!(plan.member_count, 2);
1076 }
1077
1078 #[test]
1080 fn parse_rejects_conflicting_manifest_sources() {
1081 let err = RestorePlanOptions::parse([
1082 OsString::from("--manifest"),
1083 OsString::from("manifest.json"),
1084 OsString::from("--backup-dir"),
1085 OsString::from("backups/run"),
1086 ])
1087 .expect_err("conflicting sources should fail");
1088
1089 assert!(matches!(
1090 err,
1091 RestoreCommandError::ConflictingManifestSources
1092 ));
1093 }
1094
1095 #[test]
1097 fn parse_rejects_require_verified_with_manifest_source() {
1098 let err = RestorePlanOptions::parse([
1099 OsString::from("--manifest"),
1100 OsString::from("manifest.json"),
1101 OsString::from("--require-verified"),
1102 ])
1103 .expect_err("verification should require a backup layout");
1104
1105 assert!(matches!(
1106 err,
1107 RestoreCommandError::RequireVerifiedNeedsBackupDir
1108 ));
1109 }
1110
1111 #[test]
1113 fn plan_restore_requires_verified_backup_layout() {
1114 let root = temp_dir("canic-cli-restore-plan-verified");
1115 let layout = BackupLayout::new(root.clone());
1116 let manifest = valid_manifest();
1117 write_verified_layout(&root, &layout, &manifest);
1118
1119 let options = RestorePlanOptions {
1120 manifest: None,
1121 backup_dir: Some(root.clone()),
1122 mapping: None,
1123 out: None,
1124 require_verified: true,
1125 require_restore_ready: false,
1126 };
1127
1128 let plan = plan_restore(&options).expect("plan verified restore");
1129
1130 fs::remove_dir_all(root).expect("remove temp root");
1131 assert_eq!(plan.backup_id, "backup-test");
1132 assert_eq!(plan.member_count, 2);
1133 }
1134
1135 #[test]
1137 fn plan_restore_rejects_unverified_backup_layout() {
1138 let root = temp_dir("canic-cli-restore-plan-unverified");
1139 let layout = BackupLayout::new(root.clone());
1140 layout
1141 .write_manifest(&valid_manifest())
1142 .expect("write manifest");
1143
1144 let options = RestorePlanOptions {
1145 manifest: None,
1146 backup_dir: Some(root.clone()),
1147 mapping: None,
1148 out: None,
1149 require_verified: true,
1150 require_restore_ready: false,
1151 };
1152
1153 let err = plan_restore(&options).expect_err("missing journal should fail");
1154
1155 fs::remove_dir_all(root).expect("remove temp root");
1156 assert!(matches!(err, RestoreCommandError::Persistence(_)));
1157 }
1158
1159 #[test]
1161 fn plan_restore_reads_manifest_and_mapping() {
1162 let root = temp_dir("canic-cli-restore-plan");
1163 fs::create_dir_all(&root).expect("create temp root");
1164 let manifest_path = root.join("manifest.json");
1165 let mapping_path = root.join("mapping.json");
1166
1167 fs::write(
1168 &manifest_path,
1169 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
1170 )
1171 .expect("write manifest");
1172 fs::write(
1173 &mapping_path,
1174 json!({
1175 "members": [
1176 {"source_canister": ROOT, "target_canister": ROOT},
1177 {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
1178 ]
1179 })
1180 .to_string(),
1181 )
1182 .expect("write mapping");
1183
1184 let options = RestorePlanOptions {
1185 manifest: Some(manifest_path),
1186 backup_dir: None,
1187 mapping: Some(mapping_path),
1188 out: None,
1189 require_verified: false,
1190 require_restore_ready: false,
1191 };
1192
1193 let plan = plan_restore(&options).expect("plan restore");
1194
1195 fs::remove_dir_all(root).expect("remove temp root");
1196 let members = plan.ordered_members();
1197 assert_eq!(members.len(), 2);
1198 assert_eq!(members[0].source_canister, ROOT);
1199 assert_eq!(members[1].target_canister, MAPPED_CHILD);
1200 }
1201
1202 #[test]
1204 fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
1205 let root = temp_dir("canic-cli-restore-plan-require-ready");
1206 fs::create_dir_all(&root).expect("create temp root");
1207 let manifest_path = root.join("manifest.json");
1208 let out_path = root.join("plan.json");
1209
1210 fs::write(
1211 &manifest_path,
1212 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
1213 )
1214 .expect("write manifest");
1215
1216 let err = run([
1217 OsString::from("plan"),
1218 OsString::from("--manifest"),
1219 OsString::from(manifest_path.as_os_str()),
1220 OsString::from("--out"),
1221 OsString::from(out_path.as_os_str()),
1222 OsString::from("--require-restore-ready"),
1223 ])
1224 .expect_err("restore readiness should be enforced");
1225
1226 assert!(out_path.exists());
1227 let plan: RestorePlan =
1228 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
1229
1230 fs::remove_dir_all(root).expect("remove temp root");
1231 assert!(!plan.readiness_summary.ready);
1232 assert!(matches!(
1233 err,
1234 RestoreCommandError::RestoreNotReady {
1235 reasons,
1236 ..
1237 } if reasons == [
1238 "missing-module-hash",
1239 "missing-wasm-hash",
1240 "missing-snapshot-checksum"
1241 ]
1242 ));
1243 }
1244
1245 #[test]
1247 fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
1248 let root = temp_dir("canic-cli-restore-plan-ready");
1249 fs::create_dir_all(&root).expect("create temp root");
1250 let manifest_path = root.join("manifest.json");
1251 let out_path = root.join("plan.json");
1252
1253 fs::write(
1254 &manifest_path,
1255 serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
1256 )
1257 .expect("write manifest");
1258
1259 run([
1260 OsString::from("plan"),
1261 OsString::from("--manifest"),
1262 OsString::from(manifest_path.as_os_str()),
1263 OsString::from("--out"),
1264 OsString::from(out_path.as_os_str()),
1265 OsString::from("--require-restore-ready"),
1266 ])
1267 .expect("restore-ready plan should pass");
1268
1269 let plan: RestorePlan =
1270 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
1271
1272 fs::remove_dir_all(root).expect("remove temp root");
1273 assert!(plan.readiness_summary.ready);
1274 assert!(plan.readiness_summary.reasons.is_empty());
1275 }
1276
1277 #[test]
1279 fn run_restore_status_writes_planned_status() {
1280 let root = temp_dir("canic-cli-restore-status");
1281 fs::create_dir_all(&root).expect("create temp root");
1282 let plan_path = root.join("restore-plan.json");
1283 let out_path = root.join("restore-status.json");
1284 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1285
1286 fs::write(
1287 &plan_path,
1288 serde_json::to_vec(&plan).expect("serialize plan"),
1289 )
1290 .expect("write plan");
1291
1292 run([
1293 OsString::from("status"),
1294 OsString::from("--plan"),
1295 OsString::from(plan_path.as_os_str()),
1296 OsString::from("--out"),
1297 OsString::from(out_path.as_os_str()),
1298 ])
1299 .expect("write restore status");
1300
1301 let status: RestoreStatus =
1302 serde_json::from_slice(&fs::read(&out_path).expect("read restore status"))
1303 .expect("decode restore status");
1304 let status_json: serde_json::Value = serde_json::to_value(&status).expect("encode status");
1305
1306 fs::remove_dir_all(root).expect("remove temp root");
1307 assert_eq!(status.status_version, 1);
1308 assert_eq!(status.backup_id.as_str(), "backup-test");
1309 assert!(status.ready);
1310 assert!(status.readiness_reasons.is_empty());
1311 assert_eq!(status.member_count, 2);
1312 assert_eq!(status.phase_count, 1);
1313 assert_eq!(status.planned_snapshot_loads, 2);
1314 assert_eq!(status.planned_code_reinstalls, 2);
1315 assert_eq!(status.planned_verification_checks, 2);
1316 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
1317 assert_eq!(status_json["phases"][0]["members"][0]["state"], "planned");
1318 }
1319
1320 #[test]
1322 fn run_restore_apply_dry_run_writes_operations() {
1323 let root = temp_dir("canic-cli-restore-apply-dry-run");
1324 fs::create_dir_all(&root).expect("create temp root");
1325 let plan_path = root.join("restore-plan.json");
1326 let status_path = root.join("restore-status.json");
1327 let out_path = root.join("restore-apply-dry-run.json");
1328 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1329 let status = RestoreStatus::from_plan(&plan);
1330
1331 fs::write(
1332 &plan_path,
1333 serde_json::to_vec(&plan).expect("serialize plan"),
1334 )
1335 .expect("write plan");
1336 fs::write(
1337 &status_path,
1338 serde_json::to_vec(&status).expect("serialize status"),
1339 )
1340 .expect("write status");
1341
1342 run([
1343 OsString::from("apply"),
1344 OsString::from("--plan"),
1345 OsString::from(plan_path.as_os_str()),
1346 OsString::from("--status"),
1347 OsString::from(status_path.as_os_str()),
1348 OsString::from("--dry-run"),
1349 OsString::from("--out"),
1350 OsString::from(out_path.as_os_str()),
1351 ])
1352 .expect("write apply dry-run");
1353
1354 let dry_run: RestoreApplyDryRun =
1355 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
1356 .expect("decode dry-run");
1357 let dry_run_json: serde_json::Value =
1358 serde_json::to_value(&dry_run).expect("encode dry-run");
1359
1360 fs::remove_dir_all(root).expect("remove temp root");
1361 assert_eq!(dry_run.dry_run_version, 1);
1362 assert_eq!(dry_run.backup_id.as_str(), "backup-test");
1363 assert!(dry_run.ready);
1364 assert!(dry_run.status_supplied);
1365 assert_eq!(dry_run.member_count, 2);
1366 assert_eq!(dry_run.phase_count, 1);
1367 assert_eq!(dry_run.rendered_operations, 8);
1368 assert_eq!(
1369 dry_run_json["phases"][0]["operations"][0]["operation"],
1370 "upload-snapshot"
1371 );
1372 assert_eq!(
1373 dry_run_json["phases"][0]["operations"][3]["operation"],
1374 "verify-member"
1375 );
1376 assert_eq!(
1377 dry_run_json["phases"][0]["operations"][3]["verification_kind"],
1378 "status"
1379 );
1380 assert_eq!(
1381 dry_run_json["phases"][0]["operations"][3]["verification_method"],
1382 serde_json::Value::Null
1383 );
1384 }
1385
1386 #[test]
1388 fn run_restore_apply_dry_run_validates_backup_dir_artifacts() {
1389 let root = temp_dir("canic-cli-restore-apply-artifacts");
1390 fs::create_dir_all(&root).expect("create temp root");
1391 let plan_path = root.join("restore-plan.json");
1392 let out_path = root.join("restore-apply-dry-run.json");
1393 let journal_path = root.join("restore-apply-journal.json");
1394 let status_path = root.join("restore-apply-status.json");
1395 let mut manifest = restore_ready_manifest();
1396 write_manifest_artifacts(&root, &mut manifest);
1397 let plan = RestorePlanner::plan(&manifest, None).expect("build plan");
1398
1399 fs::write(
1400 &plan_path,
1401 serde_json::to_vec(&plan).expect("serialize plan"),
1402 )
1403 .expect("write plan");
1404
1405 run([
1406 OsString::from("apply"),
1407 OsString::from("--plan"),
1408 OsString::from(plan_path.as_os_str()),
1409 OsString::from("--backup-dir"),
1410 OsString::from(root.as_os_str()),
1411 OsString::from("--dry-run"),
1412 OsString::from("--out"),
1413 OsString::from(out_path.as_os_str()),
1414 OsString::from("--journal-out"),
1415 OsString::from(journal_path.as_os_str()),
1416 ])
1417 .expect("write apply dry-run");
1418 run([
1419 OsString::from("apply-status"),
1420 OsString::from("--journal"),
1421 OsString::from(journal_path.as_os_str()),
1422 OsString::from("--out"),
1423 OsString::from(status_path.as_os_str()),
1424 ])
1425 .expect("write apply status");
1426
1427 let dry_run: RestoreApplyDryRun =
1428 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
1429 .expect("decode dry-run");
1430 let validation = dry_run
1431 .artifact_validation
1432 .expect("artifact validation should be present");
1433 let journal_json: serde_json::Value =
1434 serde_json::from_slice(&fs::read(&journal_path).expect("read journal"))
1435 .expect("decode journal");
1436 let status_json: serde_json::Value =
1437 serde_json::from_slice(&fs::read(&status_path).expect("read apply status"))
1438 .expect("decode apply status");
1439
1440 fs::remove_dir_all(root).expect("remove temp root");
1441 assert_eq!(validation.checked_members, 2);
1442 assert!(validation.artifacts_present);
1443 assert!(validation.checksums_verified);
1444 assert_eq!(validation.members_with_expected_checksums, 2);
1445 assert_eq!(journal_json["ready"], true);
1446 assert_eq!(journal_json["operation_count"], 8);
1447 assert_eq!(journal_json["ready_operations"], 8);
1448 assert_eq!(journal_json["blocked_operations"], 0);
1449 assert_eq!(journal_json["operations"][0]["state"], "ready");
1450 assert_eq!(status_json["ready"], true);
1451 assert_eq!(status_json["operation_count"], 8);
1452 assert_eq!(status_json["next_ready_sequence"], 0);
1453 assert_eq!(status_json["next_ready_operation"], "upload-snapshot");
1454 }
1455
1456 #[test]
1458 fn run_restore_apply_status_rejects_invalid_journal() {
1459 let root = temp_dir("canic-cli-restore-apply-status-invalid");
1460 fs::create_dir_all(&root).expect("create temp root");
1461 let journal_path = root.join("restore-apply-journal.json");
1462 let out_path = root.join("restore-apply-status.json");
1463 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1464 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
1465 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
1466 journal.operation_count += 1;
1467
1468 fs::write(
1469 &journal_path,
1470 serde_json::to_vec(&journal).expect("serialize journal"),
1471 )
1472 .expect("write journal");
1473
1474 let err = run([
1475 OsString::from("apply-status"),
1476 OsString::from("--journal"),
1477 OsString::from(journal_path.as_os_str()),
1478 OsString::from("--out"),
1479 OsString::from(out_path.as_os_str()),
1480 ])
1481 .expect_err("invalid journal should fail");
1482
1483 assert!(!out_path.exists());
1484 fs::remove_dir_all(root).expect("remove temp root");
1485 assert!(matches!(
1486 err,
1487 RestoreCommandError::RestoreApplyJournal(RestoreApplyJournalError::CountMismatch {
1488 field: "operation_count",
1489 ..
1490 })
1491 ));
1492 }
1493
1494 #[test]
1496 fn run_restore_apply_next_writes_next_ready_operation() {
1497 let root = temp_dir("canic-cli-restore-apply-next");
1498 fs::create_dir_all(&root).expect("create temp root");
1499 let journal_path = root.join("restore-apply-journal.json");
1500 let out_path = root.join("restore-apply-next.json");
1501 let mut journal = ready_apply_journal();
1502 journal
1503 .mark_operation_completed(0)
1504 .expect("mark first operation complete");
1505
1506 fs::write(
1507 &journal_path,
1508 serde_json::to_vec(&journal).expect("serialize journal"),
1509 )
1510 .expect("write journal");
1511
1512 run([
1513 OsString::from("apply-next"),
1514 OsString::from("--journal"),
1515 OsString::from(journal_path.as_os_str()),
1516 OsString::from("--out"),
1517 OsString::from(out_path.as_os_str()),
1518 ])
1519 .expect("write apply next");
1520
1521 let next: RestoreApplyNextOperation =
1522 serde_json::from_slice(&fs::read(&out_path).expect("read next operation"))
1523 .expect("decode next operation");
1524 let operation = next.operation.expect("operation should be available");
1525
1526 fs::remove_dir_all(root).expect("remove temp root");
1527 assert!(next.ready);
1528 assert!(next.operation_available);
1529 assert_eq!(operation.sequence, 1);
1530 assert_eq!(
1531 operation.operation,
1532 canic_backup::restore::RestoreApplyOperationKind::LoadSnapshot
1533 );
1534 }
1535
1536 #[test]
1538 fn run_restore_apply_command_writes_next_command_preview() {
1539 let root = temp_dir("canic-cli-restore-apply-command");
1540 fs::create_dir_all(&root).expect("create temp root");
1541 let journal_path = root.join("restore-apply-journal.json");
1542 let out_path = root.join("restore-apply-command.json");
1543 let journal = ready_apply_journal();
1544
1545 fs::write(
1546 &journal_path,
1547 serde_json::to_vec(&journal).expect("serialize journal"),
1548 )
1549 .expect("write journal");
1550
1551 run([
1552 OsString::from("apply-command"),
1553 OsString::from("--journal"),
1554 OsString::from(journal_path.as_os_str()),
1555 OsString::from("--dfx"),
1556 OsString::from("/tmp/dfx"),
1557 OsString::from("--network"),
1558 OsString::from("local"),
1559 OsString::from("--out"),
1560 OsString::from(out_path.as_os_str()),
1561 ])
1562 .expect("write command preview");
1563
1564 let preview: RestoreApplyCommandPreview =
1565 serde_json::from_slice(&fs::read(&out_path).expect("read command preview"))
1566 .expect("decode command preview");
1567 let command = preview.command.expect("command should be available");
1568
1569 fs::remove_dir_all(root).expect("remove temp root");
1570 assert!(preview.ready);
1571 assert!(preview.command_available);
1572 assert_eq!(command.program, "/tmp/dfx");
1573 assert_eq!(
1574 command.args,
1575 vec![
1576 "canister".to_string(),
1577 "--network".to_string(),
1578 "local".to_string(),
1579 "snapshot".to_string(),
1580 "upload".to_string(),
1581 "--dir".to_string(),
1582 "artifacts/root".to_string(),
1583 ROOT.to_string(),
1584 ]
1585 );
1586 assert!(command.mutates);
1587 }
1588
1589 #[test]
1591 fn run_restore_apply_mark_completes_operation() {
1592 let root = temp_dir("canic-cli-restore-apply-mark-complete");
1593 fs::create_dir_all(&root).expect("create temp root");
1594 let journal_path = root.join("restore-apply-journal.json");
1595 let updated_path = root.join("restore-apply-journal.updated.json");
1596 let journal = ready_apply_journal();
1597
1598 fs::write(
1599 &journal_path,
1600 serde_json::to_vec(&journal).expect("serialize journal"),
1601 )
1602 .expect("write journal");
1603
1604 run([
1605 OsString::from("apply-mark"),
1606 OsString::from("--journal"),
1607 OsString::from(journal_path.as_os_str()),
1608 OsString::from("--sequence"),
1609 OsString::from("0"),
1610 OsString::from("--state"),
1611 OsString::from("completed"),
1612 OsString::from("--out"),
1613 OsString::from(updated_path.as_os_str()),
1614 ])
1615 .expect("mark operation completed");
1616
1617 let updated: RestoreApplyJournal =
1618 serde_json::from_slice(&fs::read(&updated_path).expect("read updated journal"))
1619 .expect("decode updated journal");
1620 let status = updated.status();
1621
1622 fs::remove_dir_all(root).expect("remove temp root");
1623 assert_eq!(updated.completed_operations, 1);
1624 assert_eq!(updated.ready_operations, 7);
1625 assert_eq!(status.next_ready_sequence, Some(1));
1626 }
1627
1628 #[test]
1630 fn run_restore_apply_mark_rejects_out_of_order_operation() {
1631 let root = temp_dir("canic-cli-restore-apply-mark-out-of-order");
1632 fs::create_dir_all(&root).expect("create temp root");
1633 let journal_path = root.join("restore-apply-journal.json");
1634 let updated_path = root.join("restore-apply-journal.updated.json");
1635 let journal = ready_apply_journal();
1636
1637 fs::write(
1638 &journal_path,
1639 serde_json::to_vec(&journal).expect("serialize journal"),
1640 )
1641 .expect("write journal");
1642
1643 let err = run([
1644 OsString::from("apply-mark"),
1645 OsString::from("--journal"),
1646 OsString::from(journal_path.as_os_str()),
1647 OsString::from("--sequence"),
1648 OsString::from("1"),
1649 OsString::from("--state"),
1650 OsString::from("completed"),
1651 OsString::from("--out"),
1652 OsString::from(updated_path.as_os_str()),
1653 ])
1654 .expect_err("out-of-order operation should fail");
1655
1656 assert!(!updated_path.exists());
1657 fs::remove_dir_all(root).expect("remove temp root");
1658 assert!(matches!(
1659 err,
1660 RestoreCommandError::RestoreApplyJournal(
1661 RestoreApplyJournalError::OutOfOrderOperationTransition {
1662 requested: 1,
1663 next: 0
1664 }
1665 )
1666 ));
1667 }
1668
1669 #[test]
1671 fn run_restore_apply_mark_failed_requires_reason() {
1672 let root = temp_dir("canic-cli-restore-apply-mark-failed-reason");
1673 fs::create_dir_all(&root).expect("create temp root");
1674 let journal_path = root.join("restore-apply-journal.json");
1675 let journal = ready_apply_journal();
1676
1677 fs::write(
1678 &journal_path,
1679 serde_json::to_vec(&journal).expect("serialize journal"),
1680 )
1681 .expect("write journal");
1682
1683 let err = run([
1684 OsString::from("apply-mark"),
1685 OsString::from("--journal"),
1686 OsString::from(journal_path.as_os_str()),
1687 OsString::from("--sequence"),
1688 OsString::from("0"),
1689 OsString::from("--state"),
1690 OsString::from("failed"),
1691 ])
1692 .expect_err("failed state should require reason");
1693
1694 fs::remove_dir_all(root).expect("remove temp root");
1695 assert!(matches!(
1696 err,
1697 RestoreCommandError::RestoreApplyJournal(
1698 RestoreApplyJournalError::FailureReasonRequired(0)
1699 )
1700 ));
1701 }
1702
1703 #[test]
1705 fn run_restore_apply_dry_run_rejects_mismatched_status() {
1706 let root = temp_dir("canic-cli-restore-apply-dry-run-mismatch");
1707 fs::create_dir_all(&root).expect("create temp root");
1708 let plan_path = root.join("restore-plan.json");
1709 let status_path = root.join("restore-status.json");
1710 let out_path = root.join("restore-apply-dry-run.json");
1711 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1712 let mut status = RestoreStatus::from_plan(&plan);
1713 status.backup_id = "other-backup".to_string();
1714
1715 fs::write(
1716 &plan_path,
1717 serde_json::to_vec(&plan).expect("serialize plan"),
1718 )
1719 .expect("write plan");
1720 fs::write(
1721 &status_path,
1722 serde_json::to_vec(&status).expect("serialize status"),
1723 )
1724 .expect("write status");
1725
1726 let err = run([
1727 OsString::from("apply"),
1728 OsString::from("--plan"),
1729 OsString::from(plan_path.as_os_str()),
1730 OsString::from("--status"),
1731 OsString::from(status_path.as_os_str()),
1732 OsString::from("--dry-run"),
1733 OsString::from("--out"),
1734 OsString::from(out_path.as_os_str()),
1735 ])
1736 .expect_err("mismatched status should fail");
1737
1738 assert!(!out_path.exists());
1739 fs::remove_dir_all(root).expect("remove temp root");
1740 assert!(matches!(
1741 err,
1742 RestoreCommandError::RestoreApplyDryRun(RestoreApplyDryRunError::StatusPlanMismatch {
1743 field: "backup_id",
1744 ..
1745 })
1746 ));
1747 }
1748
1749 fn ready_apply_journal() -> RestoreApplyJournal {
1751 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1752 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
1753 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
1754
1755 journal.ready = true;
1756 journal.blocked_reasons = Vec::new();
1757 for operation in &mut journal.operations {
1758 operation.state = canic_backup::restore::RestoreApplyOperationState::Ready;
1759 operation.blocking_reasons = Vec::new();
1760 }
1761 journal.blocked_operations = 0;
1762 journal.ready_operations = journal.operation_count;
1763 journal.validate().expect("journal should validate");
1764 journal
1765 }
1766
1767 fn valid_manifest() -> FleetBackupManifest {
1769 FleetBackupManifest {
1770 manifest_version: 1,
1771 backup_id: "backup-test".to_string(),
1772 created_at: "2026-05-03T00:00:00Z".to_string(),
1773 tool: ToolMetadata {
1774 name: "canic".to_string(),
1775 version: "0.30.1".to_string(),
1776 },
1777 source: SourceMetadata {
1778 environment: "local".to_string(),
1779 root_canister: ROOT.to_string(),
1780 },
1781 consistency: ConsistencySection {
1782 mode: ConsistencyMode::CrashConsistent,
1783 backup_units: vec![BackupUnit {
1784 unit_id: "fleet".to_string(),
1785 kind: BackupUnitKind::SubtreeRooted,
1786 roles: vec!["root".to_string(), "app".to_string()],
1787 consistency_reason: None,
1788 dependency_closure: Vec::new(),
1789 topology_validation: "subtree-closed".to_string(),
1790 quiescence_strategy: None,
1791 }],
1792 },
1793 fleet: FleetSection {
1794 topology_hash_algorithm: "sha256".to_string(),
1795 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1796 discovery_topology_hash: HASH.to_string(),
1797 pre_snapshot_topology_hash: HASH.to_string(),
1798 topology_hash: HASH.to_string(),
1799 members: vec![
1800 fleet_member("root", ROOT, None, IdentityMode::Fixed),
1801 fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
1802 ],
1803 },
1804 verification: VerificationPlan::default(),
1805 }
1806 }
1807
1808 fn restore_ready_manifest() -> FleetBackupManifest {
1810 let mut manifest = valid_manifest();
1811 for member in &mut manifest.fleet.members {
1812 member.source_snapshot.module_hash = Some(HASH.to_string());
1813 member.source_snapshot.wasm_hash = Some(HASH.to_string());
1814 member.source_snapshot.checksum = Some(HASH.to_string());
1815 }
1816 manifest
1817 }
1818
1819 fn fleet_member(
1821 role: &str,
1822 canister_id: &str,
1823 parent_canister_id: Option<&str>,
1824 identity_mode: IdentityMode,
1825 ) -> FleetMember {
1826 FleetMember {
1827 role: role.to_string(),
1828 canister_id: canister_id.to_string(),
1829 parent_canister_id: parent_canister_id.map(str::to_string),
1830 subnet_canister_id: Some(ROOT.to_string()),
1831 controller_hint: None,
1832 identity_mode,
1833 restore_group: 1,
1834 verification_class: "basic".to_string(),
1835 verification_checks: vec![VerificationCheck {
1836 kind: "status".to_string(),
1837 method: None,
1838 roles: vec![role.to_string()],
1839 }],
1840 source_snapshot: SourceSnapshot {
1841 snapshot_id: format!("{role}-snapshot"),
1842 module_hash: None,
1843 wasm_hash: None,
1844 code_version: Some("v0.30.1".to_string()),
1845 artifact_path: format!("artifacts/{role}"),
1846 checksum_algorithm: "sha256".to_string(),
1847 checksum: None,
1848 },
1849 }
1850 }
1851
1852 fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
1854 layout.write_manifest(manifest).expect("write manifest");
1855
1856 let artifacts = manifest
1857 .fleet
1858 .members
1859 .iter()
1860 .map(|member| {
1861 let bytes = format!("{} artifact", member.role);
1862 let artifact_path = root.join(&member.source_snapshot.artifact_path);
1863 if let Some(parent) = artifact_path.parent() {
1864 fs::create_dir_all(parent).expect("create artifact parent");
1865 }
1866 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
1867 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
1868
1869 ArtifactJournalEntry {
1870 canister_id: member.canister_id.clone(),
1871 snapshot_id: member.source_snapshot.snapshot_id.clone(),
1872 state: ArtifactState::Durable,
1873 temp_path: None,
1874 artifact_path: member.source_snapshot.artifact_path.clone(),
1875 checksum_algorithm: checksum.algorithm,
1876 checksum: Some(checksum.hash),
1877 updated_at: "2026-05-03T00:00:00Z".to_string(),
1878 }
1879 })
1880 .collect();
1881
1882 layout
1883 .write_journal(&DownloadJournal {
1884 journal_version: 1,
1885 backup_id: manifest.backup_id.clone(),
1886 discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
1887 pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
1888 operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
1889 artifacts,
1890 })
1891 .expect("write journal");
1892 }
1893
1894 fn write_manifest_artifacts(root: &Path, manifest: &mut FleetBackupManifest) {
1896 for member in &mut manifest.fleet.members {
1897 let bytes = format!("{} apply artifact", member.role);
1898 let artifact_path = root.join(&member.source_snapshot.artifact_path);
1899 if let Some(parent) = artifact_path.parent() {
1900 fs::create_dir_all(parent).expect("create artifact parent");
1901 }
1902 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
1903 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
1904 member.source_snapshot.checksum = Some(checksum.hash);
1905 }
1906 }
1907
1908 fn temp_dir(prefix: &str) -> PathBuf {
1910 let nanos = SystemTime::now()
1911 .duration_since(UNIX_EPOCH)
1912 .expect("system time after epoch")
1913 .as_nanos();
1914 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
1915 }
1916}