1use canic_backup::{
2 manifest::FleetBackupManifest,
3 persistence::{BackupLayout, PersistenceError},
4 restore::{
5 RestoreApplyDryRun, RestoreApplyDryRunError, RestoreMapping, RestorePlan, RestorePlanError,
6 RestorePlanner, RestoreStatus,
7 },
8};
9use std::{
10 ffi::OsString,
11 fs,
12 io::{self, Write},
13 path::PathBuf,
14};
15use thiserror::Error as ThisError;
16
17#[derive(Debug, ThisError)]
22pub enum RestoreCommandError {
23 #[error("{0}")]
24 Usage(&'static str),
25
26 #[error("missing required option {0}")]
27 MissingOption(&'static str),
28
29 #[error("use either --manifest or --backup-dir, not both")]
30 ConflictingManifestSources,
31
32 #[error("--require-verified requires --backup-dir")]
33 RequireVerifiedNeedsBackupDir,
34
35 #[error("restore apply currently requires --dry-run")]
36 ApplyRequiresDryRun,
37
38 #[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
39 RestoreNotReady {
40 backup_id: String,
41 reasons: Vec<String>,
42 },
43
44 #[error("unknown option {0}")]
45 UnknownOption(String),
46
47 #[error("option {0} requires a value")]
48 MissingValue(&'static str),
49
50 #[error(transparent)]
51 Io(#[from] std::io::Error),
52
53 #[error(transparent)]
54 Json(#[from] serde_json::Error),
55
56 #[error(transparent)]
57 Persistence(#[from] PersistenceError),
58
59 #[error(transparent)]
60 RestorePlan(#[from] RestorePlanError),
61
62 #[error(transparent)]
63 RestoreApplyDryRun(#[from] RestoreApplyDryRunError),
64}
65
66#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct RestorePlanOptions {
72 pub manifest: Option<PathBuf>,
73 pub backup_dir: Option<PathBuf>,
74 pub mapping: Option<PathBuf>,
75 pub out: Option<PathBuf>,
76 pub require_verified: bool,
77 pub require_restore_ready: bool,
78}
79
80impl RestorePlanOptions {
81 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
83 where
84 I: IntoIterator<Item = OsString>,
85 {
86 let mut manifest = None;
87 let mut backup_dir = None;
88 let mut mapping = None;
89 let mut out = None;
90 let mut require_verified = false;
91 let mut require_restore_ready = false;
92
93 let mut args = args.into_iter();
94 while let Some(arg) = args.next() {
95 let arg = arg
96 .into_string()
97 .map_err(|_| RestoreCommandError::Usage(usage()))?;
98 match arg.as_str() {
99 "--manifest" => {
100 manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
101 }
102 "--backup-dir" => {
103 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
104 }
105 "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
106 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
107 "--require-verified" => require_verified = true,
108 "--require-restore-ready" => require_restore_ready = true,
109 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
110 _ => return Err(RestoreCommandError::UnknownOption(arg)),
111 }
112 }
113
114 if manifest.is_some() && backup_dir.is_some() {
115 return Err(RestoreCommandError::ConflictingManifestSources);
116 }
117
118 if manifest.is_none() && backup_dir.is_none() {
119 return Err(RestoreCommandError::MissingOption(
120 "--manifest or --backup-dir",
121 ));
122 }
123
124 if require_verified && backup_dir.is_none() {
125 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
126 }
127
128 Ok(Self {
129 manifest,
130 backup_dir,
131 mapping,
132 out,
133 require_verified,
134 require_restore_ready,
135 })
136 }
137}
138
139#[derive(Clone, Debug, Eq, PartialEq)]
144pub struct RestoreStatusOptions {
145 pub plan: PathBuf,
146 pub out: Option<PathBuf>,
147}
148
149impl RestoreStatusOptions {
150 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
152 where
153 I: IntoIterator<Item = OsString>,
154 {
155 let mut plan = None;
156 let mut out = None;
157
158 let mut args = args.into_iter();
159 while let Some(arg) = args.next() {
160 let arg = arg
161 .into_string()
162 .map_err(|_| RestoreCommandError::Usage(usage()))?;
163 match arg.as_str() {
164 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
165 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
166 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
167 _ => return Err(RestoreCommandError::UnknownOption(arg)),
168 }
169 }
170
171 Ok(Self {
172 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
173 out,
174 })
175 }
176}
177
178#[derive(Clone, Debug, Eq, PartialEq)]
183pub struct RestoreApplyOptions {
184 pub plan: PathBuf,
185 pub status: Option<PathBuf>,
186 pub backup_dir: Option<PathBuf>,
187 pub out: Option<PathBuf>,
188 pub dry_run: bool,
189}
190
191impl RestoreApplyOptions {
192 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
194 where
195 I: IntoIterator<Item = OsString>,
196 {
197 let mut plan = None;
198 let mut status = None;
199 let mut backup_dir = None;
200 let mut out = None;
201 let mut dry_run = false;
202
203 let mut args = args.into_iter();
204 while let Some(arg) = args.next() {
205 let arg = arg
206 .into_string()
207 .map_err(|_| RestoreCommandError::Usage(usage()))?;
208 match arg.as_str() {
209 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
210 "--status" => status = Some(PathBuf::from(next_value(&mut args, "--status")?)),
211 "--backup-dir" => {
212 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
213 }
214 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
215 "--dry-run" => dry_run = true,
216 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
217 _ => return Err(RestoreCommandError::UnknownOption(arg)),
218 }
219 }
220
221 if !dry_run {
222 return Err(RestoreCommandError::ApplyRequiresDryRun);
223 }
224
225 Ok(Self {
226 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
227 status,
228 backup_dir,
229 out,
230 dry_run,
231 })
232 }
233}
234
235pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
237where
238 I: IntoIterator<Item = OsString>,
239{
240 let mut args = args.into_iter();
241 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
242 return Err(RestoreCommandError::Usage(usage()));
243 };
244
245 match command.as_str() {
246 "plan" => {
247 let options = RestorePlanOptions::parse(args)?;
248 let plan = plan_restore(&options)?;
249 write_plan(&options, &plan)?;
250 enforce_restore_plan_requirements(&options, &plan)?;
251 Ok(())
252 }
253 "status" => {
254 let options = RestoreStatusOptions::parse(args)?;
255 let status = restore_status(&options)?;
256 write_status(&options, &status)?;
257 Ok(())
258 }
259 "apply" => {
260 let options = RestoreApplyOptions::parse(args)?;
261 let dry_run = restore_apply_dry_run(&options)?;
262 write_apply_dry_run(&options, &dry_run)?;
263 Ok(())
264 }
265 "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
266 _ => Err(RestoreCommandError::UnknownOption(command)),
267 }
268}
269
270pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
272 verify_backup_layout_if_required(options)?;
273
274 let manifest = read_manifest_source(options)?;
275 let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
276
277 RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
278}
279
280pub fn restore_status(
282 options: &RestoreStatusOptions,
283) -> Result<RestoreStatus, RestoreCommandError> {
284 let plan = read_plan(&options.plan)?;
285 Ok(RestoreStatus::from_plan(&plan))
286}
287
288pub fn restore_apply_dry_run(
290 options: &RestoreApplyOptions,
291) -> Result<RestoreApplyDryRun, RestoreCommandError> {
292 let plan = read_plan(&options.plan)?;
293 let status = options.status.as_ref().map(read_status).transpose()?;
294 if let Some(backup_dir) = &options.backup_dir {
295 return RestoreApplyDryRun::try_from_plan_with_artifacts(
296 &plan,
297 status.as_ref(),
298 backup_dir,
299 )
300 .map_err(RestoreCommandError::from);
301 }
302
303 RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
304}
305
306fn enforce_restore_plan_requirements(
308 options: &RestorePlanOptions,
309 plan: &RestorePlan,
310) -> Result<(), RestoreCommandError> {
311 if !options.require_restore_ready || plan.readiness_summary.ready {
312 return Ok(());
313 }
314
315 Err(RestoreCommandError::RestoreNotReady {
316 backup_id: plan.backup_id.clone(),
317 reasons: plan.readiness_summary.reasons.clone(),
318 })
319}
320
321fn verify_backup_layout_if_required(
323 options: &RestorePlanOptions,
324) -> Result<(), RestoreCommandError> {
325 if !options.require_verified {
326 return Ok(());
327 }
328
329 let Some(dir) = &options.backup_dir else {
330 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
331 };
332
333 BackupLayout::new(dir.clone()).verify_integrity()?;
334 Ok(())
335}
336
337fn read_manifest_source(
339 options: &RestorePlanOptions,
340) -> Result<FleetBackupManifest, RestoreCommandError> {
341 if let Some(path) = &options.manifest {
342 return read_manifest(path);
343 }
344
345 let Some(dir) = &options.backup_dir else {
346 return Err(RestoreCommandError::MissingOption(
347 "--manifest or --backup-dir",
348 ));
349 };
350
351 BackupLayout::new(dir.clone())
352 .read_manifest()
353 .map_err(RestoreCommandError::from)
354}
355
356fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
358 let data = fs::read_to_string(path)?;
359 serde_json::from_str(&data).map_err(RestoreCommandError::from)
360}
361
362fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
364 let data = fs::read_to_string(path)?;
365 serde_json::from_str(&data).map_err(RestoreCommandError::from)
366}
367
368fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
370 let data = fs::read_to_string(path)?;
371 serde_json::from_str(&data).map_err(RestoreCommandError::from)
372}
373
374fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
376 let data = fs::read_to_string(path)?;
377 serde_json::from_str(&data).map_err(RestoreCommandError::from)
378}
379
380fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
382 if let Some(path) = &options.out {
383 let data = serde_json::to_vec_pretty(plan)?;
384 fs::write(path, data)?;
385 return Ok(());
386 }
387
388 let stdout = io::stdout();
389 let mut handle = stdout.lock();
390 serde_json::to_writer_pretty(&mut handle, plan)?;
391 writeln!(handle)?;
392 Ok(())
393}
394
395fn write_status(
397 options: &RestoreStatusOptions,
398 status: &RestoreStatus,
399) -> Result<(), RestoreCommandError> {
400 if let Some(path) = &options.out {
401 let data = serde_json::to_vec_pretty(status)?;
402 fs::write(path, data)?;
403 return Ok(());
404 }
405
406 let stdout = io::stdout();
407 let mut handle = stdout.lock();
408 serde_json::to_writer_pretty(&mut handle, status)?;
409 writeln!(handle)?;
410 Ok(())
411}
412
413fn write_apply_dry_run(
415 options: &RestoreApplyOptions,
416 dry_run: &RestoreApplyDryRun,
417) -> Result<(), RestoreCommandError> {
418 if let Some(path) = &options.out {
419 let data = serde_json::to_vec_pretty(dry_run)?;
420 fs::write(path, data)?;
421 return Ok(());
422 }
423
424 let stdout = io::stdout();
425 let mut handle = stdout.lock();
426 serde_json::to_writer_pretty(&mut handle, dry_run)?;
427 writeln!(handle)?;
428 Ok(())
429}
430
431fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
433where
434 I: Iterator<Item = OsString>,
435{
436 args.next()
437 .and_then(|value| value.into_string().ok())
438 .ok_or(RestoreCommandError::MissingValue(option))
439}
440
441const fn usage() -> &'static str {
443 "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>]"
444}
445
446#[cfg(test)]
447mod tests {
448 use super::*;
449 use canic_backup::{
450 artifacts::ArtifactChecksum,
451 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
452 manifest::{
453 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
454 FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
455 VerificationCheck, VerificationPlan,
456 },
457 };
458 use serde_json::json;
459 use std::{
460 path::Path,
461 time::{SystemTime, UNIX_EPOCH},
462 };
463
464 const ROOT: &str = "aaaaa-aa";
465 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
466 const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
467 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
468
469 #[test]
471 fn parses_restore_plan_options() {
472 let options = RestorePlanOptions::parse([
473 OsString::from("--manifest"),
474 OsString::from("manifest.json"),
475 OsString::from("--mapping"),
476 OsString::from("mapping.json"),
477 OsString::from("--out"),
478 OsString::from("plan.json"),
479 OsString::from("--require-restore-ready"),
480 ])
481 .expect("parse options");
482
483 assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
484 assert_eq!(options.backup_dir, None);
485 assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
486 assert_eq!(options.out, Some(PathBuf::from("plan.json")));
487 assert!(!options.require_verified);
488 assert!(options.require_restore_ready);
489 }
490
491 #[test]
493 fn parses_verified_restore_plan_options() {
494 let options = RestorePlanOptions::parse([
495 OsString::from("--backup-dir"),
496 OsString::from("backups/run"),
497 OsString::from("--require-verified"),
498 ])
499 .expect("parse verified options");
500
501 assert_eq!(options.manifest, None);
502 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
503 assert_eq!(options.mapping, None);
504 assert_eq!(options.out, None);
505 assert!(options.require_verified);
506 assert!(!options.require_restore_ready);
507 }
508
509 #[test]
511 fn parses_restore_status_options() {
512 let options = RestoreStatusOptions::parse([
513 OsString::from("--plan"),
514 OsString::from("restore-plan.json"),
515 OsString::from("--out"),
516 OsString::from("restore-status.json"),
517 ])
518 .expect("parse status options");
519
520 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
521 assert_eq!(options.out, Some(PathBuf::from("restore-status.json")));
522 }
523
524 #[test]
526 fn parses_restore_apply_dry_run_options() {
527 let options = RestoreApplyOptions::parse([
528 OsString::from("--plan"),
529 OsString::from("restore-plan.json"),
530 OsString::from("--status"),
531 OsString::from("restore-status.json"),
532 OsString::from("--backup-dir"),
533 OsString::from("backups/run"),
534 OsString::from("--dry-run"),
535 OsString::from("--out"),
536 OsString::from("restore-apply-dry-run.json"),
537 ])
538 .expect("parse apply options");
539
540 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
541 assert_eq!(options.status, Some(PathBuf::from("restore-status.json")));
542 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
543 assert_eq!(
544 options.out,
545 Some(PathBuf::from("restore-apply-dry-run.json"))
546 );
547 assert!(options.dry_run);
548 }
549
550 #[test]
552 fn restore_apply_requires_dry_run() {
553 let err = RestoreApplyOptions::parse([
554 OsString::from("--plan"),
555 OsString::from("restore-plan.json"),
556 ])
557 .expect_err("apply without dry-run should fail");
558
559 assert!(matches!(err, RestoreCommandError::ApplyRequiresDryRun));
560 }
561
562 #[test]
564 fn plan_restore_reads_manifest_from_backup_dir() {
565 let root = temp_dir("canic-cli-restore-plan-layout");
566 let layout = BackupLayout::new(root.clone());
567 layout
568 .write_manifest(&valid_manifest())
569 .expect("write manifest");
570
571 let options = RestorePlanOptions {
572 manifest: None,
573 backup_dir: Some(root.clone()),
574 mapping: None,
575 out: None,
576 require_verified: false,
577 require_restore_ready: false,
578 };
579
580 let plan = plan_restore(&options).expect("plan restore");
581
582 fs::remove_dir_all(root).expect("remove temp root");
583 assert_eq!(plan.backup_id, "backup-test");
584 assert_eq!(plan.member_count, 2);
585 }
586
587 #[test]
589 fn parse_rejects_conflicting_manifest_sources() {
590 let err = RestorePlanOptions::parse([
591 OsString::from("--manifest"),
592 OsString::from("manifest.json"),
593 OsString::from("--backup-dir"),
594 OsString::from("backups/run"),
595 ])
596 .expect_err("conflicting sources should fail");
597
598 assert!(matches!(
599 err,
600 RestoreCommandError::ConflictingManifestSources
601 ));
602 }
603
604 #[test]
606 fn parse_rejects_require_verified_with_manifest_source() {
607 let err = RestorePlanOptions::parse([
608 OsString::from("--manifest"),
609 OsString::from("manifest.json"),
610 OsString::from("--require-verified"),
611 ])
612 .expect_err("verification should require a backup layout");
613
614 assert!(matches!(
615 err,
616 RestoreCommandError::RequireVerifiedNeedsBackupDir
617 ));
618 }
619
620 #[test]
622 fn plan_restore_requires_verified_backup_layout() {
623 let root = temp_dir("canic-cli-restore-plan-verified");
624 let layout = BackupLayout::new(root.clone());
625 let manifest = valid_manifest();
626 write_verified_layout(&root, &layout, &manifest);
627
628 let options = RestorePlanOptions {
629 manifest: None,
630 backup_dir: Some(root.clone()),
631 mapping: None,
632 out: None,
633 require_verified: true,
634 require_restore_ready: false,
635 };
636
637 let plan = plan_restore(&options).expect("plan verified restore");
638
639 fs::remove_dir_all(root).expect("remove temp root");
640 assert_eq!(plan.backup_id, "backup-test");
641 assert_eq!(plan.member_count, 2);
642 }
643
644 #[test]
646 fn plan_restore_rejects_unverified_backup_layout() {
647 let root = temp_dir("canic-cli-restore-plan-unverified");
648 let layout = BackupLayout::new(root.clone());
649 layout
650 .write_manifest(&valid_manifest())
651 .expect("write manifest");
652
653 let options = RestorePlanOptions {
654 manifest: None,
655 backup_dir: Some(root.clone()),
656 mapping: None,
657 out: None,
658 require_verified: true,
659 require_restore_ready: false,
660 };
661
662 let err = plan_restore(&options).expect_err("missing journal should fail");
663
664 fs::remove_dir_all(root).expect("remove temp root");
665 assert!(matches!(err, RestoreCommandError::Persistence(_)));
666 }
667
668 #[test]
670 fn plan_restore_reads_manifest_and_mapping() {
671 let root = temp_dir("canic-cli-restore-plan");
672 fs::create_dir_all(&root).expect("create temp root");
673 let manifest_path = root.join("manifest.json");
674 let mapping_path = root.join("mapping.json");
675
676 fs::write(
677 &manifest_path,
678 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
679 )
680 .expect("write manifest");
681 fs::write(
682 &mapping_path,
683 json!({
684 "members": [
685 {"source_canister": ROOT, "target_canister": ROOT},
686 {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
687 ]
688 })
689 .to_string(),
690 )
691 .expect("write mapping");
692
693 let options = RestorePlanOptions {
694 manifest: Some(manifest_path),
695 backup_dir: None,
696 mapping: Some(mapping_path),
697 out: None,
698 require_verified: false,
699 require_restore_ready: false,
700 };
701
702 let plan = plan_restore(&options).expect("plan restore");
703
704 fs::remove_dir_all(root).expect("remove temp root");
705 let members = plan.ordered_members();
706 assert_eq!(members.len(), 2);
707 assert_eq!(members[0].source_canister, ROOT);
708 assert_eq!(members[1].target_canister, MAPPED_CHILD);
709 }
710
711 #[test]
713 fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
714 let root = temp_dir("canic-cli-restore-plan-require-ready");
715 fs::create_dir_all(&root).expect("create temp root");
716 let manifest_path = root.join("manifest.json");
717 let out_path = root.join("plan.json");
718
719 fs::write(
720 &manifest_path,
721 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
722 )
723 .expect("write manifest");
724
725 let err = run([
726 OsString::from("plan"),
727 OsString::from("--manifest"),
728 OsString::from(manifest_path.as_os_str()),
729 OsString::from("--out"),
730 OsString::from(out_path.as_os_str()),
731 OsString::from("--require-restore-ready"),
732 ])
733 .expect_err("restore readiness should be enforced");
734
735 assert!(out_path.exists());
736 let plan: RestorePlan =
737 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
738
739 fs::remove_dir_all(root).expect("remove temp root");
740 assert!(!plan.readiness_summary.ready);
741 assert!(matches!(
742 err,
743 RestoreCommandError::RestoreNotReady {
744 reasons,
745 ..
746 } if reasons == [
747 "missing-module-hash",
748 "missing-wasm-hash",
749 "missing-snapshot-checksum"
750 ]
751 ));
752 }
753
754 #[test]
756 fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
757 let root = temp_dir("canic-cli-restore-plan-ready");
758 fs::create_dir_all(&root).expect("create temp root");
759 let manifest_path = root.join("manifest.json");
760 let out_path = root.join("plan.json");
761
762 fs::write(
763 &manifest_path,
764 serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
765 )
766 .expect("write manifest");
767
768 run([
769 OsString::from("plan"),
770 OsString::from("--manifest"),
771 OsString::from(manifest_path.as_os_str()),
772 OsString::from("--out"),
773 OsString::from(out_path.as_os_str()),
774 OsString::from("--require-restore-ready"),
775 ])
776 .expect("restore-ready plan should pass");
777
778 let plan: RestorePlan =
779 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
780
781 fs::remove_dir_all(root).expect("remove temp root");
782 assert!(plan.readiness_summary.ready);
783 assert!(plan.readiness_summary.reasons.is_empty());
784 }
785
786 #[test]
788 fn run_restore_status_writes_planned_status() {
789 let root = temp_dir("canic-cli-restore-status");
790 fs::create_dir_all(&root).expect("create temp root");
791 let plan_path = root.join("restore-plan.json");
792 let out_path = root.join("restore-status.json");
793 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
794
795 fs::write(
796 &plan_path,
797 serde_json::to_vec(&plan).expect("serialize plan"),
798 )
799 .expect("write plan");
800
801 run([
802 OsString::from("status"),
803 OsString::from("--plan"),
804 OsString::from(plan_path.as_os_str()),
805 OsString::from("--out"),
806 OsString::from(out_path.as_os_str()),
807 ])
808 .expect("write restore status");
809
810 let status: RestoreStatus =
811 serde_json::from_slice(&fs::read(&out_path).expect("read restore status"))
812 .expect("decode restore status");
813 let status_json: serde_json::Value = serde_json::to_value(&status).expect("encode status");
814
815 fs::remove_dir_all(root).expect("remove temp root");
816 assert_eq!(status.status_version, 1);
817 assert_eq!(status.backup_id.as_str(), "backup-test");
818 assert!(status.ready);
819 assert!(status.readiness_reasons.is_empty());
820 assert_eq!(status.member_count, 2);
821 assert_eq!(status.phase_count, 1);
822 assert_eq!(status.planned_snapshot_loads, 2);
823 assert_eq!(status.planned_code_reinstalls, 2);
824 assert_eq!(status.planned_verification_checks, 2);
825 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
826 assert_eq!(status_json["phases"][0]["members"][0]["state"], "planned");
827 }
828
829 #[test]
831 fn run_restore_apply_dry_run_writes_operations() {
832 let root = temp_dir("canic-cli-restore-apply-dry-run");
833 fs::create_dir_all(&root).expect("create temp root");
834 let plan_path = root.join("restore-plan.json");
835 let status_path = root.join("restore-status.json");
836 let out_path = root.join("restore-apply-dry-run.json");
837 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
838 let status = RestoreStatus::from_plan(&plan);
839
840 fs::write(
841 &plan_path,
842 serde_json::to_vec(&plan).expect("serialize plan"),
843 )
844 .expect("write plan");
845 fs::write(
846 &status_path,
847 serde_json::to_vec(&status).expect("serialize status"),
848 )
849 .expect("write status");
850
851 run([
852 OsString::from("apply"),
853 OsString::from("--plan"),
854 OsString::from(plan_path.as_os_str()),
855 OsString::from("--status"),
856 OsString::from(status_path.as_os_str()),
857 OsString::from("--dry-run"),
858 OsString::from("--out"),
859 OsString::from(out_path.as_os_str()),
860 ])
861 .expect("write apply dry-run");
862
863 let dry_run: RestoreApplyDryRun =
864 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
865 .expect("decode dry-run");
866 let dry_run_json: serde_json::Value =
867 serde_json::to_value(&dry_run).expect("encode dry-run");
868
869 fs::remove_dir_all(root).expect("remove temp root");
870 assert_eq!(dry_run.dry_run_version, 1);
871 assert_eq!(dry_run.backup_id.as_str(), "backup-test");
872 assert!(dry_run.ready);
873 assert!(dry_run.status_supplied);
874 assert_eq!(dry_run.member_count, 2);
875 assert_eq!(dry_run.phase_count, 1);
876 assert_eq!(dry_run.rendered_operations, 8);
877 assert_eq!(
878 dry_run_json["phases"][0]["operations"][0]["operation"],
879 "upload-snapshot"
880 );
881 assert_eq!(
882 dry_run_json["phases"][0]["operations"][3]["operation"],
883 "verify-member"
884 );
885 assert_eq!(
886 dry_run_json["phases"][0]["operations"][3]["verification_kind"],
887 "status"
888 );
889 assert_eq!(
890 dry_run_json["phases"][0]["operations"][3]["verification_method"],
891 serde_json::Value::Null
892 );
893 }
894
895 #[test]
897 fn run_restore_apply_dry_run_validates_backup_dir_artifacts() {
898 let root = temp_dir("canic-cli-restore-apply-artifacts");
899 fs::create_dir_all(&root).expect("create temp root");
900 let plan_path = root.join("restore-plan.json");
901 let out_path = root.join("restore-apply-dry-run.json");
902 let mut manifest = valid_manifest();
903 write_manifest_artifacts(&root, &mut manifest);
904 let plan = RestorePlanner::plan(&manifest, None).expect("build plan");
905
906 fs::write(
907 &plan_path,
908 serde_json::to_vec(&plan).expect("serialize plan"),
909 )
910 .expect("write plan");
911
912 run([
913 OsString::from("apply"),
914 OsString::from("--plan"),
915 OsString::from(plan_path.as_os_str()),
916 OsString::from("--backup-dir"),
917 OsString::from(root.as_os_str()),
918 OsString::from("--dry-run"),
919 OsString::from("--out"),
920 OsString::from(out_path.as_os_str()),
921 ])
922 .expect("write apply dry-run");
923
924 let dry_run: RestoreApplyDryRun =
925 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
926 .expect("decode dry-run");
927 let validation = dry_run
928 .artifact_validation
929 .expect("artifact validation should be present");
930
931 fs::remove_dir_all(root).expect("remove temp root");
932 assert_eq!(validation.checked_members, 2);
933 assert!(validation.artifacts_present);
934 assert!(validation.checksums_verified);
935 assert_eq!(validation.members_with_expected_checksums, 2);
936 }
937
938 #[test]
940 fn run_restore_apply_dry_run_rejects_mismatched_status() {
941 let root = temp_dir("canic-cli-restore-apply-dry-run-mismatch");
942 fs::create_dir_all(&root).expect("create temp root");
943 let plan_path = root.join("restore-plan.json");
944 let status_path = root.join("restore-status.json");
945 let out_path = root.join("restore-apply-dry-run.json");
946 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
947 let mut status = RestoreStatus::from_plan(&plan);
948 status.backup_id = "other-backup".to_string();
949
950 fs::write(
951 &plan_path,
952 serde_json::to_vec(&plan).expect("serialize plan"),
953 )
954 .expect("write plan");
955 fs::write(
956 &status_path,
957 serde_json::to_vec(&status).expect("serialize status"),
958 )
959 .expect("write status");
960
961 let err = run([
962 OsString::from("apply"),
963 OsString::from("--plan"),
964 OsString::from(plan_path.as_os_str()),
965 OsString::from("--status"),
966 OsString::from(status_path.as_os_str()),
967 OsString::from("--dry-run"),
968 OsString::from("--out"),
969 OsString::from(out_path.as_os_str()),
970 ])
971 .expect_err("mismatched status should fail");
972
973 assert!(!out_path.exists());
974 fs::remove_dir_all(root).expect("remove temp root");
975 assert!(matches!(
976 err,
977 RestoreCommandError::RestoreApplyDryRun(RestoreApplyDryRunError::StatusPlanMismatch {
978 field: "backup_id",
979 ..
980 })
981 ));
982 }
983
984 fn valid_manifest() -> FleetBackupManifest {
986 FleetBackupManifest {
987 manifest_version: 1,
988 backup_id: "backup-test".to_string(),
989 created_at: "2026-05-03T00:00:00Z".to_string(),
990 tool: ToolMetadata {
991 name: "canic".to_string(),
992 version: "0.30.1".to_string(),
993 },
994 source: SourceMetadata {
995 environment: "local".to_string(),
996 root_canister: ROOT.to_string(),
997 },
998 consistency: ConsistencySection {
999 mode: ConsistencyMode::CrashConsistent,
1000 backup_units: vec![BackupUnit {
1001 unit_id: "fleet".to_string(),
1002 kind: BackupUnitKind::SubtreeRooted,
1003 roles: vec!["root".to_string(), "app".to_string()],
1004 consistency_reason: None,
1005 dependency_closure: Vec::new(),
1006 topology_validation: "subtree-closed".to_string(),
1007 quiescence_strategy: None,
1008 }],
1009 },
1010 fleet: FleetSection {
1011 topology_hash_algorithm: "sha256".to_string(),
1012 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
1013 discovery_topology_hash: HASH.to_string(),
1014 pre_snapshot_topology_hash: HASH.to_string(),
1015 topology_hash: HASH.to_string(),
1016 members: vec![
1017 fleet_member("root", ROOT, None, IdentityMode::Fixed),
1018 fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
1019 ],
1020 },
1021 verification: VerificationPlan::default(),
1022 }
1023 }
1024
1025 fn restore_ready_manifest() -> FleetBackupManifest {
1027 let mut manifest = valid_manifest();
1028 for member in &mut manifest.fleet.members {
1029 member.source_snapshot.module_hash = Some(HASH.to_string());
1030 member.source_snapshot.wasm_hash = Some(HASH.to_string());
1031 member.source_snapshot.checksum = Some(HASH.to_string());
1032 }
1033 manifest
1034 }
1035
1036 fn fleet_member(
1038 role: &str,
1039 canister_id: &str,
1040 parent_canister_id: Option<&str>,
1041 identity_mode: IdentityMode,
1042 ) -> FleetMember {
1043 FleetMember {
1044 role: role.to_string(),
1045 canister_id: canister_id.to_string(),
1046 parent_canister_id: parent_canister_id.map(str::to_string),
1047 subnet_canister_id: Some(ROOT.to_string()),
1048 controller_hint: None,
1049 identity_mode,
1050 restore_group: 1,
1051 verification_class: "basic".to_string(),
1052 verification_checks: vec![VerificationCheck {
1053 kind: "status".to_string(),
1054 method: None,
1055 roles: vec![role.to_string()],
1056 }],
1057 source_snapshot: SourceSnapshot {
1058 snapshot_id: format!("{role}-snapshot"),
1059 module_hash: None,
1060 wasm_hash: None,
1061 code_version: Some("v0.30.1".to_string()),
1062 artifact_path: format!("artifacts/{role}"),
1063 checksum_algorithm: "sha256".to_string(),
1064 checksum: None,
1065 },
1066 }
1067 }
1068
1069 fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
1071 layout.write_manifest(manifest).expect("write manifest");
1072
1073 let artifacts = manifest
1074 .fleet
1075 .members
1076 .iter()
1077 .map(|member| {
1078 let bytes = format!("{} artifact", member.role);
1079 let artifact_path = root.join(&member.source_snapshot.artifact_path);
1080 if let Some(parent) = artifact_path.parent() {
1081 fs::create_dir_all(parent).expect("create artifact parent");
1082 }
1083 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
1084 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
1085
1086 ArtifactJournalEntry {
1087 canister_id: member.canister_id.clone(),
1088 snapshot_id: member.source_snapshot.snapshot_id.clone(),
1089 state: ArtifactState::Durable,
1090 temp_path: None,
1091 artifact_path: member.source_snapshot.artifact_path.clone(),
1092 checksum_algorithm: checksum.algorithm,
1093 checksum: Some(checksum.hash),
1094 updated_at: "2026-05-03T00:00:00Z".to_string(),
1095 }
1096 })
1097 .collect();
1098
1099 layout
1100 .write_journal(&DownloadJournal {
1101 journal_version: 1,
1102 backup_id: manifest.backup_id.clone(),
1103 discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
1104 pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
1105 operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
1106 artifacts,
1107 })
1108 .expect("write journal");
1109 }
1110
1111 fn write_manifest_artifacts(root: &Path, manifest: &mut FleetBackupManifest) {
1113 for member in &mut manifest.fleet.members {
1114 let bytes = format!("{} apply artifact", member.role);
1115 let artifact_path = root.join(&member.source_snapshot.artifact_path);
1116 if let Some(parent) = artifact_path.parent() {
1117 fs::create_dir_all(parent).expect("create artifact parent");
1118 }
1119 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
1120 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
1121 member.source_snapshot.checksum = Some(checksum.hash);
1122 }
1123 }
1124
1125 fn temp_dir(prefix: &str) -> PathBuf {
1127 let nanos = SystemTime::now()
1128 .duration_since(UNIX_EPOCH)
1129 .expect("system time after epoch")
1130 .as_nanos();
1131 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
1132 }
1133}