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