1#[cfg(test)]
8mod tests;
9
10use crate::{
11 durable_io::{
12 RegularFileLockError, RegularFileReadError, lock_regular_file_with_parents,
13 read_optional_regular_bytes, write_bytes,
14 },
15 network::{
16 NetworkIdentityError, resolve_canonical_network_id_from_root, validate_environment_name,
17 },
18};
19use canic_core::{
20 cdk::types::Principal,
21 ids::{AppId, CanonicalNetworkId, FleetId, FleetName, FleetNameParseError},
22};
23use serde::{Deserialize, Serialize};
24use sha2::{Digest, Sha256};
25use std::{
26 collections::{BTreeMap, BTreeSet},
27 io,
28 path::{Path, PathBuf},
29};
30use thiserror::Error as ThisError;
31
32const FLEET_CATALOG_SCHEMA_VERSION: u32 = 1;
33const FLEET_CATALOG_RELATIVE_PATH: &str = "fleets/catalog.json";
34const CANONICAL_NAME_MAX_BYTES: usize = 40;
35
36#[derive(Clone, Debug, Eq, PartialEq)]
41pub struct FleetCatalogRequest {
42 pub project_root: PathBuf,
43 pub environment: String,
44 pub generated_at: String,
45}
46
47#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
52#[serde(deny_unknown_fields)]
53pub struct FleetCatalogReportV1 {
54 pub schema_version: u32,
55 pub generated_at: String,
56 pub project_root: Option<String>,
57 pub canonical_network_id: CanonicalNetworkId,
58 pub environment: String,
59 pub entries: Vec<FleetCatalogEntryV1>,
60}
61
62#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
67#[serde(deny_unknown_fields)]
68pub struct FleetCatalogEntryV1 {
69 pub canonical_network_id: CanonicalNetworkId,
70 pub fleet_id: FleetId,
71 pub fleet_name: FleetName,
72 pub app: AppId,
73 pub environment: String,
75 pub deployed_at_unix_secs: u64,
76 pub root_principal: String,
77}
78
79#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
80#[serde(deny_unknown_fields)]
81struct FleetCatalogRecord {
82 schema_version: u32,
83 canonical_network_id: CanonicalNetworkId,
84 entries: Vec<FleetCatalogEntryV1>,
85}
86
87#[derive(Clone, Debug, Eq, PartialEq)]
92pub(crate) struct CommittedFleetCatalog {
93 entry: FleetCatalogEntryV1,
94 catalog_hash: [u8; 32],
95 path: PathBuf,
96 advanced: bool,
97}
98
99impl CommittedFleetCatalog {
100 pub(crate) const fn entry(&self) -> &FleetCatalogEntryV1 {
101 &self.entry
102 }
103
104 pub(crate) const fn catalog_hash(&self) -> [u8; 32] {
105 self.catalog_hash
106 }
107
108 pub(crate) fn belongs_to(&self, project_root: &Path) -> bool {
109 self.path == fleet_catalog_path(project_root, self.entry.canonical_network_id)
110 }
111}
112
113#[derive(Debug, ThisError)]
118pub enum FleetCatalogError {
119 #[error(transparent)]
120 Network(#[from] NetworkIdentityError),
121
122 #[error("Fleet name is invalid: {0}")]
123 FleetName(#[from] FleetNameParseError),
124
125 #[error("Fleet {fleet_name} is not known on canonical network {canonical_network_id}")]
126 UnknownFleet {
127 canonical_network_id: CanonicalNetworkId,
128 fleet_name: FleetName,
129 },
130
131 #[error("Fleet catalog is not a regular non-symlink file: {}", path.display())]
132 NotRegular { path: PathBuf },
133
134 #[error("Fleet catalog is unsupported on platform {0}")]
135 UnsupportedPlatform(&'static str),
136
137 #[error("failed to read Fleet catalog {}: {source}", path.display())]
138 Read {
139 path: PathBuf,
140 #[source]
141 source: io::Error,
142 },
143
144 #[error("failed to commit Fleet catalog {}: {source}", path.display())]
145 Write {
146 path: PathBuf,
147 #[source]
148 source: io::Error,
149 },
150
151 #[error("Fleet catalog commitment conflicts with existing {field} authority: {value}")]
152 Conflict { field: &'static str, value: String },
153
154 #[error("failed to decode Fleet catalog {}: {source}", path.display())]
155 Decode {
156 path: PathBuf,
157 #[source]
158 source: serde_json::Error,
159 },
160
161 #[error("failed to encode Fleet catalog: {source}")]
162 Encode {
163 #[source]
164 source: serde_json::Error,
165 },
166
167 #[error("invalid Fleet catalog {}: {reason}", path.display())]
168 Invalid { path: PathBuf, reason: String },
169}
170
171pub fn build_fleet_catalog_report(
173 request: &FleetCatalogRequest,
174) -> Result<FleetCatalogReportV1, FleetCatalogError> {
175 validate_environment_name(&request.environment)?;
176 let canonical_network_id =
177 resolve_canonical_network_id_from_root(&request.project_root, &request.environment)?;
178 let path = fleet_catalog_path(&request.project_root, canonical_network_id);
179 let entries = match read_catalog(&path, canonical_network_id)? {
180 Some(catalog) => catalog.entries,
181 None => Vec::new(),
182 };
183
184 Ok(FleetCatalogReportV1 {
185 schema_version: FLEET_CATALOG_SCHEMA_VERSION,
186 generated_at: request.generated_at.clone(),
187 project_root: Some(".".to_string()),
188 canonical_network_id,
189 environment: request.environment.clone(),
190 entries,
191 })
192}
193
194pub fn inspect_fleet_catalog_report(
196 request: &FleetCatalogRequest,
197 fleet_name: &str,
198) -> Result<FleetCatalogReportV1, FleetCatalogError> {
199 let mut report = build_fleet_catalog_report(request)?;
200 let entry = require_fleet_catalog_entry(&report, fleet_name)?;
201 report.entries = vec![entry];
202 Ok(report)
203}
204
205pub fn read_fleet_catalog_entry_from_root(
208 project_root: &Path,
209 environment: &str,
210 fleet_name: &str,
211) -> Result<Option<FleetCatalogEntryV1>, FleetCatalogError> {
212 let fleet_name = fleet_name.parse::<FleetName>()?;
213 let report = build_fleet_catalog_report(&FleetCatalogRequest {
214 project_root: project_root.to_path_buf(),
215 environment: environment.to_string(),
216 generated_at: String::new(),
217 })?;
218 Ok(report
219 .entries
220 .into_iter()
221 .find(|entry| entry.fleet_name == fleet_name))
222}
223
224pub(crate) fn commit_fleet_catalog_entry(
226 project_root: &Path,
227 entry: FleetCatalogEntryV1,
228) -> Result<CommittedFleetCatalog, FleetCatalogError> {
229 let path = fleet_catalog_path(project_root, entry.canonical_network_id);
230 let lock_path = fleet_catalog_lock_path(project_root, entry.canonical_network_id);
231 let _lock = lock_regular_file_with_parents(&lock_path).map_err(|error| match error {
232 RegularFileLockError::NotRegular => FleetCatalogError::NotRegular {
233 path: lock_path.clone(),
234 },
235 RegularFileLockError::Io(source) => FleetCatalogError::Write {
236 path: lock_path.clone(),
237 source,
238 },
239 #[cfg(windows)]
240 RegularFileLockError::UnsupportedPlatform => {
241 FleetCatalogError::UnsupportedPlatform(std::env::consts::OS)
242 }
243 })?;
244
245 let existing = read_catalog_document(&path, entry.canonical_network_id)?;
246 let mut catalog = existing.as_ref().map_or_else(
247 || FleetCatalogRecord {
248 schema_version: FLEET_CATALOG_SCHEMA_VERSION,
249 canonical_network_id: entry.canonical_network_id,
250 entries: Vec::new(),
251 },
252 |document| document.record.clone(),
253 );
254 if let Some(existing_entry) = existing_authority(&catalog.entries, &entry)? {
255 let bytes = existing
256 .expect("existing authority came from an existing catalog")
257 .bytes;
258 return Ok(CommittedFleetCatalog {
259 entry: existing_entry.clone(),
260 catalog_hash: Sha256::digest(&bytes).into(),
261 path,
262 advanced: false,
263 });
264 }
265
266 catalog.entries.push(entry.clone());
267 catalog
268 .entries
269 .sort_by(|left, right| left.fleet_name.cmp(&right.fleet_name));
270 validate_catalog(&path, &catalog, entry.canonical_network_id)?;
271 let bytes = canonical_catalog_bytes(&catalog)?;
272 write_bytes(&path, &bytes).map_err(|source| FleetCatalogError::Write {
273 path: path.clone(),
274 source,
275 })?;
276 let durable = read_catalog_document(&path, entry.canonical_network_id)?.ok_or_else(|| {
277 FleetCatalogError::Invalid {
278 path: path.clone(),
279 reason: "committed Fleet catalog is missing after publication".to_string(),
280 }
281 })?;
282 if durable.record != catalog || durable.bytes != bytes {
283 return invalid(
284 &path,
285 "committed Fleet catalog differs from the exact publication bytes".to_string(),
286 );
287 }
288 Ok(CommittedFleetCatalog {
289 entry,
290 catalog_hash: Sha256::digest(&bytes).into(),
291 path,
292 advanced: true,
293 })
294}
295
296pub(crate) fn read_fleet_catalog_entry_for_network(
298 project_root: &Path,
299 canonical_network_id: CanonicalNetworkId,
300 fleet_name: &FleetName,
301) -> Result<Option<FleetCatalogEntryV1>, FleetCatalogError> {
302 let path = fleet_catalog_path(project_root, canonical_network_id);
303 Ok(
304 read_catalog(&path, canonical_network_id)?.and_then(|catalog| {
305 catalog
306 .entries
307 .into_iter()
308 .find(|entry| entry.fleet_name == *fleet_name)
309 }),
310 )
311}
312
313fn require_fleet_catalog_entry(
314 report: &FleetCatalogReportV1,
315 fleet_name: &str,
316) -> Result<FleetCatalogEntryV1, FleetCatalogError> {
317 let fleet_name = fleet_name.parse::<FleetName>()?;
318 report
319 .entries
320 .iter()
321 .find(|entry| entry.fleet_name == fleet_name)
322 .cloned()
323 .ok_or(FleetCatalogError::UnknownFleet {
324 canonical_network_id: report.canonical_network_id,
325 fleet_name,
326 })
327}
328
329#[must_use]
330pub fn fleet_catalog_report_text(report: &FleetCatalogReportV1) -> String {
331 let mut lines = vec![
332 "Fleet catalog:".to_string(),
333 format!("generated_at: {}", report.generated_at),
334 format!("network: {}", report.canonical_network_id),
335 format!("environment: {}", report.environment),
336 format!("entries: {}", report.entries.len()),
337 ];
338 if let Some(project_root) = &report.project_root {
339 lines.push(format!("project_root: {project_root}"));
340 }
341 if report.entries.is_empty() {
342 lines.push("fleets: none".to_string());
343 return lines.join("\n");
344 }
345
346 lines.push("fleets:".to_string());
347 for entry in &report.entries {
348 lines.push(format!(" {}", entry.fleet_name));
349 lines.push(format!(" fleet_id: {}", entry.fleet_id));
350 lines.push(format!(" app: {}", entry.app));
351 lines.push(format!(" environment: {}", entry.environment));
352 lines.push(format!(" root_principal: {}", entry.root_principal));
353 }
354 lines.join("\n")
355}
356
357fn read_catalog(
358 path: &Path,
359 canonical_network_id: CanonicalNetworkId,
360) -> Result<Option<FleetCatalogRecord>, FleetCatalogError> {
361 Ok(read_catalog_document(path, canonical_network_id)?.map(|document| document.record))
362}
363
364struct FleetCatalogDocument {
365 record: FleetCatalogRecord,
366 bytes: Vec<u8>,
367}
368
369fn read_catalog_document(
370 path: &Path,
371 canonical_network_id: CanonicalNetworkId,
372) -> Result<Option<FleetCatalogDocument>, FleetCatalogError> {
373 let Some(bytes) = read_optional_regular_bytes(path).map_err(|error| match error {
374 RegularFileReadError::NotRegular => FleetCatalogError::NotRegular {
375 path: path.to_path_buf(),
376 },
377 RegularFileReadError::Io(source) => FleetCatalogError::Read {
378 path: path.to_path_buf(),
379 source,
380 },
381 #[cfg(not(unix))]
382 RegularFileReadError::UnsupportedPlatform => {
383 FleetCatalogError::UnsupportedPlatform(std::env::consts::OS)
384 }
385 })?
386 else {
387 return Ok(None);
388 };
389 let catalog = serde_json::from_slice::<FleetCatalogRecord>(&bytes).map_err(|source| {
390 FleetCatalogError::Decode {
391 path: path.to_path_buf(),
392 source,
393 }
394 })?;
395 validate_catalog(path, &catalog, canonical_network_id)?;
396 Ok(Some(FleetCatalogDocument {
397 record: catalog,
398 bytes,
399 }))
400}
401
402fn validate_catalog(
403 path: &Path,
404 catalog: &FleetCatalogRecord,
405 canonical_network_id: CanonicalNetworkId,
406) -> Result<(), FleetCatalogError> {
407 if catalog.schema_version != FLEET_CATALOG_SCHEMA_VERSION {
408 return invalid(
409 path,
410 format!(
411 "schema version {} is not supported; expected {}",
412 catalog.schema_version, FLEET_CATALOG_SCHEMA_VERSION
413 ),
414 );
415 }
416 if catalog.canonical_network_id != canonical_network_id {
417 return invalid(
418 path,
419 format!(
420 "catalog network {} does not match resolved network {canonical_network_id}",
421 catalog.canonical_network_id
422 ),
423 );
424 }
425
426 let mut previous_name: Option<&FleetName> = None;
427 let mut fleet_ids = BTreeSet::new();
428 let mut root_principals = BTreeMap::new();
429 for entry in &catalog.entries {
430 if entry.canonical_network_id != canonical_network_id {
431 return invalid(
432 path,
433 format!(
434 "Fleet {} records network {}, not {canonical_network_id}",
435 entry.fleet_name, entry.canonical_network_id
436 ),
437 );
438 }
439 if previous_name.is_some_and(|previous| previous >= &entry.fleet_name) {
440 return invalid(
441 path,
442 "Fleet entries must be strictly ordered by fleet_name".to_string(),
443 );
444 }
445 if !fleet_ids.insert(entry.fleet_id) {
446 return invalid(
447 path,
448 format!("Fleet ID {} appears more than once", entry.fleet_id),
449 );
450 }
451 validate_canonical_name(entry.app.as_str()).map_err(|reason| {
452 FleetCatalogError::Invalid {
453 path: path.to_path_buf(),
454 reason: format!("App {} {reason}", entry.app),
455 }
456 })?;
457 validate_environment_name(&entry.environment)?;
458 let root_principal = Principal::from_text(&entry.root_principal).map_err(|error| {
459 FleetCatalogError::Invalid {
460 path: path.to_path_buf(),
461 reason: format!(
462 "Fleet {} has invalid root principal: {error}",
463 entry.fleet_name
464 ),
465 }
466 })?;
467 if let Some(first) = root_principals.insert(root_principal, &entry.fleet_name) {
468 return invalid(
469 path,
470 format!(
471 "root principal {} belongs to both Fleet {first} and {}",
472 entry.root_principal, entry.fleet_name
473 ),
474 );
475 }
476 previous_name = Some(&entry.fleet_name);
477 }
478 Ok(())
479}
480
481fn existing_authority<'a>(
482 entries: &'a [FleetCatalogEntryV1],
483 requested: &FleetCatalogEntryV1,
484) -> Result<Option<&'a FleetCatalogEntryV1>, FleetCatalogError> {
485 let by_name = entries
486 .iter()
487 .find(|entry| entry.fleet_name == requested.fleet_name);
488 let by_id = entries
489 .iter()
490 .find(|entry| entry.fleet_id == requested.fleet_id);
491 let by_root = entries
492 .iter()
493 .find(|entry| entry.root_principal == requested.root_principal);
494 for (field, value, existing) in [
495 ("fleet_name", requested.fleet_name.to_string(), by_name),
496 ("fleet_id", requested.fleet_id.to_string(), by_id),
497 ("root_principal", requested.root_principal.clone(), by_root),
498 ] {
499 if let Some(existing) = existing {
500 if same_fleet_authority(existing, requested) {
501 return Ok(Some(existing));
502 }
503 return Err(FleetCatalogError::Conflict { field, value });
504 }
505 }
506 Ok(None)
507}
508
509fn same_fleet_authority(existing: &FleetCatalogEntryV1, requested: &FleetCatalogEntryV1) -> bool {
510 existing.canonical_network_id == requested.canonical_network_id
511 && existing.fleet_id == requested.fleet_id
512 && existing.fleet_name == requested.fleet_name
513 && existing.app == requested.app
514 && existing.root_principal == requested.root_principal
515}
516
517fn canonical_catalog_bytes(catalog: &FleetCatalogRecord) -> Result<Vec<u8>, FleetCatalogError> {
518 let mut bytes = serde_json::to_vec_pretty(catalog)
519 .map_err(|source| FleetCatalogError::Encode { source })?;
520 bytes.push(b'\n');
521 Ok(bytes)
522}
523
524fn validate_canonical_name(value: &str) -> Result<(), String> {
525 if value.is_empty() {
526 return Err("must not be empty".to_string());
527 }
528 if value.len() > CANONICAL_NAME_MAX_BYTES {
529 return Err(format!("must not exceed {CANONICAL_NAME_MAX_BYTES} bytes"));
530 }
531 if !value
532 .bytes()
533 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
534 {
535 return Err("must use only ASCII letters, numbers, '-' or '_'".to_string());
536 }
537 Ok(())
538}
539
540fn invalid<T>(path: &Path, reason: String) -> Result<T, FleetCatalogError> {
541 Err(FleetCatalogError::Invalid {
542 path: path.to_path_buf(),
543 reason,
544 })
545}
546
547fn fleet_catalog_path(project_root: &Path, canonical_network_id: CanonicalNetworkId) -> PathBuf {
548 project_root
549 .join(".canic")
550 .join("networks")
551 .join(canonical_network_id.to_string())
552 .join(FLEET_CATALOG_RELATIVE_PATH)
553}
554
555fn fleet_catalog_lock_path(
556 project_root: &Path,
557 canonical_network_id: CanonicalNetworkId,
558) -> PathBuf {
559 project_root
560 .join(".canic")
561 .join("networks")
562 .join(canonical_network_id.to_string())
563 .join("fleets/catalog.lock")
564}