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