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