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 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_pool_expectations_from_config, configured_role_auto_create_from_config,
36    configured_role_details_from_config, configured_role_kinds_from_config,
37    configured_role_lifecycle_from_config, configured_role_metrics_profiles_from_config,
38    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    #[must_use]
103    pub fn controllers(&self) -> Vec<String> {
104        configured_controllers_from_config(&self.config)
105    }
106
107    #[must_use]
108    pub fn pool_expectations(&self) -> Vec<ConfiguredPoolExpectation> {
109        configured_pool_expectations_from_config(&self.config)
110    }
111
112    #[must_use]
113    pub fn role_lifecycle(&self) -> Vec<ConfiguredRoleLifecycle> {
114        configured_role_lifecycle_from_config(&self.config)
115    }
116
117    #[must_use]
118    pub fn role_kinds(&self) -> BTreeMap<String, String> {
119        configured_role_kinds_from_config(&self.config)
120    }
121
122    pub fn role_capabilities(&self) -> Result<BTreeMap<String, Vec<String>>, AppConfigError> {
123        let mut projected = BTreeMap::new();
124
125        for role in self.config.deployable_roles() {
126            let contract = match crate::role_contract::resolve_declared_role_contract(
127                &self.path,
128                &self.config,
129                &role,
130                crate::role_contract::PackageValidationMode::Passive,
131            ) {
132                canic_core::role_contract::RoleContractResolution::Resolved { contract } => {
133                    contract
134                }
135                canic_core::role_contract::RoleContractResolution::Rejected { errors } => {
136                    return Err(AppConfigError::RoleContractRejected { errors });
137                }
138            };
139            let labels = project_role_capabilities(&contract.capabilities);
140            if !labels.is_empty() {
141                projected.insert(role.as_str().to_string(), labels);
142            }
143        }
144
145        Ok(projected)
146    }
147
148    #[must_use]
149    pub fn role_auto_create(&self) -> BTreeSet<String> {
150        configured_role_auto_create_from_config(&self.config)
151    }
152
153    #[must_use]
154    pub fn role_topups(&self) -> BTreeMap<String, String> {
155        configured_role_topups_from_config(&self.config)
156    }
157
158    #[must_use]
159    pub fn role_metrics_profiles(&self) -> BTreeMap<String, String> {
160        configured_role_metrics_profiles_from_config(&self.config)
161    }
162
163    #[must_use]
164    pub fn role_details(&self) -> BTreeMap<String, Vec<String>> {
165        configured_role_details_from_config(&self.config)
166    }
167}
168
169/// Read only `[app].name` for candidate discovery and malformed-config diagnostics.
170///
171/// Operational projections must use [`AppConfigSnapshot`].
172pub fn read_app_config_identity(path: &Path) -> Result<String, AppConfigError> {
173    let source = read_config_source(path)?;
174    app_identity_from_source(&source).map_err(|error| error.at_config_path(path))
175}
176
177// Validate a package-backed role declaration without writing `canic.toml`.
178pub fn plan_declare_app_role(
179    config_path: &Path,
180    expected_app: &str,
181    role: &str,
182    package: &str,
183) -> Result<DeclaredAppRole, AppConfigError> {
184    let source = read_config_source(config_path)?;
185    let updated = declare_app_role_source(&source, expected_app, role, package)
186        .map_err(|error| error.at_config_path(config_path))?;
187    Ok(updated.role)
188}
189
190// Validate a package-backed role topology attachment without writing `canic.toml`.
191pub fn plan_attach_app_role(
192    config_path: &Path,
193    expected_app: &str,
194    role: &str,
195    component_spec: &str,
196    kind: &str,
197) -> Result<AttachedAppRole, AppConfigError> {
198    let source = read_config_source(config_path)?;
199    let updated = attach_app_role_source(&source, expected_app, role, component_spec, kind)
200        .map_err(|error| error.at_config_path(config_path))?;
201    Ok(updated.role)
202}
203
204// Validate a role rename and package metadata update without writing files.
205pub fn plan_rename_app_role(
206    config_path: &Path,
207    expected_app: &str,
208    old_role: &str,
209    new_role: &str,
210) -> Result<RenamedAppRole, AppConfigError> {
211    let source = read_config_source(config_path)?;
212    let updated = rename_app_role_source(&source, config_path, expected_app, old_role, new_role)
213        .map_err(|error| error.at_config_path(config_path))?;
214    Ok(updated.role)
215}
216
217// Declare a package-backed role without attaching it to topology.
218pub fn declare_app_role(
219    config_path: &Path,
220    expected_app: &str,
221    role: &str,
222    package: &str,
223) -> Result<DeclaredAppRole, AppConfigError> {
224    let source = read_config_source(config_path)?;
225    let updated = declare_app_role_source(&source, expected_app, role, package)
226        .map_err(|error| error.at_config_path(config_path))?;
227    write_bytes(config_path, updated.source.as_bytes()).map_err(|source| {
228        AppConfigError::io(AppConfigIoOperation::WriteConfig, config_path, source)
229    })?;
230    Ok(updated.role)
231}
232
233// Attach a declared package-backed role directly to a Component Spec.
234pub fn attach_app_role(
235    config_path: &Path,
236    expected_app: &str,
237    role: &str,
238    component_spec: &str,
239    kind: &str,
240) -> Result<AttachedAppRole, AppConfigError> {
241    let source = read_config_source(config_path)?;
242    let updated = attach_app_role_source(&source, expected_app, role, component_spec, kind)
243        .map_err(|error| error.at_config_path(config_path))?;
244    write_bytes(config_path, updated.source.as_bytes()).map_err(|source| {
245        AppConfigError::io(AppConfigIoOperation::WriteConfig, config_path, source)
246    })?;
247    Ok(updated.role)
248}
249
250// Rename a declared role and its role-bearing topology references.
251pub fn rename_app_role(
252    config_path: &Path,
253    expected_app: &str,
254    old_role: &str,
255    new_role: &str,
256) -> Result<RenamedAppRole, AppConfigError> {
257    let source = read_config_source(config_path)?;
258    let updated = rename_app_role_source(&source, config_path, expected_app, old_role, new_role)
259        .map_err(|error| error.at_config_path(config_path))?;
260    commit_role_rename_sources(
261        config_path,
262        &source,
263        &updated.source,
264        updated
265            .package_manifest
266            .as_deref()
267            .zip(updated.package_source.as_deref()),
268    )?;
269    Ok(updated.role)
270}
271
272fn commit_role_rename_sources(
273    config_path: &Path,
274    original_config: &str,
275    updated_config: &str,
276    package_update: Option<(&Path, &str)>,
277) -> Result<(), AppConfigError> {
278    commit_role_rename_sources_with_writer(
279        config_path,
280        original_config,
281        updated_config,
282        package_update,
283        write_bytes,
284    )
285}
286
287fn commit_role_rename_sources_with_writer(
288    config_path: &Path,
289    original_config: &str,
290    updated_config: &str,
291    package_update: Option<(&Path, &str)>,
292    mut write: impl FnMut(&Path, &[u8]) -> io::Result<()>,
293) -> Result<(), AppConfigError> {
294    write(config_path, updated_config.as_bytes()).map_err(|source| {
295        AppConfigError::io(AppConfigIoOperation::WriteConfig, config_path, source)
296    })?;
297    let Some((package_path, package_source)) = package_update else {
298        return Ok(());
299    };
300
301    if let Err(source) = write(package_path, package_source.as_bytes()) {
302        let mutation = AppConfigError::io(
303            AppConfigIoOperation::WritePackageManifest,
304            package_path,
305            source,
306        );
307        if let Err(source) = write(config_path, original_config.as_bytes()) {
308            let rollback =
309                AppConfigError::io(AppConfigIoOperation::RestoreConfig, config_path, source);
310            return Err(AppConfigError::RollbackFailed {
311                mutation: Box::new(mutation),
312                rollback: Box::new(rollback),
313            });
314        }
315        return Err(mutation);
316    }
317
318    Ok(())
319}
320
321pub(in crate::release_set) fn project_role_capabilities(
322    capabilities: &BTreeSet<canic_core::role_contract::RoleCapabilityKey>,
323) -> Vec<String> {
324    use canic_core::role_contract::RoleCapabilityKey;
325
326    let mut labels = BTreeSet::new();
327    for capability in capabilities {
328        match capability {
329            RoleCapabilityKey::DelegatedTokenIssuer
330            | RoleCapabilityKey::DelegatedTokenVerifier
331            | RoleCapabilityKey::RoleAttestationSigner
332            | RoleCapabilityKey::RoleAttestationVerifier => {
333                labels.insert("auth");
334            }
335            RoleCapabilityKey::Index => {
336                labels.insert("index");
337            }
338            RoleCapabilityKey::Icrc21 => {
339                labels.insert("icrc21");
340            }
341            RoleCapabilityKey::Scaling => {
342                labels.insert("scaling");
343            }
344            RoleCapabilityKey::Sharding => {
345                labels.insert("sharding");
346            }
347            RoleCapabilityKey::FleetCoordinator
348            | RoleCapabilityKey::Root
349            | RoleCapabilityKey::RootControlPlane
350            | RoleCapabilityKey::Runtime
351            | RoleCapabilityKey::WasmStore => {}
352        }
353    }
354    labels.into_iter().map(str::to_string).collect()
355}
356
357fn read_config_source(config_path: &Path) -> Result<String, AppConfigError> {
358    fs::read_to_string(config_path)
359        .map_err(|source| AppConfigError::io(AppConfigIoOperation::ReadConfig, config_path, source))
360}