terraphim_settings/
lib.rs

1use directories::ProjectDirs;
2use serde::de::{self, Deserializer};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::PathBuf;
6use twelf::reexports::toml;
7use twelf::{config, Layer};
8
9#[cfg(feature = "onepassword")]
10use terraphim_onepassword_cli::{OnePasswordLoader, SecretLoader};
11
12#[derive(thiserror::Error, Debug)]
13pub enum Error {
14    #[error("config error: {0}")]
15    ConfigError(#[from] twelf::Error),
16    #[error("io error: {0}")]
17    IoError(#[from] std::io::Error),
18    #[error("env error: {0}")]
19    EnvError(#[from] std::env::VarError),
20    #[cfg(feature = "onepassword")]
21    #[error("1Password error: {0}")]
22    OnePasswordError(#[from] terraphim_onepassword_cli::OnePasswordError),
23}
24
25// Need to name it explicitly to avoid conflict with std::Result
26// which gets used by the `#[config]` macro below.
27pub type DeviceSettingsResult<T> = std::result::Result<T, Error>;
28
29/// Default config path
30pub const DEFAULT_CONFIG_PATH: &str = ".config";
31
32/// Default settings file
33pub const DEFAULT_SETTINGS: &str = include_str!("../default/settings_local_dev.toml");
34
35fn deserialize_bool_from_string<'de, D>(deserializer: D) -> Result<bool, D::Error>
36where
37    D: Deserializer<'de>,
38{
39    // This will accept both string and bool values
40    #[derive(Deserialize)]
41    #[serde(untagged)]
42    enum BoolOrString {
43        Bool(bool),
44        String(String),
45    }
46
47    match BoolOrString::deserialize(deserializer)? {
48        BoolOrString::Bool(b) => Ok(b),
49        BoolOrString::String(s) => match s.to_lowercase().as_str() {
50            "true" => Ok(true),
51            "false" => Ok(false),
52            _ => Err(de::Error::custom(format!("invalid boolean value: {}", s))),
53        },
54    }
55}
56
57/// Configuration settings for the device (i.e. the server or runtime).
58///
59/// These values are set when the server is initialized, and do not change while
60/// running. These are constructed from default or local files and ENV
61/// variables.
62#[config]
63#[derive(Debug, Serialize, Clone)]
64pub struct DeviceSettings {
65    /// The address to listen on
66    pub server_hostname: String,
67    /// API endpoint for the server
68    pub api_endpoint: String,
69    /// init completed
70    #[serde(deserialize_with = "deserialize_bool_from_string")]
71    pub initialized: bool,
72    /// default data path
73    pub default_data_path: String,
74    /// configured storage backends available on device
75    pub profiles: HashMap<String, HashMap<String, String>>,
76}
77
78impl Default for DeviceSettings {
79    fn default() -> Self {
80        Self::new()
81    }
82}
83
84impl DeviceSettings {
85    /// Create a new DeviceSettings
86    pub fn new() -> Self {
87        Self::load_from_env_and_file(None).unwrap_or_else(|e| {
88            log::warn!(
89                "Failed to load device settings from file: {:?}, using defaults",
90                e
91            );
92            Self::default_embedded()
93        })
94    }
95
96    /// Load settings with 1Password secret resolution
97    #[cfg(feature = "onepassword")]
98    pub async fn load_with_onepassword(config_path: Option<PathBuf>) -> DeviceSettingsResult<Self> {
99        log::info!("Loading device settings with 1Password integration...");
100        let config_path = config_path.unwrap_or_else(Self::default_config_path);
101
102        log::debug!("Settings path: {:?}", config_path);
103        let config_file = init_config_file(&config_path)?;
104        log::debug!("Loading config_file: {:?}", config_file);
105
106        // Read the raw configuration file
107        let raw_config = std::fs::read_to_string(&config_file)?;
108
109        // Process 1Password references
110        let loader = OnePasswordLoader::new();
111        let processed_config = if loader.is_available().await {
112            log::info!("1Password CLI available, processing secrets...");
113            loader.process_config(&raw_config).await?
114        } else {
115            log::warn!("1Password CLI not available, using raw configuration");
116            raw_config
117        };
118
119        // Parse the processed configuration
120        let settings: DeviceSettings = toml::from_str(&processed_config).map_err(|e| {
121            Error::IoError(std::io::Error::other(format!("TOML parsing error: {}", e)))
122        })?;
123
124        log::info!("Successfully loaded settings with 1Password integration");
125        Ok(settings)
126    }
127
128    /// Process a configuration string with 1Password secret resolution
129    #[cfg(feature = "onepassword")]
130    pub async fn process_config_with_secrets(config: &str) -> DeviceSettingsResult<String> {
131        let loader = OnePasswordLoader::new();
132        if loader.is_available().await {
133            Ok(loader.process_config(config).await?)
134        } else {
135            log::warn!("1Password CLI not available, returning raw configuration");
136            Ok(config.to_string())
137        }
138    }
139
140    /// Create default embedded DeviceSettings without filesystem operations
141    /// Used for embedded/offline mode where config files are not needed
142    pub fn default_embedded() -> Self {
143        use std::collections::HashMap;
144
145        let mut profiles = HashMap::new();
146
147        // Add minimal required profiles for embedded mode
148        let mut memory_profile = HashMap::new();
149        memory_profile.insert("type".to_string(), "memory".to_string());
150        profiles.insert("memory".to_string(), memory_profile);
151
152        let mut sqlite_profile = HashMap::new();
153        sqlite_profile.insert("type".to_string(), "sqlite".to_string());
154        sqlite_profile.insert(
155            "datadir".to_string(),
156            "/tmp/terraphim_sqlite_embedded".to_string(),
157        );
158        sqlite_profile.insert(
159            "connection_string".to_string(),
160            "/tmp/terraphim_sqlite_embedded/terraphim.db".to_string(),
161        );
162        sqlite_profile.insert("table".to_string(), "terraphim_kv".to_string());
163        profiles.insert("sqlite".to_string(), sqlite_profile);
164
165        Self {
166            server_hostname: "127.0.0.1:8000".to_string(),
167            api_endpoint: "http://localhost:8000/api".to_string(),
168            initialized: true,
169            default_data_path: "/tmp/terraphim_embedded".to_string(),
170            profiles,
171        }
172    }
173    /// Get the default path for the config file
174    ///
175    /// This is the default path where the config file is stored.
176    pub fn default_config_path() -> PathBuf {
177        if let Some(proj_dirs) = ProjectDirs::from("com", "aks", "terraphim") {
178            let config_dir = proj_dirs.config_dir();
179            config_dir.to_path_buf()
180        } else {
181            PathBuf::from(DEFAULT_CONFIG_PATH)
182        }
183    }
184
185    /// Load settings from environment and file
186    /// config path shall be a folder and not file
187    pub fn load_from_env_and_file(config_path: Option<PathBuf>) -> DeviceSettingsResult<Self> {
188        log::info!("Loading device settings...");
189        let config_path = match config_path {
190            Some(path) => path,
191            None => DeviceSettings::default_config_path(),
192        };
193
194        log::debug!("Settings path: {:?}", config_path);
195        let config_file = init_config_file(&config_path)?;
196        log::debug!("Loading config_file: {:?}", config_file);
197
198        Ok(DeviceSettings::with_layers(&[
199            Layer::Toml(config_file),
200            Layer::Env(Some(String::from("TERRAPHIM_"))),
201        ])?)
202    }
203    pub fn update_initialized_flag(
204        &mut self,
205        settings_path: Option<PathBuf>,
206        initialized: bool,
207    ) -> Result<(), Error> {
208        let settings_path = settings_path.unwrap_or_else(Self::default_config_path);
209        let settings_path = settings_path.join("settings.toml");
210        self.initialized = initialized;
211        self.save(&settings_path)?;
212        Ok(())
213    }
214
215    /// Save the current settings to a file
216    pub fn save(&self, path: &PathBuf) -> Result<(), Error> {
217        log::info!("Saving device settings to: {:?}", path);
218        self.save_to_file(path)?;
219        Ok(())
220    }
221
222    /// Save settings to a specified file
223    fn save_to_file(&self, path: &PathBuf) -> Result<(), Error> {
224        let serialized_settings =
225            toml::to_string_pretty(self).map_err(|e| Error::IoError(std::io::Error::other(e)))?;
226
227        std::fs::write(path, serialized_settings).map_err(Error::IoError)?;
228
229        Ok(())
230    }
231}
232
233/// Initialize the config file if it doesn't exist
234fn init_config_file(path: &PathBuf) -> Result<PathBuf, std::io::Error> {
235    if !path.exists() {
236        std::fs::create_dir_all(path)?;
237    }
238    let config_file = path.join("settings.toml");
239    if !config_file.exists() {
240        log::info!("Initializing default config file at: {:?}", path);
241        std::fs::write(&config_file, DEFAULT_SETTINGS)?;
242    } else {
243        log::debug!("Config file exists at: {:?}", config_file);
244    }
245    Ok(config_file)
246}
247
248/// To run test with logs and variables use:
249/// RUST_LOG="info,warn" TERRAPHIM_API_ENDPOINT="test_endpoint" TERRAPHIM_PROFILE_S3_REGION="us-west-1" cargo test -- --nocapture
250#[cfg(test)]
251mod tests {
252    use super::*;
253    use test_log::test;
254
255    use envtestkit::lock::lock_test;
256    use envtestkit::set_env;
257    use std::ffi::OsString;
258
259    #[test]
260    fn test_env_variable() {
261        let _lock = lock_test();
262        let _test = set_env(OsString::from("TERRAPHIM_PROFILE_S3_REGION"), "us-west-1");
263        let _test2 = set_env(
264            OsString::from("TERRAPHIM_PROFILE_S3_ENABLE_VIRTUAL_HOST_STYLE"),
265            "on",
266        );
267
268        log::debug!("Env: {:?}", std::env::var("TERRAPHIM_PROFILE_S3_REGION"));
269        let config =
270            DeviceSettings::load_from_env_and_file(Some(PathBuf::from("./test_settings/")));
271
272        log::debug!("Config: {:?}", config);
273        log::debug!(
274            "Region: {:?}",
275            config
276                .as_ref()
277                .unwrap()
278                .profiles
279                .get("s3")
280                .unwrap()
281                .get("region")
282                .unwrap()
283        );
284
285        assert_eq!(
286            config
287                .unwrap()
288                .profiles
289                .get("s3")
290                .unwrap()
291                .get("region")
292                .unwrap(),
293            &String::from("us-west-1")
294        );
295    }
296
297    #[test]
298    fn test_update_initialized_flag() {
299        let test_config_path = PathBuf::from("./test_settings/");
300
301        // Check if initialized is false
302        let mut config =
303            DeviceSettings::load_from_env_and_file(Some(test_config_path.clone())).unwrap();
304        config.initialized = false;
305        assert!(!config.initialized);
306
307        // Update to true
308        config
309            .update_initialized_flag(Some(test_config_path.clone()), true)
310            .unwrap();
311
312        // Check if initialized is now true
313        let config_copy =
314            DeviceSettings::load_from_env_and_file(Some(test_config_path.clone())).unwrap();
315        assert!(config_copy.initialized);
316    }
317}