Skip to main content

canic_host/fleet_catalog/
mod.rs

1//! Module: fleet_catalog
2//!
3//! Responsibility: read and project the legacy network-scoped Fleet catalog.
4//! Does not own: Fleet installation, Registry mutation, or identity allocation.
5//! Boundary: readers fail closed; the multi-root install flow does not write this catalog.
6
7#[cfg(test)]
8mod tests;
9
10use crate::{
11    durable_io::{RegularFileReadError, read_optional_regular_bytes},
12    network::{
13        NetworkIdentityError, resolve_canonical_network_id_from_root, validate_environment_name,
14    },
15};
16use canic_core::{
17    cdk::types::Principal,
18    ids::{AppId, CanonicalNetworkId, FleetId, FleetName, FleetNameParseError},
19};
20use serde::{Deserialize, Serialize};
21use std::{
22    collections::{BTreeMap, BTreeSet},
23    io,
24    path::{Path, PathBuf},
25};
26use thiserror::Error as ThisError;
27
28const FLEET_CATALOG_SCHEMA_VERSION: u32 = 1;
29const FLEET_CATALOG_RELATIVE_PATH: &str = "fleets/catalog.json";
30const CANONICAL_NAME_MAX_BYTES: usize = 40;
31
32///
33/// FleetCatalogRequest
34///
35
36#[derive(Clone, Debug, Eq, PartialEq)]
37pub struct FleetCatalogRequest {
38    pub project_root: PathBuf,
39    pub environment: String,
40    pub generated_at: String,
41}
42
43///
44/// FleetCatalogReportV1
45///
46
47#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
48#[serde(deny_unknown_fields)]
49pub struct FleetCatalogReportV1 {
50    pub schema_version: u32,
51    pub generated_at: String,
52    pub project_root: Option<String>,
53    pub canonical_network_id: CanonicalNetworkId,
54    pub environment: String,
55    pub entries: Vec<FleetCatalogEntryV1>,
56}
57
58///
59/// FleetCatalogEntryV1
60///
61
62#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
63#[serde(deny_unknown_fields)]
64pub struct FleetCatalogEntryV1 {
65    pub canonical_network_id: CanonicalNetworkId,
66    pub fleet_id: FleetId,
67    pub fleet_name: FleetName,
68    pub app: AppId,
69    /// Non-authoritative environment-profile provenance from installation.
70    pub environment: String,
71    pub deployed_at_unix_secs: u64,
72    pub root_principal: String,
73}
74
75#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
76#[serde(deny_unknown_fields)]
77struct FleetCatalogRecord {
78    schema_version: u32,
79    canonical_network_id: CanonicalNetworkId,
80    entries: Vec<FleetCatalogEntryV1>,
81}
82
83///
84/// FleetCatalogError
85///
86
87#[derive(Debug, ThisError)]
88pub enum FleetCatalogError {
89    #[error(transparent)]
90    Network(#[from] NetworkIdentityError),
91
92    #[error("Fleet name is invalid: {0}")]
93    FleetName(#[from] FleetNameParseError),
94
95    #[error("Fleet {fleet_name} is not known on canonical network {canonical_network_id}")]
96    UnknownFleet {
97        canonical_network_id: CanonicalNetworkId,
98        fleet_name: FleetName,
99    },
100
101    #[error("Fleet catalog is not a regular non-symlink file: {}", path.display())]
102    NotRegular { path: PathBuf },
103
104    #[error("Fleet catalog is unsupported on platform {0}")]
105    UnsupportedPlatform(&'static str),
106
107    #[error("failed to read Fleet catalog {}: {source}", path.display())]
108    Read {
109        path: PathBuf,
110        #[source]
111        source: io::Error,
112    },
113
114    #[error("failed to decode Fleet catalog {}: {source}", path.display())]
115    Decode {
116        path: PathBuf,
117        #[source]
118        source: serde_json::Error,
119    },
120
121    #[error("invalid Fleet catalog {}: {reason}", path.display())]
122    Invalid { path: PathBuf, reason: String },
123}
124
125/// Build a read-only report from the one catalog selected by canonical network identity.
126pub fn build_fleet_catalog_report(
127    request: &FleetCatalogRequest,
128) -> Result<FleetCatalogReportV1, FleetCatalogError> {
129    validate_environment_name(&request.environment)?;
130    let canonical_network_id =
131        resolve_canonical_network_id_from_root(&request.project_root, &request.environment)?;
132    let path = fleet_catalog_path(&request.project_root, canonical_network_id);
133    let entries = match read_catalog(&path, canonical_network_id)? {
134        Some(catalog) => catalog.entries,
135        None => Vec::new(),
136    };
137
138    Ok(FleetCatalogReportV1 {
139        schema_version: FLEET_CATALOG_SCHEMA_VERSION,
140        generated_at: request.generated_at.clone(),
141        project_root: Some(".".to_string()),
142        canonical_network_id,
143        environment: request.environment.clone(),
144        entries,
145    })
146}
147
148/// Build a report containing one exact Fleet-name lookup.
149pub fn inspect_fleet_catalog_report(
150    request: &FleetCatalogRequest,
151    fleet_name: &str,
152) -> Result<FleetCatalogReportV1, FleetCatalogError> {
153    let mut report = build_fleet_catalog_report(request)?;
154    let entry = require_fleet_catalog_entry(&report, fleet_name)?;
155    report.entries = vec![entry];
156    Ok(report)
157}
158
159/// Resolve one exact installed Fleet from the catalog selected by canonical
160/// network identity.
161pub fn read_fleet_catalog_entry_from_root(
162    project_root: &Path,
163    environment: &str,
164    fleet_name: &str,
165) -> Result<Option<FleetCatalogEntryV1>, FleetCatalogError> {
166    let fleet_name = fleet_name.parse::<FleetName>()?;
167    let report = build_fleet_catalog_report(&FleetCatalogRequest {
168        project_root: project_root.to_path_buf(),
169        environment: environment.to_string(),
170        generated_at: String::new(),
171    })?;
172    Ok(report
173        .entries
174        .into_iter()
175        .find(|entry| entry.fleet_name == fleet_name))
176}
177
178fn require_fleet_catalog_entry(
179    report: &FleetCatalogReportV1,
180    fleet_name: &str,
181) -> Result<FleetCatalogEntryV1, FleetCatalogError> {
182    let fleet_name = fleet_name.parse::<FleetName>()?;
183    report
184        .entries
185        .iter()
186        .find(|entry| entry.fleet_name == fleet_name)
187        .cloned()
188        .ok_or(FleetCatalogError::UnknownFleet {
189            canonical_network_id: report.canonical_network_id,
190            fleet_name,
191        })
192}
193
194#[must_use]
195pub fn fleet_catalog_report_text(report: &FleetCatalogReportV1) -> String {
196    let mut lines = vec![
197        "Fleet catalog:".to_string(),
198        format!("generated_at: {}", report.generated_at),
199        format!("network: {}", report.canonical_network_id),
200        format!("environment: {}", report.environment),
201        format!("entries: {}", report.entries.len()),
202    ];
203    if let Some(project_root) = &report.project_root {
204        lines.push(format!("project_root: {project_root}"));
205    }
206    if report.entries.is_empty() {
207        lines.push("fleets: none".to_string());
208        return lines.join("\n");
209    }
210
211    lines.push("fleets:".to_string());
212    for entry in &report.entries {
213        lines.push(format!("  {}", entry.fleet_name));
214        lines.push(format!("    fleet_id: {}", entry.fleet_id));
215        lines.push(format!("    app: {}", entry.app));
216        lines.push(format!("    environment: {}", entry.environment));
217        lines.push(format!("    root_principal: {}", entry.root_principal));
218    }
219    lines.join("\n")
220}
221
222fn read_catalog(
223    path: &Path,
224    canonical_network_id: CanonicalNetworkId,
225) -> Result<Option<FleetCatalogRecord>, FleetCatalogError> {
226    read_catalog_document(path, canonical_network_id)
227}
228
229fn read_catalog_document(
230    path: &Path,
231    canonical_network_id: CanonicalNetworkId,
232) -> Result<Option<FleetCatalogRecord>, FleetCatalogError> {
233    let Some(bytes) = read_optional_regular_bytes(path).map_err(|error| match error {
234        RegularFileReadError::NotRegular => FleetCatalogError::NotRegular {
235            path: path.to_path_buf(),
236        },
237        RegularFileReadError::Io(source) => FleetCatalogError::Read {
238            path: path.to_path_buf(),
239            source,
240        },
241        #[cfg(not(unix))]
242        RegularFileReadError::UnsupportedPlatform => {
243            FleetCatalogError::UnsupportedPlatform(std::env::consts::OS)
244        }
245    })?
246    else {
247        return Ok(None);
248    };
249    let catalog = serde_json::from_slice::<FleetCatalogRecord>(&bytes).map_err(|source| {
250        FleetCatalogError::Decode {
251            path: path.to_path_buf(),
252            source,
253        }
254    })?;
255    validate_catalog(path, &catalog, canonical_network_id)?;
256    Ok(Some(catalog))
257}
258
259fn validate_catalog(
260    path: &Path,
261    catalog: &FleetCatalogRecord,
262    canonical_network_id: CanonicalNetworkId,
263) -> Result<(), FleetCatalogError> {
264    if catalog.schema_version != FLEET_CATALOG_SCHEMA_VERSION {
265        return invalid(
266            path,
267            format!(
268                "schema version {} is not supported; expected {}",
269                catalog.schema_version, FLEET_CATALOG_SCHEMA_VERSION
270            ),
271        );
272    }
273    if catalog.canonical_network_id != canonical_network_id {
274        return invalid(
275            path,
276            format!(
277                "catalog network {} does not match resolved network {canonical_network_id}",
278                catalog.canonical_network_id
279            ),
280        );
281    }
282
283    let mut previous_name: Option<&FleetName> = None;
284    let mut fleet_ids = BTreeSet::new();
285    let mut root_principals = BTreeMap::new();
286    for entry in &catalog.entries {
287        if entry.canonical_network_id != canonical_network_id {
288            return invalid(
289                path,
290                format!(
291                    "Fleet {} records network {}, not {canonical_network_id}",
292                    entry.fleet_name, entry.canonical_network_id
293                ),
294            );
295        }
296        if previous_name.is_some_and(|previous| previous >= &entry.fleet_name) {
297            return invalid(
298                path,
299                "Fleet entries must be strictly ordered by fleet_name".to_string(),
300            );
301        }
302        if !fleet_ids.insert(entry.fleet_id) {
303            return invalid(
304                path,
305                format!("Fleet ID {} appears more than once", entry.fleet_id),
306            );
307        }
308        validate_canonical_name(entry.app.as_str()).map_err(|reason| {
309            FleetCatalogError::Invalid {
310                path: path.to_path_buf(),
311                reason: format!("App {} {reason}", entry.app),
312            }
313        })?;
314        validate_environment_name(&entry.environment)?;
315        let root_principal = Principal::from_text(&entry.root_principal).map_err(|error| {
316            FleetCatalogError::Invalid {
317                path: path.to_path_buf(),
318                reason: format!(
319                    "Fleet {} has invalid root principal: {error}",
320                    entry.fleet_name
321                ),
322            }
323        })?;
324        if let Some(first) = root_principals.insert(root_principal, &entry.fleet_name) {
325            return invalid(
326                path,
327                format!(
328                    "root principal {} belongs to both Fleet {first} and {}",
329                    entry.root_principal, entry.fleet_name
330                ),
331            );
332        }
333        previous_name = Some(&entry.fleet_name);
334    }
335    Ok(())
336}
337
338fn validate_canonical_name(value: &str) -> Result<(), String> {
339    if value.is_empty() {
340        return Err("must not be empty".to_string());
341    }
342    if value.len() > CANONICAL_NAME_MAX_BYTES {
343        return Err(format!("must not exceed {CANONICAL_NAME_MAX_BYTES} bytes"));
344    }
345    if !value
346        .bytes()
347        .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
348    {
349        return Err("must use only ASCII letters, numbers, '-' or '_'".to_string());
350    }
351    Ok(())
352}
353
354fn invalid<T>(path: &Path, reason: String) -> Result<T, FleetCatalogError> {
355    Err(FleetCatalogError::Invalid {
356        path: path.to_path_buf(),
357        reason,
358    })
359}
360
361fn fleet_catalog_path(project_root: &Path, canonical_network_id: CanonicalNetworkId) -> PathBuf {
362    project_root
363        .join(".canic")
364        .join("networks")
365        .join(canonical_network_id.to_string())
366        .join(FLEET_CATALOG_RELATIVE_PATH)
367}