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-install 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},
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 project_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 project_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 installation.
75    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///
89/// CommittedFleetCatalog
90///
91/// Exact durable catalog result returned to the terminal-install publication workflow.
92///
93
94#[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///
102/// FleetCatalogError
103///
104
105#[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
159/// Build a read-only report from the one catalog selected by canonical network identity.
160pub 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
182/// Build a report containing one exact Fleet-name lookup.
183pub 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
193/// Resolve one exact installed Fleet from the catalog selected by canonical
194/// network identity.
195pub 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
212/// Commit one exact Coordinator-anchored row after the caller validates terminal evidence.
213pub(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}