1#[cfg(test)]
8mod tests;
9
10use crate::{
11 durable_io::{RegularFileReadError, read_optional_regular_bytes},
12 network::{
13 NetworkIdentityError, resolve_canonical_network_id_from_root, validate_environment_name,
14 },
15};
16use canic_core::{
17 cdk::types::Principal,
18 ids::{AppId, CanonicalNetworkId, FleetId, FleetName, FleetNameParseError},
19};
20use serde::{Deserialize, Serialize};
21use std::{
22 collections::BTreeSet,
23 io,
24 path::{Path, PathBuf},
25};
26use thiserror::Error as ThisError;
27
28const FLEET_CATALOG_SCHEMA_VERSION: u32 = 1;
29const FLEET_CATALOG_RELATIVE_PATH: &str = "fleets/catalog.json";
30const CANONICAL_NAME_MAX_BYTES: usize = 40;
31
32#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct FleetCatalogRequest {
38 pub project_root: PathBuf,
39 pub environment: String,
40 pub generated_at: String,
41}
42
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48#[serde(deny_unknown_fields)]
49pub struct FleetCatalogReportV1 {
50 pub schema_version: u32,
51 pub generated_at: String,
52 pub project_root: Option<String>,
53 pub canonical_network_id: CanonicalNetworkId,
54 pub environment: String,
55 pub entries: Vec<FleetCatalogEntryV1>,
56}
57
58#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
63#[serde(deny_unknown_fields)]
64pub struct FleetCatalogEntryV1 {
65 pub canonical_network_id: CanonicalNetworkId,
66 pub fleet_id: FleetId,
67 pub fleet_name: FleetName,
68 pub app: AppId,
69 pub environment: String,
71 pub deployed_at_unix_secs: u64,
72 pub root_principal: String,
73 pub root_verification: FleetCatalogRootVerificationV1,
74}
75
76#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
81pub enum FleetCatalogRootVerificationV1 {
82 #[serde(rename = "not_verified")]
83 NotVerified,
84 #[serde(rename = "verified")]
85 Verified,
86}
87
88#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
89#[serde(deny_unknown_fields)]
90struct FleetCatalogRecord {
91 schema_version: u32,
92 canonical_network_id: CanonicalNetworkId,
93 entries: Vec<FleetCatalogEntryV1>,
94}
95
96#[derive(Debug, ThisError)]
101pub enum FleetCatalogError {
102 #[error(transparent)]
103 Network(#[from] NetworkIdentityError),
104
105 #[error("Fleet name is invalid: {0}")]
106 FleetName(#[from] FleetNameParseError),
107
108 #[error("Fleet {fleet_name} is not known on canonical network {canonical_network_id}")]
109 UnknownFleet {
110 canonical_network_id: CanonicalNetworkId,
111 fleet_name: FleetName,
112 },
113
114 #[error("Fleet catalog is not a regular non-symlink file: {}", path.display())]
115 NotRegular { path: PathBuf },
116
117 #[error("Fleet catalog is unsupported on platform {0}")]
118 UnsupportedPlatform(&'static str),
119
120 #[error("failed to read Fleet catalog {}: {source}", path.display())]
121 Read {
122 path: PathBuf,
123 #[source]
124 source: io::Error,
125 },
126
127 #[error("failed to decode Fleet catalog {}: {source}", path.display())]
128 Decode {
129 path: PathBuf,
130 #[source]
131 source: serde_json::Error,
132 },
133
134 #[error("invalid Fleet catalog {}: {reason}", path.display())]
135 Invalid { path: PathBuf, reason: String },
136}
137
138pub fn build_fleet_catalog_report(
140 request: &FleetCatalogRequest,
141) -> Result<FleetCatalogReportV1, FleetCatalogError> {
142 validate_environment_name(&request.environment)?;
143 let canonical_network_id =
144 resolve_canonical_network_id_from_root(&request.project_root, &request.environment)?;
145 let path = fleet_catalog_path(&request.project_root, canonical_network_id);
146 let entries = match read_catalog(&path, canonical_network_id)? {
147 Some(catalog) => catalog.entries,
148 None => Vec::new(),
149 };
150
151 Ok(FleetCatalogReportV1 {
152 schema_version: FLEET_CATALOG_SCHEMA_VERSION,
153 generated_at: request.generated_at.clone(),
154 project_root: Some(".".to_string()),
155 canonical_network_id,
156 environment: request.environment.clone(),
157 entries,
158 })
159}
160
161pub fn inspect_fleet_catalog_report(
163 request: &FleetCatalogRequest,
164 fleet_name: &str,
165) -> Result<FleetCatalogReportV1, FleetCatalogError> {
166 let fleet_name = fleet_name.parse::<FleetName>()?;
167 let mut report = build_fleet_catalog_report(request)?;
168 let entry = report
169 .entries
170 .iter()
171 .find(|entry| entry.fleet_name == fleet_name)
172 .cloned()
173 .ok_or(FleetCatalogError::UnknownFleet {
174 canonical_network_id: report.canonical_network_id,
175 fleet_name,
176 })?;
177 report.entries = vec![entry];
178 Ok(report)
179}
180
181#[must_use]
182pub fn fleet_catalog_report_text(report: &FleetCatalogReportV1) -> String {
183 let mut lines = vec![
184 "Fleet catalog:".to_string(),
185 format!("generated_at: {}", report.generated_at),
186 format!("network: {}", report.canonical_network_id),
187 format!("environment: {}", report.environment),
188 format!("entries: {}", report.entries.len()),
189 ];
190 if let Some(project_root) = &report.project_root {
191 lines.push(format!("project_root: {project_root}"));
192 }
193 if report.entries.is_empty() {
194 lines.push("fleets: none".to_string());
195 return lines.join("\n");
196 }
197
198 lines.push("fleets:".to_string());
199 for entry in &report.entries {
200 lines.push(format!(" {}", entry.fleet_name));
201 lines.push(format!(" fleet_id: {}", entry.fleet_id));
202 lines.push(format!(" app: {}", entry.app));
203 lines.push(format!(" environment: {}", entry.environment));
204 lines.push(format!(" root_principal: {}", entry.root_principal));
205 lines.push(format!(
206 " root_verification: {}",
207 root_verification_label(entry.root_verification)
208 ));
209 }
210 lines.join("\n")
211}
212
213fn read_catalog(
214 path: &Path,
215 canonical_network_id: CanonicalNetworkId,
216) -> Result<Option<FleetCatalogRecord>, FleetCatalogError> {
217 let Some(bytes) = read_optional_regular_bytes(path).map_err(|error| match error {
218 RegularFileReadError::NotRegular => FleetCatalogError::NotRegular {
219 path: path.to_path_buf(),
220 },
221 RegularFileReadError::Io(source) => FleetCatalogError::Read {
222 path: path.to_path_buf(),
223 source,
224 },
225 #[cfg(not(unix))]
226 RegularFileReadError::UnsupportedPlatform => {
227 FleetCatalogError::UnsupportedPlatform(std::env::consts::OS)
228 }
229 })?
230 else {
231 return Ok(None);
232 };
233 let catalog = serde_json::from_slice::<FleetCatalogRecord>(&bytes).map_err(|source| {
234 FleetCatalogError::Decode {
235 path: path.to_path_buf(),
236 source,
237 }
238 })?;
239 validate_catalog(path, &catalog, canonical_network_id)?;
240 Ok(Some(catalog))
241}
242
243fn validate_catalog(
244 path: &Path,
245 catalog: &FleetCatalogRecord,
246 canonical_network_id: CanonicalNetworkId,
247) -> Result<(), FleetCatalogError> {
248 if catalog.schema_version != FLEET_CATALOG_SCHEMA_VERSION {
249 return invalid(
250 path,
251 format!(
252 "schema version {} is not supported; expected {}",
253 catalog.schema_version, FLEET_CATALOG_SCHEMA_VERSION
254 ),
255 );
256 }
257 if catalog.canonical_network_id != canonical_network_id {
258 return invalid(
259 path,
260 format!(
261 "catalog network {} does not match resolved network {canonical_network_id}",
262 catalog.canonical_network_id
263 ),
264 );
265 }
266
267 let mut previous_name: Option<&FleetName> = None;
268 let mut fleet_ids = BTreeSet::new();
269 for entry in &catalog.entries {
270 if entry.canonical_network_id != canonical_network_id {
271 return invalid(
272 path,
273 format!(
274 "Fleet {} records network {}, not {canonical_network_id}",
275 entry.fleet_name, entry.canonical_network_id
276 ),
277 );
278 }
279 if previous_name.is_some_and(|previous| previous >= &entry.fleet_name) {
280 return invalid(
281 path,
282 "Fleet entries must be strictly ordered by fleet_name".to_string(),
283 );
284 }
285 if !fleet_ids.insert(entry.fleet_id) {
286 return invalid(
287 path,
288 format!("Fleet ID {} appears more than once", entry.fleet_id),
289 );
290 }
291 validate_canonical_name(entry.app.as_str()).map_err(|reason| {
292 FleetCatalogError::Invalid {
293 path: path.to_path_buf(),
294 reason: format!("App {} {reason}", entry.app),
295 }
296 })?;
297 validate_environment_name(&entry.environment)?;
298 Principal::from_text(&entry.root_principal).map_err(|error| {
299 FleetCatalogError::Invalid {
300 path: path.to_path_buf(),
301 reason: format!(
302 "Fleet {} has invalid root principal: {error}",
303 entry.fleet_name
304 ),
305 }
306 })?;
307 previous_name = Some(&entry.fleet_name);
308 }
309 Ok(())
310}
311
312fn validate_canonical_name(value: &str) -> Result<(), String> {
313 if value.is_empty() {
314 return Err("must not be empty".to_string());
315 }
316 if value.len() > CANONICAL_NAME_MAX_BYTES {
317 return Err(format!("must not exceed {CANONICAL_NAME_MAX_BYTES} bytes"));
318 }
319 if !value
320 .bytes()
321 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
322 {
323 return Err("must use only ASCII letters, numbers, '-' or '_'".to_string());
324 }
325 Ok(())
326}
327
328fn invalid<T>(path: &Path, reason: String) -> Result<T, FleetCatalogError> {
329 Err(FleetCatalogError::Invalid {
330 path: path.to_path_buf(),
331 reason,
332 })
333}
334
335fn fleet_catalog_path(project_root: &Path, canonical_network_id: CanonicalNetworkId) -> PathBuf {
336 project_root
337 .join(".canic")
338 .join("networks")
339 .join(canonical_network_id.to_string())
340 .join(FLEET_CATALOG_RELATIVE_PATH)
341}
342
343const fn root_verification_label(status: FleetCatalogRootVerificationV1) -> &'static str {
344 match status {
345 FleetCatalogRootVerificationV1::NotVerified => "not_verified",
346 FleetCatalogRootVerificationV1::Verified => "verified",
347 }
348}