Skip to main content

canic_host/
deployment_catalog.rs

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///
18/// DeploymentCatalogRequest
19///
20#[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///
28/// DeploymentCatalogReportV1
29///
30#[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///
40/// DeploymentCatalogEntryV1
41///
42#[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///
54/// DeploymentCatalogRootVerificationV1
55///
56#[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///
65/// DeploymentCatalogError
66///
67#[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}