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