Skip to main content

canic_cli/manifest/
mod.rs

1use canic_backup::manifest::{
2    BackupUnitKind, ConsistencyMode, FleetBackupManifest, ManifestValidationError,
3};
4use serde_json::json;
5use std::{
6    ffi::OsString,
7    fs,
8    io::{self, Write},
9    path::PathBuf,
10};
11use thiserror::Error as ThisError;
12
13///
14/// ManifestCommandError
15///
16
17#[derive(Debug, ThisError)]
18pub enum ManifestCommandError {
19    #[error("{0}")]
20    Usage(&'static str),
21
22    #[error("missing required option {0}")]
23    MissingOption(&'static str),
24
25    #[error("unknown option {0}")]
26    UnknownOption(String),
27
28    #[error("option {0} requires a value")]
29    MissingValue(&'static str),
30
31    #[error(transparent)]
32    Io(#[from] std::io::Error),
33
34    #[error(transparent)]
35    Json(#[from] serde_json::Error),
36
37    #[error(transparent)]
38    InvalidManifest(#[from] ManifestValidationError),
39
40    #[error("manifest {backup_id} is not design-v1 ready")]
41    DesignConformanceNotReady { backup_id: String },
42}
43
44///
45/// ManifestValidateOptions
46///
47
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct ManifestValidateOptions {
50    pub manifest: PathBuf,
51    pub out: Option<PathBuf>,
52    pub require_design_v1: bool,
53}
54
55impl ManifestValidateOptions {
56    /// Parse manifest validation options from CLI arguments.
57    pub fn parse<I>(args: I) -> Result<Self, ManifestCommandError>
58    where
59        I: IntoIterator<Item = OsString>,
60    {
61        let mut manifest = None;
62        let mut out = None;
63        let mut require_design_v1 = false;
64
65        let mut args = args.into_iter();
66        while let Some(arg) = args.next() {
67            let arg = arg
68                .into_string()
69                .map_err(|_| ManifestCommandError::Usage(usage()))?;
70            match arg.as_str() {
71                "--manifest" => {
72                    manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
73                }
74                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
75                "--require-design-v1" => require_design_v1 = true,
76                "--help" | "-h" => return Err(ManifestCommandError::Usage(usage())),
77                _ => return Err(ManifestCommandError::UnknownOption(arg)),
78            }
79        }
80
81        Ok(Self {
82            manifest: manifest.ok_or(ManifestCommandError::MissingOption("--manifest"))?,
83            out,
84            require_design_v1,
85        })
86    }
87}
88
89/// Run a manifest subcommand.
90pub fn run<I>(args: I) -> Result<(), ManifestCommandError>
91where
92    I: IntoIterator<Item = OsString>,
93{
94    let mut args = args.into_iter();
95    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
96        return Err(ManifestCommandError::Usage(usage()));
97    };
98
99    match command.as_str() {
100        "validate" => {
101            let options = ManifestValidateOptions::parse(args)?;
102            let manifest = validate_manifest(&options)?;
103            write_validation_summary(&options, &manifest)?;
104            require_design_conformance(&options, &manifest)?;
105            Ok(())
106        }
107        "help" | "--help" | "-h" => {
108            println!("{}", usage());
109            Ok(())
110        }
111        _ => Err(ManifestCommandError::UnknownOption(command)),
112    }
113}
114
115/// Read and validate a fleet backup manifest from disk.
116pub fn validate_manifest(
117    options: &ManifestValidateOptions,
118) -> Result<FleetBackupManifest, ManifestCommandError> {
119    let data = fs::read_to_string(&options.manifest)?;
120    let manifest: FleetBackupManifest = serde_json::from_str(&data)?;
121    manifest.validate()?;
122    Ok(manifest)
123}
124
125// Write a concise validation summary for shell use.
126fn write_validation_summary(
127    options: &ManifestValidateOptions,
128    manifest: &FleetBackupManifest,
129) -> Result<(), ManifestCommandError> {
130    let summary = manifest_validation_summary(manifest);
131
132    if let Some(path) = &options.out {
133        let data = serde_json::to_vec_pretty(&summary)?;
134        fs::write(path, data)?;
135        return Ok(());
136    }
137
138    let stdout = io::stdout();
139    let mut handle = stdout.lock();
140    serde_json::to_writer_pretty(&mut handle, &summary)?;
141    writeln!(handle)?;
142    Ok(())
143}
144
145// Build the manifest validation summary emitted by CLI and preflight workflows.
146fn manifest_validation_summary(manifest: &FleetBackupManifest) -> serde_json::Value {
147    let design_conformance = manifest.design_conformance_report();
148
149    json!({
150        "status": "valid",
151        "backup_id": manifest.backup_id,
152        "members": manifest.fleet.members.len(),
153        "backup_unit_count": manifest.consistency.backup_units.len(),
154        "consistency_mode": consistency_mode_name(&manifest.consistency.mode),
155        "topology_hash": manifest.fleet.topology_hash,
156        "topology_hash_algorithm": manifest.fleet.topology_hash_algorithm,
157        "topology_hash_input": manifest.fleet.topology_hash_input,
158        "topology_validation_status": "validated",
159        "design_conformance": design_conformance,
160        "backup_unit_kinds": backup_unit_kind_counts(manifest),
161        "backup_units": manifest
162            .consistency
163            .backup_units
164            .iter()
165            .map(|unit| json!({
166                "unit_id": unit.unit_id,
167                "kind": backup_unit_kind_name(&unit.kind),
168                "role_count": unit.roles.len(),
169                "dependency_count": unit.dependency_closure.len(),
170                "topology_validation": unit.topology_validation,
171            }))
172            .collect::<Vec<_>>(),
173    })
174}
175
176// Fail closed when callers require the v1 backup/restore design contract.
177fn require_design_conformance(
178    options: &ManifestValidateOptions,
179    manifest: &FleetBackupManifest,
180) -> Result<(), ManifestCommandError> {
181    if !options.require_design_v1 {
182        return Ok(());
183    }
184
185    let report = manifest.design_conformance_report();
186    if report.design_v1_ready {
187        Ok(())
188    } else {
189        Err(ManifestCommandError::DesignConformanceNotReady {
190            backup_id: manifest.backup_id.clone(),
191        })
192    }
193}
194
195// Count backup units by stable serialized kind name.
196fn backup_unit_kind_counts(manifest: &FleetBackupManifest) -> serde_json::Value {
197    let mut whole_fleet = 0;
198    let mut control_plane_subset = 0;
199    let mut subtree_rooted = 0;
200    let mut flat = 0;
201    for unit in &manifest.consistency.backup_units {
202        match &unit.kind {
203            BackupUnitKind::WholeFleet => whole_fleet += 1,
204            BackupUnitKind::ControlPlaneSubset => control_plane_subset += 1,
205            BackupUnitKind::SubtreeRooted => subtree_rooted += 1,
206            BackupUnitKind::Flat => flat += 1,
207        }
208    }
209
210    json!({
211        "whole_fleet": whole_fleet,
212        "control_plane_subset": control_plane_subset,
213        "subtree_rooted": subtree_rooted,
214        "flat": flat,
215    })
216}
217
218// Return the stable serialized name for a consistency mode.
219const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
220    match mode {
221        ConsistencyMode::CrashConsistent => "crash-consistent",
222        ConsistencyMode::QuiescedUnit => "quiesced-unit",
223    }
224}
225
226// Return the stable serialized name for a backup unit kind.
227const fn backup_unit_kind_name(kind: &BackupUnitKind) -> &'static str {
228    match kind {
229        BackupUnitKind::WholeFleet => "whole-fleet",
230        BackupUnitKind::ControlPlaneSubset => "control-plane-subset",
231        BackupUnitKind::SubtreeRooted => "subtree-rooted",
232        BackupUnitKind::Flat => "flat",
233    }
234}
235
236// Read the next required option value.
237fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, ManifestCommandError>
238where
239    I: Iterator<Item = OsString>,
240{
241    args.next()
242        .and_then(|value| value.into_string().ok())
243        .ok_or(ManifestCommandError::MissingValue(option))
244}
245
246// Return manifest command usage text.
247const fn usage() -> &'static str {
248    "usage: canic manifest validate --manifest <file> [--out <file>] [--require-design-v1]"
249}
250
251#[cfg(test)]
252mod tests {
253    use super::*;
254    use canic_backup::manifest::{
255        BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember, FleetSection,
256        IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
257        VerificationPlan,
258    };
259    use std::time::{SystemTime, UNIX_EPOCH};
260
261    const ROOT: &str = "aaaaa-aa";
262    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
263
264    // Ensure manifest validation options parse the intended command shape.
265    #[test]
266    fn parses_manifest_validate_options() {
267        let options = ManifestValidateOptions::parse([
268            OsString::from("--manifest"),
269            OsString::from("manifest.json"),
270            OsString::from("--out"),
271            OsString::from("summary.json"),
272            OsString::from("--require-design-v1"),
273        ])
274        .expect("parse options");
275
276        assert_eq!(options.manifest, PathBuf::from("manifest.json"));
277        assert_eq!(options.out, Some(PathBuf::from("summary.json")));
278        assert!(options.require_design_v1);
279    }
280
281    // Ensure manifest validation loads JSON and runs the manifest contract.
282    #[test]
283    fn validate_manifest_reads_and_validates_manifest() {
284        let root = temp_dir("canic-cli-manifest-validate");
285        fs::create_dir_all(&root).expect("create temp root");
286        let manifest_path = root.join("manifest.json");
287
288        fs::write(
289            &manifest_path,
290            serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
291        )
292        .expect("write manifest");
293
294        let options = ManifestValidateOptions {
295            manifest: manifest_path,
296            out: None,
297            require_design_v1: false,
298        };
299
300        let manifest = validate_manifest(&options).expect("validate manifest");
301
302        fs::remove_dir_all(root).expect("remove temp root");
303        assert_eq!(manifest.backup_id, "backup-test");
304        assert_eq!(manifest.fleet.members.len(), 1);
305    }
306
307    // Ensure manifest validation summaries can be written for automation.
308    #[test]
309    fn write_validation_summary_writes_out_file() {
310        let root = temp_dir("canic-cli-manifest-summary");
311        fs::create_dir_all(&root).expect("create temp root");
312        let out = root.join("summary.json");
313        let options = ManifestValidateOptions {
314            manifest: root.join("manifest.json"),
315            out: Some(out.clone()),
316            require_design_v1: false,
317        };
318
319        write_validation_summary(&options, &valid_manifest()).expect("write summary");
320        let summary: serde_json::Value =
321            serde_json::from_slice(&fs::read(&out).expect("read summary")).expect("parse summary");
322
323        fs::remove_dir_all(root).expect("remove temp root");
324        assert_eq!(summary["status"], "valid");
325        assert_eq!(summary["backup_id"], "backup-test");
326        assert_eq!(summary["members"], 1);
327        assert_eq!(summary["backup_unit_count"], 1);
328        assert_eq!(summary["consistency_mode"], "crash-consistent");
329        assert_eq!(summary["topology_validation_status"], "validated");
330        assert_eq!(summary["backup_unit_kinds"]["subtree_rooted"], 1);
331        assert_eq!(summary["backup_units"][0]["unit_id"], "fleet");
332        assert_eq!(summary["backup_units"][0]["kind"], "subtree-rooted");
333        assert_eq!(summary["backup_units"][0]["role_count"], 1);
334        assert_eq!(summary["design_conformance"]["design_v1_ready"], true);
335        assert_eq!(
336            summary["design_conformance"]["topology"]["canonical_input"],
337            true
338        );
339        assert_eq!(
340            summary["design_conformance"]["snapshot_provenance"]["all_members_have_checksum"],
341            true
342        );
343    }
344
345    // Ensure manifest validation can fail closed after writing conformance output.
346    #[test]
347    fn require_design_v1_fails_after_writing_summary() {
348        let root = temp_dir("canic-cli-manifest-design-v1");
349        fs::create_dir_all(&root).expect("create temp root");
350        let out = root.join("summary.json");
351        let mut manifest = valid_manifest();
352        manifest.fleet.topology_hash_input = "legacy-input".to_string();
353        let options = ManifestValidateOptions {
354            manifest: root.join("manifest.json"),
355            out: Some(out.clone()),
356            require_design_v1: true,
357        };
358
359        write_validation_summary(&options, &manifest).expect("write summary");
360        let err = require_design_conformance(&options, &manifest)
361            .expect_err("design-v1 gate should fail");
362        let summary: serde_json::Value =
363            serde_json::from_slice(&fs::read(&out).expect("read summary")).expect("parse summary");
364
365        fs::remove_dir_all(root).expect("remove temp root");
366        assert!(matches!(
367            err,
368            ManifestCommandError::DesignConformanceNotReady { .. }
369        ));
370        assert_eq!(summary["design_conformance"]["design_v1_ready"], false);
371        assert_eq!(
372            summary["design_conformance"]["topology"]["canonical_input"],
373            false
374        );
375    }
376
377    // Build one valid manifest for validation tests.
378    fn valid_manifest() -> FleetBackupManifest {
379        FleetBackupManifest {
380            manifest_version: 1,
381            backup_id: "backup-test".to_string(),
382            created_at: "2026-05-03T00:00:00Z".to_string(),
383            tool: ToolMetadata {
384                name: "canic".to_string(),
385                version: "0.30.1".to_string(),
386            },
387            source: SourceMetadata {
388                environment: "local".to_string(),
389                root_canister: ROOT.to_string(),
390            },
391            consistency: ConsistencySection {
392                mode: ConsistencyMode::CrashConsistent,
393                backup_units: vec![BackupUnit {
394                    unit_id: "fleet".to_string(),
395                    kind: BackupUnitKind::SubtreeRooted,
396                    roles: vec!["root".to_string()],
397                    consistency_reason: None,
398                    dependency_closure: Vec::new(),
399                    topology_validation: "subtree-closed".to_string(),
400                    quiescence_strategy: None,
401                }],
402            },
403            fleet: FleetSection {
404                topology_hash_algorithm: "sha256".to_string(),
405                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
406                discovery_topology_hash: HASH.to_string(),
407                pre_snapshot_topology_hash: HASH.to_string(),
408                topology_hash: HASH.to_string(),
409                members: vec![fleet_member()],
410            },
411            verification: VerificationPlan::default(),
412        }
413    }
414
415    // Build one valid manifest member.
416    fn fleet_member() -> FleetMember {
417        FleetMember {
418            role: "root".to_string(),
419            canister_id: ROOT.to_string(),
420            parent_canister_id: None,
421            subnet_canister_id: Some(ROOT.to_string()),
422            controller_hint: None,
423            identity_mode: IdentityMode::Fixed,
424            restore_group: 1,
425            verification_class: "basic".to_string(),
426            verification_checks: vec![VerificationCheck {
427                kind: "status".to_string(),
428                method: None,
429                roles: vec!["root".to_string()],
430            }],
431            source_snapshot: SourceSnapshot {
432                snapshot_id: "root-snapshot".to_string(),
433                module_hash: None,
434                wasm_hash: None,
435                code_version: Some("v0.30.1".to_string()),
436                artifact_path: "artifacts/root".to_string(),
437                checksum_algorithm: "sha256".to_string(),
438                checksum: Some(HASH.to_string()),
439            },
440        }
441    }
442
443    // Build a unique temporary directory.
444    fn temp_dir(prefix: &str) -> PathBuf {
445        let nanos = SystemTime::now()
446            .duration_since(UNIX_EPOCH)
447            .expect("system time after epoch")
448            .as_nanos();
449        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
450    }
451}