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::{BTreeMap, 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}
74
75#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
76#[serde(deny_unknown_fields)]
77struct FleetCatalogRecord {
78 schema_version: u32,
79 canonical_network_id: CanonicalNetworkId,
80 entries: Vec<FleetCatalogEntryV1>,
81}
82
83#[derive(Debug, ThisError)]
88pub enum FleetCatalogError {
89 #[error(transparent)]
90 Network(#[from] NetworkIdentityError),
91
92 #[error("Fleet name is invalid: {0}")]
93 FleetName(#[from] FleetNameParseError),
94
95 #[error("Fleet {fleet_name} is not known on canonical network {canonical_network_id}")]
96 UnknownFleet {
97 canonical_network_id: CanonicalNetworkId,
98 fleet_name: FleetName,
99 },
100
101 #[error("Fleet catalog is not a regular non-symlink file: {}", path.display())]
102 NotRegular { path: PathBuf },
103
104 #[error("Fleet catalog is unsupported on platform {0}")]
105 UnsupportedPlatform(&'static str),
106
107 #[error("failed to read Fleet catalog {}: {source}", path.display())]
108 Read {
109 path: PathBuf,
110 #[source]
111 source: io::Error,
112 },
113
114 #[error("failed to decode Fleet catalog {}: {source}", path.display())]
115 Decode {
116 path: PathBuf,
117 #[source]
118 source: serde_json::Error,
119 },
120
121 #[error("invalid Fleet catalog {}: {reason}", path.display())]
122 Invalid { path: PathBuf, reason: String },
123}
124
125pub fn build_fleet_catalog_report(
127 request: &FleetCatalogRequest,
128) -> Result<FleetCatalogReportV1, FleetCatalogError> {
129 validate_environment_name(&request.environment)?;
130 let canonical_network_id =
131 resolve_canonical_network_id_from_root(&request.project_root, &request.environment)?;
132 let path = fleet_catalog_path(&request.project_root, canonical_network_id);
133 let entries = match read_catalog(&path, canonical_network_id)? {
134 Some(catalog) => catalog.entries,
135 None => Vec::new(),
136 };
137
138 Ok(FleetCatalogReportV1 {
139 schema_version: FLEET_CATALOG_SCHEMA_VERSION,
140 generated_at: request.generated_at.clone(),
141 project_root: Some(".".to_string()),
142 canonical_network_id,
143 environment: request.environment.clone(),
144 entries,
145 })
146}
147
148pub fn inspect_fleet_catalog_report(
150 request: &FleetCatalogRequest,
151 fleet_name: &str,
152) -> Result<FleetCatalogReportV1, FleetCatalogError> {
153 let mut report = build_fleet_catalog_report(request)?;
154 let entry = require_fleet_catalog_entry(&report, fleet_name)?;
155 report.entries = vec![entry];
156 Ok(report)
157}
158
159pub fn read_fleet_catalog_entry_from_root(
162 project_root: &Path,
163 environment: &str,
164 fleet_name: &str,
165) -> Result<Option<FleetCatalogEntryV1>, FleetCatalogError> {
166 let fleet_name = fleet_name.parse::<FleetName>()?;
167 let report = build_fleet_catalog_report(&FleetCatalogRequest {
168 project_root: project_root.to_path_buf(),
169 environment: environment.to_string(),
170 generated_at: String::new(),
171 })?;
172 Ok(report
173 .entries
174 .into_iter()
175 .find(|entry| entry.fleet_name == fleet_name))
176}
177
178fn require_fleet_catalog_entry(
179 report: &FleetCatalogReportV1,
180 fleet_name: &str,
181) -> Result<FleetCatalogEntryV1, FleetCatalogError> {
182 let fleet_name = fleet_name.parse::<FleetName>()?;
183 report
184 .entries
185 .iter()
186 .find(|entry| entry.fleet_name == fleet_name)
187 .cloned()
188 .ok_or(FleetCatalogError::UnknownFleet {
189 canonical_network_id: report.canonical_network_id,
190 fleet_name,
191 })
192}
193
194#[must_use]
195pub fn fleet_catalog_report_text(report: &FleetCatalogReportV1) -> String {
196 let mut lines = vec![
197 "Fleet catalog:".to_string(),
198 format!("generated_at: {}", report.generated_at),
199 format!("network: {}", report.canonical_network_id),
200 format!("environment: {}", report.environment),
201 format!("entries: {}", report.entries.len()),
202 ];
203 if let Some(project_root) = &report.project_root {
204 lines.push(format!("project_root: {project_root}"));
205 }
206 if report.entries.is_empty() {
207 lines.push("fleets: none".to_string());
208 return lines.join("\n");
209 }
210
211 lines.push("fleets:".to_string());
212 for entry in &report.entries {
213 lines.push(format!(" {}", entry.fleet_name));
214 lines.push(format!(" fleet_id: {}", entry.fleet_id));
215 lines.push(format!(" app: {}", entry.app));
216 lines.push(format!(" environment: {}", entry.environment));
217 lines.push(format!(" root_principal: {}", entry.root_principal));
218 }
219 lines.join("\n")
220}
221
222fn read_catalog(
223 path: &Path,
224 canonical_network_id: CanonicalNetworkId,
225) -> Result<Option<FleetCatalogRecord>, FleetCatalogError> {
226 read_catalog_document(path, canonical_network_id)
227}
228
229fn read_catalog_document(
230 path: &Path,
231 canonical_network_id: CanonicalNetworkId,
232) -> Result<Option<FleetCatalogRecord>, FleetCatalogError> {
233 let Some(bytes) = read_optional_regular_bytes(path).map_err(|error| match error {
234 RegularFileReadError::NotRegular => FleetCatalogError::NotRegular {
235 path: path.to_path_buf(),
236 },
237 RegularFileReadError::Io(source) => FleetCatalogError::Read {
238 path: path.to_path_buf(),
239 source,
240 },
241 #[cfg(not(unix))]
242 RegularFileReadError::UnsupportedPlatform => {
243 FleetCatalogError::UnsupportedPlatform(std::env::consts::OS)
244 }
245 })?
246 else {
247 return Ok(None);
248 };
249 let catalog = serde_json::from_slice::<FleetCatalogRecord>(&bytes).map_err(|source| {
250 FleetCatalogError::Decode {
251 path: path.to_path_buf(),
252 source,
253 }
254 })?;
255 validate_catalog(path, &catalog, canonical_network_id)?;
256 Ok(Some(catalog))
257}
258
259fn validate_catalog(
260 path: &Path,
261 catalog: &FleetCatalogRecord,
262 canonical_network_id: CanonicalNetworkId,
263) -> Result<(), FleetCatalogError> {
264 if catalog.schema_version != FLEET_CATALOG_SCHEMA_VERSION {
265 return invalid(
266 path,
267 format!(
268 "schema version {} is not supported; expected {}",
269 catalog.schema_version, FLEET_CATALOG_SCHEMA_VERSION
270 ),
271 );
272 }
273 if catalog.canonical_network_id != canonical_network_id {
274 return invalid(
275 path,
276 format!(
277 "catalog network {} does not match resolved network {canonical_network_id}",
278 catalog.canonical_network_id
279 ),
280 );
281 }
282
283 let mut previous_name: Option<&FleetName> = None;
284 let mut fleet_ids = BTreeSet::new();
285 let mut root_principals = BTreeMap::new();
286 for entry in &catalog.entries {
287 if entry.canonical_network_id != canonical_network_id {
288 return invalid(
289 path,
290 format!(
291 "Fleet {} records network {}, not {canonical_network_id}",
292 entry.fleet_name, entry.canonical_network_id
293 ),
294 );
295 }
296 if previous_name.is_some_and(|previous| previous >= &entry.fleet_name) {
297 return invalid(
298 path,
299 "Fleet entries must be strictly ordered by fleet_name".to_string(),
300 );
301 }
302 if !fleet_ids.insert(entry.fleet_id) {
303 return invalid(
304 path,
305 format!("Fleet ID {} appears more than once", entry.fleet_id),
306 );
307 }
308 validate_canonical_name(entry.app.as_str()).map_err(|reason| {
309 FleetCatalogError::Invalid {
310 path: path.to_path_buf(),
311 reason: format!("App {} {reason}", entry.app),
312 }
313 })?;
314 validate_environment_name(&entry.environment)?;
315 let root_principal = Principal::from_text(&entry.root_principal).map_err(|error| {
316 FleetCatalogError::Invalid {
317 path: path.to_path_buf(),
318 reason: format!(
319 "Fleet {} has invalid root principal: {error}",
320 entry.fleet_name
321 ),
322 }
323 })?;
324 if let Some(first) = root_principals.insert(root_principal, &entry.fleet_name) {
325 return invalid(
326 path,
327 format!(
328 "root principal {} belongs to both Fleet {first} and {}",
329 entry.root_principal, entry.fleet_name
330 ),
331 );
332 }
333 previous_name = Some(&entry.fleet_name);
334 }
335 Ok(())
336}
337
338fn validate_canonical_name(value: &str) -> Result<(), String> {
339 if value.is_empty() {
340 return Err("must not be empty".to_string());
341 }
342 if value.len() > CANONICAL_NAME_MAX_BYTES {
343 return Err(format!("must not exceed {CANONICAL_NAME_MAX_BYTES} bytes"));
344 }
345 if !value
346 .bytes()
347 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
348 {
349 return Err("must use only ASCII letters, numbers, '-' or '_'".to_string());
350 }
351 Ok(())
352}
353
354fn invalid<T>(path: &Path, reason: String) -> Result<T, FleetCatalogError> {
355 Err(FleetCatalogError::Invalid {
356 path: path.to_path_buf(),
357 reason,
358 })
359}
360
361fn fleet_catalog_path(project_root: &Path, canonical_network_id: CanonicalNetworkId) -> PathBuf {
362 project_root
363 .join(".canic")
364 .join("networks")
365 .join(canonical_network_id.to_string())
366 .join(FLEET_CATALOG_RELATIVE_PATH)
367}