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#[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#[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#[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#[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#[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
87pub fn build_deployment_catalog_report(
88 request: &DeploymentCatalogRequest,
89) -> Result<DeploymentCatalogReportV1, DeploymentCatalogError> {
90 validate_environment_name(&request.environment)?;
91 let deployments_dir = deployment_state_dir(&request.icp_root, &request.environment);
92 let mut entries = Vec::new();
93 let mut warnings = Vec::new();
94
95 if !deployments_dir.exists() {
96 warnings.push(catalog_warning(
97 NO_DEPLOYMENT_STATE_WARNING_CODE,
98 format!(
99 "no deployment-target state exists for environment {}",
100 request.environment
101 ),
102 Some(path_subject(&deployments_dir, &request.icp_root)),
103 ));
104 return Ok(report(request, entries, warnings));
105 }
106
107 let read_dir = fs::read_dir(&deployments_dir).map_err(|source| {
108 DeploymentCatalogError::StateDirectory {
109 path: deployments_dir.clone(),
110 source,
111 }
112 })?;
113
114 let mut paths = read_dir
115 .map(|entry| entry.map(|entry| entry.path()))
116 .collect::<Result<Vec<_>, _>>()
117 .map_err(|source| DeploymentCatalogError::StateDirectory {
118 path: deployments_dir.clone(),
119 source,
120 })?;
121 paths.sort();
122
123 for path in paths {
124 if path.extension() != Some(OsStr::new("json")) {
125 continue;
126 }
127 match catalog_entry_from_path(&request.icp_root, &request.environment, &path) {
128 Ok(entry) => entries.push(entry),
129 Err(warning) => warnings.push(warning),
130 }
131 }
132
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 environment: request.environment.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(environment) = &entry.environment {
185 lines.push(format!(" environment: {environment}"));
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 environment: &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 = decode_install_state(&bytes, path, environment, &deployment)
235 .map_err(|error| malformed_install_state_warning(path, root, error))?;
236
237 let (local_state_ref, mut warnings) =
238 match file_input_fingerprint("deployment_state", path, root, None, None) {
239 Ok(fingerprint) => (Some(fingerprint), Vec::new()),
240 Err(err) => (
241 None,
242 vec![catalog_warning(
243 LOCAL_STATE_FINGERPRINT_FAILED_WARNING_CODE,
244 format!("failed to fingerprint deployment state: {err}"),
245 Some(path_subject(path, root)),
246 )],
247 ),
248 };
249
250 warnings.sort_by(|left, right| left.code.cmp(&right.code));
251 Ok(DeploymentCatalogEntryV1 {
252 deployment: state.deployment_name,
253 fleet: Some(state.fleet_template),
254 environment: Some(state.environment),
255 root_principal: Some(state.root_canister_id),
256 root_verification: catalog_root_verification(&state.root_verification),
257 local_state_ref,
258 warnings,
259 })
260}
261
262fn malformed_install_state_warning(
263 path: &Path,
264 root: &Path,
265 error: InstallStateError,
266) -> EvidenceMessageV1 {
267 let message = match error {
268 InstallStateError::Decode { source, .. } => format!("failed to decode state: {source}"),
269 InstallStateError::DeploymentMismatch {
270 state_deployment,
271 requested_deployment,
272 } => format!(
273 "deployment state filename is {requested_deployment}, but state records {state_deployment}"
274 ),
275 InstallStateError::EnvironmentMismatch {
276 state_environment,
277 requested_environment,
278 } => format!(
279 "deployment state is for environment {state_environment}, but catalog environment is {requested_environment}"
280 ),
281 other => other.to_string(),
282 };
283 malformed_state_warning(path, root, message)
284}
285
286fn deployment_state_dir(root: &Path, environment: &str) -> PathBuf {
287 root.join(".canic").join(environment).join("deployments")
288}
289
290const fn catalog_root_verification(
291 status: &RootVerificationStatus,
292) -> DeploymentCatalogRootVerificationV1 {
293 match status {
294 RootVerificationStatus::Verified => DeploymentCatalogRootVerificationV1::Verified,
295 RootVerificationStatus::NotVerified => DeploymentCatalogRootVerificationV1::NotVerified,
296 }
297}
298
299const fn root_verification_label(status: DeploymentCatalogRootVerificationV1) -> &'static str {
300 match status {
301 DeploymentCatalogRootVerificationV1::Unknown => "unknown",
302 DeploymentCatalogRootVerificationV1::NotVerified => "not_verified",
303 DeploymentCatalogRootVerificationV1::Verified => "verified",
304 }
305}
306
307fn malformed_state_warning(
308 path: &Path,
309 root: &Path,
310 message: impl Into<String>,
311) -> EvidenceMessageV1 {
312 catalog_warning(
313 MALFORMED_DEPLOYMENT_STATE_WARNING_CODE,
314 message,
315 Some(path_subject(path, root)),
316 )
317}
318
319fn catalog_warning(
320 code: &str,
321 message: impl Into<String>,
322 source: Option<String>,
323) -> EvidenceMessageV1 {
324 EvidenceMessageV1 {
325 code: code.to_string(),
326 message: message.into(),
327 severity: EvidenceMessageSeverityV1::Warning,
328 source,
329 related_input: None,
330 }
331}
332
333fn path_subject(path: &Path, root: &Path) -> String {
334 crate::evidence_envelope::command_path_for_root(path, root)
335}
336
337#[cfg(test)]
338mod tests;