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