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#[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#[derive(Clone, Debug, Eq, PartialEq)]
46pub struct ManifestValidateOptions {
47 pub manifest: PathBuf,
48 pub out: Option<PathBuf>,
49}
50
51impl ManifestValidateOptions {
52 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
82pub 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
104pub 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
114fn 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
134fn 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
162fn 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
185const fn consistency_mode_name(mode: &ConsistencyMode) -> &'static str {
187 match mode {
188 ConsistencyMode::CrashConsistent => "crash-consistent",
189 ConsistencyMode::QuiescedUnit => "quiesced-unit",
190 }
191}
192
193const 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
203fn 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
213const 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 #[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 #[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 #[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 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 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 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}