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