1use crate::{
2 evidence_envelope::{
3 EvidenceMessageSeverityV1, EvidenceMessageV1, InputFingerprintV1, file_input_fingerprint,
4 },
5 install_root::{InstallState, RootVerificationStatus},
6};
7use serde::{Deserialize, Serialize};
8use std::{
9 ffi::OsStr,
10 fs, io,
11 path::{Path, PathBuf},
12};
13use thiserror::Error as ThisError;
14
15pub const DEPLOYMENT_CATALOG_REPORT_SCHEMA_ID: &str = "canic.deployment_catalog_report.v1";
16
17#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct DeploymentCatalogRequest {
22 pub icp_root: PathBuf,
23 pub network: String,
24 pub generated_at: String,
25}
26
27#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
31pub struct DeploymentCatalogReportV1 {
32 pub schema_version: u32,
33 pub generated_at: String,
34 pub project_root: Option<String>,
35 pub entries: Vec<DeploymentCatalogEntryV1>,
36 pub warnings: Vec<EvidenceMessageV1>,
37}
38
39#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
43pub struct DeploymentCatalogEntryV1 {
44 pub deployment: String,
45 pub fleet: Option<String>,
46 pub network: Option<String>,
47 pub root_principal: Option<String>,
48 pub root_verification: DeploymentCatalogRootVerificationV1,
49 pub local_state_ref: Option<InputFingerprintV1>,
50 pub warnings: Vec<EvidenceMessageV1>,
51}
52
53#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
57#[serde(rename_all = "snake_case")]
58pub enum DeploymentCatalogRootVerificationV1 {
59 Unknown,
60 NotVerified,
61 Verified,
62}
63
64#[derive(Debug, ThisError)]
68pub enum DeploymentCatalogError {
69 #[error("deployment target {deployment} is not known on network {network}")]
70 UnknownDeployment { network: String, deployment: String },
71
72 #[error("failed to read deployment catalog state directory {}: {source}", path.display())]
73 StateDirectory { path: PathBuf, source: io::Error },
74}
75
76#[must_use]
77pub const fn deployment_catalog_report_schema_id() -> &'static str {
78 DEPLOYMENT_CATALOG_REPORT_SCHEMA_ID
79}
80
81pub fn build_deployment_catalog_report(
82 request: &DeploymentCatalogRequest,
83) -> Result<DeploymentCatalogReportV1, DeploymentCatalogError> {
84 let deployments_dir = deployment_state_dir(&request.icp_root, &request.network);
85 let mut entries = Vec::new();
86 let mut warnings = Vec::new();
87
88 if !deployments_dir.exists() {
89 warnings.push(catalog_warning(
90 "catalog.no_deployment_state",
91 format!(
92 "no deployment-target state exists for network {}",
93 request.network
94 ),
95 Some(path_subject(&deployments_dir, &request.icp_root)),
96 ));
97 append_legacy_state_warning(&request.icp_root, &request.network, &mut warnings);
98 return Ok(report(request, entries, warnings));
99 }
100
101 let read_dir = fs::read_dir(&deployments_dir).map_err(|source| {
102 DeploymentCatalogError::StateDirectory {
103 path: deployments_dir.clone(),
104 source,
105 }
106 })?;
107
108 let mut paths = read_dir
109 .map(|entry| entry.map(|entry| entry.path()))
110 .collect::<Result<Vec<_>, _>>()
111 .map_err(|source| DeploymentCatalogError::StateDirectory {
112 path: deployments_dir.clone(),
113 source,
114 })?;
115 paths.sort();
116
117 for path in paths {
118 if path.extension() != Some(OsStr::new("json")) {
119 continue;
120 }
121 match catalog_entry_from_path(&request.icp_root, &request.network, &path) {
122 Ok(entry) => entries.push(entry),
123 Err(warning) => warnings.push(warning),
124 }
125 }
126
127 append_legacy_state_warning(&request.icp_root, &request.network, &mut warnings);
128 entries.sort_by(|left, right| left.deployment.cmp(&right.deployment));
129 Ok(report(request, entries, warnings))
130}
131
132pub fn inspect_deployment_catalog_report(
133 request: &DeploymentCatalogRequest,
134 deployment: &str,
135) -> Result<DeploymentCatalogReportV1, DeploymentCatalogError> {
136 let mut report = build_deployment_catalog_report(request)?;
137 if let Some(entry) = report
138 .entries
139 .iter()
140 .find(|entry| entry.deployment == deployment)
141 .cloned()
142 {
143 report.entries = vec![entry];
144 return Ok(report);
145 }
146
147 Err(DeploymentCatalogError::UnknownDeployment {
148 network: request.network.clone(),
149 deployment: deployment.to_string(),
150 })
151}
152
153#[must_use]
154pub fn deployment_catalog_report_text(report: &DeploymentCatalogReportV1) -> String {
155 let mut lines = Vec::new();
156 lines.push("Deployment catalog:".to_string());
157 lines.push(format!("generated_at: {}", report.generated_at));
158 lines.push(format!("entries: {}", report.entries.len()));
159 if let Some(project_root) = &report.project_root {
160 lines.push(format!("project_root: {project_root}"));
161 }
162 if !report.warnings.is_empty() {
163 lines.push("warnings:".to_string());
164 for warning in &report.warnings {
165 lines.push(format!(" {}: {}", warning.code, warning.message));
166 }
167 }
168 if report.entries.is_empty() {
169 lines.push("deployments: none".to_string());
170 return lines.join("\n");
171 }
172
173 lines.push("deployments:".to_string());
174 for entry in &report.entries {
175 lines.push(format!(" {}", entry.deployment));
176 if let Some(fleet) = &entry.fleet {
177 lines.push(format!(" fleet: {fleet}"));
178 }
179 if let Some(network) = &entry.network {
180 lines.push(format!(" network: {network}"));
181 }
182 if let Some(root) = &entry.root_principal {
183 lines.push(format!(" root_principal: {root}"));
184 }
185 lines.push(format!(
186 " root_verification: {}",
187 root_verification_label(entry.root_verification)
188 ));
189 if !entry.warnings.is_empty() {
190 lines.push(" warnings:".to_string());
191 for warning in &entry.warnings {
192 lines.push(format!(" {}: {}", warning.code, warning.message));
193 }
194 }
195 }
196
197 lines.join("\n")
198}
199
200fn report(
201 request: &DeploymentCatalogRequest,
202 entries: Vec<DeploymentCatalogEntryV1>,
203 warnings: Vec<EvidenceMessageV1>,
204) -> DeploymentCatalogReportV1 {
205 DeploymentCatalogReportV1 {
206 schema_version: 1,
207 generated_at: request.generated_at.clone(),
208 project_root: Some(".".to_string()),
209 entries,
210 warnings,
211 }
212}
213
214fn catalog_entry_from_path(
215 root: &Path,
216 network: &str,
217 path: &Path,
218) -> Result<DeploymentCatalogEntryV1, EvidenceMessageV1> {
219 let deployment = path
220 .file_stem()
221 .and_then(OsStr::to_str)
222 .ok_or_else(|| {
223 malformed_state_warning(path, root, "deployment state file name is not UTF-8")
224 })?
225 .to_string();
226 let bytes = fs::read(path).map_err(|err| {
227 malformed_state_warning(path, root, format!("failed to read state: {err}"))
228 })?;
229 let state = serde_json::from_slice::<InstallState>(&bytes).map_err(|err| {
230 malformed_state_warning(path, root, format!("failed to decode state: {err}"))
231 })?;
232
233 if state.deployment_name != deployment {
234 return Err(malformed_state_warning(
235 path,
236 root,
237 format!(
238 "deployment state filename is {deployment}, but state records {}",
239 state.deployment_name
240 ),
241 ));
242 }
243 if state.network != network {
244 return Err(malformed_state_warning(
245 path,
246 root,
247 format!(
248 "deployment state is for network {}, but catalog network is {network}",
249 state.network
250 ),
251 ));
252 }
253
254 let (local_state_ref, mut warnings) =
255 match file_input_fingerprint("deployment_state", path, root, None, None) {
256 Ok(fingerprint) => (Some(fingerprint), Vec::new()),
257 Err(err) => (
258 None,
259 vec![catalog_warning(
260 "catalog.local_state_fingerprint_failed",
261 format!("failed to fingerprint deployment state: {err}"),
262 Some(path_subject(path, root)),
263 )],
264 ),
265 };
266
267 warnings.sort_by(|left, right| left.code.cmp(&right.code));
268 Ok(DeploymentCatalogEntryV1 {
269 deployment: state.deployment_name,
270 fleet: Some(state.fleet_template),
271 network: Some(state.network),
272 root_principal: Some(state.root_canister_id),
273 root_verification: catalog_root_verification(&state.root_verification),
274 local_state_ref,
275 warnings,
276 })
277}
278
279fn append_legacy_state_warning(root: &Path, network: &str, warnings: &mut Vec<EvidenceMessageV1>) {
280 let path = root.join(".canic").join(network).join("fleets");
281 if path.exists() {
282 warnings.push(catalog_warning(
283 "catalog.legacy_fleet_state_ignored",
284 "legacy fleet-named install state was ignored; catalog entries come only from deployment-target state",
285 Some(path_subject(&path, root)),
286 ));
287 }
288}
289
290fn deployment_state_dir(root: &Path, network: &str) -> PathBuf {
291 root.join(".canic").join(network).join("deployments")
292}
293
294const fn catalog_root_verification(
295 status: &RootVerificationStatus,
296) -> DeploymentCatalogRootVerificationV1 {
297 match status {
298 RootVerificationStatus::Verified => DeploymentCatalogRootVerificationV1::Verified,
299 RootVerificationStatus::NotVerified => DeploymentCatalogRootVerificationV1::NotVerified,
300 }
301}
302
303const fn root_verification_label(status: DeploymentCatalogRootVerificationV1) -> &'static str {
304 match status {
305 DeploymentCatalogRootVerificationV1::Unknown => "unknown",
306 DeploymentCatalogRootVerificationV1::NotVerified => "not_verified",
307 DeploymentCatalogRootVerificationV1::Verified => "verified",
308 }
309}
310
311fn malformed_state_warning(
312 path: &Path,
313 root: &Path,
314 message: impl Into<String>,
315) -> EvidenceMessageV1 {
316 catalog_warning(
317 "catalog.malformed_deployment_state",
318 message,
319 Some(path_subject(path, root)),
320 )
321}
322
323fn catalog_warning(
324 code: &str,
325 message: impl Into<String>,
326 source: Option<String>,
327) -> EvidenceMessageV1 {
328 EvidenceMessageV1 {
329 code: code.to_string(),
330 message: message.into(),
331 severity: EvidenceMessageSeverityV1::Warning,
332 source,
333 related_input: None,
334 }
335}
336
337fn path_subject(path: &Path, root: &Path) -> String {
338 crate::evidence_envelope::command_path_for_root(path, root)
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::test_support::temp_dir;
345
346 #[test]
347 fn catalog_lists_deployment_target_state_sorted_by_deployment() {
348 let root = temp_dir("canic-catalog-list");
349 write_state(&root, "local", sample_state("zeta", "demo", "root-z"));
350 write_state(&root, "local", sample_state("alpha", "demo", "root-a"));
351 let request = request(&root);
352
353 let report = build_deployment_catalog_report(&request).expect("catalog");
354
355 fs::remove_dir_all(root).expect("clean");
356 assert_eq!(
357 report
358 .entries
359 .iter()
360 .map(|entry| entry.deployment.as_str())
361 .collect::<Vec<_>>(),
362 vec!["alpha", "zeta"]
363 );
364 assert_eq!(report.entries[0].fleet.as_deref(), Some("demo"));
365 assert_eq!(report.entries[0].network.as_deref(), Some("local"));
366 assert_eq!(report.entries[0].root_principal.as_deref(), Some("root-a"));
367 assert_eq!(
368 report.entries[0].root_verification,
369 DeploymentCatalogRootVerificationV1::Verified
370 );
371 let state_ref = report.entries[0]
372 .local_state_ref
373 .as_ref()
374 .expect("fingerprint");
375 assert_eq!(state_ref.kind, "deployment_state");
376 assert_eq!(
377 state_ref.path.as_deref(),
378 Some(".canic/local/deployments/alpha.json")
379 );
380 }
381
382 #[test]
383 fn catalog_returns_empty_warning_when_deployment_state_is_missing() {
384 let root = temp_dir("canic-catalog-empty");
385 fs::create_dir_all(&root).expect("create temp root");
386 let request = request(&root);
387
388 let report = build_deployment_catalog_report(&request).expect("catalog");
389
390 fs::remove_dir_all(root).expect("clean");
391 assert!(report.entries.is_empty());
392 assert!(
393 report
394 .warnings
395 .iter()
396 .any(|warning| warning.code == "catalog.no_deployment_state")
397 );
398 }
399
400 #[test]
401 fn catalog_ignores_legacy_fleet_state() {
402 let root = temp_dir("canic-catalog-legacy");
403 let legacy = root.join(".canic/local/fleets");
404 fs::create_dir_all(&legacy).expect("legacy dir");
405 fs::write(legacy.join("demo.json"), "{}").expect("legacy state");
406 let request = request(&root);
407
408 let report = build_deployment_catalog_report(&request).expect("catalog");
409
410 fs::remove_dir_all(root).expect("clean");
411 assert!(report.entries.is_empty());
412 assert!(
413 report
414 .warnings
415 .iter()
416 .any(|warning| warning.code == "catalog.legacy_fleet_state_ignored")
417 );
418 }
419
420 #[test]
421 fn catalog_warns_and_keeps_valid_entries_when_one_entry_is_malformed() {
422 let root = temp_dir("canic-catalog-malformed");
423 write_state(&root, "local", sample_state("demo", "demo", "root"));
424 let dir = root.join(".canic/local/deployments");
425 fs::write(dir.join("broken.json"), "{not-json").expect("broken state");
426 let request = request(&root);
427
428 let report = build_deployment_catalog_report(&request).expect("catalog");
429
430 fs::remove_dir_all(root).expect("clean");
431 assert_eq!(report.entries.len(), 1);
432 assert_eq!(report.entries[0].deployment, "demo");
433 assert!(
434 report
435 .warnings
436 .iter()
437 .any(|warning| warning.code == "catalog.malformed_deployment_state")
438 );
439 }
440
441 #[test]
442 fn catalog_inspect_filters_known_deployment() {
443 let root = temp_dir("canic-catalog-inspect");
444 write_state(&root, "local", sample_state("alpha", "demo", "root-a"));
445 write_state(&root, "local", sample_state("beta", "demo", "root-b"));
446 let request = request(&root);
447
448 let report = inspect_deployment_catalog_report(&request, "beta").expect("inspect");
449
450 fs::remove_dir_all(root).expect("clean");
451 assert_eq!(report.entries.len(), 1);
452 assert_eq!(report.entries[0].deployment, "beta");
453 }
454
455 #[test]
456 fn catalog_inspect_rejects_unknown_deployment() {
457 let root = temp_dir("canic-catalog-unknown");
458 write_state(&root, "local", sample_state("alpha", "demo", "root-a"));
459 let request = request(&root);
460
461 let err =
462 inspect_deployment_catalog_report(&request, "demo").expect_err("unknown deployment");
463
464 fs::remove_dir_all(root).expect("clean");
465 assert!(matches!(
466 err,
467 DeploymentCatalogError::UnknownDeployment { deployment, .. } if deployment == "demo"
468 ));
469 }
470
471 #[test]
472 fn catalog_text_uses_deployment_target_terms() {
473 let root = temp_dir("canic-catalog-text");
474 write_state(&root, "local", sample_state("demo-local", "demo", "root"));
475 let request = request(&root);
476 let report = build_deployment_catalog_report(&request).expect("catalog");
477
478 let text = deployment_catalog_report_text(&report);
479
480 fs::remove_dir_all(root).expect("clean");
481 assert!(text.contains("Deployment catalog:"));
482 assert!(text.contains("demo-local"));
483 assert!(text.contains("root_verification: verified"));
484 assert!(!text.contains("fleet template catalog"));
485 }
486
487 fn request(root: &Path) -> DeploymentCatalogRequest {
488 DeploymentCatalogRequest {
489 icp_root: root.to_path_buf(),
490 network: "local".to_string(),
491 generated_at: "unix:54".to_string(),
492 }
493 }
494
495 fn write_state(root: &Path, network: &str, state: InstallState) {
496 let path = root
497 .join(".canic")
498 .join(network)
499 .join("deployments")
500 .join(format!("{}.json", state.deployment_name));
501 fs::create_dir_all(path.parent().expect("state parent")).expect("state dir");
502 fs::write(path, serde_json::to_vec_pretty(&state).expect("state json"))
503 .expect("write state");
504 }
505
506 fn sample_state(deployment: &str, fleet: &str, root: &str) -> InstallState {
507 InstallState {
508 schema_version: 2,
509 deployment_name: deployment.to_string(),
510 fleet_template: fleet.to_string(),
511 created_at_unix_secs: 1,
512 updated_at_unix_secs: 2,
513 network: "local".to_string(),
514 root_target: "root".to_string(),
515 root_canister_id: root.to_string(),
516 root_verification: RootVerificationStatus::Verified,
517 root_build_target: "root".to_string(),
518 workspace_root: ".".to_string(),
519 icp_root: ".".to_string(),
520 config_path: "fleets/demo/canic.toml".to_string(),
521 release_set_manifest_path: ".canic/local/release-set.json".to_string(),
522 }
523 }
524}