golem_common/
config.rs

1// Copyright 2024-2025 Golem Cloud
2//
3// Licensed under the Golem Source License v1.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://license.golem.cloud/LICENSE
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use crate::model::RetryConfig;
16use figment::providers::{Env, Format, Serialized, Toml};
17use figment::value::Value;
18use figment::Figment;
19use serde::{Deserialize, Serialize};
20use std::path::{Path, PathBuf};
21use std::time::Duration;
22use url::Url;
23
24const ENV_VAR_PREFIX: &str = "GOLEM__";
25const ENV_VAR_NESTED_SEPARATOR: &str = "__";
26
27pub trait ConfigLoaderConfig: Default + Serialize + Deserialize<'static> {}
28impl<T: Default + Serialize + Deserialize<'static>> ConfigLoaderConfig for T {}
29
30pub type ConfigExample<T> = (&'static str, T);
31
32pub trait HasConfigExamples<T> {
33    fn examples() -> Vec<ConfigExample<T>>;
34}
35
36pub struct ConfigLoader<T: ConfigLoaderConfig> {
37    pub config_file_name: PathBuf,
38    pub make_examples: Option<fn() -> Vec<ConfigExample<T>>>,
39    config_type: std::marker::PhantomData<T>,
40}
41
42impl<T: ConfigLoaderConfig> ConfigLoader<T> {
43    pub fn new(config_file_name: &Path) -> ConfigLoader<T> {
44        let config_file_name = std::env::current_dir()
45            .expect("Failed to get current directory")
46            .join(config_file_name);
47        Self {
48            config_file_name,
49            make_examples: None,
50            config_type: std::marker::PhantomData,
51        }
52    }
53
54    pub fn new_with_examples(config_file_name: &Path) -> ConfigLoader<T>
55    where
56        T: HasConfigExamples<T>,
57    {
58        let config_file_name = std::env::current_dir()
59            .expect("Failed to get current directory")
60            .join(config_file_name);
61        Self {
62            config_file_name,
63            make_examples: Some(T::examples),
64            config_type: std::marker::PhantomData,
65        }
66    }
67
68    pub fn default_figment(&self) -> Figment {
69        Figment::new().merge(Serialized::defaults(T::default()))
70    }
71
72    pub fn example_figments(&self) -> Vec<(&'static str, Figment)> {
73        self.make_examples
74            .map_or(Vec::new(), |make| make())
75            .into_iter()
76            .map(|(name, example)| (name, Figment::new().merge(Serialized::defaults(example))))
77            .collect()
78    }
79
80    pub fn figment(&self) -> Figment {
81        Figment::new()
82            .merge(Serialized::defaults(T::default()))
83            .merge(Toml::file_exact(self.config_file_name.clone()))
84            .merge(env_config_provider())
85    }
86
87    pub fn load(&self) -> figment::Result<T> {
88        self.figment().extract()
89    }
90
91    fn default_dump_source(&self) -> dump::Source {
92        dump::Source::Default {
93            default: self.default_figment(),
94            examples: self.example_figments(),
95        }
96    }
97
98    fn loaded_dump_source(&self) -> dump::Source {
99        dump::Source::Loaded(self.figment())
100    }
101
102    /// Parses command line arguments looking for config dump flags
103    /// If found, dumps the config and returns None
104    /// Otherwise it tries to load the configuration, and returns it.
105    /// If loading the configuration fails, it prints a user-friendly error and
106    /// returns None.
107    pub fn load_or_dump_config(&self) -> Option<T> {
108        let args: Vec<String> = std::env::args().collect();
109        match args.get(1).map_or("", |a| a.as_str()) {
110            "--dump-config-default" => self.dump(self.default_dump_source(), &dump::print(false)),
111            "--dump-config-default-env-var" => {
112                self.dump(self.default_dump_source(), &dump::env_var())
113            }
114            "--dump-config-default-toml" => self.dump(self.default_dump_source(), &dump::toml()),
115            "--dump-config" => self.dump(self.loaded_dump_source(), &dump::print(true)),
116            "--dump-config-env-var" => self.dump(self.loaded_dump_source(), &dump::env_var()),
117            "--dump-config-toml" => self.dump(self.loaded_dump_source(), &dump::toml()),
118            other => {
119                if other.starts_with("--dump-config") {
120                    panic!("Unknown dump config parameter: {}", other);
121                } else {
122                    match self.load() {
123                        Ok(config) => Some(config),
124                        Err(err) => {
125                            eprintln!("Failed to load config: {err}");
126                            None
127                        }
128                    }
129                }
130            }
131        }
132    }
133
134    fn dump<U>(&self, source: dump::Source, dump: &dump::Dump) -> Option<U> {
135        let extract =
136            |figment: &Figment| -> Value { figment.extract().expect("Failed to extract config") };
137
138        let dump_figment = |header: &str, is_example: bool, figment: &Figment| match dump {
139            dump::Dump::RootValue(dump) => dump.root_value(header, is_example, &extract(figment)),
140            dump::Dump::Visitor(dump) => {
141                dump.begin(header, is_example);
142                Self::visit_dump(
143                    figment,
144                    dump.as_ref(),
145                    &mut Vec::new(),
146                    "",
147                    &extract(figment),
148                )
149            }
150        };
151
152        match source {
153            dump::Source::Default { default, examples } => {
154                dump_figment("Generated from default config", false, &default);
155                for (name, example) in examples {
156                    dump_figment(
157                        &format!("Generated from example config: {}", name),
158                        true,
159                        &example,
160                    );
161                }
162            }
163            dump::Source::Loaded(loaded) => {
164                dump_figment("Generated from loaded config", false, &loaded);
165            }
166        }
167
168        None
169    }
170
171    fn visit_dump<'a>(
172        figment: &Figment,
173        dump: &dyn dump::VisitorDump,
174        path: &mut Vec<&'a str>,
175        name: &'a str,
176        value: &'a Value,
177    ) {
178        match value {
179            Value::Dict(_, dict) => {
180                if !name.is_empty() {
181                    path.push(name);
182                }
183
184                for (name, value) in dict.iter().filter(|(_, v)| v.as_dict().is_none()) {
185                    Self::visit_dump(figment, dump, path, name, value)
186                }
187                for (name, value) in dict.iter().filter(|(_, v)| v.as_dict().is_some()) {
188                    Self::visit_dump(figment, dump, path, name, value)
189                }
190
191                if !name.is_empty() {
192                    path.pop();
193                }
194            }
195            value => dump.value(path, name, figment.get_metadata(value.tag()), value),
196        }
197    }
198}
199
200pub(crate) mod dump {
201    use crate::config::{ENV_VAR_NESTED_SEPARATOR, ENV_VAR_PREFIX};
202    use figment::value::Value;
203    use figment::{Figment, Metadata};
204
205    pub enum Source {
206        Default {
207            default: Figment,
208            examples: Vec<(&'static str, Figment)>,
209        },
210        Loaded(Figment),
211    }
212
213    pub fn print(show_source: bool) -> Dump {
214        Dump::Visitor(Box::new(Print { show_source }))
215    }
216
217    pub fn env_var() -> Dump {
218        Dump::Visitor(Box::new(EnvVar))
219    }
220
221    pub fn toml() -> Dump {
222        Dump::RootValue(Box::new(Toml))
223    }
224
225    pub enum Dump {
226        RootValue(Box<dyn RootValueDump>),
227        Visitor(Box<dyn VisitorDump>),
228    }
229
230    pub trait RootValueDump {
231        fn root_value(&self, header: &str, is_example: bool, value: &Value);
232    }
233
234    pub trait VisitorDump {
235        fn begin(&self, header: &str, is_example: bool);
236        fn value(&self, path: &[&str], name: &str, metadata: Option<&Metadata>, value: &Value);
237    }
238
239    struct Print {
240        pub show_source: bool,
241    }
242
243    impl VisitorDump for Print {
244        fn begin(&self, header: &str, is_example: bool) {
245            if is_example {
246                println!();
247            }
248            println!(":: {}\n", header)
249        }
250
251        fn value(&self, path: &[&str], name: &str, metadata: Option<&Metadata>, value: &Value) {
252            if !path.is_empty() {
253                for elem in path {
254                    print!("{}.", elem);
255                }
256            }
257            if !name.is_empty() {
258                print!("{}", name);
259            }
260
261            print!(
262                ": {}",
263                serde_json::to_string(value).expect("Failed to pretty print value")
264            );
265
266            if self.show_source {
267                let name = metadata.map_or("unknown", |m| &m.name);
268                let source = metadata
269                    .and_then(|m| m.source.as_ref())
270                    .map_or("unknown".to_owned(), |s| s.to_string());
271                println!(" ({} - {})", name, source);
272            } else {
273                println!()
274            }
275        }
276    }
277
278    struct EnvVar;
279
280    impl VisitorDump for EnvVar {
281        fn begin(&self, header: &str, is_example: bool) {
282            if is_example {
283                println!();
284            }
285            println!("### {}\n", header)
286        }
287
288        fn value(&self, path: &[&str], name: &str, _metadata: Option<&Metadata>, value: &Value) {
289            let is_empty_value = value.to_empty().is_some();
290
291            if is_empty_value {
292                print!("#");
293            }
294
295            print!("{}", ENV_VAR_PREFIX);
296
297            if !path.is_empty() {
298                for elem in path {
299                    print!("{}{}", elem.to_uppercase(), ENV_VAR_NESTED_SEPARATOR);
300                }
301            }
302            if !name.is_empty() {
303                print!("{}", name.to_uppercase());
304            }
305
306            if !is_empty_value {
307                println!(
308                    "={}",
309                    serde_json::to_string(value).expect("Failed to pretty print value")
310                );
311            } else {
312                println!("=");
313            }
314        }
315    }
316
317    pub struct Toml;
318
319    impl RootValueDump for Toml {
320        fn root_value(&self, header: &str, is_example: bool, value: &Value) {
321            if is_example {
322                println!();
323            }
324            println!("## {}", header);
325
326            let mut config_as_toml_str =
327                toml::to_string(value).expect("Failed to serialize as TOML");
328
329            if is_example {
330                config_as_toml_str = config_as_toml_str
331                    .lines()
332                    .map(|l| format!("# {}", l))
333                    .collect::<Vec<String>>()
334                    .join("\n")
335            }
336
337            println!("{}", config_as_toml_str);
338        }
339    }
340}
341
342#[derive(Clone, Debug, Serialize, Deserialize)]
343pub struct RedisConfig {
344    pub host: String,
345    pub port: u16,
346    pub database: usize,
347    pub tracing: bool,
348    pub pool_size: usize,
349    pub retries: RetryConfig,
350    pub key_prefix: String,
351    pub username: Option<String>,
352    pub password: Option<String>,
353}
354
355impl RedisConfig {
356    pub fn url(&self) -> Url {
357        Url::parse(&format!(
358            "redis://{}:{}/{}",
359            self.host, self.port, self.database
360        ))
361        .expect("Failed to parse Redis URL")
362    }
363}
364
365impl Default for RedisConfig {
366    fn default() -> Self {
367        Self {
368            host: "localhost".to_string(),
369            port: 6380,
370            database: 0,
371            tracing: false,
372            pool_size: 8,
373            retries: RetryConfig::default(),
374            key_prefix: "".to_string(),
375            username: None,
376            password: None,
377        }
378    }
379}
380
381impl Default for RetryConfig {
382    fn default() -> Self {
383        Self::max_attempts_5()
384    }
385}
386
387impl RetryConfig {
388    pub fn max_attempts_3() -> RetryConfig {
389        Self {
390            max_attempts: 3,
391            min_delay: Duration::from_millis(100),
392            max_delay: Duration::from_secs(1),
393            multiplier: 3.0,
394            max_jitter_factor: Some(0.15),
395        }
396    }
397
398    pub fn max_attempts_5() -> RetryConfig {
399        Self {
400            max_attempts: 5,
401            min_delay: Duration::from_millis(100),
402            max_delay: Duration::from_secs(2),
403            multiplier: 2.0,
404            max_jitter_factor: Some(0.15),
405        }
406    }
407}
408
409pub fn env_config_provider() -> Env {
410    Env::prefixed(ENV_VAR_PREFIX).split(ENV_VAR_NESTED_SEPARATOR)
411}
412
413#[derive(Clone, Debug, Serialize, Deserialize)]
414#[serde(tag = "type", content = "config")]
415pub enum DbConfig {
416    Postgres(DbPostgresConfig),
417    Sqlite(DbSqliteConfig),
418}
419
420impl Default for DbConfig {
421    fn default() -> Self {
422        DbConfig::Sqlite(DbSqliteConfig {
423            database: "golem_service.db".to_string(),
424            max_connections: 10,
425        })
426    }
427}
428
429impl DbConfig {
430    pub fn postgres_example() -> Self {
431        Self::Postgres(DbPostgresConfig {
432            host: "localhost".to_string(),
433            database: "postgres".to_string(),
434            username: "postgres".to_string(),
435            password: "postgres".to_string(),
436            port: 5432,
437            max_connections: 10,
438            schema: None,
439        })
440    }
441}
442
443#[derive(Clone, Debug, Serialize, Deserialize)]
444pub struct DbSqliteConfig {
445    pub database: String,
446    pub max_connections: u32,
447}
448
449impl DbSqliteConfig {
450    #[cfg(feature = "sql")]
451    pub fn connect_options(&self) -> sqlx::sqlite::SqliteConnectOptions {
452        sqlx::sqlite::SqliteConnectOptions::new()
453            .filename(&self.database)
454            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
455            .create_if_missing(true)
456    }
457}
458
459#[derive(Clone, Debug, Serialize, Deserialize)]
460pub struct DbPostgresConfig {
461    pub host: String,
462    pub database: String,
463    pub username: String,
464    pub password: String,
465    pub port: u16,
466    pub max_connections: u32,
467    pub schema: Option<String>,
468}
469
470impl DbPostgresConfig {
471    #[cfg(feature = "sql")]
472    pub fn connect_options(&self) -> sqlx::postgres::PgConnectOptions {
473        sqlx::postgres::PgConnectOptions::new()
474            .host(&self.host)
475            .port(self.port)
476            .database(&self.database)
477            .username(&self.username)
478            .password(&self.password)
479    }
480}