Skip to main content

canic_host/release_set/config/
mod.rs

1mod error;
2mod model;
3mod mutation;
4mod projection;
5#[cfg(test)]
6mod tests;
7
8use crate::durable_io::write_bytes;
9use canic_core::bootstrap::{compiled::ConfigModel, parse_config_model};
10use std::{
11    collections::{BTreeMap, BTreeSet},
12    fs, io,
13    path::{Path, PathBuf},
14};
15
16pub use error::{
17    FleetConfigDeclaration, FleetConfigError, FleetConfigIoOperation, FleetConfigMutationConflict,
18    FleetConfigNameField, FleetConfigNameIssue, FleetConfigOperation, FleetConfigPackageIssue,
19    FleetConfigTomlOperation,
20};
21pub use model::{
22    AttachedFleetRole, ConfiguredPoolExpectation, ConfiguredRoleLifecycle, DeclaredFleetRole,
23    LOCAL_ROOT_MIN_READY_CYCLES, RenamedFleetRole,
24};
25pub(super) use mutation::{
26    attach_fleet_role_source, declare_fleet_role_source, rename_fleet_role_source,
27};
28pub use projection::configured_release_roles_from_config;
29pub(super) use projection::{
30    configured_bootstrap_roles_from_config, configured_controllers_from_config,
31    configured_deployable_roles_from_config, configured_local_root_create_cycles_from_config,
32    configured_pool_expectations_from_config, configured_role_auto_create_from_config,
33    configured_role_details_from_config, configured_role_kinds_from_config,
34    configured_role_lifecycle_from_config, configured_role_metrics_profiles_from_config,
35    configured_role_topups_from_config, fleet_identity_from_source,
36};
37
38/// One immutable, validated view of a fleet configuration file.
39///
40/// Commands that need several projections should load this once so every
41/// decision is derived from the same bytes on disk.
42#[derive(Debug)]
43pub struct FleetConfigSnapshot {
44    path: PathBuf,
45    config: ConfigModel,
46    fleet_name: String,
47}
48
49impl FleetConfigSnapshot {
50    pub fn load(path: &Path) -> Result<Self, FleetConfigError> {
51        let source = read_config_source(path)?;
52        let config = parse_config_model(&source)
53            .map_err(|source| FleetConfigError::CoreConfig {
54                operation: FleetConfigOperation::Project,
55                source,
56            })
57            .map_err(|error| error.at_config_path(path))?;
58        let fleet_name = config
59            .fleet_name()
60            .ok_or(FleetConfigError::DeclarationMissing {
61                declaration: FleetConfigDeclaration::FleetName,
62            })
63            .map_err(|error| error.at_config_path(path))?
64            .to_string();
65        Ok(Self {
66            path: path.to_path_buf(),
67            config,
68            fleet_name,
69        })
70    }
71
72    #[must_use]
73    pub const fn model(&self) -> &ConfigModel {
74        &self.config
75    }
76
77    #[must_use]
78    pub const fn fleet_name(&self) -> &str {
79        self.fleet_name.as_str()
80    }
81
82    #[must_use]
83    pub fn deployable_roles(&self) -> Vec<String> {
84        configured_deployable_roles_from_config(&self.config)
85    }
86
87    #[must_use]
88    pub fn bootstrap_roles(&self) -> Vec<String> {
89        configured_bootstrap_roles_from_config(&self.config)
90    }
91
92    #[must_use]
93    pub fn local_root_create_cycles(&self) -> u128 {
94        configured_local_root_create_cycles_from_config(&self.config)
95    }
96
97    #[must_use]
98    pub fn controllers(&self) -> Vec<String> {
99        configured_controllers_from_config(&self.config)
100    }
101
102    #[must_use]
103    pub fn pool_expectations(&self) -> Vec<ConfiguredPoolExpectation> {
104        configured_pool_expectations_from_config(&self.config)
105    }
106
107    #[must_use]
108    pub fn role_lifecycle(&self) -> Vec<ConfiguredRoleLifecycle> {
109        configured_role_lifecycle_from_config(&self.config)
110    }
111
112    #[must_use]
113    pub fn role_kinds(&self) -> BTreeMap<String, String> {
114        configured_role_kinds_from_config(&self.config)
115    }
116
117    pub fn role_capabilities(&self) -> Result<BTreeMap<String, Vec<String>>, FleetConfigError> {
118        let mut projected = BTreeMap::new();
119
120        for role in self.config.attached_roles() {
121            let contract = match crate::role_contract::resolve_declared_role_contract(
122                &self.path,
123                &self.config,
124                &role,
125                crate::role_contract::PackageValidationMode::Passive,
126            ) {
127                canic_core::role_contract::RoleContractResolution::Resolved { contract } => {
128                    contract
129                }
130                canic_core::role_contract::RoleContractResolution::Rejected { errors } => {
131                    return Err(FleetConfigError::RoleContractRejected { errors });
132                }
133            };
134            let labels = project_role_capabilities(&contract.capabilities);
135            if !labels.is_empty() {
136                projected.insert(role.as_str().to_string(), labels);
137            }
138        }
139
140        Ok(projected)
141    }
142
143    #[must_use]
144    pub fn role_auto_create(&self) -> BTreeSet<String> {
145        configured_role_auto_create_from_config(&self.config)
146    }
147
148    #[must_use]
149    pub fn role_topups(&self) -> BTreeMap<String, String> {
150        configured_role_topups_from_config(&self.config)
151    }
152
153    #[must_use]
154    pub fn role_metrics_profiles(&self) -> BTreeMap<String, String> {
155        configured_role_metrics_profiles_from_config(&self.config)
156    }
157
158    #[must_use]
159    pub fn role_details(&self) -> BTreeMap<String, Vec<String>> {
160        configured_role_details_from_config(&self.config)
161    }
162}
163
164/// Read only `[fleet].name` for candidate discovery and malformed-config diagnostics.
165///
166/// Operational projections must use [`FleetConfigSnapshot`].
167pub fn read_fleet_config_identity(path: &Path) -> Result<String, FleetConfigError> {
168    let source = read_config_source(path)?;
169    fleet_identity_from_source(&source).map_err(|error| error.at_config_path(path))
170}
171
172// Validate a package-backed role declaration without writing `canic.toml`.
173pub fn plan_declare_fleet_role(
174    config_path: &Path,
175    expected_fleet: &str,
176    role: &str,
177    package: &str,
178) -> Result<DeclaredFleetRole, FleetConfigError> {
179    let source = read_config_source(config_path)?;
180    let updated = declare_fleet_role_source(&source, expected_fleet, role, package)
181        .map_err(|error| error.at_config_path(config_path))?;
182    Ok(updated.role)
183}
184
185// Validate a package-backed role topology attachment without writing `canic.toml`.
186pub fn plan_attach_fleet_role(
187    config_path: &Path,
188    expected_fleet: &str,
189    role: &str,
190    subnet: &str,
191    kind: &str,
192) -> Result<AttachedFleetRole, FleetConfigError> {
193    let source = read_config_source(config_path)?;
194    let updated = attach_fleet_role_source(&source, expected_fleet, role, subnet, kind)
195        .map_err(|error| error.at_config_path(config_path))?;
196    Ok(updated.role)
197}
198
199// Validate a role rename and package metadata update without writing files.
200pub fn plan_rename_fleet_role(
201    config_path: &Path,
202    expected_fleet: &str,
203    old_role: &str,
204    new_role: &str,
205) -> Result<RenamedFleetRole, FleetConfigError> {
206    let source = read_config_source(config_path)?;
207    let updated =
208        rename_fleet_role_source(&source, config_path, expected_fleet, old_role, new_role)
209            .map_err(|error| error.at_config_path(config_path))?;
210    Ok(updated.role)
211}
212
213// Declare a package-backed role without attaching it to topology.
214pub fn declare_fleet_role(
215    config_path: &Path,
216    expected_fleet: &str,
217    role: &str,
218    package: &str,
219) -> Result<DeclaredFleetRole, FleetConfigError> {
220    let source = read_config_source(config_path)?;
221    let updated = declare_fleet_role_source(&source, expected_fleet, role, package)
222        .map_err(|error| error.at_config_path(config_path))?;
223    write_bytes(config_path, updated.source.as_bytes()).map_err(|source| {
224        FleetConfigError::io(FleetConfigIoOperation::WriteConfig, config_path, source)
225    })?;
226    Ok(updated.role)
227}
228
229// Attach a declared package-backed role directly to subnet topology.
230pub fn attach_fleet_role(
231    config_path: &Path,
232    expected_fleet: &str,
233    role: &str,
234    subnet: &str,
235    kind: &str,
236) -> Result<AttachedFleetRole, FleetConfigError> {
237    let source = read_config_source(config_path)?;
238    let updated = attach_fleet_role_source(&source, expected_fleet, role, subnet, kind)
239        .map_err(|error| error.at_config_path(config_path))?;
240    write_bytes(config_path, updated.source.as_bytes()).map_err(|source| {
241        FleetConfigError::io(FleetConfigIoOperation::WriteConfig, config_path, source)
242    })?;
243    Ok(updated.role)
244}
245
246// Rename a declared role and its role-bearing topology references.
247pub fn rename_fleet_role(
248    config_path: &Path,
249    expected_fleet: &str,
250    old_role: &str,
251    new_role: &str,
252) -> Result<RenamedFleetRole, FleetConfigError> {
253    let source = read_config_source(config_path)?;
254    let updated =
255        rename_fleet_role_source(&source, config_path, expected_fleet, old_role, new_role)
256            .map_err(|error| error.at_config_path(config_path))?;
257    commit_role_rename_sources(
258        config_path,
259        &source,
260        &updated.source,
261        updated
262            .package_manifest
263            .as_deref()
264            .zip(updated.package_source.as_deref()),
265    )?;
266    Ok(updated.role)
267}
268
269fn commit_role_rename_sources(
270    config_path: &Path,
271    original_config: &str,
272    updated_config: &str,
273    package_update: Option<(&Path, &str)>,
274) -> Result<(), FleetConfigError> {
275    commit_role_rename_sources_with_writer(
276        config_path,
277        original_config,
278        updated_config,
279        package_update,
280        write_bytes,
281    )
282}
283
284fn commit_role_rename_sources_with_writer(
285    config_path: &Path,
286    original_config: &str,
287    updated_config: &str,
288    package_update: Option<(&Path, &str)>,
289    mut write: impl FnMut(&Path, &[u8]) -> io::Result<()>,
290) -> Result<(), FleetConfigError> {
291    write(config_path, updated_config.as_bytes()).map_err(|source| {
292        FleetConfigError::io(FleetConfigIoOperation::WriteConfig, config_path, source)
293    })?;
294    let Some((package_path, package_source)) = package_update else {
295        return Ok(());
296    };
297
298    if let Err(source) = write(package_path, package_source.as_bytes()) {
299        let mutation = FleetConfigError::io(
300            FleetConfigIoOperation::WritePackageManifest,
301            package_path,
302            source,
303        );
304        if let Err(source) = write(config_path, original_config.as_bytes()) {
305            let rollback =
306                FleetConfigError::io(FleetConfigIoOperation::RestoreConfig, config_path, source);
307            return Err(FleetConfigError::RollbackFailed {
308                mutation: Box::new(mutation),
309                rollback: Box::new(rollback),
310            });
311        }
312        return Err(mutation);
313    }
314
315    Ok(())
316}
317
318pub(in crate::release_set) fn project_role_capabilities(
319    capabilities: &BTreeSet<canic_core::role_contract::RoleCapabilityKey>,
320) -> Vec<String> {
321    use canic_core::role_contract::RoleCapabilityKey;
322
323    let mut labels = BTreeSet::new();
324    for capability in capabilities {
325        match capability {
326            RoleCapabilityKey::DelegatedTokenIssuer
327            | RoleCapabilityKey::DelegatedTokenVerifier
328            | RoleCapabilityKey::RoleAttestationSigner
329            | RoleCapabilityKey::RoleAttestationVerifier => {
330                labels.insert("auth");
331            }
332            RoleCapabilityKey::Directory => {
333                labels.insert("directory");
334            }
335            RoleCapabilityKey::Icrc21 => {
336                labels.insert("icrc21");
337            }
338            RoleCapabilityKey::Scaling => {
339                labels.insert("scaling");
340            }
341            RoleCapabilityKey::Sharding => {
342                labels.insert("sharding");
343            }
344            RoleCapabilityKey::Root
345            | RoleCapabilityKey::RootControlPlane
346            | RoleCapabilityKey::Runtime
347            | RoleCapabilityKey::WasmStore => {}
348        }
349    }
350    labels.into_iter().map(str::to_string).collect()
351}
352
353fn read_config_source(config_path: &Path) -> Result<String, FleetConfigError> {
354    fs::read_to_string(config_path).map_err(|source| {
355        FleetConfigError::io(FleetConfigIoOperation::ReadConfig, config_path, source)
356    })
357}