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