1use canic_backup::{
2 manifest::FleetBackupManifest,
3 persistence::{BackupLayout, PersistenceError},
4 restore::{RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner},
5};
6use std::{
7 ffi::OsString,
8 fs,
9 io::{self, Write},
10 path::PathBuf,
11};
12use thiserror::Error as ThisError;
13
14#[derive(Debug, ThisError)]
19pub enum RestoreCommandError {
20 #[error("{0}")]
21 Usage(&'static str),
22
23 #[error("missing required option {0}")]
24 MissingOption(&'static str),
25
26 #[error("use either --manifest or --backup-dir, not both")]
27 ConflictingManifestSources,
28
29 #[error("unknown option {0}")]
30 UnknownOption(String),
31
32 #[error("option {0} requires a value")]
33 MissingValue(&'static str),
34
35 #[error(transparent)]
36 Io(#[from] std::io::Error),
37
38 #[error(transparent)]
39 Json(#[from] serde_json::Error),
40
41 #[error(transparent)]
42 Persistence(#[from] PersistenceError),
43
44 #[error(transparent)]
45 RestorePlan(#[from] RestorePlanError),
46}
47
48#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct RestorePlanOptions {
54 pub manifest: Option<PathBuf>,
55 pub backup_dir: Option<PathBuf>,
56 pub mapping: Option<PathBuf>,
57 pub out: Option<PathBuf>,
58}
59
60impl RestorePlanOptions {
61 pub fn parse<I>(args: I) -> Result<Self, RestoreCommandError>
63 where
64 I: IntoIterator<Item = OsString>,
65 {
66 let mut manifest = None;
67 let mut backup_dir = None;
68 let mut mapping = None;
69 let mut out = None;
70
71 let mut args = args.into_iter();
72 while let Some(arg) = args.next() {
73 let arg = arg
74 .into_string()
75 .map_err(|_| RestoreCommandError::Usage(usage()))?;
76 match arg.as_str() {
77 "--manifest" => {
78 manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
79 }
80 "--backup-dir" => {
81 backup_dir = Some(PathBuf::from(next_value(&mut args, "--backup-dir")?));
82 }
83 "--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
84 "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
85 "--help" | "-h" => return Err(RestoreCommandError::Usage(usage())),
86 _ => return Err(RestoreCommandError::UnknownOption(arg)),
87 }
88 }
89
90 if manifest.is_some() && backup_dir.is_some() {
91 return Err(RestoreCommandError::ConflictingManifestSources);
92 }
93
94 if manifest.is_none() && backup_dir.is_none() {
95 return Err(RestoreCommandError::MissingOption(
96 "--manifest or --backup-dir",
97 ));
98 }
99
100 Ok(Self {
101 manifest,
102 backup_dir,
103 mapping,
104 out,
105 })
106 }
107}
108
109pub fn run<I>(args: I) -> Result<(), RestoreCommandError>
111where
112 I: IntoIterator<Item = OsString>,
113{
114 let mut args = args.into_iter();
115 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
116 return Err(RestoreCommandError::Usage(usage()));
117 };
118
119 match command.as_str() {
120 "plan" => {
121 let options = RestorePlanOptions::parse(args)?;
122 let plan = plan_restore(&options)?;
123 write_plan(&options, &plan)?;
124 Ok(())
125 }
126 "help" | "--help" | "-h" => Err(RestoreCommandError::Usage(usage())),
127 _ => Err(RestoreCommandError::UnknownOption(command)),
128 }
129}
130
131pub fn plan_restore(options: &RestorePlanOptions) -> Result<RestorePlan, RestoreCommandError> {
133 let manifest = read_manifest_source(options)?;
134 let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
135
136 RestorePlanner::plan(&manifest, mapping.as_ref()).map_err(RestoreCommandError::from)
137}
138
139fn read_manifest_source(
141 options: &RestorePlanOptions,
142) -> Result<FleetBackupManifest, RestoreCommandError> {
143 if let Some(path) = &options.manifest {
144 return read_manifest(path);
145 }
146
147 let Some(dir) = &options.backup_dir else {
148 return Err(RestoreCommandError::MissingOption(
149 "--manifest or --backup-dir",
150 ));
151 };
152
153 BackupLayout::new(dir.clone())
154 .read_manifest()
155 .map_err(RestoreCommandError::from)
156}
157
158fn read_manifest(path: &PathBuf) -> Result<FleetBackupManifest, RestoreCommandError> {
160 let data = fs::read_to_string(path)?;
161 serde_json::from_str(&data).map_err(RestoreCommandError::from)
162}
163
164fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, RestoreCommandError> {
166 let data = fs::read_to_string(path)?;
167 serde_json::from_str(&data).map_err(RestoreCommandError::from)
168}
169
170fn write_plan(options: &RestorePlanOptions, plan: &RestorePlan) -> Result<(), RestoreCommandError> {
172 if let Some(path) = &options.out {
173 let data = serde_json::to_vec_pretty(plan)?;
174 fs::write(path, data)?;
175 return Ok(());
176 }
177
178 let stdout = io::stdout();
179 let mut handle = stdout.lock();
180 serde_json::to_writer_pretty(&mut handle, plan)?;
181 writeln!(handle)?;
182 Ok(())
183}
184
185fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, RestoreCommandError>
187where
188 I: Iterator<Item = OsString>,
189{
190 args.next()
191 .and_then(|value| value.into_string().ok())
192 .ok_or(RestoreCommandError::MissingValue(option))
193}
194
195const fn usage() -> &'static str {
197 "usage: canic restore plan (--manifest <file> | --backup-dir <dir>) [--mapping <file>] [--out <file>]"
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use canic_backup::manifest::{
204 BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember, FleetSection,
205 IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
206 VerificationPlan,
207 };
208 use serde_json::json;
209 use std::time::{SystemTime, UNIX_EPOCH};
210
211 const ROOT: &str = "aaaaa-aa";
212 const CHILD: &str = "renrk-eyaaa-aaaaa-aaada-cai";
213 const MAPPED_CHILD: &str = "rno2w-sqaaa-aaaaa-aaacq-cai";
214 const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
215
216 #[test]
218 fn parses_restore_plan_options() {
219 let options = RestorePlanOptions::parse([
220 OsString::from("--manifest"),
221 OsString::from("manifest.json"),
222 OsString::from("--mapping"),
223 OsString::from("mapping.json"),
224 OsString::from("--out"),
225 OsString::from("plan.json"),
226 ])
227 .expect("parse options");
228
229 assert_eq!(options.manifest, Some(PathBuf::from("manifest.json")));
230 assert_eq!(options.backup_dir, None);
231 assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
232 assert_eq!(options.out, Some(PathBuf::from("plan.json")));
233 }
234
235 #[test]
237 fn plan_restore_reads_manifest_from_backup_dir() {
238 let root = temp_dir("canic-cli-restore-plan-layout");
239 let layout = BackupLayout::new(root.clone());
240 layout
241 .write_manifest(&valid_manifest())
242 .expect("write manifest");
243
244 let options = RestorePlanOptions {
245 manifest: None,
246 backup_dir: Some(root.clone()),
247 mapping: None,
248 out: None,
249 };
250
251 let plan = plan_restore(&options).expect("plan restore");
252
253 fs::remove_dir_all(root).expect("remove temp root");
254 assert_eq!(plan.backup_id, "backup-test");
255 assert_eq!(plan.member_count, 2);
256 }
257
258 #[test]
260 fn parse_rejects_conflicting_manifest_sources() {
261 let err = RestorePlanOptions::parse([
262 OsString::from("--manifest"),
263 OsString::from("manifest.json"),
264 OsString::from("--backup-dir"),
265 OsString::from("backups/run"),
266 ])
267 .expect_err("conflicting sources should fail");
268
269 assert!(matches!(
270 err,
271 RestoreCommandError::ConflictingManifestSources
272 ));
273 }
274
275 #[test]
277 fn plan_restore_reads_manifest_and_mapping() {
278 let root = temp_dir("canic-cli-restore-plan");
279 fs::create_dir_all(&root).expect("create temp root");
280 let manifest_path = root.join("manifest.json");
281 let mapping_path = root.join("mapping.json");
282
283 fs::write(
284 &manifest_path,
285 serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
286 )
287 .expect("write manifest");
288 fs::write(
289 &mapping_path,
290 json!({
291 "members": [
292 {"source_canister": ROOT, "target_canister": ROOT},
293 {"source_canister": CHILD, "target_canister": MAPPED_CHILD}
294 ]
295 })
296 .to_string(),
297 )
298 .expect("write mapping");
299
300 let options = RestorePlanOptions {
301 manifest: Some(manifest_path),
302 backup_dir: None,
303 mapping: Some(mapping_path),
304 out: None,
305 };
306
307 let plan = plan_restore(&options).expect("plan restore");
308
309 fs::remove_dir_all(root).expect("remove temp root");
310 let members = plan.ordered_members();
311 assert_eq!(members.len(), 2);
312 assert_eq!(members[0].source_canister, ROOT);
313 assert_eq!(members[1].target_canister, MAPPED_CHILD);
314 }
315
316 fn valid_manifest() -> FleetBackupManifest {
318 FleetBackupManifest {
319 manifest_version: 1,
320 backup_id: "backup-test".to_string(),
321 created_at: "2026-05-03T00:00:00Z".to_string(),
322 tool: ToolMetadata {
323 name: "canic".to_string(),
324 version: "0.30.1".to_string(),
325 },
326 source: SourceMetadata {
327 environment: "local".to_string(),
328 root_canister: ROOT.to_string(),
329 },
330 consistency: ConsistencySection {
331 mode: ConsistencyMode::CrashConsistent,
332 backup_units: vec![BackupUnit {
333 unit_id: "fleet".to_string(),
334 kind: BackupUnitKind::SubtreeRooted,
335 roles: vec!["root".to_string(), "app".to_string()],
336 consistency_reason: None,
337 dependency_closure: Vec::new(),
338 topology_validation: "subtree-closed".to_string(),
339 quiescence_strategy: None,
340 }],
341 },
342 fleet: FleetSection {
343 topology_hash_algorithm: "sha256".to_string(),
344 topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
345 discovery_topology_hash: HASH.to_string(),
346 pre_snapshot_topology_hash: HASH.to_string(),
347 topology_hash: HASH.to_string(),
348 members: vec![
349 fleet_member("root", ROOT, None, IdentityMode::Fixed),
350 fleet_member("app", CHILD, Some(ROOT), IdentityMode::Relocatable),
351 ],
352 },
353 verification: VerificationPlan::default(),
354 }
355 }
356
357 fn fleet_member(
359 role: &str,
360 canister_id: &str,
361 parent_canister_id: Option<&str>,
362 identity_mode: IdentityMode,
363 ) -> FleetMember {
364 FleetMember {
365 role: role.to_string(),
366 canister_id: canister_id.to_string(),
367 parent_canister_id: parent_canister_id.map(str::to_string),
368 subnet_canister_id: Some(ROOT.to_string()),
369 controller_hint: None,
370 identity_mode,
371 restore_group: 1,
372 verification_class: "basic".to_string(),
373 verification_checks: vec![VerificationCheck {
374 kind: "status".to_string(),
375 method: None,
376 roles: vec![role.to_string()],
377 }],
378 source_snapshot: SourceSnapshot {
379 snapshot_id: format!("{role}-snapshot"),
380 module_hash: None,
381 wasm_hash: None,
382 code_version: Some("v0.30.1".to_string()),
383 artifact_path: format!("artifacts/{role}"),
384 checksum_algorithm: "sha256".to_string(),
385 },
386 }
387 }
388
389 fn temp_dir(prefix: &str) -> PathBuf {
391 let nanos = SystemTime::now()
392 .duration_since(UNIX_EPOCH)
393 .expect("system time after epoch")
394 .as_nanos();
395 std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
396 }
397}