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 out: Option<PathBuf>,
187 pub dry_run: bool,
188}
189
190impl RestoreApplyOptions {
191 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
193 where
194 I: IntoIterator<Item = OsString>,
195 {
196 let mut plan = None;
197 let mut status = None;
198 let mut out = None;
199 let mut dry_run = false;
200
201 let mut args = args.into_iter();
202 while let Some(arg) = args.next() {
203 let arg = arg
204 .into_string()
205 .map_err(|_| RestoreCommandError::Usage(usage()))?;
206 match arg.as_str() {
207 "--plan" => plan = Some(PathBuf::from(next_value(&mut args, "--plan")?)),
208 "--status" => status = Some(PathBuf::from(next_value(&mut args, "--status")?)),
209 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
210 "--dry-run" => dry_run = true,
211 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
212 _ => return Err(RestoreCommandError::UnknownOption(arg)),
213 }
214 }
215
216 if !dry_run {
217 return Err(RestoreCommandError::ApplyRequiresDryRun);
218 }
219
220 Ok(Self {
221 plan: plan.ok_or(RestoreCommandError::MissingOption("--plan"))?,
222 status,
223 out,
224 dry_run,
225 })
226 }
227}
228
229pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
231where
232 I: IntoIterator<Item = OsString>,
233{
234 let mut args = args.into_iter();
235 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
236 return Err(RestoreCommandError::Usage(usage()));
237 };
238
239 match command.as_str() {
240 "plan" => {
241 let options = RestorePlanOptions::parse(args)?;
242 let plan = plan_restore(&options)?;
243 write_plan(&options, &plan)?;
244 enforce_restore_plan_requirements(&options, &plan)?;
245 Ok(())
246 }
247 "status" => {
248 let options = RestoreStatusOptions::parse(args)?;
249 let status = restore_status(&options)?;
250 write_status(&options, &status)?;
251 Ok(())
252 }
253 "apply" => {
254 let options = RestoreApplyOptions::parse(args)?;
255 let dry_run = restore_apply_dry_run(&options)?;
256 write_apply_dry_run(&options, &dry_run)?;
257 Ok(())
258 }
259 "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
260 _ => Err(RestoreCommandError::UnknownOption(command)),
261 }
262}
263
264pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
266 verify_backup_layout_if_required(options)?;
267
268 let manifest = read_manifest_source(options)?;
269 let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
270
271 RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
272}
273
274pub fn restore_status(
276 options: &RestoreStatusOptions,
277) -> Result<RestoreStatus, RestoreCommandError> {
278 let plan = read_plan(&options.plan)?;
279 Ok(RestoreStatus::from_plan(&plan))
280}
281
282pub fn restore_apply_dry_run(
284 options: &RestoreApplyOptions,
285) -> Result<RestoreApplyDryRun, RestoreCommandError> {
286 let plan = read_plan(&options.plan)?;
287 let status = options.status.as_ref().map(read_status).transpose()?;
288 RestoreApplyDryRun::try_from_plan(&plan, status.as_ref()).map_err(RestoreCommandError::from)
289}
290
291fn enforce_restore_plan_requirements(
293 options: &RestorePlanOptions,
294 plan: &RestorePlan,
295) -> Result<(), RestoreCommandError> {
296 if !options.require_restore_ready || plan.readiness_summary.ready {
297 return Ok(());
298 }
299
300 Err(RestoreCommandError::RestoreNotReady {
301 backup_id: plan.backup_id.clone(),
302 reasons: plan.readiness_summary.reasons.clone(),
303 })
304}
305
306fn verify_backup_layout_if_required(
308 options: &RestorePlanOptions,
309) -> Result<(), RestoreCommandError> {
310 if !options.require_verified {
311 return Ok(());
312 }
313
314 let Some(dir) = &options.backup_dir else {
315 return Err(RestoreCommandError::RequireVerifiedNeedsBackupDir);
316 };
317
318 BackupLayout::new(dir.clone()).verify_integrity()?;
319 Ok(())
320}
321
322fn read_manifest_source(
324 options: &RestorePlanOptions,
325) -> Result<FleetBackupManifest, RestoreCommandError> {
326 if let Some(path) = &options.manifest {
327 return read_manifest(path);
328 }
329
330 let Some(dir) = &options.backup_dir else {
331 return Err(RestoreCommandError::MissingOption(
332 "--manifest or --backup-dir",
333 ));
334 };
335
336 BackupLayout::new(dir.clone())
337 .read_manifest()
338 .map_err(RestoreCommandError::from)
339}
340
341fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
343 let data = fs::read_to_string(path)?;
344 serde_json::from_str(&data).map_err(RestoreCommandError::from)
345}
346
347fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
349 let data = fs::read_to_string(path)?;
350 serde_json::from_str(&data).map_err(RestoreCommandError::from)
351}
352
353fn read_plan(path: &PathBuf) -> Result<RestorePlan, RestoreCommandError> {
355 let data = fs::read_to_string(path)?;
356 serde_json::from_str(&data).map_err(RestoreCommandError::from)
357}
358
359fn read_status(path: &PathBuf) -> Result<RestoreStatus, RestoreCommandError> {
361 let data = fs::read_to_string(path)?;
362 serde_json::from_str(&data).map_err(RestoreCommandError::from)
363}
364
365fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
367 if let Some(path) = &options.out {
368 let data = serde_json::to_vec_pretty(plan)?;
369 fs::write(path, data)?;
370 return Ok(());
371 }
372
373 let stdout = io::stdout();
374 let mut handle = stdout.lock();
375 serde_json::to_writer_pretty(&mut handle, plan)?;
376 writeln!(handle)?;
377 Ok(())
378}
379
380fn write_status(
382 options: &RestoreStatusOptions,
383 status: &RestoreStatus,
384) -> Result<(), RestoreCommandError> {
385 if let Some(path) = &options.out {
386 let data = serde_json::to_vec_pretty(status)?;
387 fs::write(path, data)?;
388 return Ok(());
389 }
390
391 let stdout = io::stdout();
392 let mut handle = stdout.lock();
393 serde_json::to_writer_pretty(&mut handle, status)?;
394 writeln!(handle)?;
395 Ok(())
396}
397
398fn write_apply_dry_run(
400 options: &RestoreApplyOptions,
401 dry_run: &RestoreApplyDryRun,
402) -> Result<(), RestoreCommandError> {
403 if let Some(path) = &options.out {
404 let data = serde_json::to_vec_pretty(dry_run)?;
405 fs::write(path, data)?;
406 return Ok(());
407 }
408
409 let stdout = io::stdout();
410 let mut handle = stdout.lock();
411 serde_json::to_writer_pretty(&mut handle, dry_run)?;
412 writeln!(handle)?;
413 Ok(())
414}
415
416fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
418where
419 I: Iterator<Item = OsString>,
420{
421 args.next()
422 .and_then(|value| value.into_string().ok())
423 .ok_or(RestoreCommandError::MissingValue(option))
424}
425
426const fn usage() -> &'static str {
428 "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>] --dry-run [--out <file>]"
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434 use canic_backup::{
435 artifacts::ArtifactChecksum,
436 journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
437 manifest::{
438 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember,
439 FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
440 VerificationCheck, VerificationPlan,
441 },
442 };
443 use serde_json::json;
444 use std::{
445 path::Path,
446 time::{SystemTime, UNIX_EPOCH},
447 };
448
449 const ROOT: &str = "aaaaa-aa";
450 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
451 const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
452 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
453
454 #[test]
456 fn parses_restore_plan_options() {
457 let options = RestorePlanOptions::parse([
458 OsString::from("--manifest"),
459 OsString::from("manifest.json"),
460 OsString::from("--mapping"),
461 OsString::from("mapping.json"),
462 OsString::from("--out"),
463 OsString::from("plan.json"),
464 OsString::from("--require-restore-ready"),
465 ])
466 .expect("parse options");
467
468 assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
469 assert_eq!(options.backup_dir, None);
470 assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
471 assert_eq!(options.out, Some(PathBuf::from("plan.json")));
472 assert!(!options.require_verified);
473 assert!(options.require_restore_ready);
474 }
475
476 #[test]
478 fn parses_verified_restore_plan_options() {
479 let options = RestorePlanOptions::parse([
480 OsString::from("--backup-dir"),
481 OsString::from("backups/run"),
482 OsString::from("--require-verified"),
483 ])
484 .expect("parse verified options");
485
486 assert_eq!(options.manifest, None);
487 assert_eq!(options.backup_dir, Some(PathBuf::from("backups/run")));
488 assert_eq!(options.mapping, None);
489 assert_eq!(options.out, None);
490 assert!(options.require_verified);
491 assert!(!options.require_restore_ready);
492 }
493
494 #[test]
496 fn parses_restore_status_options() {
497 let options = RestoreStatusOptions::parse([
498 OsString::from("--plan"),
499 OsString::from("restore-plan.json"),
500 OsString::from("--out"),
501 OsString::from("restore-status.json"),
502 ])
503 .expect("parse status options");
504
505 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
506 assert_eq!(options.out, Some(PathBuf::from("restore-status.json")));
507 }
508
509 #[test]
511 fn parses_restore_apply_dry_run_options() {
512 let options = RestoreApplyOptions::parse([
513 OsString::from("--plan"),
514 OsString::from("restore-plan.json"),
515 OsString::from("--status"),
516 OsString::from("restore-status.json"),
517 OsString::from("--dry-run"),
518 OsString::from("--out"),
519 OsString::from("restore-apply-dry-run.json"),
520 ])
521 .expect("parse apply options");
522
523 assert_eq!(options.plan, PathBuf::from("restore-plan.json"));
524 assert_eq!(options.status, Some(PathBuf::from("restore-status.json")));
525 assert_eq!(
526 options.out,
527 Some(PathBuf::from("restore-apply-dry-run.json"))
528 );
529 assert!(options.dry_run);
530 }
531
532 #[test]
534 fn restore_apply_requires_dry_run() {
535 let err = RestoreApplyOptions::parse([
536 OsString::from("--plan"),
537 OsString::from("restore-plan.json"),
538 ])
539 .expect_err("apply without dry-run should fail");
540
541 assert!(matches!(err, RestoreCommandError::ApplyRequiresDryRun));
542 }
543
544 #[test]
546 fn plan_restore_reads_manifest_from_backup_dir() {
547 let root = temp_dir("canic-cli-restore-plan-layout");
548 let layout = BackupLayout::new(root.clone());
549 layout
550 .write_manifest(&valid_manifest())
551 .expect("write manifest");
552
553 let options = RestorePlanOptions {
554 manifest: None,
555 backup_dir: Some(root.clone()),
556 mapping: None,
557 out: None,
558 require_verified: false,
559 require_restore_ready: false,
560 };
561
562 let plan = plan_restore(&options).expect("plan restore");
563
564 fs::remove_dir_all(root).expect("remove temp root");
565 assert_eq!(plan.backup_id, "backup-test");
566 assert_eq!(plan.member_count, 2);
567 }
568
569 #[test]
571 fn parse_rejects_conflicting_manifest_sources() {
572 let err = RestorePlanOptions::parse([
573 OsString::from("--manifest"),
574 OsString::from("manifest.json"),
575 OsString::from("--backup-dir"),
576 OsString::from("backups/run"),
577 ])
578 .expect_err("conflicting sources should fail");
579
580 assert!(matches!(
581 err,
582 RestoreCommandError::ConflictingManifestSources
583 ));
584 }
585
586 #[test]
588 fn parse_rejects_require_verified_with_manifest_source() {
589 let err = RestorePlanOptions::parse([
590 OsString::from("--manifest"),
591 OsString::from("manifest.json"),
592 OsString::from("--require-verified"),
593 ])
594 .expect_err("verification should require a backup layout");
595
596 assert!(matches!(
597 err,
598 RestoreCommandError::RequireVerifiedNeedsBackupDir
599 ));
600 }
601
602 #[test]
604 fn plan_restore_requires_verified_backup_layout() {
605 let root = temp_dir("canic-cli-restore-plan-verified");
606 let layout = BackupLayout::new(root.clone());
607 let manifest = valid_manifest();
608 write_verified_layout(&root, &layout, &manifest);
609
610 let options = RestorePlanOptions {
611 manifest: None,
612 backup_dir: Some(root.clone()),
613 mapping: None,
614 out: None,
615 require_verified: true,
616 require_restore_ready: false,
617 };
618
619 let plan = plan_restore(&options).expect("plan verified restore");
620
621 fs::remove_dir_all(root).expect("remove temp root");
622 assert_eq!(plan.backup_id, "backup-test");
623 assert_eq!(plan.member_count, 2);
624 }
625
626 #[test]
628 fn plan_restore_rejects_unverified_backup_layout() {
629 let root = temp_dir("canic-cli-restore-plan-unverified");
630 let layout = BackupLayout::new(root.clone());
631 layout
632 .write_manifest(&valid_manifest())
633 .expect("write manifest");
634
635 let options = RestorePlanOptions {
636 manifest: None,
637 backup_dir: Some(root.clone()),
638 mapping: None,
639 out: None,
640 require_verified: true,
641 require_restore_ready: false,
642 };
643
644 let err = plan_restore(&options).expect_err("missing journal should fail");
645
646 fs::remove_dir_all(root).expect("remove temp root");
647 assert!(matches!(err, RestoreCommandError::Persistence(_)));
648 }
649
650 #[test]
652 fn plan_restore_reads_manifest_and_mapping() {
653 let root = temp_dir("canic-cli-restore-plan");
654 fs::create_dir_all(&root).expect("create temp root");
655 let manifest_path = root.join("manifest.json");
656 let mapping_path = root.join("mapping.json");
657
658 fs::write(
659 &manifest_path,
660 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
661 )
662 .expect("write manifest");
663 fs::write(
664 &mapping_path,
665 json!({
666 "members": [
667 {"source_canister": ROOT, "target_canister": ROOT},
668 {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
669 ]
670 })
671 .to_string(),
672 )
673 .expect("write mapping");
674
675 let options = RestorePlanOptions {
676 manifest: Some(manifest_path),
677 backup_dir: None,
678 mapping: Some(mapping_path),
679 out: None,
680 require_verified: false,
681 require_restore_ready: false,
682 };
683
684 let plan = plan_restore(&options).expect("plan restore");
685
686 fs::remove_dir_all(root).expect("remove temp root");
687 let members = plan.ordered_members();
688 assert_eq!(members.len(), 2);
689 assert_eq!(members[0].source_canister, ROOT);
690 assert_eq!(members[1].target_canister, MAPPED_CHILD);
691 }
692
693 #[test]
695 fn run_restore_plan_require_restore_ready_writes_plan_then_fails() {
696 let root = temp_dir("canic-cli-restore-plan-require-ready");
697 fs::create_dir_all(&root).expect("create temp root");
698 let manifest_path = root.join("manifest.json");
699 let out_path = root.join("plan.json");
700
701 fs::write(
702 &manifest_path,
703 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
704 )
705 .expect("write manifest");
706
707 let err = run([
708 OsString::from("plan"),
709 OsString::from("--manifest"),
710 OsString::from(manifest_path.as_os_str()),
711 OsString::from("--out"),
712 OsString::from(out_path.as_os_str()),
713 OsString::from("--require-restore-ready"),
714 ])
715 .expect_err("restore readiness should be enforced");
716
717 assert!(out_path.exists());
718 let plan: RestorePlan =
719 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
720
721 fs::remove_dir_all(root).expect("remove temp root");
722 assert!(!plan.readiness_summary.ready);
723 assert!(matches!(
724 err,
725 RestoreCommandError::RestoreNotReady {
726 reasons,
727 ..
728 } if reasons == [
729 "missing-module-hash",
730 "missing-wasm-hash",
731 "missing-snapshot-checksum"
732 ]
733 ));
734 }
735
736 #[test]
738 fn run_restore_plan_require_restore_ready_accepts_ready_plan() {
739 let root = temp_dir("canic-cli-restore-plan-ready");
740 fs::create_dir_all(&root).expect("create temp root");
741 let manifest_path = root.join("manifest.json");
742 let out_path = root.join("plan.json");
743
744 fs::write(
745 &manifest_path,
746 serde_json::to_vec(&restore_ready_manifest()).expect("serialize manifest"),
747 )
748 .expect("write manifest");
749
750 run([
751 OsString::from("plan"),
752 OsString::from("--manifest"),
753 OsString::from(manifest_path.as_os_str()),
754 OsString::from("--out"),
755 OsString::from(out_path.as_os_str()),
756 OsString::from("--require-restore-ready"),
757 ])
758 .expect("restore-ready plan should pass");
759
760 let plan: RestorePlan =
761 serde_json::from_slice(&fs::read(&out_path).expect("read plan")).expect("decode plan");
762
763 fs::remove_dir_all(root).expect("remove temp root");
764 assert!(plan.readiness_summary.ready);
765 assert!(plan.readiness_summary.reasons.is_empty());
766 }
767
768 #[test]
770 fn run_restore_status_writes_planned_status() {
771 let root = temp_dir("canic-cli-restore-status");
772 fs::create_dir_all(&root).expect("create temp root");
773 let plan_path = root.join("restore-plan.json");
774 let out_path = root.join("restore-status.json");
775 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
776
777 fs::write(
778 &plan_path,
779 serde_json::to_vec(&plan).expect("serialize plan"),
780 )
781 .expect("write plan");
782
783 run([
784 OsString::from("status"),
785 OsString::from("--plan"),
786 OsString::from(plan_path.as_os_str()),
787 OsString::from("--out"),
788 OsString::from(out_path.as_os_str()),
789 ])
790 .expect("write restore status");
791
792 let status: RestoreStatus =
793 serde_json::from_slice(&fs::read(&out_path).expect("read restore status"))
794 .expect("decode restore status");
795 let status_json: serde_json::Value = serde_json::to_value(&status).expect("encode status");
796
797 fs::remove_dir_all(root).expect("remove temp root");
798 assert_eq!(status.status_version, 1);
799 assert_eq!(status.backup_id.as_str(), "backup-test");
800 assert!(status.ready);
801 assert!(status.readiness_reasons.is_empty());
802 assert_eq!(status.member_count, 2);
803 assert_eq!(status.phase_count, 1);
804 assert_eq!(status.planned_snapshot_loads, 2);
805 assert_eq!(status.planned_code_reinstalls, 2);
806 assert_eq!(status.planned_verification_checks, 2);
807 assert_eq!(status.phases[0].members[0].source_canister, ROOT);
808 assert_eq!(status_json["phases"][0]["members"][0]["state"], "planned");
809 }
810
811 #[test]
813 fn run_restore_apply_dry_run_writes_operations() {
814 let root = temp_dir("canic-cli-restore-apply-dry-run");
815 fs::create_dir_all(&root).expect("create temp root");
816 let plan_path = root.join("restore-plan.json");
817 let status_path = root.join("restore-status.json");
818 let out_path = root.join("restore-apply-dry-run.json");
819 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
820 let status = RestoreStatus::from_plan(&plan);
821
822 fs::write(
823 &plan_path,
824 serde_json::to_vec(&plan).expect("serialize plan"),
825 )
826 .expect("write plan");
827 fs::write(
828 &status_path,
829 serde_json::to_vec(&status).expect("serialize status"),
830 )
831 .expect("write status");
832
833 run([
834 OsString::from("apply"),
835 OsString::from("--plan"),
836 OsString::from(plan_path.as_os_str()),
837 OsString::from("--status"),
838 OsString::from(status_path.as_os_str()),
839 OsString::from("--dry-run"),
840 OsString::from("--out"),
841 OsString::from(out_path.as_os_str()),
842 ])
843 .expect("write apply dry-run");
844
845 let dry_run: RestoreApplyDryRun =
846 serde_json::from_slice(&fs::read(&out_path).expect("read dry-run"))
847 .expect("decode dry-run");
848 let dry_run_json: serde_json::Value =
849 serde_json::to_value(&dry_run).expect("encode dry-run");
850
851 fs::remove_dir_all(root).expect("remove temp root");
852 assert_eq!(dry_run.dry_run_version, 1);
853 assert_eq!(dry_run.backup_id.as_str(), "backup-test");
854 assert!(dry_run.ready);
855 assert!(dry_run.status_supplied);
856 assert_eq!(dry_run.member_count, 2);
857 assert_eq!(dry_run.phase_count, 1);
858 assert_eq!(dry_run.rendered_operations, 8);
859 assert_eq!(
860 dry_run_json["phases"][0]["operations"][0]["operation"],
861 "upload-snapshot"
862 );
863 assert_eq!(
864 dry_run_json["phases"][0]["operations"][3]["operation"],
865 "verify-member"
866 );
867 assert_eq!(
868 dry_run_json["phases"][0]["operations"][3]["verification_kind"],
869 "status"
870 );
871 assert_eq!(
872 dry_run_json["phases"][0]["operations"][3]["verification_method"],
873 serde_json::Value::Null
874 );
875 }
876
877 #[test]
879 fn run_restore_apply_dry_run_rejects_mismatched_status() {
880 let root = temp_dir("canic-cli-restore-apply-dry-run-mismatch");
881 fs::create_dir_all(&root).expect("create temp root");
882 let plan_path = root.join("restore-plan.json");
883 let status_path = root.join("restore-status.json");
884 let out_path = root.join("restore-apply-dry-run.json");
885 let plan = RestorePlanner::plan(&restore_ready_manifest(), None).expect("build plan");
886 let mut status = RestoreStatus::from_plan(&plan);
887 status.backup_id = "other-backup".to_string();
888
889 fs::write(
890 &plan_path,
891 serde_json::to_vec(&plan).expect("serialize plan"),
892 )
893 .expect("write plan");
894 fs::write(
895 &status_path,
896 serde_json::to_vec(&status).expect("serialize status"),
897 )
898 .expect("write status");
899
900 let err = run([
901 OsString::from("apply"),
902 OsString::from("--plan"),
903 OsString::from(plan_path.as_os_str()),
904 OsString::from("--status"),
905 OsString::from(status_path.as_os_str()),
906 OsString::from("--dry-run"),
907 OsString::from("--out"),
908 OsString::from(out_path.as_os_str()),
909 ])
910 .expect_err("mismatched status should fail");
911
912 assert!(!out_path.exists());
913 fs::remove_dir_all(root).expect("remove temp root");
914 assert!(matches!(
915 err,
916 RestoreCommandError::RestoreApplyDryRun(RestoreApplyDryRunError::StatusPlanMismatch {
917 field: "backup_id",
918 ..
919 })
920 ));
921 }
922
923 fn valid_manifest() -> FleetBackupManifest {
925 FleetBackupManifest {
926 manifest_version: 1,
927 backup_id: "backup-test".to_string(),
928 created_at: "2026-05-03T00:00:00Z".to_string(),
929 tool: ToolMetadata {
930 name: "canic".to_string(),
931 version: "0.30.1".to_string(),
932 },
933 source: SourceMetadata {
934 environment: "local".to_string(),
935 root_canister: ROOT.to_string(),
936 },
937 consistency: ConsistencySection {
938 mode: ConsistencyMode::CrashConsistent,
939 backup_units: vec![BackupUnit {
940 unit_id: "fleet".to_string(),
941 kind: BackupUnitKind::SubtreeRooted,
942 roles: vec!["root".to_string(), "app".to_string()],
943 consistency_reason: None,
944 dependency_closure: Vec::new(),
945 topology_validation: "subtree-closed".to_string(),
946 quiescence_strategy: None,
947 }],
948 },
949 fleet: FleetSection {
950 topology_hash_algorithm: "sha256".to_string(),
951 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
952 discovery_topology_hash: HASH.to_string(),
953 pre_snapshot_topology_hash: HASH.to_string(),
954 topology_hash: HASH.to_string(),
955 members: vec![
956 fleet_member("root", ROOT, None, IdentityMode::Fixed),
957 fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
958 ],
959 },
960 verification: VerificationPlan::default(),
961 }
962 }
963
964 fn restore_ready_manifest() -> FleetBackupManifest {
966 let mut manifest = valid_manifest();
967 for member in &mut manifest.fleet.members {
968 member.source_snapshot.module_hash = Some(HASH.to_string());
969 member.source_snapshot.wasm_hash = Some(HASH.to_string());
970 member.source_snapshot.checksum = Some(HASH.to_string());
971 }
972 manifest
973 }
974
975 fn fleet_member(
977 role: &str,
978 canister_id: &str,
979 parent_canister_id: Option<&str>,
980 identity_mode: IdentityMode,
981 ) -> FleetMember {
982 FleetMember {
983 role: role.to_string(),
984 canister_id: canister_id.to_string(),
985 parent_canister_id: parent_canister_id.map(str::to_string),
986 subnet_canister_id: Some(ROOT.to_string()),
987 controller_hint: None,
988 identity_mode,
989 restore_group: 1,
990 verification_class: "basic".to_string(),
991 verification_checks: vec![VerificationCheck {
992 kind: "status".to_string(),
993 method: None,
994 roles: vec![role.to_string()],
995 }],
996 source_snapshot: SourceSnapshot {
997 snapshot_id: format!("{role}-snapshot"),
998 module_hash: None,
999 wasm_hash: None,
1000 code_version: Some("v0.30.1".to_string()),
1001 artifact_path: format!("artifacts/{role}"),
1002 checksum_algorithm: "sha256".to_string(),
1003 checksum: None,
1004 },
1005 }
1006 }
1007
1008 fn write_verified_layout(root: &Path, layout: &BackupLayout, manifest: &FleetBackupManifest) {
1010 layout.write_manifest(manifest).expect("write manifest");
1011
1012 let artifacts = manifest
1013 .fleet
1014 .members
1015 .iter()
1016 .map(|member| {
1017 let bytes = format!("{} artifact", member.role);
1018 let artifact_path = root.join(&member.source_snapshot.artifact_path);
1019 if let Some(parent) = artifact_path.parent() {
1020 fs::create_dir_all(parent).expect("create artifact parent");
1021 }
1022 fs::write(&artifact_path, bytes.as_bytes()).expect("write artifact");
1023 let checksum = ArtifactChecksum::from_bytes(bytes.as_bytes());
1024
1025 ArtifactJournalEntry {
1026 canister_id: member.canister_id.clone(),
1027 snapshot_id: member.source_snapshot.snapshot_id.clone(),
1028 state: ArtifactState::Durable,
1029 temp_path: None,
1030 artifact_path: member.source_snapshot.artifact_path.clone(),
1031 checksum_algorithm: checksum.algorithm,
1032 checksum: Some(checksum.hash),
1033 updated_at: "2026-05-03T00:00:00Z".to_string(),
1034 }
1035 })
1036 .collect();
1037
1038 layout
1039 .write_journal(&DownloadJournal {
1040 journal_version: 1,
1041 backup_id: manifest.backup_id.clone(),
1042 discovery_topology_hash: Some(manifest.fleet.discovery_topology_hash.clone()),
1043 pre_snapshot_topology_hash: Some(manifest.fleet.pre_snapshot_topology_hash.clone()),
1044 operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
1045 artifacts,
1046 })
1047 .expect("write journal");
1048 }
1049
1050 fn temp_dir(prefix: &str) -> PathBuf {
1052 let nanos = SystemTime::now()
1053 .duration_since(UNIX_EPOCH)
1054 .expect("system time after epoch")
1055 .as_nanos();
1056 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
1057 }
1058}