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