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#[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#[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#[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#[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#[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;