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 LEGACY_FLEET_STATE_IGNORED_WARNING_CODE: &str = "catalog.legacy_fleet_state_ignored";
19const LEGACY_FLEET_STATE_IGNORED_WARNING_MESSAGE: &str = "legacy fleet-named install state was ignored; catalog entries come only from current deployment-target state";
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("deployment target {deployment} is not known on network {network}")]
75 UnknownDeployment { network: String, deployment: String },
76
77 #[error("failed to read deployment catalog state directory {}: {source}", path.display())]
78 StateDirectory { path: PathBuf, source: io::Error },
79}
80
81#[must_use]
82pub const fn deployment_catalog_report_schema_id() -> &'static str {
83 DEPLOYMENT_CATALOG_REPORT_SCHEMA_ID
84}
85
86pub fn build_deployment_catalog_report(
87 request: &DeploymentCatalogRequest,
88) -> Result<DeploymentCatalogReportV1, DeploymentCatalogError> {
89 let deployments_dir = deployment_state_dir(&request.icp_root, &request.network);
90 let mut entries = Vec::new();
91 let mut warnings = Vec::new();
92
93 if !deployments_dir.exists() {
94 warnings.push(catalog_warning(
95 NO_DEPLOYMENT_STATE_WARNING_CODE,
96 format!(
97 "no deployment-target state exists for network {}",
98 request.network
99 ),
100 Some(path_subject(&deployments_dir, &request.icp_root)),
101 ));
102 append_legacy_state_warning(&request.icp_root, &request.network, &mut warnings);
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.network, &path) {
127 Ok(entry) => entries.push(entry),
128 Err(warning) => warnings.push(warning),
129 }
130 }
131
132 append_legacy_state_warning(&request.icp_root, &request.network, &mut warnings);
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 network: request.network.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(network) = &entry.network {
185 lines.push(format!(" network: {network}"));
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 network: &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 = serde_json::from_slice::<InstallState>(&bytes).map_err(|err| {
235 malformed_state_warning(path, root, format!("failed to decode state: {err}"))
236 })?;
237
238 if state.deployment_name != deployment {
239 return Err(malformed_state_warning(
240 path,
241 root,
242 format!(
243 "deployment state filename is {deployment}, but state records {}",
244 state.deployment_name
245 ),
246 ));
247 }
248 if state.network != network {
249 return Err(malformed_state_warning(
250 path,
251 root,
252 format!(
253 "deployment state is for network {}, but catalog network is {network}",
254 state.network
255 ),
256 ));
257 }
258
259 let (local_state_ref, mut warnings) =
260 match file_input_fingerprint("deployment_state", path, root, None, None) {
261 Ok(fingerprint) => (Some(fingerprint), Vec::new()),
262 Err(err) => (
263 None,
264 vec![catalog_warning(
265 LOCAL_STATE_FINGERPRINT_FAILED_WARNING_CODE,
266 format!("failed to fingerprint deployment state: {err}"),
267 Some(path_subject(path, root)),
268 )],
269 ),
270 };
271
272 warnings.sort_by(|left, right| left.code.cmp(&right.code));
273 Ok(DeploymentCatalogEntryV1 {
274 deployment: state.deployment_name,
275 fleet: Some(state.fleet_template),
276 network: Some(state.network),
277 root_principal: Some(state.root_canister_id),
278 root_verification: catalog_root_verification(&state.root_verification),
279 local_state_ref,
280 warnings,
281 })
282}
283
284fn append_legacy_state_warning(root: &Path, network: &str, warnings: &mut Vec<EvidenceMessageV1>) {
285 let path = root.join(".canic").join(network).join("fleets");
286 if path.exists() {
287 warnings.push(catalog_warning(
288 LEGACY_FLEET_STATE_IGNORED_WARNING_CODE,
289 LEGACY_FLEET_STATE_IGNORED_WARNING_MESSAGE,
290 Some(path_subject(&path, root)),
291 ));
292 }
293}
294
295fn deployment_state_dir(root: &Path, network: &str) -> PathBuf {
296 root.join(".canic").join(network).join("deployments")
297}
298
299const fn catalog_root_verification(
300 status: &RootVerificationStatus,
301) -> DeploymentCatalogRootVerificationV1 {
302 match status {
303 RootVerificationStatus::Verified => DeploymentCatalogRootVerificationV1::Verified,
304 RootVerificationStatus::NotVerified => DeploymentCatalogRootVerificationV1::NotVerified,
305 }
306}
307
308const fn root_verification_label(status: DeploymentCatalogRootVerificationV1) -> &'static str {
309 match status {
310 DeploymentCatalogRootVerificationV1::Unknown => "unknown",
311 DeploymentCatalogRootVerificationV1::NotVerified => "not_verified",
312 DeploymentCatalogRootVerificationV1::Verified => "verified",
313 }
314}
315
316fn malformed_state_warning(
317 path: &Path,
318 root: &Path,
319 message: impl Into<String>,
320) -> EvidenceMessageV1 {
321 catalog_warning(
322 MALFORMED_DEPLOYMENT_STATE_WARNING_CODE,
323 message,
324 Some(path_subject(path, root)),
325 )
326}
327
328fn catalog_warning(
329 code: &str,
330 message: impl Into<String>,
331 source: Option<String>,
332) -> EvidenceMessageV1 {
333 EvidenceMessageV1 {
334 code: code.to_string(),
335 message: message.into(),
336 severity: EvidenceMessageSeverityV1::Warning,
337 source,
338 related_input: None,
339 }
340}
341
342fn path_subject(path: &Path, root: &Path) -> String {
343 crate::evidence_envelope::command_path_for_root(path, root)
344}
345
346#[cfg(test)]
347mod tests;