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