Skip to main content

canic_host/fleet_catalog/
mod.rs

1//! Module: fleet_catalog
2//!
3//! Responsibility: commit, read, and project the network-scoped Fleet discovery catalog.
4//! Does not own: terminal-ensure validation, Registry mutation, or Fleet identity allocation.
5//! Boundary: one network lock serializes atomic Coordinator-anchored publication; readers fail
6//! closed.
7
8#[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///
38/// FleetCatalogRequest
39///
40
41#[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///
49/// FleetCatalogReportV1
50///
51
52#[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///
64/// FleetCatalogEntryV1
65///
66
67#[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    /// Non-authoritative environment-profile provenance from Fleet ensure.
75    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///
90/// CommittedFleetCatalog
91///
92/// Exact durable catalog result returned to the terminal-ensure publication workflow.
93///
94
95#[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///
103/// FleetCatalogError
104///
105
106#[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
160/// Build a read-only report from the one catalog selected by canonical network identity.
161pub 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
183/// Build a report containing one exact Fleet-name lookup.
184pub 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
194/// Resolve one exact installed Fleet from the catalog selected by canonical
195/// network identity.
196pub 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
213/// Commit one exact Coordinator-anchored row after the caller validates terminal evidence.
214pub 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}