Skip to main content

orca_core/config/
mod.rs

1mod ai;
2mod cluster;
3mod service;
4
5use std::path::Path;
6
7use crate::error::{OrcaError, Result};
8
9// -- Re-exports --
10
11pub use crate::backup::{BackupConfig, BackupTarget};
12pub use ai::{AiAlertConfig, AiConfig, AlertDeliveryChannels, AutoRemediateConfig};
13pub use cluster::NetworkConfig;
14pub use cluster::{
15    AlertChannelConfig, ApiToken, ClusterConfig, ClusterMeta, FallbackConfig, NodeConfig,
16    NodeGpuConfig, ObservabilityConfig, Role,
17};
18pub use service::{BuildConfig, ProbeConfig, ServiceConfig, ServicesConfig};
19
20// -- Load methods --
21
22impl ClusterConfig {
23    pub fn load(path: &Path) -> Result<Self> {
24        let content = std::fs::read_to_string(path)
25            .map_err(|e| OrcaError::Config(format!("failed to read {}: {e}", path.display())))?;
26        let mut config: Self = toml::from_str(&content)
27            .map_err(|e| OrcaError::Config(format!("failed to parse {}: {e}", path.display())))?;
28        config.resolve_secrets();
29        Ok(config)
30    }
31
32    /// Resolve `${secrets.X}` patterns in config fields that may contain secrets.
33    fn resolve_secrets(&mut self) {
34        let store = match crate::secrets::SecretStore::open(crate::secrets::default_path()) {
35            Ok(s) => s,
36            Err(_) => return,
37        };
38        let resolve = |s: &mut Option<String>| {
39            if let Some(val) = s
40                && val.contains("${secrets.")
41            {
42                let as_map = std::collections::HashMap::from([("_".to_string(), val.clone())]);
43                let resolved = store.resolve_env(&as_map);
44                *val = resolved.get("_").cloned().unwrap_or_default();
45            }
46        };
47        // AI config
48        if let Some(ai) = &mut self.ai {
49            resolve(&mut ai.api_key);
50            resolve(&mut ai.endpoint);
51        }
52        // Network config
53        if let Some(net) = &mut self.network {
54            resolve(&mut net.setup_key);
55        }
56    }
57}
58
59impl ServicesConfig {
60    pub fn load(path: &Path) -> Result<Self> {
61        let content = std::fs::read_to_string(path)
62            .map_err(|e| OrcaError::Config(format!("failed to read {}: {e}", path.display())))?;
63        toml::from_str(&content)
64            .map_err(|e| OrcaError::Config(format!("failed to parse {}: {e}", path.display())))
65    }
66
67    /// Auto-discover services from subdirectories.
68    ///
69    /// Scans `dir/*/service.toml` and merges all service definitions.
70    /// If a `secrets.json` exists in the same subdirectory, secret patterns
71    /// in env vars (`${secrets.KEY}`) are resolved before returning.
72    pub fn load_dir(dir: &Path) -> Result<Self> {
73        let mut all_services = Vec::new();
74        let entries = std::fs::read_dir(dir)
75            .map_err(|e| OrcaError::Config(format!("failed to read {}: {e}", dir.display())))?;
76
77        let mut subdirs: Vec<_> = entries
78            .filter_map(|e| e.ok())
79            .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
80            .collect();
81        subdirs.sort_by_key(|e| e.file_name());
82
83        for entry in subdirs {
84            let svc_file = entry.path().join("service.toml");
85            if svc_file.exists() {
86                let mut config = Self::load(&svc_file)?;
87                let project_name = entry.file_name().to_string_lossy().to_string();
88
89                // Secrets are resolved later in service_config_to_spec()
90                // so that spec_matches() compares unresolved templates,
91                // preventing unnecessary restarts when token values change.
92
93                // Set project name and default network from directory
94                for svc in &mut config.service {
95                    svc.project = Some(project_name.clone());
96                    if svc.network.is_none() {
97                        svc.network = Some(project_name.clone());
98                    }
99                }
100
101                all_services.extend(config.service);
102            }
103        }
104
105        if all_services.is_empty() {
106            return Err(OrcaError::Config(format!(
107                "no service.toml files found in {}",
108                dir.display()
109            )));
110        }
111
112        Ok(ServicesConfig {
113            service: all_services,
114        })
115    }
116}
117
118#[cfg(test)]
119#[path = "tests_parse.rs"]
120mod tests_parse;
121
122#[cfg(test)]
123#[path = "tests_load.rs"]
124mod tests_load;
125
126#[cfg(test)]
127#[path = "tests_secrets.rs"]
128mod tests_secrets;