Skip to main content

canic_host/deployment_catalog/
mod.rs

1use crate::{
2    evidence_envelope::{
3        EvidenceMessageSeverityV1, EvidenceMessageV1, InputFingerprintV1, file_input_fingerprint,
4    },
5    install_root::{
6        InstallStateError, RootVerificationStatus, decode_install_state, validate_environment_name,
7    },
8};
9use serde::{Deserialize, Serialize};
10use std::{
11    ffi::OsStr,
12    fs, io,
13    path::{Path, PathBuf},
14};
15use thiserror::Error as ThisError;
16
17const NO_DEPLOYMENT_STATE_WARNING_CODE: &str = "catalog.no_deployment_state";
18const LOCAL_STATE_FINGERPRINT_FAILED_WARNING_CODE: &str = "catalog.local_state_fingerprint_failed";
19const MALFORMED_DEPLOYMENT_STATE_WARNING_CODE: &str = "catalog.malformed_deployment_state";
20
21///
22/// DeploymentCatalogRequest
23///
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct DeploymentCatalogRequest {
26    pub icp_root: PathBuf,
27    pub environment: String,
28    pub generated_at: String,
29}
30
31///
32/// DeploymentCatalogReportV1
33///
34#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
35pub struct DeploymentCatalogReportV1 {
36    pub schema_version: u32,
37    pub generated_at: String,
38    pub project_root: Option<String>,
39    pub entries: Vec<DeploymentCatalogEntryV1>,
40    pub warnings: Vec<EvidenceMessageV1>,
41}
42
43///
44/// DeploymentCatalogEntryV1
45///
46#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
47pub struct DeploymentCatalogEntryV1 {
48    pub deployment: String,
49    pub fleet: Option<String>,
50    pub environment: Option<String>,
51    pub root_principal: Option<String>,
52    pub root_verification: DeploymentCatalogRootVerificationV1,
53    pub local_state_ref: Option<InputFingerprintV1>,
54    pub warnings: Vec<EvidenceMessageV1>,
55}
56
57///
58/// DeploymentCatalogRootVerificationV1
59///
60#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
61#[serde(rename_all = "snake_case")]
62pub enum DeploymentCatalogRootVerificationV1 {
63    Unknown,
64    NotVerified,
65    Verified,
66}
67
68///
69/// DeploymentCatalogError
70///
71#[derive(Debug, ThisError)]
72pub enum DeploymentCatalogError {
73    #[error(transparent)]
74    InstallState(#[from] InstallStateError),
75
76    #[error("deployment target {deployment} is not known on environment {environment}")]
77    UnknownDeployment {
78        environment: String,
79        deployment: String,
80    },
81
82    #[error("failed to read deployment catalog state directory {}: {source}", path.display())]
83    StateDirectory { path: PathBuf, source: io::Error },
84}
85
86pub fn build_deployment_catalog_report(
87    request: &DeploymentCatalogRequest,
88) -> Result<DeploymentCatalogReportV1, DeploymentCatalogError> {
89    validate_environment_name(&request.environment)?;
90    let deployments_dir = deployment_state_dir(&request.icp_root, &request.environment);
91    let mut entries = Vec::new();
92    let mut warnings = Vec::new();
93
94    if !deployments_dir.exists() {
95        warnings.push(catalog_warning(
96            NO_DEPLOYMENT_STATE_WARNING_CODE,
97            format!(
98                "no deployment-target state exists for environment {}",
99                request.environment
100            ),
101            Some(path_subject(&deployments_dir, &request.icp_root)),
102        ));
103        return Ok(report(request, entries, warnings));
104    }
105
106    let read_dir = fs::read_dir(&deployments_dir).map_err(|source| {
107        DeploymentCatalogError::StateDirectory {
108            path: deployments_dir.clone(),
109            source,
110        }
111    })?;
112
113    let mut paths = read_dir
114        .map(|entry| entry.map(|entry| entry.path()))
115        .collect::<Result<Vec<_>, _>>()
116        .map_err(|source| DeploymentCatalogError::StateDirectory {
117            path: deployments_dir.clone(),
118            source,
119        })?;
120    paths.sort();
121
122    for path in paths {
123        if path.extension() != Some(OsStr::new("json")) {
124            continue;
125        }
126        match catalog_entry_from_path(&request.icp_root, &request.environment, &path) {
127            Ok(entry) => entries.push(entry),
128            Err(warning) => warnings.push(warning),
129        }
130    }
131
132    entries.sort_by(|left, right| left.deployment.cmp(&right.deployment));
133    Ok(report(request, entries, warnings))
134}
135
136pub fn inspect_deployment_catalog_report(
137    request: &DeploymentCatalogRequest,
138    deployment: &str,
139) -> Result<DeploymentCatalogReportV1, DeploymentCatalogError> {
140    let mut report = build_deployment_catalog_report(request)?;
141    if let Some(entry) = report
142        .entries
143        .iter()
144        .find(|entry| entry.deployment == deployment)
145        .cloned()
146    {
147        report.entries = vec![entry];
148        return Ok(report);
149    }
150
151    Err(DeploymentCatalogError::UnknownDeployment {
152        environment: request.environment.clone(),
153        deployment: deployment.to_string(),
154    })
155}
156
157#[must_use]
158pub fn deployment_catalog_report_text(report: &DeploymentCatalogReportV1) -> String {
159    let mut lines = Vec::new();
160    lines.push("Deployment catalog:".to_string());
161    lines.push(format!("generated_at: {}", report.generated_at));
162    lines.push(format!("entries: {}", report.entries.len()));
163    if let Some(project_root) = &report.project_root {
164        lines.push(format!("project_root: {project_root}"));
165    }
166    if !report.warnings.is_empty() {
167        lines.push("warnings:".to_string());
168        for warning in &report.warnings {
169            lines.push(format!("  {}: {}", warning.code, warning.message));
170        }
171    }
172    if report.entries.is_empty() {
173        lines.push("deployments: none".to_string());
174        return lines.join("\n");
175    }
176
177    lines.push("deployments:".to_string());
178    for entry in &report.entries {
179        lines.push(format!("  {}", entry.deployment));
180        if let Some(fleet) = &entry.fleet {
181            lines.push(format!("    fleet: {fleet}"));
182        }
183        if let Some(environment) = &entry.environment {
184            lines.push(format!("    environment: {environment}"));
185        }
186        if let Some(root) = &entry.root_principal {
187            lines.push(format!("    root_principal: {root}"));
188        }
189        lines.push(format!(
190            "    root_verification: {}",
191            root_verification_label(entry.root_verification)
192        ));
193        if !entry.warnings.is_empty() {
194            lines.push("    warnings:".to_string());
195            for warning in &entry.warnings {
196                lines.push(format!("      {}: {}", warning.code, warning.message));
197            }
198        }
199    }
200
201    lines.join("\n")
202}
203
204fn report(
205    request: &DeploymentCatalogRequest,
206    entries: Vec<DeploymentCatalogEntryV1>,
207    warnings: Vec<EvidenceMessageV1>,
208) -> DeploymentCatalogReportV1 {
209    DeploymentCatalogReportV1 {
210        schema_version: 1,
211        generated_at: request.generated_at.clone(),
212        project_root: Some(".".to_string()),
213        entries,
214        warnings,
215    }
216}
217
218fn catalog_entry_from_path(
219    root: &Path,
220    environment: &str,
221    path: &Path,
222) -> Result<DeploymentCatalogEntryV1, EvidenceMessageV1> {
223    let deployment = path
224        .file_stem()
225        .and_then(OsStr::to_str)
226        .ok_or_else(|| {
227            malformed_state_warning(path, root, "deployment state file name is not UTF-8")
228        })?
229        .to_string();
230    let bytes = fs::read(path).map_err(|err| {
231        malformed_state_warning(path, root, format!("failed to read state: {err}"))
232    })?;
233    let state = decode_install_state(&bytes, path, environment, &deployment)
234        .map_err(|error| malformed_install_state_warning(path, root, error))?;
235
236    let (local_state_ref, mut warnings) =
237        match file_input_fingerprint("deployment_state", path, root, None, None) {
238            Ok(fingerprint) => (Some(fingerprint), Vec::new()),
239            Err(err) => (
240                None,
241                vec![catalog_warning(
242                    LOCAL_STATE_FINGERPRINT_FAILED_WARNING_CODE,
243                    format!("failed to fingerprint deployment state: {err}"),
244                    Some(path_subject(path, root)),
245                )],
246            ),
247        };
248
249    warnings.sort_by(|left, right| left.code.cmp(&right.code));
250    Ok(DeploymentCatalogEntryV1 {
251        deployment: state.deployment_name,
252        fleet: Some(state.fleet_template),
253        environment: Some(state.environment),
254        root_principal: Some(state.root_canister_id),
255        root_verification: catalog_root_verification(&state.root_verification),
256        local_state_ref,
257        warnings,
258    })
259}
260
261fn malformed_install_state_warning(
262    path: &Path,
263    root: &Path,
264    error: InstallStateError,
265) -> EvidenceMessageV1 {
266    let message = match error {
267        InstallStateError::Decode { source, .. } => format!("failed to decode state: {source}"),
268        InstallStateError::DeploymentMismatch {
269            state_deployment,
270            requested_deployment,
271        } => format!(
272            "deployment state filename is {requested_deployment}, but state records {state_deployment}"
273        ),
274        InstallStateError::EnvironmentMismatch {
275            state_environment,
276            requested_environment,
277        } => format!(
278            "deployment state is for environment {state_environment}, but catalog environment is {requested_environment}"
279        ),
280        other => other.to_string(),
281    };
282    malformed_state_warning(path, root, message)
283}
284
285fn deployment_state_dir(root: &Path, environment: &str) -> PathBuf {
286    root.join(".canic").join(environment).join("deployments")
287}
288
289const fn catalog_root_verification(
290    status: &RootVerificationStatus,
291) -> DeploymentCatalogRootVerificationV1 {
292    match status {
293        RootVerificationStatus::Verified => DeploymentCatalogRootVerificationV1::Verified,
294        RootVerificationStatus::NotVerified => DeploymentCatalogRootVerificationV1::NotVerified,
295    }
296}
297
298const fn root_verification_label(status: DeploymentCatalogRootVerificationV1) -> &'static str {
299    match status {
300        DeploymentCatalogRootVerificationV1::Unknown => "unknown",
301        DeploymentCatalogRootVerificationV1::NotVerified => "not_verified",
302        DeploymentCatalogRootVerificationV1::Verified => "verified",
303    }
304}
305
306fn malformed_state_warning(
307    path: &Path,
308    root: &Path,
309    message: impl Into<String>,
310) -> EvidenceMessageV1 {
311    catalog_warning(
312        MALFORMED_DEPLOYMENT_STATE_WARNING_CODE,
313        message,
314        Some(path_subject(path, root)),
315    )
316}
317
318fn catalog_warning(
319    code: &str,
320    message: impl Into<String>,
321    source: Option<String>,
322) -> EvidenceMessageV1 {
323    EvidenceMessageV1 {
324        code: code.to_string(),
325        message: message.into(),
326        severity: EvidenceMessageSeverityV1::Warning,
327        source,
328        related_input: None,
329    }
330}
331
332fn path_subject(path: &Path, root: &Path) -> String {
333    crate::evidence_envelope::command_path_for_root(path, root)
334}
335
336#[cfg(test)]
337mod tests;