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