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