Skip to main content

canic_cli/restore/
mod.rs

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///
15/// RestoreCommandError
16///
17
18#[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///
49/// RestorePlanOptions
50///
51
52#[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    /// Parse restore planning options from CLI arguments.
62    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
109/// Run a restore subcommand.
110pub 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
131/// Build a no-mutation restore plan from a manifest and optional mapping.
132pub 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
139// Read the manifest from a direct path or canonical backup layout.
140fn 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
158// Read and decode a fleet backup manifest from disk.
159fn 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
164// Read and decode an optional source-to-target restore mapping from disk.
165fn 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
170// Write the computed plan to stdout or a requested output file.
171fn 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
185// Read the next required option value.
186fn 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
195// Return restore command usage text.
196const 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    // Ensure restore plan options parse the intended no-mutation command.
217    #[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    // Ensure backup-dir restore planning reads the canonical layout manifest.
236    #[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    // Ensure restore planning has exactly one manifest source.
259    #[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    // Ensure the CLI planning path validates manifests and applies mappings.
276    #[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    // Build one valid manifest for restore planning tests.
317    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    // Build one valid manifest member.
358    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    // Build a unique temporary directory.
390    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}