use canic_backup::{
journal::{DownloadOperationMetrics, JournalResumeReport},
manifest::{BackupUnitKind, ConsistencyMode, FleetBackupManifest},
persistence::{
BackupInspectionReport, BackupIntegrityReport, BackupLayout, BackupProvenanceReport,
PersistenceError,
},
restore::{RestoreMapping, RestorePlan, RestorePlanError, RestorePlanner, RestoreStatus},
};
use serde_json::json;
use std::{
ffi::OsString,
fs,
io::{self, Write},
path::{Path, PathBuf},
};
use thiserror::Error as ThisError;
#[derive(Debug, ThisError)]
pub enum BackupCommandError {
#[error("{0}")]
Usage(&'static str),
#[error("missing required option {0}")]
MissingOption(&'static str),
#[error("unknown option {0}")]
UnknownOption(String),
#[error("option {0} requires a value")]
MissingValue(&'static str),
#[error(
"backup journal {backup_id} is incomplete: {pending_artifacts}/{total_artifacts} artifacts still require resume work"
)]
IncompleteJournal {
backup_id: String,
total_artifacts: usize,
pending_artifacts: usize,
},
#[error(
"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}"
)]
InspectionNotReady {
backup_id: String,
backup_id_matches: bool,
topology_receipts_match: bool,
journal_complete: bool,
topology_mismatches: usize,
missing_artifacts: usize,
unexpected_artifacts: usize,
path_mismatches: usize,
checksum_mismatches: usize,
},
#[error(
"backup provenance {backup_id} is not consistent: backup_id_matches={backup_id_matches}, topology_receipts_match={topology_receipts_match}, topology_mismatches={topology_mismatches}"
)]
ProvenanceNotConsistent {
backup_id: String,
backup_id_matches: bool,
topology_receipts_match: bool,
topology_mismatches: usize,
},
#[error("restore plan for backup {backup_id} is not restore-ready: reasons={reasons:?}")]
RestoreNotReady {
backup_id: String,
reasons: Vec<String>,
},
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
#[error(transparent)]
Persistence(#[from] PersistenceError),
#[error(transparent)]
RestorePlan(#[from] RestorePlanError),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BackupPreflightOptions {
pub dir: PathBuf,
pub out_dir: PathBuf,
pub mapping: Option<PathBuf>,
pub require_restore_ready: bool,
}
impl BackupPreflightOptions {
pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut dir = None;
let mut out_dir = None;
let mut mapping = None;
let mut require_restore_ready = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let arg = arg
.into_string()
.map_err(|_| BackupCommandError::Usage(usage()))?;
match arg.as_str() {
"--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
"--out-dir" => out_dir = Some(PathBuf::from(next_value(&mut args, "--out-dir")?)),
"--mapping" => mapping = Some(PathBuf::from(next_value(&mut args, "--mapping")?)),
"--require-restore-ready" => require_restore_ready = true,
"--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
_ => return Err(BackupCommandError::UnknownOption(arg)),
}
}
Ok(Self {
dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
out_dir: out_dir.ok_or(BackupCommandError::MissingOption("--out-dir"))?,
mapping,
require_restore_ready,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[expect(
clippy::struct_excessive_bools,
reason = "preflight reports intentionally mirror machine-readable JSON status flags"
)]
pub struct BackupPreflightReport {
pub status: String,
pub backup_id: String,
pub backup_dir: String,
pub source_environment: String,
pub source_root_canister: String,
pub topology_hash: String,
pub mapping_path: Option<String>,
pub journal_complete: bool,
pub journal_operation_metrics: DownloadOperationMetrics,
pub inspection_status: String,
pub provenance_status: String,
pub backup_id_status: String,
pub topology_receipts_status: String,
pub topology_mismatch_count: usize,
pub integrity_verified: bool,
pub manifest_members: usize,
pub backup_unit_count: usize,
pub restore_plan_members: usize,
pub restore_mapping_supplied: bool,
pub restore_all_sources_mapped: bool,
pub restore_fixed_members: usize,
pub restore_relocatable_members: usize,
pub restore_in_place_members: usize,
pub restore_mapped_members: usize,
pub restore_remapped_members: usize,
pub restore_ready: bool,
pub restore_readiness_reasons: Vec<String>,
pub restore_all_members_have_module_hash: bool,
pub restore_all_members_have_wasm_hash: bool,
pub restore_all_members_have_code_version: bool,
pub restore_all_members_have_checksum: bool,
pub restore_members_with_module_hash: usize,
pub restore_members_with_wasm_hash: usize,
pub restore_members_with_code_version: usize,
pub restore_members_with_checksum: usize,
pub restore_verification_required: bool,
pub restore_all_members_have_checks: bool,
pub restore_fleet_checks: usize,
pub restore_member_check_groups: usize,
pub restore_member_checks: usize,
pub restore_members_with_checks: usize,
pub restore_total_checks: usize,
pub restore_planned_snapshot_uploads: usize,
pub restore_planned_snapshot_loads: usize,
pub restore_planned_code_reinstalls: usize,
pub restore_planned_verification_checks: usize,
pub restore_planned_operations: usize,
pub restore_planned_phases: usize,
pub restore_phase_count: usize,
pub restore_dependency_free_members: usize,
pub restore_in_group_parent_edges: usize,
pub restore_cross_group_parent_edges: usize,
pub manifest_validation_path: String,
pub backup_status_path: String,
pub backup_inspection_path: String,
pub backup_provenance_path: String,
pub backup_integrity_path: String,
pub restore_plan_path: String,
pub restore_status_path: String,
pub preflight_summary_path: String,
}
struct PreflightArtifactPaths {
manifest_validation: PathBuf,
backup_status: PathBuf,
backup_inspection: PathBuf,
backup_provenance: PathBuf,
backup_integrity: PathBuf,
restore_plan: PathBuf,
restore_status: PathBuf,
preflight_summary: PathBuf,
}
struct PreflightReportInput<'a> {
options: &'a BackupPreflightOptions,
manifest: &'a FleetBackupManifest,
status: &'a JournalResumeReport,
inspection: &'a BackupInspectionReport,
provenance: &'a BackupProvenanceReport,
integrity: &'a BackupIntegrityReport,
restore_plan: &'a RestorePlan,
paths: &'a PreflightArtifactPaths,
}
struct PreflightArtifactInput<'a> {
paths: &'a PreflightArtifactPaths,
manifest: &'a FleetBackupManifest,
status: &'a JournalResumeReport,
inspection: &'a BackupInspectionReport,
provenance: &'a BackupProvenanceReport,
integrity: &'a BackupIntegrityReport,
restore_plan: &'a RestorePlan,
restore_status: &'a RestoreStatus,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BackupInspectOptions {
pub dir: PathBuf,
pub out: Option<PathBuf>,
pub require_ready: bool,
}
impl BackupInspectOptions {
pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut dir = None;
let mut out = None;
let mut require_ready = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let arg = arg
.into_string()
.map_err(|_| BackupCommandError::Usage(usage()))?;
match arg.as_str() {
"--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
"--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
"--require-ready" => require_ready = true,
"--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
_ => return Err(BackupCommandError::UnknownOption(arg)),
}
}
Ok(Self {
dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
out,
require_ready,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BackupProvenanceOptions {
pub dir: PathBuf,
pub out: Option<PathBuf>,
pub require_consistent: bool,
}
impl BackupProvenanceOptions {
pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut dir = None;
let mut out = None;
let mut require_consistent = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let arg = arg
.into_string()
.map_err(|_| BackupCommandError::Usage(usage()))?;
match arg.as_str() {
"--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
"--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
"--require-consistent" => require_consistent = true,
"--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
_ => return Err(BackupCommandError::UnknownOption(arg)),
}
}
Ok(Self {
dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
out,
require_consistent,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BackupVerifyOptions {
pub dir: PathBuf,
pub out: Option<PathBuf>,
}
impl BackupVerifyOptions {
pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut dir = None;
let mut out = None;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let arg = arg
.into_string()
.map_err(|_| BackupCommandError::Usage(usage()))?;
match arg.as_str() {
"--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
"--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
"--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
_ => return Err(BackupCommandError::UnknownOption(arg)),
}
}
Ok(Self {
dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
out,
})
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct BackupStatusOptions {
pub dir: PathBuf,
pub out: Option<PathBuf>,
pub require_complete: bool,
}
impl BackupStatusOptions {
pub fn parse<I>(args: I) -> Result<Self, BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut dir = None;
let mut out = None;
let mut require_complete = false;
let mut args = args.into_iter();
while let Some(arg) = args.next() {
let arg = arg
.into_string()
.map_err(|_| BackupCommandError::Usage(usage()))?;
match arg.as_str() {
"--dir" => dir = Some(PathBuf::from(next_value(&mut args, "--dir")?)),
"--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
"--require-complete" => require_complete = true,
"--help" | "-h" => return Err(BackupCommandError::Usage(usage())),
_ => return Err(BackupCommandError::UnknownOption(arg)),
}
}
Ok(Self {
dir: dir.ok_or(BackupCommandError::MissingOption("--dir"))?,
out,
require_complete,
})
}
}
pub fn run<I>(args: I) -> Result<(), BackupCommandError>
where
I: IntoIterator<Item = OsString>,
{
let mut args = args.into_iter();
let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
return Err(BackupCommandError::Usage(usage()));
};
match command.as_str() {
"preflight" => {
let options = BackupPreflightOptions::parse(args)?;
backup_preflight(&options)?;
Ok(())
}
"inspect" => {
let options = BackupInspectOptions::parse(args)?;
let report = inspect_backup(&options)?;
write_inspect_report(&options, &report)?;
enforce_inspection_requirements(&options, &report)?;
Ok(())
}
"provenance" => {
let options = BackupProvenanceOptions::parse(args)?;
let report = backup_provenance(&options)?;
write_provenance_report(&options, &report)?;
enforce_provenance_requirements(&options, &report)?;
Ok(())
}
"status" => {
let options = BackupStatusOptions::parse(args)?;
let report = backup_status(&options)?;
write_status_report(&options, &report)?;
enforce_status_requirements(&options, &report)?;
Ok(())
}
"verify" => {
let options = BackupVerifyOptions::parse(args)?;
let report = verify_backup(&options)?;
write_report(&options, &report)?;
Ok(())
}
"help" | "--help" | "-h" => Err(BackupCommandError::Usage(usage())),
_ => Err(BackupCommandError::UnknownOption(command)),
}
}
pub fn backup_preflight(
options: &BackupPreflightOptions,
) -> Result<BackupPreflightReport, BackupCommandError> {
fs::create_dir_all(&options.out_dir)?;
let layout = BackupLayout::new(options.dir.clone());
let manifest = layout.read_manifest()?;
let status = layout.read_journal()?.resume_report();
ensure_complete_status(&status)?;
let inspection = layout.inspect()?;
let provenance = layout.provenance()?;
let integrity = layout.verify_integrity()?;
let mapping = options.mapping.as_ref().map(read_mapping).transpose()?;
let restore_plan = RestorePlanner::plan(&manifest, mapping.as_ref())?;
let restore_status = RestoreStatus::from_plan(&restore_plan);
let paths = preflight_artifact_paths(&options.out_dir);
write_preflight_artifacts(PreflightArtifactInput {
paths: &paths,
manifest: &manifest,
status: &status,
inspection: &inspection,
provenance: &provenance,
integrity: &integrity,
restore_plan: &restore_plan,
restore_status: &restore_status,
})?;
let report = build_preflight_report(PreflightReportInput {
options,
manifest: &manifest,
status: &status,
inspection: &inspection,
provenance: &provenance,
integrity: &integrity,
restore_plan: &restore_plan,
paths: &paths,
});
write_json_value_file(&paths.preflight_summary, &preflight_summary_value(&report))?;
enforce_preflight_requirements(options, &report)?;
Ok(report)
}
fn enforce_preflight_requirements(
options: &BackupPreflightOptions,
report: &BackupPreflightReport,
) -> Result<(), BackupCommandError> {
if !options.require_restore_ready || report.restore_ready {
return Ok(());
}
Err(BackupCommandError::RestoreNotReady {
backup_id: report.backup_id.clone(),
reasons: report.restore_readiness_reasons.clone(),
})
}
fn preflight_artifact_paths(out_dir: &Path) -> PreflightArtifactPaths {
PreflightArtifactPaths {
manifest_validation: out_dir.join("manifest-validation.json"),
backup_status: out_dir.join("backup-status.json"),
backup_inspection: out_dir.join("backup-inspection.json"),
backup_provenance: out_dir.join("backup-provenance.json"),
backup_integrity: out_dir.join("backup-integrity.json"),
restore_plan: out_dir.join("restore-plan.json"),
restore_status: out_dir.join("restore-status.json"),
preflight_summary: out_dir.join("preflight-summary.json"),
}
}
fn write_preflight_artifacts(input: PreflightArtifactInput<'_>) -> Result<(), BackupCommandError> {
write_json_value_file(
&input.paths.manifest_validation,
&manifest_validation_summary(input.manifest),
)?;
fs::write(
&input.paths.backup_status,
serde_json::to_vec_pretty(&input.status)?,
)?;
fs::write(
&input.paths.backup_inspection,
serde_json::to_vec_pretty(&input.inspection)?,
)?;
fs::write(
&input.paths.backup_provenance,
serde_json::to_vec_pretty(&input.provenance)?,
)?;
fs::write(
&input.paths.backup_integrity,
serde_json::to_vec_pretty(&input.integrity)?,
)?;
fs::write(
&input.paths.restore_plan,
serde_json::to_vec_pretty(&input.restore_plan)?,
)?;
fs::write(
&input.paths.restore_status,
serde_json::to_vec_pretty(&input.restore_status)?,
)?;
Ok(())
}
fn build_preflight_report(input: PreflightReportInput<'_>) -> BackupPreflightReport {
let identity = &input.restore_plan.identity_summary;
let snapshot = &input.restore_plan.snapshot_summary;
let verification = &input.restore_plan.verification_summary;
let operation = &input.restore_plan.operation_summary;
let ordering = &input.restore_plan.ordering_summary;
BackupPreflightReport {
status: "ready".to_string(),
backup_id: input.manifest.backup_id.clone(),
backup_dir: input.options.dir.display().to_string(),
source_environment: input.manifest.source.environment.clone(),
source_root_canister: input.manifest.source.root_canister.clone(),
topology_hash: input.manifest.fleet.topology_hash.clone(),
mapping_path: input
.options
.mapping
.as_ref()
.map(|path| path.display().to_string()),
journal_complete: input.status.is_complete,
journal_operation_metrics: input.status.operation_metrics.clone(),
inspection_status: readiness_status(input.inspection.ready_for_verify).to_string(),
provenance_status: consistency_status(
input.provenance.backup_id_matches && input.provenance.topology_receipts_match,
)
.to_string(),
backup_id_status: match_status(input.provenance.backup_id_matches).to_string(),
topology_receipts_status: match_status(input.provenance.topology_receipts_match)
.to_string(),
topology_mismatch_count: input.provenance.topology_receipt_mismatches.len(),
integrity_verified: input.integrity.verified,
manifest_members: input.manifest.fleet.members.len(),
backup_unit_count: input.provenance.backup_unit_count,
restore_plan_members: input.restore_plan.member_count,
restore_mapping_supplied: identity.mapping_supplied,
restore_all_sources_mapped: identity.all_sources_mapped,
restore_fixed_members: identity.fixed_members,
restore_relocatable_members: identity.relocatable_members,
restore_in_place_members: identity.in_place_members,
restore_mapped_members: identity.mapped_members,
restore_remapped_members: identity.remapped_members,
restore_ready: input.restore_plan.readiness_summary.ready,
restore_readiness_reasons: input.restore_plan.readiness_summary.reasons.clone(),
restore_all_members_have_module_hash: snapshot.all_members_have_module_hash,
restore_all_members_have_wasm_hash: snapshot.all_members_have_wasm_hash,
restore_all_members_have_code_version: snapshot.all_members_have_code_version,
restore_all_members_have_checksum: snapshot.all_members_have_checksum,
restore_members_with_module_hash: snapshot.members_with_module_hash,
restore_members_with_wasm_hash: snapshot.members_with_wasm_hash,
restore_members_with_code_version: snapshot.members_with_code_version,
restore_members_with_checksum: snapshot.members_with_checksum,
restore_verification_required: verification.verification_required,
restore_all_members_have_checks: verification.all_members_have_checks,
restore_fleet_checks: verification.fleet_checks,
restore_member_check_groups: verification.member_check_groups,
restore_member_checks: verification.member_checks,
restore_members_with_checks: verification.members_with_checks,
restore_total_checks: verification.total_checks,
restore_planned_snapshot_uploads: operation
.effective_planned_snapshot_uploads(input.restore_plan.member_count),
restore_planned_snapshot_loads: operation.planned_snapshot_loads,
restore_planned_code_reinstalls: operation.planned_code_reinstalls,
restore_planned_verification_checks: operation.planned_verification_checks,
restore_planned_operations: operation
.effective_planned_operations(input.restore_plan.member_count),
restore_planned_phases: operation.planned_phases,
restore_phase_count: ordering.phase_count,
restore_dependency_free_members: ordering.dependency_free_members,
restore_in_group_parent_edges: ordering.in_group_parent_edges,
restore_cross_group_parent_edges: ordering.cross_group_parent_edges,
manifest_validation_path: input.paths.manifest_validation.display().to_string(),
backup_status_path: input.paths.backup_status.display().to_string(),
backup_inspection_path: input.paths.backup_inspection.display().to_string(),
backup_provenance_path: input.paths.backup_provenance.display().to_string(),
backup_integrity_path: input.paths.backup_integrity.display().to_string(),
restore_plan_path: input.paths.restore_plan.display().to_string(),
restore_status_path: input.paths.restore_status.display().to_string(),
preflight_summary_path: input.paths.preflight_summary.display().to_string(),
}
}
pub fn inspect_backup(
options: &BackupInspectOptions,
) -> Result<BackupInspectionReport, BackupCommandError> {
let layout = BackupLayout::new(options.dir.clone());
layout.inspect().map_err(BackupCommandError::from)
}
pub fn backup_provenance(
options: &BackupProvenanceOptions,
) -> Result<BackupProvenanceReport, BackupCommandError> {
let layout = BackupLayout::new(options.dir.clone());
layout.provenance().map_err(BackupCommandError::from)
}
fn enforce_provenance_requirements(
options: &BackupProvenanceOptions,
report: &BackupProvenanceReport,
) -> Result<(), BackupCommandError> {
if !options.require_consistent || (report.backup_id_matches && report.topology_receipts_match) {
return Ok(());
}
Err(BackupCommandError::ProvenanceNotConsistent {
backup_id: report.backup_id.clone(),
backup_id_matches: report.backup_id_matches,
topology_receipts_match: report.topology_receipts_match,
topology_mismatches: report.topology_receipt_mismatches.len(),
})
}
fn enforce_inspection_requirements(
options: &BackupInspectOptions,
report: &BackupInspectionReport,
) -> Result<(), BackupCommandError> {
if !options.require_ready || report.ready_for_verify {
return Ok(());
}
Err(BackupCommandError::InspectionNotReady {
backup_id: report.backup_id.clone(),
backup_id_matches: report.backup_id_matches,
topology_receipts_match: report.topology_receipt_mismatches.is_empty(),
journal_complete: report.journal_complete,
topology_mismatches: report.topology_receipt_mismatches.len(),
missing_artifacts: report.missing_journal_artifacts.len(),
unexpected_artifacts: report.unexpected_journal_artifacts.len(),
path_mismatches: report.path_mismatches.len(),
checksum_mismatches: report.checksum_mismatches.len(),
})
}
pub fn backup_status(
options: &BackupStatusOptions,
) -> Result<JournalResumeReport, BackupCommandError> {
let layout = BackupLayout::new(options.dir.clone());
let journal = layout.read_journal()?;
Ok(journal.resume_report())
}
fn ensure_complete_status(report: &JournalResumeReport) -> Result<(), BackupCommandError> {
if report.is_complete {
return Ok(());
}
Err(BackupCommandError::IncompleteJournal {
backup_id: report.backup_id.clone(),
total_artifacts: report.total_artifacts,
pending_artifacts: report.pending_artifacts,
})
}
fn enforce_status_requirements(
options: &BackupStatusOptions,
report: &JournalResumeReport,
) -> Result<(), BackupCommandError> {
if !options.require_complete {
return Ok(());
}
ensure_complete_status(report)
}
pub fn verify_backup(
options: &BackupVerifyOptions,
) -> Result<BackupIntegrityReport, BackupCommandError> {
let layout = BackupLayout::new(options.dir.clone());
layout.verify_integrity().map_err(BackupCommandError::from)
}
fn write_status_report(
options: &BackupStatusOptions,
report: &JournalResumeReport,
) -> Result<(), BackupCommandError> {
if let Some(path) = &options.out {
let data = serde_json::to_vec_pretty(report)?;
fs::write(path, data)?;
return Ok(());
}
let stdout = io::stdout();
let mut handle = stdout.lock();
serde_json::to_writer_pretty(&mut handle, report)?;
writeln!(handle)?;
Ok(())
}
fn write_inspect_report(
options: &BackupInspectOptions,
report: &BackupInspectionReport,
) -> Result<(), BackupCommandError> {
if let Some(path) = &options.out {
let data = serde_json::to_vec_pretty(report)?;
fs::write(path, data)?;
return Ok(());
}
let stdout = io::stdout();
let mut handle = stdout.lock();
serde_json::to_writer_pretty(&mut handle, report)?;
writeln!(handle)?;
Ok(())
}
fn write_provenance_report(
options: &BackupProvenanceOptions,
report: &BackupProvenanceReport,
) -> Result<(), BackupCommandError> {
if let Some(path) = &options.out {
let data = serde_json::to_vec_pretty(report)?;
fs::write(path, data)?;
return Ok(());
}
let stdout = io::stdout();
let mut handle = stdout.lock();
serde_json::to_writer_pretty(&mut handle, report)?;
writeln!(handle)?;
Ok(())
}
fn write_report(
options: &BackupVerifyOptions,
report: &BackupIntegrityReport,
) -> Result<(), BackupCommandError> {
if let Some(path) = &options.out {
let data = serde_json::to_vec_pretty(report)?;
fs::write(path, data)?;
return Ok(());
}
let stdout = io::stdout();
let mut handle = stdout.lock();
serde_json::to_writer_pretty(&mut handle, report)?;
writeln!(handle)?;
Ok(())
}
fn write_json_value_file(
path: &PathBuf,
value: &serde_json::Value,
) -> Result<(), BackupCommandError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let data = serde_json::to_vec_pretty(value)?;
fs::write(path, data)?;
Ok(())
}
fn preflight_summary_value(report: &BackupPreflightReport) -> serde_json::Value {
let mut summary = serde_json::Map::new();
insert_preflight_source_summary(&mut summary, report);
insert_preflight_restore_summary(&mut summary, report);
insert_preflight_report_paths(&mut summary, report);
serde_json::Value::Object(summary)
}
fn insert_summary_value(
summary: &mut serde_json::Map<String, serde_json::Value>,
key: &'static str,
value: serde_json::Value,
) {
summary.insert(key.to_string(), value);
}
fn insert_preflight_source_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(summary, "status", json!(report.status));
insert_summary_value(summary, "backup_id", json!(report.backup_id));
insert_summary_value(summary, "backup_dir", json!(report.backup_dir));
insert_summary_value(
summary,
"source_environment",
json!(report.source_environment),
);
insert_summary_value(
summary,
"source_root_canister",
json!(report.source_root_canister),
);
insert_summary_value(summary, "topology_hash", json!(report.topology_hash));
insert_summary_value(summary, "mapping_path", json!(report.mapping_path));
insert_summary_value(summary, "journal_complete", json!(report.journal_complete));
insert_summary_value(
summary,
"journal_operation_metrics",
json!(report.journal_operation_metrics),
);
insert_summary_value(
summary,
"inspection_status",
json!(report.inspection_status),
);
insert_summary_value(
summary,
"provenance_status",
json!(report.provenance_status),
);
insert_summary_value(summary, "backup_id_status", json!(report.backup_id_status));
insert_summary_value(
summary,
"topology_receipts_status",
json!(report.topology_receipts_status),
);
insert_summary_value(
summary,
"topology_mismatch_count",
json!(report.topology_mismatch_count),
);
insert_summary_value(
summary,
"integrity_verified",
json!(report.integrity_verified),
);
insert_summary_value(summary, "manifest_members", json!(report.manifest_members));
insert_summary_value(
summary,
"backup_unit_count",
json!(report.backup_unit_count),
);
}
fn insert_preflight_restore_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"restore_plan_members",
json!(report.restore_plan_members),
);
insert_summary_value(
summary,
"restore_mapping_supplied",
json!(report.restore_mapping_supplied),
);
insert_summary_value(
summary,
"restore_all_sources_mapped",
json!(report.restore_all_sources_mapped),
);
insert_preflight_restore_identity_summary(summary, report);
insert_preflight_restore_readiness_summary(summary, report);
insert_preflight_restore_snapshot_summary(summary, report);
insert_preflight_restore_verification_summary(summary, report);
insert_preflight_restore_operation_summary(summary, report);
insert_preflight_restore_ordering_summary(summary, report);
}
fn insert_preflight_restore_identity_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"restore_fixed_members",
json!(report.restore_fixed_members),
);
insert_summary_value(
summary,
"restore_relocatable_members",
json!(report.restore_relocatable_members),
);
insert_summary_value(
summary,
"restore_in_place_members",
json!(report.restore_in_place_members),
);
insert_summary_value(
summary,
"restore_mapped_members",
json!(report.restore_mapped_members),
);
insert_summary_value(
summary,
"restore_remapped_members",
json!(report.restore_remapped_members),
);
}
fn insert_preflight_restore_readiness_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(summary, "restore_ready", json!(report.restore_ready));
insert_summary_value(
summary,
"restore_readiness_reasons",
json!(report.restore_readiness_reasons),
);
}
fn insert_preflight_restore_snapshot_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"restore_all_members_have_module_hash",
json!(report.restore_all_members_have_module_hash),
);
insert_summary_value(
summary,
"restore_all_members_have_wasm_hash",
json!(report.restore_all_members_have_wasm_hash),
);
insert_summary_value(
summary,
"restore_all_members_have_code_version",
json!(report.restore_all_members_have_code_version),
);
insert_summary_value(
summary,
"restore_all_members_have_checksum",
json!(report.restore_all_members_have_checksum),
);
insert_summary_value(
summary,
"restore_members_with_module_hash",
json!(report.restore_members_with_module_hash),
);
insert_summary_value(
summary,
"restore_members_with_wasm_hash",
json!(report.restore_members_with_wasm_hash),
);
insert_summary_value(
summary,
"restore_members_with_code_version",
json!(report.restore_members_with_code_version),
);
insert_summary_value(
summary,
"restore_members_with_checksum",
json!(report.restore_members_with_checksum),
);
}
fn insert_preflight_restore_verification_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"restore_verification_required",
json!(report.restore_verification_required),
);
insert_summary_value(
summary,
"restore_all_members_have_checks",
json!(report.restore_all_members_have_checks),
);
insert_summary_value(
summary,
"restore_fleet_checks",
json!(report.restore_fleet_checks),
);
insert_summary_value(
summary,
"restore_member_check_groups",
json!(report.restore_member_check_groups),
);
insert_summary_value(
summary,
"restore_member_checks",
json!(report.restore_member_checks),
);
insert_summary_value(
summary,
"restore_members_with_checks",
json!(report.restore_members_with_checks),
);
insert_summary_value(
summary,
"restore_total_checks",
json!(report.restore_total_checks),
);
}
fn insert_preflight_restore_operation_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"restore_planned_snapshot_uploads",
json!(report.restore_planned_snapshot_uploads),
);
insert_summary_value(
summary,
"restore_planned_snapshot_loads",
json!(report.restore_planned_snapshot_loads),
);
insert_summary_value(
summary,
"restore_planned_code_reinstalls",
json!(report.restore_planned_code_reinstalls),
);
insert_summary_value(
summary,
"restore_planned_verification_checks",
json!(report.restore_planned_verification_checks),
);
insert_summary_value(
summary,
"restore_planned_operations",
json!(report.restore_planned_operations),
);
insert_summary_value(
summary,
"restore_planned_phases",
json!(report.restore_planned_phases),
);
}
fn insert_preflight_restore_ordering_summary(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"restore_phase_count",
json!(report.restore_phase_count),
);
insert_summary_value(
summary,
"restore_dependency_free_members",
json!(report.restore_dependency_free_members),
);
insert_summary_value(
summary,
"restore_in_group_parent_edges",
json!(report.restore_in_group_parent_edges),
);
insert_summary_value(
summary,
"restore_cross_group_parent_edges",
json!(report.restore_cross_group_parent_edges),
);
}
fn insert_preflight_report_paths(
summary: &mut serde_json::Map<String, serde_json::Value>,
report: &BackupPreflightReport,
) {
insert_summary_value(
summary,
"manifest_validation_path",
json!(report.manifest_validation_path),
);
insert_summary_value(
summary,
"backup_status_path",
json!(report.backup_status_path),
);
insert_summary_value(
summary,
"backup_inspection_path",
json!(report.backup_inspection_path),
);
insert_summary_value(
summary,
"backup_provenance_path",
json!(report.backup_provenance_path),
);
insert_summary_value(
summary,
"backup_integrity_path",
json!(report.backup_integrity_path),
);
insert_summary_value(
summary,
"restore_plan_path",
json!(report.restore_plan_path),
);
insert_summary_value(
summary,
"restore_status_path",
json!(report.restore_status_path),
);
insert_summary_value(
summary,
"preflight_summary_path",
json!(report.preflight_summary_path),
);
}
fn manifest_validation_summary(manifest: &FleetBackupManifest) -> serde_json::Value {
json!({
"status": "valid",
"backup_id": manifest.backup_id,
"members": manifest.fleet.members.len(),
"backup_unit_count": manifest.consistency.backup_units.len(),
"consistency_mode": consistency_mode_name(&manifest.consistency.mode),
"topology_hash": manifest.fleet.topology_hash,
"topology_hash_algorithm": manifest.fleet.topology_hash_algorithm,
"topology_hash_input": manifest.fleet.topology_hash_input,
"topology_validation_status": "validated",
"backup_unit_kinds": backup_unit_kind_counts(manifest),
"backup_units": manifest
.consistency
.backup_units
.iter()
.map(|unit| json!({
"unit_id": unit.unit_id,
"kind": backup_unit_kind_name(&unit.kind),
"role_count": unit.roles.len(),
"dependency_count": unit.dependency_closure.len(),
"topology_validation": unit.topology_validation,
}))
.collect::<Vec<_>>(),
})
}
fn backup_unit_kind_counts(manifest: &FleetBackupManifest) -> serde_json::Value {
let mut whole_fleet = 0;
let mut control_plane_subset = 0;
let mut subtree_rooted = 0;
let mut flat = 0;
for unit in &manifest.consistency.backup_units {
match &unit.kind {
BackupUnitKind::WholeFleet => whole_fleet += 1,
BackupUnitKind::ControlPlaneSubset => control_plane_subset += 1,
BackupUnitKind::SubtreeRooted => subtree_rooted += 1,
BackupUnitKind::Flat => flat += 1,
}
}
json!({
"whole_fleet": whole_fleet,
"control_plane_subset": control_plane_subset,
"subtree_rooted": subtree_rooted,
"flat": flat,
})
}
const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
match mode {
ConsistencyMode::CrashConsistent => "crash-consistent",
ConsistencyMode::QuiescedUnit => "quiesced-unit",
}
}
const fn backup_unit_kind_name(kind: &BackupUnitKind) -> &'static str {
match kind {
BackupUnitKind::WholeFleet => "whole-fleet",
BackupUnitKind::ControlPlaneSubset => "control-plane-subset",
BackupUnitKind::SubtreeRooted => "subtree-rooted",
BackupUnitKind::Flat => "flat",
}
}
const fn readiness_status(ready: bool) -> &'static str {
if ready { "ready" } else { "not-ready" }
}
const fn consistency_status(consistent: bool) -> &'static str {
if consistent {
"consistent"
} else {
"inconsistent"
}
}
const fn match_status(matches: bool) -> &'static str {
if matches { "matched" } else { "mismatched" }
}
fn read_mapping(path: &PathBuf) -> Result<RestoreMapping, BackupCommandError> {
let data = fs::read_to_string(path)?;
serde_json::from_str(&data).map_err(BackupCommandError::from)
}
fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, BackupCommandError>
where
I: Iterator<Item = OsString>,
{
args.next()
.and_then(|value| value.into_string().ok())
.ok_or(BackupCommandError::MissingValue(option))
}
const fn usage() -> &'static str {
"usage: canic backup preflight --dir <backup-dir> --out-dir <dir> [--mapping <file>] [--require-restore-ready]\n canic backup inspect --dir <backup-dir> [--out <file>] [--require-ready]\n canic backup provenance --dir <backup-dir> [--out <file>] [--require-consistent]\n canic backup status --dir <backup-dir> [--out <file>] [--require-complete]\n canic backup verify --dir <backup-dir> [--out <file>]"
}
#[cfg(test)]
mod tests {
use super::*;
use canic_backup::{
artifacts::ArtifactChecksum,
journal::{ArtifactJournalEntry, ArtifactState, DownloadJournal},
manifest::{
BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetBackupManifest,
FleetMember, FleetSection, IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata,
VerificationCheck, VerificationPlan,
},
restore::RestoreMemberState,
};
use std::{
fs,
path::Path,
time::{SystemTime, UNIX_EPOCH},
};
const ROOT: &str = "aaaaa-aa";
const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
#[test]
fn parses_backup_preflight_options() {
let options = BackupPreflightOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out-dir"),
OsString::from("reports/run"),
OsString::from("--mapping"),
OsString::from("mapping.json"),
OsString::from("--require-restore-ready"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out_dir, PathBuf::from("reports/run"));
assert_eq!(options.mapping, Some(PathBuf::from("mapping.json")));
assert!(options.require_restore_ready);
}
#[test]
fn backup_preflight_writes_standard_reports() {
let root = temp_dir("canic-cli-backup-preflight");
let out_dir = root.join("reports");
let backup_dir = root.join("backup");
let layout = BackupLayout::new(backup_dir.clone());
let checksum = write_artifact(&backup_dir, b"root artifact");
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(checksum.hash))
.expect("write journal");
let options = BackupPreflightOptions {
dir: backup_dir,
out_dir: out_dir.clone(),
mapping: None,
require_restore_ready: false,
};
let report = backup_preflight(&options).expect("run preflight");
assert_eq!(report.status, "ready");
assert_eq!(report.backup_id, "backup-test");
assert_eq!(report.source_environment, "local");
assert_eq!(report.source_root_canister, ROOT);
assert_eq!(report.topology_hash, HASH);
assert_eq!(report.mapping_path, None);
assert!(report.journal_complete);
assert_eq!(
report.journal_operation_metrics,
DownloadOperationMetrics::default()
);
assert_eq!(report.inspection_status, "ready");
assert_eq!(report.provenance_status, "consistent");
assert_eq!(report.backup_id_status, "matched");
assert_eq!(report.topology_receipts_status, "matched");
assert_eq!(report.topology_mismatch_count, 0);
assert!(report.integrity_verified);
assert_eq!(report.manifest_members, 1);
assert_eq!(report.backup_unit_count, 1);
assert_eq!(report.restore_plan_members, 1);
assert!(!report.restore_mapping_supplied);
assert!(!report.restore_all_sources_mapped);
assert_preflight_report_restore_counts(&report);
assert!(out_dir.join("manifest-validation.json").exists());
assert!(out_dir.join("backup-status.json").exists());
assert!(out_dir.join("backup-inspection.json").exists());
assert!(out_dir.join("backup-provenance.json").exists());
assert!(out_dir.join("backup-integrity.json").exists());
assert!(out_dir.join("restore-plan.json").exists());
assert!(out_dir.join("restore-status.json").exists());
assert!(out_dir.join("preflight-summary.json").exists());
let summary: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
)
.expect("decode summary");
let manifest_validation: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("manifest-validation.json")).expect("read manifest summary"),
)
.expect("decode manifest summary");
let restore_status: RestoreStatus = serde_json::from_slice(
&fs::read(out_dir.join("restore-status.json")).expect("read restore status"),
)
.expect("decode restore status");
fs::remove_dir_all(root).expect("remove temp root");
assert_preflight_summary_matches_report(&summary, &report);
assert_eq!(restore_status.status_version, 1);
assert_eq!(restore_status.backup_id.as_str(), report.backup_id.as_str());
assert_eq!(restore_status.member_count, report.restore_plan_members);
assert_eq!(restore_status.phase_count, report.restore_phase_count);
assert_eq!(
restore_status.phases[0].members[0].state,
RestoreMemberState::Planned
);
assert_eq!(manifest_validation["backup_unit_count"], 1);
assert_eq!(manifest_validation["consistency_mode"], "crash-consistent");
assert_eq!(
manifest_validation["topology_validation_status"],
"validated"
);
assert_eq!(
manifest_validation["backup_unit_kinds"]["subtree_rooted"],
1
);
assert_eq!(
manifest_validation["backup_units"][0]["kind"],
"subtree-rooted"
);
}
#[test]
fn backup_preflight_require_restore_ready_writes_reports_then_fails() {
let root = temp_dir("canic-cli-backup-preflight-require-restore-ready");
let out_dir = root.join("reports");
let backup_dir = root.join("backup");
let layout = BackupLayout::new(backup_dir.clone());
let checksum = write_artifact(&backup_dir, b"root artifact");
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(checksum.hash))
.expect("write journal");
let options = BackupPreflightOptions {
dir: backup_dir,
out_dir: out_dir.clone(),
mapping: None,
require_restore_ready: true,
};
let err = backup_preflight(&options).expect_err("restore readiness should be enforced");
assert!(out_dir.join("preflight-summary.json").exists());
assert!(out_dir.join("restore-status.json").exists());
let summary: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
)
.expect("decode summary");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(summary["restore_ready"], false);
assert!(matches!(
err,
BackupCommandError::RestoreNotReady {
reasons,
..
} if reasons == [
"missing-module-hash",
"missing-wasm-hash",
"missing-snapshot-checksum"
]
));
}
#[test]
fn backup_preflight_require_restore_ready_accepts_ready_report() {
let root = temp_dir("canic-cli-backup-preflight-ready");
let out_dir = root.join("reports");
let backup_dir = root.join("backup");
let layout = BackupLayout::new(backup_dir.clone());
let checksum = write_artifact(&backup_dir, b"root artifact");
layout
.write_manifest(&restore_ready_manifest(&checksum.hash))
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(checksum.hash))
.expect("write journal");
let options = BackupPreflightOptions {
dir: backup_dir,
out_dir: out_dir.clone(),
mapping: None,
require_restore_ready: true,
};
let report = backup_preflight(&options).expect("ready preflight should pass");
let summary: serde_json::Value = serde_json::from_slice(
&fs::read(out_dir.join("preflight-summary.json")).expect("read summary"),
)
.expect("decode summary");
fs::remove_dir_all(root).expect("remove temp root");
assert!(report.restore_ready);
assert!(report.restore_readiness_reasons.is_empty());
assert_eq!(summary["restore_ready"], true);
assert_eq!(summary["restore_readiness_reasons"], json!([]));
assert_eq!(
summary["restore_status_path"],
out_dir.join("restore-status.json").display().to_string()
);
}
fn assert_preflight_report_restore_counts(report: &BackupPreflightReport) {
assert_eq!(report.restore_fixed_members, 1);
assert_eq!(report.restore_relocatable_members, 0);
assert_eq!(report.restore_in_place_members, 1);
assert_eq!(report.restore_mapped_members, 0);
assert_eq!(report.restore_remapped_members, 0);
assert!(!report.restore_ready);
assert_eq!(
report.restore_readiness_reasons,
[
"missing-module-hash",
"missing-wasm-hash",
"missing-snapshot-checksum"
]
);
assert!(!report.restore_all_members_have_module_hash);
assert!(!report.restore_all_members_have_wasm_hash);
assert!(report.restore_all_members_have_code_version);
assert!(!report.restore_all_members_have_checksum);
assert_eq!(report.restore_members_with_module_hash, 0);
assert_eq!(report.restore_members_with_wasm_hash, 0);
assert_eq!(report.restore_members_with_code_version, 1);
assert_eq!(report.restore_members_with_checksum, 0);
assert!(report.restore_verification_required);
assert!(report.restore_all_members_have_checks);
assert_eq!(report.restore_fleet_checks, 0);
assert_eq!(report.restore_member_check_groups, 0);
assert_eq!(report.restore_member_checks, 1);
assert_eq!(report.restore_members_with_checks, 1);
assert_eq!(report.restore_total_checks, 1);
assert_eq!(report.restore_planned_snapshot_uploads, 1);
assert_eq!(report.restore_planned_snapshot_loads, 1);
assert_eq!(report.restore_planned_code_reinstalls, 1);
assert_eq!(report.restore_planned_verification_checks, 1);
assert_eq!(report.restore_planned_operations, 4);
assert_eq!(report.restore_planned_phases, 1);
assert_eq!(report.restore_phase_count, 1);
assert_eq!(report.restore_dependency_free_members, 1);
assert_eq!(report.restore_in_group_parent_edges, 0);
assert_eq!(report.restore_cross_group_parent_edges, 0);
}
fn assert_preflight_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_preflight_source_summary_matches_report(summary, report);
assert_preflight_restore_identity_summary_matches_report(summary, report);
assert_preflight_restore_readiness_summary_matches_report(summary, report);
assert_preflight_restore_snapshot_summary_matches_report(summary, report);
assert_preflight_restore_verification_summary_matches_report(summary, report);
assert_preflight_restore_operation_summary_matches_report(summary, report);
assert_preflight_restore_ordering_summary_matches_report(summary, report);
assert_preflight_path_summary_matches_report(summary, report);
}
fn assert_preflight_source_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(summary["status"], report.status);
assert_eq!(summary["backup_id"], report.backup_id);
assert_eq!(summary["source_environment"], report.source_environment);
assert_eq!(summary["source_root_canister"], report.source_root_canister);
assert_eq!(summary["topology_hash"], report.topology_hash);
assert_eq!(summary["journal_complete"], report.journal_complete);
assert_eq!(
summary["journal_operation_metrics"],
json!(report.journal_operation_metrics)
);
assert_eq!(summary["inspection_status"], report.inspection_status);
assert_eq!(summary["provenance_status"], report.provenance_status);
assert_eq!(summary["backup_id_status"], report.backup_id_status);
assert_eq!(
summary["topology_receipts_status"],
report.topology_receipts_status
);
assert_eq!(
summary["topology_mismatch_count"],
report.topology_mismatch_count
);
assert_eq!(summary["integrity_verified"], report.integrity_verified);
assert_eq!(summary["manifest_members"], report.manifest_members);
assert_eq!(summary["backup_unit_count"], report.backup_unit_count);
assert_eq!(summary["restore_plan_members"], report.restore_plan_members);
assert_eq!(
summary["restore_mapping_supplied"],
report.restore_mapping_supplied
);
assert_eq!(
summary["restore_all_sources_mapped"],
report.restore_all_sources_mapped
);
}
fn assert_preflight_restore_identity_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(
summary["restore_fixed_members"],
report.restore_fixed_members
);
assert_eq!(
summary["restore_relocatable_members"],
report.restore_relocatable_members
);
assert_eq!(
summary["restore_in_place_members"],
report.restore_in_place_members
);
assert_eq!(
summary["restore_mapped_members"],
report.restore_mapped_members
);
assert_eq!(
summary["restore_remapped_members"],
report.restore_remapped_members
);
}
fn assert_preflight_restore_readiness_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(summary["restore_ready"], report.restore_ready);
assert_eq!(
summary["restore_readiness_reasons"],
json!(report.restore_readiness_reasons)
);
}
fn assert_preflight_restore_snapshot_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(
summary["restore_all_members_have_module_hash"],
report.restore_all_members_have_module_hash
);
assert_eq!(
summary["restore_all_members_have_wasm_hash"],
report.restore_all_members_have_wasm_hash
);
assert_eq!(
summary["restore_all_members_have_code_version"],
report.restore_all_members_have_code_version
);
assert_eq!(
summary["restore_all_members_have_checksum"],
report.restore_all_members_have_checksum
);
assert_eq!(
summary["restore_members_with_module_hash"],
report.restore_members_with_module_hash
);
assert_eq!(
summary["restore_members_with_wasm_hash"],
report.restore_members_with_wasm_hash
);
assert_eq!(
summary["restore_members_with_code_version"],
report.restore_members_with_code_version
);
assert_eq!(
summary["restore_members_with_checksum"],
report.restore_members_with_checksum
);
}
fn assert_preflight_restore_verification_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(
summary["restore_verification_required"],
report.restore_verification_required
);
assert_eq!(
summary["restore_all_members_have_checks"],
report.restore_all_members_have_checks
);
assert_eq!(summary["restore_fleet_checks"], report.restore_fleet_checks);
assert_eq!(
summary["restore_member_check_groups"],
report.restore_member_check_groups
);
assert_eq!(
summary["restore_member_checks"],
report.restore_member_checks
);
assert_eq!(
summary["restore_members_with_checks"],
report.restore_members_with_checks
);
assert_eq!(summary["restore_total_checks"], report.restore_total_checks);
}
fn assert_preflight_restore_operation_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(
summary["restore_planned_snapshot_uploads"],
report.restore_planned_snapshot_uploads
);
assert_eq!(
summary["restore_planned_snapshot_loads"],
report.restore_planned_snapshot_loads
);
assert_eq!(
summary["restore_planned_code_reinstalls"],
report.restore_planned_code_reinstalls
);
assert_eq!(
summary["restore_planned_verification_checks"],
report.restore_planned_verification_checks
);
assert_eq!(
summary["restore_planned_operations"],
report.restore_planned_operations
);
assert_eq!(
summary["restore_planned_phases"],
report.restore_planned_phases
);
}
fn assert_preflight_restore_ordering_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(summary["restore_phase_count"], report.restore_phase_count);
assert_eq!(
summary["restore_dependency_free_members"],
report.restore_dependency_free_members
);
assert_eq!(
summary["restore_in_group_parent_edges"],
report.restore_in_group_parent_edges
);
assert_eq!(
summary["restore_cross_group_parent_edges"],
report.restore_cross_group_parent_edges
);
}
fn assert_preflight_path_summary_matches_report(
summary: &serde_json::Value,
report: &BackupPreflightReport,
) {
assert_eq!(
summary["manifest_validation_path"],
report.manifest_validation_path
);
assert_eq!(summary["backup_status_path"], report.backup_status_path);
assert_eq!(
summary["backup_inspection_path"],
report.backup_inspection_path
);
assert_eq!(
summary["backup_provenance_path"],
report.backup_provenance_path
);
assert_eq!(
summary["backup_integrity_path"],
report.backup_integrity_path
);
assert_eq!(summary["restore_plan_path"], report.restore_plan_path);
assert_eq!(summary["restore_status_path"], report.restore_status_path);
assert_eq!(
summary["preflight_summary_path"],
report.preflight_summary_path
);
}
#[test]
fn backup_preflight_rejects_incomplete_journal() {
let root = temp_dir("canic-cli-backup-preflight-incomplete");
let out_dir = root.join("reports");
let backup_dir = root.join("backup");
let layout = BackupLayout::new(backup_dir.clone());
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&created_journal())
.expect("write journal");
let options = BackupPreflightOptions {
dir: backup_dir,
out_dir,
mapping: None,
require_restore_ready: false,
};
let err = backup_preflight(&options).expect_err("incomplete journal should fail");
fs::remove_dir_all(root).expect("remove temp root");
assert!(matches!(
err,
BackupCommandError::IncompleteJournal {
pending_artifacts: 1,
total_artifacts: 1,
..
}
));
}
#[test]
fn parses_backup_verify_options() {
let options = BackupVerifyOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out"),
OsString::from("report.json"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out, Some(PathBuf::from("report.json")));
}
#[test]
fn parses_backup_inspect_options() {
let options = BackupInspectOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out"),
OsString::from("inspect.json"),
OsString::from("--require-ready"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out, Some(PathBuf::from("inspect.json")));
assert!(options.require_ready);
}
#[test]
fn parses_backup_provenance_options() {
let options = BackupProvenanceOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out"),
OsString::from("provenance.json"),
OsString::from("--require-consistent"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out, Some(PathBuf::from("provenance.json")));
assert!(options.require_consistent);
}
#[test]
fn parses_backup_status_options() {
let options = BackupStatusOptions::parse([
OsString::from("--dir"),
OsString::from("backups/run"),
OsString::from("--out"),
OsString::from("status.json"),
OsString::from("--require-complete"),
])
.expect("parse options");
assert_eq!(options.dir, PathBuf::from("backups/run"));
assert_eq!(options.out, Some(PathBuf::from("status.json")));
assert!(options.require_complete);
}
#[test]
fn backup_status_reads_journal_resume_report() {
let root = temp_dir("canic-cli-backup-status");
let layout = BackupLayout::new(root.clone());
layout
.write_journal(&journal_with_checksum(HASH.to_string()))
.expect("write journal");
let options = BackupStatusOptions {
dir: root.clone(),
out: None,
require_complete: false,
};
let report = backup_status(&options).expect("read backup status");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(report.backup_id, "backup-test");
assert_eq!(report.total_artifacts, 1);
assert!(report.is_complete);
assert_eq!(report.pending_artifacts, 0);
assert_eq!(report.counts.skip, 1);
}
#[test]
fn inspect_backup_reads_layout_metadata() {
let root = temp_dir("canic-cli-backup-inspect");
let layout = BackupLayout::new(root.clone());
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(HASH.to_string()))
.expect("write journal");
let options = BackupInspectOptions {
dir: root.clone(),
out: None,
require_ready: false,
};
let report = inspect_backup(&options).expect("inspect backup");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(report.backup_id, "backup-test");
assert!(report.backup_id_matches);
assert!(report.journal_complete);
assert!(report.ready_for_verify);
assert!(report.topology_receipt_mismatches.is_empty());
assert_eq!(report.matched_artifacts, 1);
}
#[test]
fn backup_provenance_reads_layout_metadata() {
let root = temp_dir("canic-cli-backup-provenance");
let layout = BackupLayout::new(root.clone());
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(HASH.to_string()))
.expect("write journal");
let options = BackupProvenanceOptions {
dir: root.clone(),
out: None,
require_consistent: false,
};
let report = backup_provenance(&options).expect("read provenance");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(report.backup_id, "backup-test");
assert!(report.backup_id_matches);
assert_eq!(report.source_environment, "local");
assert_eq!(report.discovery_topology_hash, HASH);
assert!(report.topology_receipts_match);
assert!(report.topology_receipt_mismatches.is_empty());
assert_eq!(report.backup_unit_count, 1);
assert_eq!(report.member_count, 1);
assert_eq!(report.backup_units[0].kind, "subtree-rooted");
assert_eq!(report.members[0].canister_id, ROOT);
assert_eq!(report.members[0].snapshot_id, "root-snapshot");
assert_eq!(report.members[0].journal_state, Some("Durable".to_string()));
}
#[test]
fn require_consistent_accepts_matching_provenance() {
let options = BackupProvenanceOptions {
dir: PathBuf::from("unused"),
out: None,
require_consistent: true,
};
let report = ready_provenance_report();
enforce_provenance_requirements(&options, &report)
.expect("matching provenance should pass");
}
#[test]
fn require_consistent_rejects_provenance_drift() {
let options = BackupProvenanceOptions {
dir: PathBuf::from("unused"),
out: None,
require_consistent: true,
};
let mut report = ready_provenance_report();
report.backup_id_matches = false;
report.journal_backup_id = "other-backup".to_string();
report.topology_receipts_match = false;
report.topology_receipt_mismatches.push(
canic_backup::persistence::TopologyReceiptMismatch {
field: "pre_snapshot_topology_hash".to_string(),
manifest: HASH.to_string(),
journal: None,
},
);
let err = enforce_provenance_requirements(&options, &report)
.expect_err("provenance drift should fail");
assert!(matches!(
err,
BackupCommandError::ProvenanceNotConsistent {
backup_id_matches: false,
topology_receipts_match: false,
topology_mismatches: 1,
..
}
));
}
#[test]
fn require_ready_accepts_ready_inspection() {
let options = BackupInspectOptions {
dir: PathBuf::from("unused"),
out: None,
require_ready: true,
};
let report = ready_inspection_report();
enforce_inspection_requirements(&options, &report).expect("ready inspection should pass");
}
#[test]
fn require_ready_rejects_unready_inspection() {
let options = BackupInspectOptions {
dir: PathBuf::from("unused"),
out: None,
require_ready: true,
};
let mut report = ready_inspection_report();
report.ready_for_verify = false;
report
.path_mismatches
.push(canic_backup::persistence::ArtifactPathMismatch {
canister_id: ROOT.to_string(),
snapshot_id: "root-snapshot".to_string(),
manifest: "artifacts/root".to_string(),
journal: "artifacts/other-root".to_string(),
});
let err = enforce_inspection_requirements(&options, &report)
.expect_err("unready inspection should fail");
assert!(matches!(
err,
BackupCommandError::InspectionNotReady {
path_mismatches: 1,
..
}
));
}
#[test]
fn require_ready_rejects_topology_receipt_drift() {
let options = BackupInspectOptions {
dir: PathBuf::from("unused"),
out: None,
require_ready: true,
};
let mut report = ready_inspection_report();
report.ready_for_verify = false;
report.topology_receipt_mismatches.push(
canic_backup::persistence::TopologyReceiptMismatch {
field: "discovery_topology_hash".to_string(),
manifest: HASH.to_string(),
journal: None,
},
);
let err = enforce_inspection_requirements(&options, &report)
.expect_err("topology receipt drift should fail");
assert!(matches!(
err,
BackupCommandError::InspectionNotReady {
topology_receipts_match: false,
topology_mismatches: 1,
..
}
));
}
#[test]
fn require_complete_accepts_complete_status() {
let options = BackupStatusOptions {
dir: PathBuf::from("unused"),
out: None,
require_complete: true,
};
let report = journal_with_checksum(HASH.to_string()).resume_report();
enforce_status_requirements(&options, &report).expect("complete status should pass");
}
#[test]
fn require_complete_rejects_incomplete_status() {
let options = BackupStatusOptions {
dir: PathBuf::from("unused"),
out: None,
require_complete: true,
};
let report = created_journal().resume_report();
let err = enforce_status_requirements(&options, &report)
.expect_err("incomplete status should fail");
assert!(matches!(
err,
BackupCommandError::IncompleteJournal {
pending_artifacts: 1,
total_artifacts: 1,
..
}
));
}
#[test]
fn verify_backup_reads_layout_and_artifacts() {
let root = temp_dir("canic-cli-backup-verify");
let layout = BackupLayout::new(root.clone());
let checksum = write_artifact(&root, b"root artifact");
layout
.write_manifest(&valid_manifest())
.expect("write manifest");
layout
.write_journal(&journal_with_checksum(checksum.hash.clone()))
.expect("write journal");
let options = BackupVerifyOptions {
dir: root.clone(),
out: None,
};
let report = verify_backup(&options).expect("verify backup");
fs::remove_dir_all(root).expect("remove temp root");
assert_eq!(report.backup_id, "backup-test");
assert!(report.verified);
assert_eq!(report.durable_artifacts, 1);
assert_eq!(report.artifacts[0].checksum, checksum.hash);
}
fn valid_manifest() -> FleetBackupManifest {
FleetBackupManifest {
manifest_version: 1,
backup_id: "backup-test".to_string(),
created_at: "2026-05-03T00:00:00Z".to_string(),
tool: ToolMetadata {
name: "canic".to_string(),
version: "0.30.3".to_string(),
},
source: SourceMetadata {
environment: "local".to_string(),
root_canister: ROOT.to_string(),
},
consistency: ConsistencySection {
mode: ConsistencyMode::CrashConsistent,
backup_units: vec![BackupUnit {
unit_id: "fleet".to_string(),
kind: BackupUnitKind::SubtreeRooted,
roles: vec!["root".to_string()],
consistency_reason: None,
dependency_closure: Vec::new(),
topology_validation: "subtree-closed".to_string(),
quiescence_strategy: None,
}],
},
fleet: FleetSection {
topology_hash_algorithm: "sha256".to_string(),
topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
discovery_topology_hash: HASH.to_string(),
pre_snapshot_topology_hash: HASH.to_string(),
topology_hash: HASH.to_string(),
members: vec![fleet_member()],
},
verification: VerificationPlan::default(),
}
}
fn fleet_member() -> FleetMember {
FleetMember {
role: "root".to_string(),
canister_id: ROOT.to_string(),
parent_canister_id: None,
subnet_canister_id: Some(ROOT.to_string()),
controller_hint: None,
identity_mode: IdentityMode::Fixed,
restore_group: 1,
verification_class: "basic".to_string(),
verification_checks: vec![VerificationCheck {
kind: "status".to_string(),
method: None,
roles: vec!["root".to_string()],
}],
source_snapshot: SourceSnapshot {
snapshot_id: "root-snapshot".to_string(),
module_hash: None,
wasm_hash: None,
code_version: Some("v0.30.3".to_string()),
artifact_path: "artifacts/root".to_string(),
checksum_algorithm: "sha256".to_string(),
checksum: None,
},
}
}
fn restore_ready_manifest(checksum: &str) -> FleetBackupManifest {
let mut manifest = valid_manifest();
let snapshot = &mut manifest.fleet.members[0].source_snapshot;
snapshot.module_hash = Some(HASH.to_string());
snapshot.wasm_hash = Some(HASH.to_string());
snapshot.checksum = Some(checksum.to_string());
manifest
}
fn journal_with_checksum(checksum: String) -> DownloadJournal {
DownloadJournal {
journal_version: 1,
backup_id: "backup-test".to_string(),
discovery_topology_hash: Some(HASH.to_string()),
pre_snapshot_topology_hash: Some(HASH.to_string()),
operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
artifacts: vec![ArtifactJournalEntry {
canister_id: ROOT.to_string(),
snapshot_id: "root-snapshot".to_string(),
state: ArtifactState::Durable,
temp_path: None,
artifact_path: "artifacts/root".to_string(),
checksum_algorithm: "sha256".to_string(),
checksum: Some(checksum),
updated_at: "2026-05-03T00:00:00Z".to_string(),
}],
}
}
fn created_journal() -> DownloadJournal {
DownloadJournal {
journal_version: 1,
backup_id: "backup-test".to_string(),
discovery_topology_hash: Some(HASH.to_string()),
pre_snapshot_topology_hash: Some(HASH.to_string()),
operation_metrics: canic_backup::journal::DownloadOperationMetrics::default(),
artifacts: vec![ArtifactJournalEntry {
canister_id: ROOT.to_string(),
snapshot_id: "root-snapshot".to_string(),
state: ArtifactState::Created,
temp_path: None,
artifact_path: "artifacts/root".to_string(),
checksum_algorithm: "sha256".to_string(),
checksum: None,
updated_at: "2026-05-03T00:00:00Z".to_string(),
}],
}
}
fn ready_inspection_report() -> BackupInspectionReport {
BackupInspectionReport {
backup_id: "backup-test".to_string(),
manifest_backup_id: "backup-test".to_string(),
journal_backup_id: "backup-test".to_string(),
backup_id_matches: true,
journal_complete: true,
ready_for_verify: true,
manifest_members: 1,
journal_artifacts: 1,
matched_artifacts: 1,
topology_receipt_mismatches: Vec::new(),
missing_journal_artifacts: Vec::new(),
unexpected_journal_artifacts: Vec::new(),
path_mismatches: Vec::new(),
checksum_mismatches: Vec::new(),
}
}
fn ready_provenance_report() -> BackupProvenanceReport {
BackupProvenanceReport {
backup_id: "backup-test".to_string(),
manifest_backup_id: "backup-test".to_string(),
journal_backup_id: "backup-test".to_string(),
backup_id_matches: true,
manifest_version: 1,
journal_version: 1,
created_at: "2026-05-03T00:00:00Z".to_string(),
tool_name: "canic".to_string(),
tool_version: "0.30.12".to_string(),
source_environment: "local".to_string(),
source_root_canister: ROOT.to_string(),
topology_hash_algorithm: "sha256".to_string(),
topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
discovery_topology_hash: HASH.to_string(),
pre_snapshot_topology_hash: HASH.to_string(),
accepted_topology_hash: HASH.to_string(),
journal_discovery_topology_hash: Some(HASH.to_string()),
journal_pre_snapshot_topology_hash: Some(HASH.to_string()),
topology_receipts_match: true,
topology_receipt_mismatches: Vec::new(),
backup_unit_count: 1,
member_count: 1,
consistency_mode: "crash-consistent".to_string(),
backup_units: Vec::new(),
members: Vec::new(),
}
}
fn write_artifact(root: &Path, bytes: &[u8]) -> ArtifactChecksum {
let path = root.join("artifacts/root");
fs::create_dir_all(path.parent().expect("artifact has parent")).expect("create artifacts");
fs::write(&path, bytes).expect("write artifact");
ArtifactChecksum::from_bytes(bytes)
}
fn temp_dir(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time after epoch")
.as_nanos();
std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
}
}