1use canic_backup::{
2 manifest::FleetBackupManifest,
3 persistence::{BackupLayout, PersistenceError},
4 restore::{
5 RestoreApplyDryRun, RestoreApplyDryRunError, RestoreApplyJournal, RestoreApplyJournalError,
6 RestoreApplyJournalStatus, RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner,
7 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(transparent)]
52 Io(#[from] std::io::Error),
53
54 #[error(transparent)]
55 Json(#[from] serde_json::Error),
56
57 #[error(transparent)]
58 Persistence(#[from] PersistenceError),
59
60 #[error(transparent)]
61 RestorePlan(#[from] RestorePlanError),
62
63 #[error(transparent)]
64 RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
65
66 #[error(transparent)]
67 RestoreApplyJournal(#[from] RestoreApplyJournalError),
68}
69
70#[derive(Clone, Debug, Eq, PartialEq)]
75pub struct RestorePlanOptions {
76 pub manifest: Option<PathBuf>,
77 pub backup_dir: Option<PathBuf>,
78 pub mapping: Option<PathBuf>,
79 pub out: Option<PathBuf>,
80 pub require_verified: bool,
81 pub require_restore_ready: bool,
82}
83
84impl RestorePlanOptions {
85 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
87 where
88 I: IntoIterator<Item = OsString>,
89 {
90 let mut manifest = None;
91 let mut backup_dir = None;
92 let mut mapping = None;
93 let mut out = None;
94 let mut require_verified = false;
95 let mut require_restore_ready = false;
96
97 let mut args = args.into_iter();
98 while let Some(arg) = args.next() {
99 let arg = arg
100 .into_string()
101 .map_err(|_| RestoreCommandError::Usage(usage()))?;
102 match arg.as_str() {
103 "--manifest" => {
104 manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
105 }
106 "--backup-dir" => {
107 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
108 }
109 "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
110 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
111 "--require-verified" => require_verified = true,
112 "--require-restore-ready" => require_restore_ready = true,
113 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
114 _ => return Err(RestoreCommandError::UnknownOption(arg)),
115 }
116 }
117
118 if manifest.is_some() && backup_dir.is_some() {
119 return Err(RestoreCommandError::ConflictingManifestSources);
120 }
121
122 if manifest.is_none() && backup_dir.is_none() {
123 return Err(RestoreCommandError::MissingOption(
124 "--manifest or --backup-dir",
125 ));
126 }
127
128 if require_verified && backup_dir.is_none() {
129 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
130 }
131
132 Ok(Self {
133 manifest,
134 backup_dir,
135 mapping,
136 out,
137 require_verified,
138 require_restore_ready,
139 })
140 }
141}
142
143#[derive(Clone, Debug, Eq, PartialEq)]
148pub struct RestoreStatusOptions {
149 pub plan: PathBuf,
150 pub out: Option<PathBuf>,
151}
152
153impl RestoreStatusOptions {
154 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
156 where
157 I: IntoIterator<Item = OsString>,
158 {
159 let mut plan = None;
160 let mut out = None;
161
162 let mut args = args.into_iter();
163 while let Some(arg) = args.next() {
164 let arg = arg
165 .into_string()
166 .map_err(|_| RestoreCommandError::Usage(usage()))?;
167 match arg.as_str() {
168 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
169 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
170 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
171 _ => return Err(RestoreCommandError::UnknownOption(arg)),
172 }
173 }
174
175 Ok(Self {
176 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
177 out,
178 })
179 }
180}
181
182#[derive(Clone, Debug, Eq, PartialEq)]
187pub struct RestoreApplyOptions {
188 pub plan: PathBuf,
189 pub status: Option<PathBuf>,
190 pub backup_dir: Option<PathBuf>,
191 pub out: Option<PathBuf>,
192 pub journal_out: Option<PathBuf>,
193 pub dry_run: bool,
194}
195
196impl RestoreApplyOptions {
197 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
199 where
200 I: IntoIterator<Item = OsString>,
201 {
202 let mut plan = None;
203 let mut status = None;
204 let mut backup_dir = None;
205 let mut out = None;
206 let mut journal_out = None;
207 let mut dry_run = false;
208
209 let mut args = args.into_iter();
210 while let Some(arg) = args.next() {
211 let arg = arg
212 .into_string()
213 .map_err(|_| RestoreCommandError::Usage(usage()))?;
214 match arg.as_str() {
215 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
216 "--status" => status = Some(PathBuf::from(next_value(&mut args, "--status")?)),
217 "--backup-dir" => {
218 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
219 }
220 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
221 "--journal-out" => {
222 journal_out = Some(PathBuf::from(next_value(&mut args, "--journal-out")?));
223 }
224 "--dry-run" => dry_run = true,
225 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
226 _ => return Err(RestoreCommandError::UnknownOption(arg)),
227 }
228 }
229
230 if !dry_run {
231 return Err(RestoreCommandError::ApplyRequiresDryRun);
232 }
233
234 Ok(Self {
235 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
236 status,
237 backup_dir,
238 out,
239 journal_out,
240 dry_run,
241 })
242 }
243}
244
245#[derive(Clone, Debug, Eq, PartialEq)]
250pub struct RestoreApplyStatusOptions {
251 pub journal: PathBuf,
252 pub out: Option<PathBuf>,
253}
254
255impl RestoreApplyStatusOptions {
256 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
258 where
259 I: IntoIterator<Item = OsString>,
260 {
261 let mut journal = None;
262 let mut out = None;
263
264 let mut args = args.into_iter();
265 while let Some(arg) = args.next() {
266 let arg = arg
267 .into_string()
268 .map_err(|_| RestoreCommandError::Usage(usage()))?;
269 match arg.as_str() {
270 "--journal" => journal = Some(PathBuf::from(next_value(&mut args, "--journal")?)),
271 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
272 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
273 _ => return Err(RestoreCommandError::UnknownOption(arg)),
274 }
275 }
276
277 Ok(Self {
278 journal: journal.ok_or(RestoreCommandError::MissingOption("--journal"))?,
279 out,
280 })
281 }
282}
283
284pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
286where
287 I: IntoIterator<Item = OsString>,
288{
289 let mut args = args.into_iter();
290 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
291 return Err(RestoreCommandError::Usage(usage()));
292 };
293
294 match command.as_str() {
295 "plan" => {
296 let options = RestorePlanOptions::parse(args)?;
297 let plan = plan_restore(&options)?;
298 write_plan(&options, &plan)?;
299 enforce_restore_plan_requirements(&options, &plan)?;
300 Ok(())
301 }
302 "status" => {
303 let options = RestoreStatusOptions::parse(args)?;
304 let status = restore_status(&options)?;
305 write_status(&options, &status)?;
306 Ok(())
307 }
308 "apply" => {
309 let options = RestoreApplyOptions::parse(args)?;
310 let dry_run = restore_apply_dry_run(&options)?;
311 write_apply_dry_run(&options, &dry_run)?;
312 write_apply_journal_if_requested(&options, &dry_run)?;
313 Ok(())
314 }
315 "apply-status" => {
316 let options = RestoreApplyStatusOptions::parse(args)?;
317 let status = restore_apply_status(&options)?;
318 write_apply_status(&options, &status)?;
319 Ok(())
320 }
321 "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
322 _ => Err(RestoreCommandError::UnknownOption(command)),
323 }
324}
325
326pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
328 verify_backup_layout_if_required(options)?;
329
330 let manifest = read_manifest_source(options)?;
331 let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
332
333 RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
334}
335
336pub fn restore_status(
338 options: &RestoreStatusOptions,
339) -> Result<RestoreStatus, RestoreCommandError> {
340 let plan = read_plan(&options.plan)?;
341 Ok(RestoreStatus::from_plan(&plan))
342}
343
344pub fn restore_apply_dry_run(
346 options: &RestoreApplyOptions,
347) -> Result<RestoreApplyDryRun, RestoreCommandError> {
348 let plan = read_plan(&options.plan)?;
349 let status = options.status.as_ref().map(read_status).transpose()?;
350 if let Some(backup_dir) = &options.backup_dir {
351 return RestoreApplyDryRun::try_from_plan_with_artifacts(
352 &plan,
353 status.as_ref(),
354 backup_dir,
355 )
356 .map_err(RestoreCommandError::from);
357 }
358
359 RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
360}
361
362pub fn restore_apply_status(
364 options: &RestoreApplyStatusOptions,
365) -> Result<RestoreApplyJournalStatus, RestoreCommandError> {
366 let journal = read_apply_journal(&options.journal)?;
367 Ok(journal.status())
368}
369
370fn enforce_restore_plan_requirements(
372 options: &RestorePlanOptions,
373 plan: &RestorePlan,
374) -> Result<(), RestoreCommandError> {
375 if !options.require_restore_ready || plan.readiness_summary.ready {
376 return Ok(());
377 }
378
379 Err(RestoreCommandError::RestoreNotReady {
380 backup_id: plan.backup_id.clone(),
381 reasons: plan.readiness_summary.reasons.clone(),
382 })
383}
384
385fn verify_backup_layout_if_required(
387 options: &RestorePlanOptions,
388) -> Result<(), RestoreCommandError> {
389 if !options.require_verified {
390 return Ok(());
391 }
392
393 let Some(dir) = &options.backup_dir else {
394 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
395 };
396
397 BackupLayout::new(dir.clone()).verify_integrity()?;
398 Ok(())
399}
400
401fn read_manifest_source(
403 options: &RestorePlanOptions,
404) -> Result<FleetBackupManifest, RestoreCommandError> {
405 if let Some(path) = &options.manifest {
406 return read_manifest(path);
407 }
408
409 let Some(dir) = &options.backup_dir else {
410 return Err(RestoreCommandError::MissingOption(
411 "--manifest or --backup-dir",
412 ));
413 };
414
415 BackupLayout::new(dir.clone())
416 .read_manifest()
417 .map_err(RestoreCommandError::from)
418}
419
420fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
422 let data = fs::read_to_string(path)?;
423 serde_json::from_str(&data).map_err(RestoreCommandError::from)
424}
425
426fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
428 let data = fs::read_to_string(path)?;
429 serde_json::from_str(&data).map_err(RestoreCommandError::from)
430}
431
432fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
434 let data = fs::read_to_string(path)?;
435 serde_json::from_str(&data).map_err(RestoreCommandError::from)
436}
437
438fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
440 let data = fs::read_to_string(path)?;
441 serde_json::from_str(&data).map_err(RestoreCommandError::from)
442}
443
444fn read_apply_journal(path: &PathBuf) -> Result<RestoreApplyJournal, RestoreCommandError> {
446 let data = fs::read_to_string(path)?;
447 let journal: RestoreApplyJournal = serde_json::from_str(&data)?;
448 journal.validate()?;
449 Ok(journal)
450}
451
452fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
454 if let Some(path) = &options.out {
455 let data = serde_json::to_vec_pretty(plan)?;
456 fs::write(path, data)?;
457 return Ok(());
458 }
459
460 let stdout = io::stdout();
461 let mut handle = stdout.lock();
462 serde_json::to_writer_pretty(&mut handle, plan)?;
463 writeln!(handle)?;
464 Ok(())
465}
466
467fn write_status(
469 options: &RestoreStatusOptions,
470 status: &RestoreStatus,
471) -> Result<(), RestoreCommandError> {
472 if let Some(path) = &options.out {
473 let data = serde_json::to_vec_pretty(status)?;
474 fs::write(path, data)?;
475 return Ok(());
476 }
477
478 let stdout = io::stdout();
479 let mut handle = stdout.lock();
480 serde_json::to_writer_pretty(&mut handle, status)?;
481 writeln!(handle)?;
482 Ok(())
483}
484
485fn write_apply_dry_run(
487 options: &RestoreApplyOptions,
488 dry_run: &RestoreApplyDryRun,
489) -> Result<(), RestoreCommandError> {
490 if let Some(path) = &options.out {
491 let data = serde_json::to_vec_pretty(dry_run)?;
492 fs::write(path, data)?;
493 return Ok(());
494 }
495
496 let stdout = io::stdout();
497 let mut handle = stdout.lock();
498 serde_json::to_writer_pretty(&mut handle, dry_run)?;
499 writeln!(handle)?;
500 Ok(())
501}
502
503fn write_apply_journal_if_requested(
505 options: &RestoreApplyOptions,
506 dry_run: &RestoreApplyDryRun,
507) -> Result<(), RestoreCommandError> {
508 let Some(path) = &options.journal_out else {
509 return Ok(());
510 };
511
512 let journal = RestoreApplyJournal::from_dry_run(dry_run);
513 let data = serde_json::to_vec_pretty(&journal)?;
514 fs::write(path, data)?;
515 Ok(())
516}
517
518fn write_apply_status(
520 options: &RestoreApplyStatusOptions,
521 status: &RestoreApplyJournalStatus,
522) -> Result<(), RestoreCommandError> {
523 if let Some(path) = &options.out {
524 let data = serde_json::to_vec_pretty(status)?;
525 fs::write(path, data)?;
526 return Ok(());
527 }
528
529 let stdout = io::stdout();
530 let mut handle = stdout.lock();
531 serde_json::to_writer_pretty(&mut handle, status)?;
532 writeln!(handle)?;
533 Ok(())
534}
535
536fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
538where
539 I: Iterator<Item = OsString>,
540{
541 args.next()
542 .and_then(|value| value.into_string().ok())
543 .ok_or(RestoreCommandError::MissingValue(option))
544}
545
546const fn usage() -> &'static str {
548 "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>]"
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use canic_backup::{
555 artifacts::ArtifactChecksum,
556 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
557 manifest::{
558 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
559 FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
560 VerificationCheck, VerificationPlan,
561 },
562 };
563 use serde_json::json;
564 use std::{
565 path::Path,
566 time::{SystemTime, UNIX_EPOCH},
567 };
568
569 const ROOT: &str = "aaaaa-aa";
570 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
571 const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
572 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
573
574 #[test]
576 fn parses_restore_plan_options() {
577 let options = RestorePlanOptions::parse([
578 OsString::from("--manifest"),
579 OsString::from("manifest.json"),
580 OsString::from("--mapping"),
581 OsString::from("mapping.json"),
582 OsString::from("--out"),
583 OsString::from("plan.json"),
584 OsString::from("--require-restore-ready"),
585 ])
586 .expect("parse options");
587
588 assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
589 assert_eq!(options.backup_dir, None);
590 assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
591 assert_eq!(options.out, Some(PathBuf::from("plan.json")));
592 assert!(!options.require_verified);
593 assert!(options.require_restore_ready);
594 }
595
596 #[test]
598 fn parses_verified_restore_plan_options() {
599 let options = RestorePlanOptions::parse([
600 OsString::from("--backup-dir"),
601 OsString::from("backups/run"),
602 OsString::from("--require-verified"),
603 ])
604 .expect("parse verified options");
605
606 assert_eq!(options.manifest, None);
607 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
608 assert_eq!(options.mapping, None);
609 assert_eq!(options.out, None);
610 assert!(options.require_verified);
611 assert!(!options.require_restore_ready);
612 }
613
614 #[test]
616 fn parses_restore_status_options() {
617 let options = RestoreStatusOptions::parse([
618 OsString::from("--plan"),
619 OsString::from("restore-plan.json"),
620 OsString::from("--out"),
621 OsString::from("restore-status.json"),
622 ])
623 .expect("parse status options");
624
625 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
626 assert_eq!(options.out, Some(PathBuf::from("restore-status.json")));
627 }
628
629 #[test]
631 fn parses_restore_apply_dry_run_options() {
632 let options = RestoreApplyOptions::parse([
633 OsString::from("--plan"),
634 OsString::from("restore-plan.json"),
635 OsString::from("--status"),
636 OsString::from("restore-status.json"),
637 OsString::from("--backup-dir"),
638 OsString::from("backups/run"),
639 OsString::from("--dry-run"),
640 OsString::from("--out"),
641 OsString::from("restore-apply-dry-run.json"),
642 OsString::from("--journal-out"),
643 OsString::from("restore-apply-journal.json"),
644 ])
645 .expect("parse apply options");
646
647 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
648 assert_eq!(options.status, Some(PathBuf::from("restore-status.json")));
649 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
650 assert_eq!(
651 options.out,
652 Some(PathBuf::from("restore-apply-dry-run.json"))
653 );
654 assert_eq!(
655 options.journal_out,
656 Some(PathBuf::from("restore-apply-journal.json"))
657 );
658 assert!(options.dry_run);
659 }
660
661 #[test]
663 fn parses_restore_apply_status_options() {
664 let options = RestoreApplyStatusOptions::parse([
665 OsString::from("--journal"),
666 OsString::from("restore-apply-journal.json"),
667 OsString::from("--out"),
668 OsString::from("restore-apply-status.json"),
669 ])
670 .expect("parse apply-status options");
671
672 assert_eq!(options.journal, PathBuf::from("restore-apply-journal.json"));
673 assert_eq!(
674 options.out,
675 Some(PathBuf::from("restore-apply-status.json"))
676 );
677 }
678
679 #[test]
681 fn restore_apply_requires_dry_run() {
682 let err = RestoreApplyOptions::parse([
683 OsString::from("--plan"),
684 OsString::from("restore-plan.json"),
685 ])
686 .expect_err("apply without dry-run should fail");
687
688 assert!(matches!(err, RestoreCommandError::ApplyRequiresDryRun));
689 }
690
691 #[test]
693 fn plan_restore_reads_manifest_from_backup_dir() {
694 let root = temp_dir("canic-cli-restore-plan-layout");
695 let layout = BackupLayout::new(root.clone());
696 layout
697 .write_manifest(&valid_manifest())
698 .expect("write manifest");
699
700 let options = RestorePlanOptions {
701 manifest: None,
702 backup_dir: Some(root.clone()),
703 mapping: None,
704 out: None,
705 require_verified: false,
706 require_restore_ready: false,
707 };
708
709 let plan = plan_restore(&options).expect("plan restore");
710
711 fs::remove_dir_all(root).expect("remove temp root");
712 assert_eq!(plan.backup_id, "backup-test");
713 assert_eq!(plan.member_count, 2);
714 }
715
716 #[test]
718 fn parse_rejects_conflicting_manifest_sources() {
719 let err = RestorePlanOptions::parse([
720 OsString::from("--manifest"),
721 OsString::from("manifest.json"),
722 OsString::from("--backup-dir"),
723 OsString::from("backups/run"),
724 ])
725 .expect_err("conflicting sources should fail");
726
727 assert!(matches!(
728 err,
729 RestoreCommandError::ConflictingManifestSources
730 ));
731 }
732
733 #[test]
735 fn parse_rejects_require_verified_with_manifest_source() {
736 let err = RestorePlanOptions::parse([
737 OsString::from("--manifest"),
738 OsString::from("manifest.json"),
739 OsString::from("--require-verified"),
740 ])
741 .expect_err("verification should require a backup layout");
742
743 assert!(matches!(
744 err,
745 RestoreCommandError::RequireVerifiedNeedsBackupDir
746 ));
747 }
748
749 #[test]
751 fn plan_restore_requires_verified_backup_layout() {
752 let root = temp_dir("canic-cli-restore-plan-verified");
753 let layout = BackupLayout::new(root.clone());
754 let manifest = valid_manifest();
755 write_verified_layout(&root, &layout, &manifest);
756
757 let options = RestorePlanOptions {
758 manifest: None,
759 backup_dir: Some(root.clone()),
760 mapping: None,
761 out: None,
762 require_verified: true,
763 require_restore_ready: false,
764 };
765
766 let plan = plan_restore(&options).expect("plan verified restore");
767
768 fs::remove_dir_all(root).expect("remove temp root");
769 assert_eq!(plan.backup_id, "backup-test");
770 assert_eq!(plan.member_count, 2);
771 }
772
773 #[test]
775 fn plan_restore_rejects_unverified_backup_layout() {
776 let root = temp_dir("canic-cli-restore-plan-unverified");
777 let layout = BackupLayout::new(root.clone());
778 layout
779 .write_manifest(&valid_manifest())
780 .expect("write manifest");
781
782 let options = RestorePlanOptions {
783 manifest: None,
784 backup_dir: Some(root.clone()),
785 mapping: None,
786 out: None,
787 require_verified: true,
788 require_restore_ready: false,
789 };
790
791 let err = plan_restore(&options).expect_err("missing journal should fail");
792
793 fs::remove_dir_all(root).expect("remove temp root");
794 assert!(matches!(err, RestoreCommandError::Persistence(_)));
795 }
796
797 #[test]
799 fn plan_restore_reads_manifest_and_mapping() {
800 let root = temp_dir("canic-cli-restore-plan");
801 fs::create_dir_all(&root).expect("create temp root");
802 let manifest_path = root.join("manifest.json");
803 let mapping_path = root.join("mapping.json");
804
805 fs::write(
806 &manifest_path,
807 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
808 )
809 .expect("write manifest");
810 fs::write(
811 &mapping_path,
812 json!({
813 "members": [
814 {"source_canister": ROOT, "target_canister": ROOT},
815 {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
816 ]
817 })
818 .to_string(),
819 )
820 .expect("write mapping");
821
822 let options = RestorePlanOptions {
823 manifest: Some(manifest_path),
824 backup_dir: None,
825 mapping: Some(mapping_path),
826 out: None,
827 require_verified: false,
828 require_restore_ready: false,
829 };
830
831 let plan = plan_restore(&options).expect("plan restore");
832
833 fs::remove_dir_all(root).expect("remove temp root");
834 let members = plan.ordered_members();
835 assert_eq!(members.len(), 2);
836 assert_eq!(members[0].source_canister, ROOT);
837 assert_eq!(members[1].target_canister, MAPPED_CHILD);
838 }
839
840 #[test]
842 fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
843 let root = temp_dir("canic-cli-restore-plan-require-ready");
844 fs::create_dir_all(&root).expect("create temp root");
845 let manifest_path = root.join("manifest.json");
846 let out_path = root.join("plan.json");
847
848 fs::write(
849 &manifest_path,
850 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
851 )
852 .expect("write manifest");
853
854 let err = run([
855 OsString::from("plan"),
856 OsString::from("--manifest"),
857 OsString::from(manifest_path.as_os_str()),
858 OsString::from("--out"),
859 OsString::from(out_path.as_os_str()),
860 OsString::from("--require-restore-ready"),
861 ])
862 .expect_err("restore readiness should be enforced");
863
864 assert!(out_path.exists());
865 let plan: RestorePlan =
866 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
867
868 fs::remove_dir_all(root).expect("remove temp root");
869 assert!(!plan.readiness_summary.ready);
870 assert!(matches!(
871 err,
872 RestoreCommandError::RestoreNotReady {
873 reasons,
874 ..
875 } if reasons == [
876 "missing-module-hash",
877 "missing-wasm-hash",
878 "missing-snapshot-checksum"
879 ]
880 ));
881 }
882
883 #[test]
885 fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
886 let root = temp_dir("canic-cli-restore-plan-ready");
887 fs::create_dir_all(&root).expect("create temp root");
888 let manifest_path = root.join("manifest.json");
889 let out_path = root.join("plan.json");
890
891 fs::write(
892 &manifest_path,
893 serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
894 )
895 .expect("write manifest");
896
897 run([
898 OsString::from("plan"),
899 OsString::from("--manifest"),
900 OsString::from(manifest_path.as_os_str()),
901 OsString::from("--out"),
902 OsString::from(out_path.as_os_str()),
903 OsString::from("--require-restore-ready"),
904 ])
905 .expect("restore-ready plan should pass");
906
907 let plan: RestorePlan =
908 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
909
910 fs::remove_dir_all(root).expect("remove temp root");
911 assert!(plan.readiness_summary.ready);
912 assert!(plan.readiness_summary.reasons.is_empty());
913 }
914
915 #[test]
917 fn run_restore_status_writes_planned_status() {
918 let root = temp_dir("canic-cli-restore-status");
919 fs::create_dir_all(&root).expect("create temp root");
920 let plan_path = root.join("restore-plan.json");
921 let out_path = root.join("restore-status.json");
922 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
923
924 fs::write(
925 &plan_path,
926 serde_json::to_vec(&plan).expect("serialize plan"),
927 )
928 .expect("write plan");
929
930 run([
931 OsString::from("status"),
932 OsString::from("--plan"),
933 OsString::from(plan_path.as_os_str()),
934 OsString::from("--out"),
935 OsString::from(out_path.as_os_str()),
936 ])
937 .expect("write restore status");
938
939 let status: RestoreStatus =
940 serde_json::from_slice(&fs::read(&out_path).expect("read restore status"))
941 .expect("decode restore status");
942 let status_json: serde_json::Value = serde_json::to_value(&status).expect("encode status");
943
944 fs::remove_dir_all(root).expect("remove temp root");
945 assert_eq!(status.status_version, 1);
946 assert_eq!(status.backup_id.as_str(), "backup-test");
947 assert!(status.ready);
948 assert!(status.readiness_reasons.is_empty());
949 assert_eq!(status.member_count, 2);
950 assert_eq!(status.phase_count, 1);
951 assert_eq!(status.planned_snapshot_loads, 2);
952 assert_eq!(status.planned_code_reinstalls, 2);
953 assert_eq!(status.planned_verification_checks, 2);
954 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
955 assert_eq!(status_json["phases"][0]["members"][0]["state"], "planned");
956 }
957
958 #[test]
960 fn run_restore_apply_dry_run_writes_operations() {
961 let root = temp_dir("canic-cli-restore-apply-dry-run");
962 fs::create_dir_all(&root).expect("create temp root");
963 let plan_path = root.join("restore-plan.json");
964 let status_path = root.join("restore-status.json");
965 let out_path = root.join("restore-apply-dry-run.json");
966 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
967 let status = RestoreStatus::from_plan(&plan);
968
969 fs::write(
970 &plan_path,
971 serde_json::to_vec(&plan).expect("serialize plan"),
972 )
973 .expect("write plan");
974 fs::write(
975 &status_path,
976 serde_json::to_vec(&status).expect("serialize status"),
977 )
978 .expect("write status");
979
980 run([
981 OsString::from("apply"),
982 OsString::from("--plan"),
983 OsString::from(plan_path.as_os_str()),
984 OsString::from("--status"),
985 OsString::from(status_path.as_os_str()),
986 OsString::from("--dry-run"),
987 OsString::from("--out"),
988 OsString::from(out_path.as_os_str()),
989 ])
990 .expect("write apply dry-run");
991
992 let dry_run: RestoreApplyDryRun =
993 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
994 .expect("decode dry-run");
995 let dry_run_json: serde_json::Value =
996 serde_json::to_value(&dry_run).expect("encode dry-run");
997
998 fs::remove_dir_all(root).expect("remove temp root");
999 assert_eq!(dry_run.dry_run_version, 1);
1000 assert_eq!(dry_run.backup_id.as_str(), "backup-test");
1001 assert!(dry_run.ready);
1002 assert!(dry_run.status_supplied);
1003 assert_eq!(dry_run.member_count, 2);
1004 assert_eq!(dry_run.phase_count, 1);
1005 assert_eq!(dry_run.rendered_operations, 8);
1006 assert_eq!(
1007 dry_run_json["phases"][0]["operations"][0]["operation"],
1008 "upload-snapshot"
1009 );
1010 assert_eq!(
1011 dry_run_json["phases"][0]["operations"][3]["operation"],
1012 "verify-member"
1013 );
1014 assert_eq!(
1015 dry_run_json["phases"][0]["operations"][3]["verification_kind"],
1016 "status"
1017 );
1018 assert_eq!(
1019 dry_run_json["phases"][0]["operations"][3]["verification_method"],
1020 serde_json::Value::Null
1021 );
1022 }
1023
1024 #[test]
1026 fn run_restore_apply_dry_run_validates_backup_dir_artifacts() {
1027 let root = temp_dir("canic-cli-restore-apply-artifacts");
1028 fs::create_dir_all(&root).expect("create temp root");
1029 let plan_path = root.join("restore-plan.json");
1030 let out_path = root.join("restore-apply-dry-run.json");
1031 let journal_path = root.join("restore-apply-journal.json");
1032 let status_path = root.join("restore-apply-status.json");
1033 let mut manifest = restore_ready_manifest();
1034 write_manifest_artifacts(&root, &mut manifest);
1035 let plan = RestorePlanner::plan(&manifest, None).expect("build plan");
1036
1037 fs::write(
1038 &plan_path,
1039 serde_json::to_vec(&plan).expect("serialize plan"),
1040 )
1041 .expect("write plan");
1042
1043 run([
1044 OsString::from("apply"),
1045 OsString::from("--plan"),
1046 OsString::from(plan_path.as_os_str()),
1047 OsString::from("--backup-dir"),
1048 OsString::from(root.as_os_str()),
1049 OsString::from("--dry-run"),
1050 OsString::from("--out"),
1051 OsString::from(out_path.as_os_str()),
1052 OsString::from("--journal-out"),
1053 OsString::from(journal_path.as_os_str()),
1054 ])
1055 .expect("write apply dry-run");
1056 run([
1057 OsString::from("apply-status"),
1058 OsString::from("--journal"),
1059 OsString::from(journal_path.as_os_str()),
1060 OsString::from("--out"),
1061 OsString::from(status_path.as_os_str()),
1062 ])
1063 .expect("write apply status");
1064
1065 let dry_run: RestoreApplyDryRun =
1066 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
1067 .expect("decode dry-run");
1068 let validation = dry_run
1069 .artifact_validation
1070 .expect("artifact validation should be present");
1071 let journal_json: serde_json::Value =
1072 serde_json::from_slice(&fs::read(&journal_path).expect("read journal"))
1073 .expect("decode journal");
1074 let status_json: serde_json::Value =
1075 serde_json::from_slice(&fs::read(&status_path).expect("read apply status"))
1076 .expect("decode apply status");
1077
1078 fs::remove_dir_all(root).expect("remove temp root");
1079 assert_eq!(validation.checked_members, 2);
1080 assert!(validation.artifacts_present);
1081 assert!(validation.checksums_verified);
1082 assert_eq!(validation.members_with_expected_checksums, 2);
1083 assert_eq!(journal_json["ready"], true);
1084 assert_eq!(journal_json["operation_count"], 8);
1085 assert_eq!(journal_json["ready_operations"], 8);
1086 assert_eq!(journal_json["blocked_operations"], 0);
1087 assert_eq!(journal_json["operations"][0]["state"], "ready");
1088 assert_eq!(status_json["ready"], true);
1089 assert_eq!(status_json["operation_count"], 8);
1090 assert_eq!(status_json["next_ready_sequence"], 0);
1091 assert_eq!(status_json["next_ready_operation"], "upload-snapshot");
1092 }
1093
1094 #[test]
1096 fn run_restore_apply_status_rejects_invalid_journal() {
1097 let root = temp_dir("canic-cli-restore-apply-status-invalid");
1098 fs::create_dir_all(&root).expect("create temp root");
1099 let journal_path = root.join("restore-apply-journal.json");
1100 let out_path = root.join("restore-apply-status.json");
1101 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1102 let dry_run = RestoreApplyDryRun::try_from_plan(&plan, None).expect("dry-run");
1103 let mut journal = RestoreApplyJournal::from_dry_run(&dry_run);
1104 journal.operation_count += 1;
1105
1106 fs::write(
1107 &journal_path,
1108 serde_json::to_vec(&journal).expect("serialize journal"),
1109 )
1110 .expect("write journal");
1111
1112 let err = run([
1113 OsString::from("apply-status"),
1114 OsString::from("--journal"),
1115 OsString::from(journal_path.as_os_str()),
1116 OsString::from("--out"),
1117 OsString::from(out_path.as_os_str()),
1118 ])
1119 .expect_err("invalid journal should fail");
1120
1121 assert!(!out_path.exists());
1122 fs::remove_dir_all(root).expect("remove temp root");
1123 assert!(matches!(
1124 err,
1125 RestoreCommandError::RestoreApplyJournal(RestoreApplyJournalError::CountMismatch {
1126 field: "operation_count",
1127 ..
1128 })
1129 ));
1130 }
1131
1132 #[test]
1134 fn run_restore_apply_dry_run_rejects_mismatched_status() {
1135 let root = temp_dir("canic-cli-restore-apply-dry-run-mismatch");
1136 fs::create_dir_all(&root).expect("create temp root");
1137 let plan_path = root.join("restore-plan.json");
1138 let status_path = root.join("restore-status.json");
1139 let out_path = root.join("restore-apply-dry-run.json");
1140 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
1141 let mut status = RestoreStatus::from_plan(&plan);
1142 status.backup_id = "other-backup".to_string();
1143
1144 fs::write(
1145 &plan_path,
1146 serde_json::to_vec(&plan).expect("serialize plan"),
1147 )
1148 .expect("write plan");
1149 fs::write(
1150 &status_path,
1151 serde_json::to_vec(&status).expect("serialize status"),
1152 )
1153 .expect("write status");
1154
1155 let err = run([
1156 OsString::from("apply"),
1157 OsString::from("--plan"),
1158 OsString::from(plan_path.as_os_str()),
1159 OsString::from("--status"),
1160 OsString::from(status_path.as_os_str()),
1161 OsString::from("--dry-run"),
1162 OsString::from("--out"),
1163 OsString::from(out_path.as_os_str()),
1164 ])
1165 .expect_err("mismatched status should fail");
1166
1167 assert!(!out_path.exists());
1168 fs::remove_dir_all(root).expect("remove temp root");
1169 assert!(matches!(
1170 err,
1171 RestoreCommandError::RestoreApplyDryRun(RestoreApplyDryRunError::StatusPlanMismatch {
1172 field: "backup_id",
1173 ..
1174 })
1175 ));
1176 }
1177
1178 fn valid_manifest() -> FleetBackupManifest {
1180 FleetBackupManifest {
1181 manifest_version: 1,
1182 backup_id: "backup-test".to_string(),
1183 created_at: "2026-05-03T00:00:00Z".to_string(),
1184 tool: ToolMetadata {
1185 name: "canic".to_string(),
1186 version: "0.30.1".to_string(),
1187 },
1188 source: SourceMetadata {
1189 environment: "local".to_string(),
1190 root_canister: ROOT.to_string(),
1191 },
1192 consistency: ConsistencySection {
1193 mode: ConsistencyMode::CrashConsistent,
1194 backup_units: vec![BackupUnit {
1195 unit_id: "fleet".to_string(),
1196 kind: BackupUnitKind::SubtreeRooted,
1197 roles: vec!["root".to_string(), "app".to_string()],
1198 consistency_reason: None,
1199 dependency_closure: Vec::new(),
1200 topology_validation: "subtree-closed".to_string(),
1201 quiescence_strategy: None,
1202 }],
1203 },
1204 fleet: FleetSection {
1205 topology_hash_algorithm: "sha256".to_string(),
1206 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1207 discovery_topology_hash: HASH.to_string(),
1208 pre_snapshot_topology_hash: HASH.to_string(),
1209 topology_hash: HASH.to_string(),
1210 members: vec![
1211 fleet_member("root", ROOT, None, IdentityMode::Fixed),
1212 fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
1213 ],
1214 },
1215 verification: VerificationPlan::default(),
1216 }
1217 }
1218
1219 fn restore_ready_manifest() -> FleetBackupManifest {
1221 let mut manifest = valid_manifest();
1222 for member in &mut manifest.fleet.members {
1223 member.source_snapshot.module_hash = Some(HASH.to_string());
1224 member.source_snapshot.wasm_hash = Some(HASH.to_string());
1225 member.source_snapshot.checksum = Some(HASH.to_string());
1226 }
1227 manifest
1228 }
1229
1230 fn fleet_member(
1232 role: &str,
1233 canister_id: &str,
1234 parent_canister_id: Option<&str>,
1235 identity_mode: IdentityMode,
1236 ) -> FleetMember {
1237 FleetMember {
1238 role: role.to_string(),
1239 canister_id: canister_id.to_string(),
1240 parent_canister_id: parent_canister_id.map(str::to_string),
1241 subnet_canister_id: Some(ROOT.to_string()),
1242 controller_hint: None,
1243 identity_mode,
1244 restore_group: 1,
1245 verification_class: "basic".to_string(),
1246 verification_checks: vec![VerificationCheck {
1247 kind: "status".to_string(),
1248 method: None,
1249 roles: vec![role.to_string()],
1250 }],
1251 source_snapshot: SourceSnapshot {
1252 snapshot_id: format!("{role}-snapshot"),
1253 module_hash: None,
1254 wasm_hash: None,
1255 code_version: Some("v0.30.1".to_string()),
1256 artifact_path: format!("artifacts/{role}"),
1257 checksum_algorithm: "sha256".to_string(),
1258 checksum: None,
1259 },
1260 }
1261 }
1262
1263 fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
1265 layout.write_manifest(manifest).expect("write manifest");
1266
1267 let artifacts = manifest
1268 .fleet
1269 .members
1270 .iter()
1271 .map(|member| {
1272 let bytes = format!("{} artifact", member.role);
1273 let artifact_path = root.join(&member.source_snapshot.artifact_path);
1274 if let Some(parent) = artifact_path.parent() {
1275 fs::create_dir_all(parent).expect("create artifact parent");
1276 }
1277 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
1278 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
1279
1280 ArtifactJournalEntry {
1281 canister_id: member.canister_id.clone(),
1282 snapshot_id: member.source_snapshot.snapshot_id.clone(),
1283 state: ArtifactState::Durable,
1284 temp_path: None,
1285 artifact_path: member.source_snapshot.artifact_path.clone(),
1286 checksum_algorithm: checksum.algorithm,
1287 checksum: Some(checksum.hash),
1288 updated_at: "2026-05-03T00:00:00Z".to_string(),
1289 }
1290 })
1291 .collect();
1292
1293 layout
1294 .write_journal(&DownloadJournal {
1295 journal_version: 1,
1296 backup_id: manifest.backup_id.clone(),
1297 discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
1298 pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
1299 operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
1300 artifacts,
1301 })
1302 .expect("write journal");
1303 }
1304
1305 fn write_manifest_artifacts(root: &Path, manifest: &mut FleetBackupManifest) {
1307 for member in &mut manifest.fleet.members {
1308 let bytes = format!("{} apply artifact", member.role);
1309 let artifact_path = root.join(&member.source_snapshot.artifact_path);
1310 if let Some(parent) = artifact_path.parent() {
1311 fs::create_dir_all(parent).expect("create artifact parent");
1312 }
1313 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
1314 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
1315 member.source_snapshot.checksum = Some(checksum.hash);
1316 }
1317 }
1318
1319 fn temp_dir(prefix: &str) -> PathBuf {
1321 let nanos = SystemTime::now()
1322 .duration_since(UNIX_EPOCH)
1323 .expect("system time after epoch")
1324 .as_nanos();
1325 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
1326 }
1327}