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