Skip to main content

canic_cli/backup/
mod.rs

1use crate::{output, restore as cli_restore, version_text};
2use canic_backup::{
3    journal::JournalResumeReport,
4    persistence::{
5        BackupInspectionReport, BackupIntegrityReport, BackupLayout, BackupProvenanceReport,
6        PersistenceError,
7    },
8    restore::RestorePlanError,
9};
10use serde::Serialize;
11use std::{ffi::OsString, path::PathBuf};
12use thiserror::Error as ThisError;
13
14mod options;
15mod preflight;
16mod smoke;
17
18pub use options::{
19    BackupInspectOptions, BackupPreflightOptions, BackupProvenanceOptions, BackupSmokeOptions,
20    BackupStatusOptions, BackupVerifyOptions,
21};
22pub use preflight::{BackupPreflightReport, backup_preflight};
23pub use smoke::{BackupSmokeReport, backup_smoke};
24
25///
26/// BackupCommandError
27///
28
29#[derive(Debug, ThisError)]
30pub enum BackupCommandError {
31    #[error("{0}")]
32    Usage(&'static str),
33
34    #[error("missing required option {0}")]
35    MissingOption(&'static str),
36
37    #[error("unknown option {0}")]
38    UnknownOption(String),
39
40    #[error(
41        "backup journal {backup_id} is incomplete: {pending_artifacts}/{total_artifacts} artifacts still require resume work"
42    )]
43    IncompleteJournal {
44        backup_id: String,
45        total_artifacts: usize,
46        pending_artifacts: usize,
47    },
48
49    #[error(
50        "backup inspection {backup_id} is not ready for verification: backup_id_matches={backup_id_matches}, topology_receipts_match={topology_receipts_match}, journal_complete={journal_complete}, topology_mismatches={topology_mismatches}, missing={missing_artifacts}, unexpected={unexpected_artifacts}, path_mismatches={path_mismatches}, checksum_mismatches={checksum_mismatches}"
51    )]
52    InspectionNotReady {
53        backup_id: String,
54        backup_id_matches: bool,
55        topology_receipts_match: bool,
56        journal_complete: bool,
57        topology_mismatches: usize,
58        missing_artifacts: usize,
59        unexpected_artifacts: usize,
60        path_mismatches: usize,
61        checksum_mismatches: usize,
62    },
63
64    #[error(
65        "backup provenance {backup_id} is not consistent: backup_id_matches={backup_id_matches}, topology_receipts_match={topology_receipts_match}, topology_mismatches={topology_mismatches}"
66    )]
67    ProvenanceNotConsistent {
68        backup_id: String,
69        backup_id_matches: bool,
70        topology_receipts_match: bool,
71        topology_mismatches: usize,
72    },
73
74    #[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
75    RestoreNotReady {
76        backup_id: String,
77        reasons: Vec<String>,
78    },
79
80    #[error("backup manifest {backup_id} is not design ready")]
81    DesignConformanceNotReady { backup_id: String },
82
83    #[error(transparent)]
84    Io(#[from] std::io::Error),
85
86    #[error(transparent)]
87    Json(#[from] serde_json::Error),
88
89    #[error(transparent)]
90    Persistence(#[from] PersistenceError),
91
92    #[error(transparent)]
93    RestorePlan(#[from] RestorePlanError),
94
95    #[error(transparent)]
96    RestoreCli(#[from] cli_restore::RestoreCommandError),
97}
98
99/// Run a backup subcommand.
100pub fn run<I>(args: I) -> Result<(), BackupCommandError>
101where
102    I: IntoIterator<Item = OsString>,
103{
104    let mut args = args.into_iter();
105    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
106        return Err(BackupCommandError::Usage(usage()));
107    };
108
109    match command.as_str() {
110        "preflight" => {
111            let options = BackupPreflightOptions::parse(args)?;
112            backup_preflight(&options)?;
113            Ok(())
114        }
115        "smoke" => {
116            let options = BackupSmokeOptions::parse(args)?;
117            backup_smoke(&options)?;
118            Ok(())
119        }
120        "inspect" => {
121            let options = BackupInspectOptions::parse(args)?;
122            let report = inspect_backup(&options)?;
123            write_inspect_report(&options, &report)?;
124            enforce_inspection_requirements(&options, &report)?;
125            Ok(())
126        }
127        "provenance" => {
128            let options = BackupProvenanceOptions::parse(args)?;
129            let report = backup_provenance(&options)?;
130            write_provenance_report(&options, &report)?;
131            enforce_provenance_requirements(&options, &report)?;
132            Ok(())
133        }
134        "status" => {
135            let options = BackupStatusOptions::parse(args)?;
136            let report = backup_status(&options)?;
137            write_status_report(&options, &report)?;
138            enforce_status_requirements(&options, &report)?;
139            Ok(())
140        }
141        "verify" => {
142            let options = BackupVerifyOptions::parse(args)?;
143            let report = verify_backup(&options)?;
144            write_report(&options, &report)?;
145            Ok(())
146        }
147        "help" | "--help" | "-h" => {
148            println!("{}", usage());
149            Ok(())
150        }
151        "version" | "--version" | "-V" => {
152            println!("{}", version_text());
153            Ok(())
154        }
155        _ => Err(BackupCommandError::UnknownOption(command)),
156    }
157}
158
159/// Inspect manifest and journal agreement without reading artifact bytes.
160pub fn inspect_backup(
161    options: &BackupInspectOptions,
162) -> Result<BackupInspectionReport, BackupCommandError> {
163    let layout = BackupLayout::new(options.dir.clone());
164    layout.inspect().map_err(BackupCommandError::from)
165}
166
167/// Report manifest and journal provenance without reading artifact bytes.
168pub fn backup_provenance(
169    options: &BackupProvenanceOptions,
170) -> Result<BackupProvenanceReport, BackupCommandError> {
171    let layout = BackupLayout::new(options.dir.clone());
172    layout.provenance().map_err(BackupCommandError::from)
173}
174
175/// Summarize a backup journal's resumable state.
176pub fn backup_status(
177    options: &BackupStatusOptions,
178) -> Result<JournalResumeReport, BackupCommandError> {
179    let layout = BackupLayout::new(options.dir.clone());
180    let journal = layout.read_journal()?;
181    Ok(journal.resume_report())
182}
183
184/// Verify a backup directory's manifest, journal, and durable artifacts.
185pub fn verify_backup(
186    options: &BackupVerifyOptions,
187) -> Result<BackupIntegrityReport, BackupCommandError> {
188    let layout = BackupLayout::new(options.dir.clone());
189    layout.verify_integrity().map_err(BackupCommandError::from)
190}
191
192// Ensure provenance is internally consistent when requested by scripts.
193fn enforce_provenance_requirements(
194    options: &BackupProvenanceOptions,
195    report: &BackupProvenanceReport,
196) -> Result<(), BackupCommandError> {
197    if !options.require_consistent || (report.backup_id_matches && report.topology_receipts_match) {
198        return Ok(());
199    }
200
201    Err(BackupCommandError::ProvenanceNotConsistent {
202        backup_id: report.backup_id.clone(),
203        backup_id_matches: report.backup_id_matches,
204        topology_receipts_match: report.topology_receipts_match,
205        topology_mismatches: report.topology_receipt_mismatches.len(),
206    })
207}
208
209// Ensure an inspection report is ready for full verification when requested.
210fn enforce_inspection_requirements(
211    options: &BackupInspectOptions,
212    report: &BackupInspectionReport,
213) -> Result<(), BackupCommandError> {
214    if !options.require_ready || report.ready_for_verify {
215        return Ok(());
216    }
217
218    Err(BackupCommandError::InspectionNotReady {
219        backup_id: report.backup_id.clone(),
220        backup_id_matches: report.backup_id_matches,
221        topology_receipts_match: report.topology_receipt_mismatches.is_empty(),
222        journal_complete: report.journal_complete,
223        topology_mismatches: report.topology_receipt_mismatches.len(),
224        missing_artifacts: report.missing_journal_artifacts.len(),
225        unexpected_artifacts: report.unexpected_journal_artifacts.len(),
226        path_mismatches: report.path_mismatches.len(),
227        checksum_mismatches: report.checksum_mismatches.len(),
228    })
229}
230
231// Ensure a journal status report has no remaining resume work.
232pub(super) fn ensure_complete_status(
233    report: &JournalResumeReport,
234) -> Result<(), BackupCommandError> {
235    if report.is_complete {
236        return Ok(());
237    }
238
239    Err(BackupCommandError::IncompleteJournal {
240        backup_id: report.backup_id.clone(),
241        total_artifacts: report.total_artifacts,
242        pending_artifacts: report.pending_artifacts,
243    })
244}
245
246// Enforce caller-requested status requirements after the JSON report is written.
247fn enforce_status_requirements(
248    options: &BackupStatusOptions,
249    report: &JournalResumeReport,
250) -> Result<(), BackupCommandError> {
251    if !options.require_complete {
252        return Ok(());
253    }
254
255    ensure_complete_status(report)
256}
257
258// Write the journal status report to stdout or a requested output file.
259fn write_status_report(
260    options: &BackupStatusOptions,
261    report: &JournalResumeReport,
262) -> Result<(), BackupCommandError> {
263    output::write_pretty_json(options.out.as_ref(), report)
264}
265
266// Write the inspection report to stdout or a requested output file.
267fn write_inspect_report(
268    options: &BackupInspectOptions,
269    report: &BackupInspectionReport,
270) -> Result<(), BackupCommandError> {
271    output::write_pretty_json(options.out.as_ref(), report)
272}
273
274// Write the provenance report to stdout or a requested output file.
275fn write_provenance_report(
276    options: &BackupProvenanceOptions,
277    report: &BackupProvenanceReport,
278) -> Result<(), BackupCommandError> {
279    output::write_pretty_json(options.out.as_ref(), report)
280}
281
282// Write the integrity report to stdout or a requested output file.
283fn write_report(
284    options: &BackupVerifyOptions,
285    report: &BackupIntegrityReport,
286) -> Result<(), BackupCommandError> {
287    output::write_pretty_json(options.out.as_ref(), report)
288}
289
290// Write one pretty JSON value artifact, creating its parent directory when needed.
291pub(super) fn write_json_file<T>(path: &PathBuf, value: &T) -> Result<(), BackupCommandError>
292where
293    T: Serialize,
294{
295    output::write_pretty_json_file(path, value)
296}
297
298// Return backup command usage text.
299const fn usage() -> &'static str {
300    "usage: canic backup <command> [<args>]\n\ncommands:\n  smoke       Run the post-capture no-mutation smoke path.\n  preflight   Write the standard validation, integrity, plan, and status bundle.\n  inspect     Check manifest and journal agreement without reading artifact bytes.\n  provenance  Summarize backup source, topology, and artifact provenance.\n  status      Summarize resumable download journal state.\n  verify      Verify layout and durable artifact checksums."
301}
302
303#[cfg(test)]
304mod tests;