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