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
41///
42/// ManifestValidateOptions
43///
44
45#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct ManifestValidateOptions {
47    pub manifest: PathBuf,
48    pub out: Option<PathBuf>,
49}
50
51impl ManifestValidateOptions {
52    /// Parse manifest validation options from CLI arguments.
53    pub fn parse<I>(args: I) -> Result<Self, ManifestCommandError>
54    where
55        I: IntoIterator<Item = OsString>,
56    {
57        let mut manifest = None;
58        let mut out = None;
59
60        let mut args = args.into_iter();
61        while let Some(arg) = args.next() {
62            let arg = arg
63                .into_string()
64                .map_err(|_| ManifestCommandError::Usage(usage()))?;
65            match arg.as_str() {
66                "--manifest" => {
67                    manifest = Some(PathBuf::from(next_value(&mut args, "--manifest")?));
68                }
69                "--out" => out = Some(PathBuf::from(next_value(&mut args, "--out")?)),
70                "--help" | "-h" => return Err(ManifestCommandError::Usage(usage())),
71                _ => return Err(ManifestCommandError::UnknownOption(arg)),
72            }
73        }
74
75        Ok(Self {
76            manifest: manifest.ok_or(ManifestCommandError::MissingOption("--manifest"))?,
77            out,
78        })
79    }
80}
81
82/// Run a manifest subcommand.
83pub fn run<I>(args: I) -> Result<(), ManifestCommandError>
84where
85    I: IntoIterator<Item = OsString>,
86{
87    let mut args = args.into_iter();
88    let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
89        return Err(ManifestCommandError::Usage(usage()));
90    };
91
92    match command.as_str() {
93        "validate" => {
94            let options = ManifestValidateOptions::parse(args)?;
95            let manifest = validate_manifest(&options)?;
96            write_validation_summary(&options, &manifest)?;
97            Ok(())
98        }
99        "help" | "--help" | "-h" => Err(ManifestCommandError::Usage(usage())),
100        _ => Err(ManifestCommandError::UnknownOption(command)),
101    }
102}
103
104/// Read and validate a fleet backup manifest from disk.
105pub fn validate_manifest(
106    options: &ManifestValidateOptions,
107) -> Result<FleetBackupManifest, ManifestCommandError> {
108    let data = fs::read_to_string(&options.manifest)?;
109    let manifest: FleetBackupManifest = serde_json::from_str(&data)?;
110    manifest.validate()?;
111    Ok(manifest)
112}
113
114// Write a concise validation summary for shell use.
115fn write_validation_summary(
116    options: &ManifestValidateOptions,
117    manifest: &FleetBackupManifest,
118) -> Result<(), ManifestCommandError> {
119    let summary = manifest_validation_summary(manifest);
120
121    if let Some(path) = &options.out {
122        let data = serde_json::to_vec_pretty(&summary)?;
123        fs::write(path, data)?;
124        return Ok(());
125    }
126
127    let stdout = io::stdout();
128    let mut handle = stdout.lock();
129    serde_json::to_writer_pretty(&mut handle, &summary)?;
130    writeln!(handle)?;
131    Ok(())
132}
133
134// Build the manifest validation summary emitted by CLI and preflight workflows.
135fn manifest_validation_summary(manifest: &FleetBackupManifest) -> serde_json::Value {
136    json!({
137        "status": "valid",
138        "backup_id": manifest.backup_id,
139        "members": manifest.fleet.members.len(),
140        "backup_unit_count": manifest.consistency.backup_units.len(),
141        "consistency_mode": consistency_mode_name(&manifest.consistency.mode),
142        "topology_hash": manifest.fleet.topology_hash,
143        "topology_hash_algorithm": manifest.fleet.topology_hash_algorithm,
144        "topology_hash_input": manifest.fleet.topology_hash_input,
145        "topology_validation_status": "validated",
146        "backup_unit_kinds": backup_unit_kind_counts(manifest),
147        "backup_units": manifest
148            .consistency
149            .backup_units
150            .iter()
151            .map(|unit| json!({
152                "unit_id": unit.unit_id,
153                "kind": backup_unit_kind_name(&unit.kind),
154                "role_count": unit.roles.len(),
155                "dependency_count": unit.dependency_closure.len(),
156                "topology_validation": unit.topology_validation,
157            }))
158            .collect::<Vec<_>>(),
159    })
160}
161
162// Count backup units by stable serialized kind name.
163fn backup_unit_kind_counts(manifest: &FleetBackupManifest) -> serde_json::Value {
164    let mut whole_fleet = 0;
165    let mut control_plane_subset = 0;
166    let mut subtree_rooted = 0;
167    let mut flat = 0;
168    for unit in &manifest.consistency.backup_units {
169        match &unit.kind {
170            BackupUnitKind::WholeFleet => whole_fleet += 1,
171            BackupUnitKind::ControlPlaneSubset => control_plane_subset += 1,
172            BackupUnitKind::SubtreeRooted => subtree_rooted += 1,
173            BackupUnitKind::Flat => flat += 1,
174        }
175    }
176
177    json!({
178        "whole_fleet": whole_fleet,
179        "control_plane_subset": control_plane_subset,
180        "subtree_rooted": subtree_rooted,
181        "flat": flat,
182    })
183}
184
185// Return the stable serialized name for a consistency mode.
186const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
187    match mode {
188        ConsistencyMode::CrashConsistent => "crash-consistent",
189        ConsistencyMode::QuiescedUnit => "quiesced-unit",
190    }
191}
192
193// Return the stable serialized name for a backup unit kind.
194const fn backup_unit_kind_name(kind: &BackupUnitKind) -> &'static str {
195    match kind {
196        BackupUnitKind::WholeFleet => "whole-fleet",
197        BackupUnitKind::ControlPlaneSubset => "control-plane-subset",
198        BackupUnitKind::SubtreeRooted => "subtree-rooted",
199        BackupUnitKind::Flat => "flat",
200    }
201}
202
203// Read the next required option value.
204fn next_value<I>(args: &mut I, option: &'static str) -> Result<String, ManifestCommandError>
205where
206    I: Iterator<Item = OsString>,
207{
208    args.next()
209        .and_then(|value| value.into_string().ok())
210        .ok_or(ManifestCommandError::MissingValue(option))
211}
212
213// Return manifest command usage text.
214const fn usage() -> &'static str {
215    "usage: canic manifest validate --manifest <file> [--out <file>]"
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use canic_backup::manifest::{
222        BackupUnit, BackupUnitKind, ConsistencyMode, ConsistencySection, FleetMember, FleetSection,
223        IdentityMode, SourceMetadata, SourceSnapshot, ToolMetadata, VerificationCheck,
224        VerificationPlan,
225    };
226    use std::time::{SystemTime, UNIX_EPOCH};
227
228    const ROOT: &str = "aaaaa-aa";
229    const HASH: &str = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
230
231    // Ensure manifest validation options parse the intended command shape.
232    #[test]
233    fn parses_manifest_validate_options() {
234        let options = ManifestValidateOptions::parse([
235            OsString::from("--manifest"),
236            OsString::from("manifest.json"),
237            OsString::from("--out"),
238            OsString::from("summary.json"),
239        ])
240        .expect("parse options");
241
242        assert_eq!(options.manifest, PathBuf::from("manifest.json"));
243        assert_eq!(options.out, Some(PathBuf::from("summary.json")));
244    }
245
246    // Ensure manifest validation loads JSON and runs the manifest contract.
247    #[test]
248    fn validate_manifest_reads_and_validates_manifest() {
249        let root = temp_dir("canic-cli-manifest-validate");
250        fs::create_dir_all(&root).expect("create temp root");
251        let manifest_path = root.join("manifest.json");
252
253        fs::write(
254            &manifest_path,
255            serde_json::to_vec(&valid_manifest()).expect("serialize manifest"),
256        )
257        .expect("write manifest");
258
259        let options = ManifestValidateOptions {
260            manifest: manifest_path,
261            out: None,
262        };
263
264        let manifest = validate_manifest(&options).expect("validate manifest");
265
266        fs::remove_dir_all(root).expect("remove temp root");
267        assert_eq!(manifest.backup_id, "backup-test");
268        assert_eq!(manifest.fleet.members.len(), 1);
269    }
270
271    // Ensure manifest validation summaries can be written for automation.
272    #[test]
273    fn write_validation_summary_writes_out_file() {
274        let root = temp_dir("canic-cli-manifest-summary");
275        fs::create_dir_all(&root).expect("create temp root");
276        let out = root.join("summary.json");
277        let options = ManifestValidateOptions {
278            manifest: root.join("manifest.json"),
279            out: Some(out.clone()),
280        };
281
282        write_validation_summary(&options, &valid_manifest()).expect("write summary");
283        let summary: serde_json::Value =
284            serde_json::from_slice(&fs::read(&out).expect("read summary")).expect("parse summary");
285
286        fs::remove_dir_all(root).expect("remove temp root");
287        assert_eq!(summary["status"], "valid");
288        assert_eq!(summary["backup_id"], "backup-test");
289        assert_eq!(summary["members"], 1);
290        assert_eq!(summary["backup_unit_count"], 1);
291        assert_eq!(summary["consistency_mode"], "crash-consistent");
292        assert_eq!(summary["topology_validation_status"], "validated");
293        assert_eq!(summary["backup_unit_kinds"]["subtree_rooted"], 1);
294        assert_eq!(summary["backup_units"][0]["unit_id"], "fleet");
295        assert_eq!(summary["backup_units"][0]["kind"], "subtree-rooted");
296        assert_eq!(summary["backup_units"][0]["role_count"], 1);
297    }
298
299    // Build one valid manifest for validation tests.
300    fn valid_manifest() -> FleetBackupManifest {
301        FleetBackupManifest {
302            manifest_version: 1,
303            backup_id: "backup-test".to_string(),
304            created_at: "2026-05-03T00:00:00Z".to_string(),
305            tool: ToolMetadata {
306                name: "canic".to_string(),
307                version: "0.30.1".to_string(),
308            },
309            source: SourceMetadata {
310                environment: "local".to_string(),
311                root_canister: ROOT.to_string(),
312            },
313            consistency: ConsistencySection {
314                mode: ConsistencyMode::CrashConsistent,
315                backup_units: vec![BackupUnit {
316                    unit_id: "fleet".to_string(),
317                    kind: BackupUnitKind::SubtreeRooted,
318                    roles: vec!["root".to_string()],
319                    consistency_reason: None,
320                    dependency_closure: Vec::new(),
321                    topology_validation: "subtree-closed".to_string(),
322                    quiescence_strategy: None,
323                }],
324            },
325            fleet: FleetSection {
326                topology_hash_algorithm: "sha256".to_string(),
327                topology_hash_input: "sorted(pid,parent_pid,role,module_hash)".to_string(),
328                discovery_topology_hash: HASH.to_string(),
329                pre_snapshot_topology_hash: HASH.to_string(),
330                topology_hash: HASH.to_string(),
331                members: vec![fleet_member()],
332            },
333            verification: VerificationPlan::default(),
334        }
335    }
336
337    // Build one valid manifest member.
338    fn fleet_member() -> FleetMember {
339        FleetMember {
340            role: "root".to_string(),
341            canister_id: ROOT.to_string(),
342            parent_canister_id: None,
343            subnet_canister_id: Some(ROOT.to_string()),
344            controller_hint: None,
345            identity_mode: IdentityMode::Fixed,
346            restore_group: 1,
347            verification_class: "basic".to_string(),
348            verification_checks: vec![VerificationCheck {
349                kind: "status".to_string(),
350                method: None,
351                roles: vec!["root".to_string()],
352            }],
353            source_snapshot: SourceSnapshot {
354                snapshot_id: "root-snapshot".to_string(),
355                module_hash: None,
356                wasm_hash: None,
357                code_version: Some("v0.30.1".to_string()),
358                artifact_path: "artifacts/root".to_string(),
359                checksum_algorithm: "sha256".to_string(),
360                checksum: None,
361            },
362        }
363    }
364
365    // Build a unique temporary directory.
366    fn temp_dir(prefix: &str) -> PathBuf {
367        let nanos = SystemTime::now()
368            .duration_since(UNIX_EPOCH)
369            .expect("system time after epoch")
370            .as_nanos();
371        std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
372    }
373}