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