cat_gateway/settings/
mod.rs

1//! Command line and environment variable settings for the service
2use std::{
3    net::{IpAddr, Ipv4Addr, SocketAddr},
4    str::FromStr,
5    sync::{LazyLock, OnceLock},
6    time::Duration,
7};
8
9use anyhow::anyhow;
10use cardano_chain_follower::{Network, Slot};
11use clap::Args;
12use dotenvy::dotenv;
13use str_env_var::StringEnvVar;
14use tracing::error;
15use url::Url;
16
17use crate::{
18    build_info::{BUILD_INFO, log_build_info},
19    logger::{self, LOG_LEVEL_DEFAULT, LogLevel},
20    service::utilities::net::{get_public_ipv4, get_public_ipv6},
21    telemetry::{self, TelemetryGuard},
22    utils::blake2b_hash::generate_uuid_string_from_data,
23};
24
25pub(crate) mod admin;
26pub(crate) mod cardano_assets_cache;
27pub(crate) mod cassandra_db;
28pub(crate) mod chain_follower;
29pub(crate) mod event_db;
30pub(crate) mod rbac;
31pub(crate) mod signed_doc;
32mod str_env_var;
33
34/// Default address to start service on, '0.0.0.0:3030'.
35const ADDRESS_DEFAULT: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 3030);
36
37/// Default Github repo owner
38const GITHUB_REPO_OWNER_DEFAULT: &str = "input-output-hk";
39
40/// Default Github repo name
41const GITHUB_REPO_NAME_DEFAULT: &str = "catalyst-voices";
42
43/// Default Github issue template to use
44const GITHUB_ISSUE_TEMPLATE_DEFAULT: &str = "bug_report.yml";
45
46/// Default `CLIENT_ID_KEY` used in development.
47const CLIENT_ID_KEY_DEFAULT: &str = "3db5301e-40f2-47ed-ab11-55b37674631a";
48
49/// Default `API_URL_PREFIX` used in development.
50const API_URL_PREFIX_DEFAULT: &str = "/api";
51
52/// Default `CHECK_CONFIG_TICK` used in development, 5 seconds.
53const CHECK_CONFIG_TICK_DEFAULT: Duration = Duration::from_secs(5);
54
55/// Default number of slots used as overlap when purging Live Index data.
56const PURGE_BACKWARD_SLOT_BUFFER_DEFAULT: u64 = 100;
57
58/// Default `SERVICE_LIVE_TIMEOUT_INTERVAL`, that is used to determine if the service is
59/// live, 30 seconds.
60const SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT: Duration = Duration::from_secs(30);
61
62/// Default `SERVICE_LIVE_COUNTER_THRESHOLD`, that is used to determine if the service is
63/// live.
64const SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT: u64 = 100;
65
66/// Hash the Public IPv4 and IPv6 address of the machine, and convert to a 128 bit V4
67/// UUID.
68fn calculate_service_uuid() -> String {
69    let ip_addr: Vec<String> = vec![get_public_ipv4().to_string(), get_public_ipv6().to_string()];
70
71    generate_uuid_string_from_data("Catalyst-Gateway-Machine-UID", &ip_addr)
72}
73
74/// Settings for the application.
75///
76/// This struct represents the configuration settings for the application.
77/// It is used to specify the server binding address,
78/// the URL to the `PostgreSQL` event database,
79/// and the logging level.
80#[derive(Args, Clone)]
81#[clap(version = BUILD_INFO)]
82pub(crate) struct ServiceSettings {
83    /// Logging level
84    #[clap(long, default_value = LOG_LEVEL_DEFAULT)]
85    pub(crate) log_level: LogLevel,
86}
87
88/// All the `EnvVars` used by the service.
89struct EnvVars {
90    /// The github repo owner
91    github_repo_owner: StringEnvVar,
92
93    /// The github repo name
94    github_repo_name: StringEnvVar,
95
96    /// The github issue template to use
97    github_issue_template: StringEnvVar,
98
99    /// Server binding address
100    address: SocketAddr,
101
102    /// Server name
103    server_name: Option<StringEnvVar>,
104
105    /// Id of the Service.
106    service_id: StringEnvVar,
107
108    /// The client id key used to anonymize client connections.
109    client_id_key: StringEnvVar,
110
111    /// A List of servers to provide
112    api_host_names: Vec<String>,
113
114    /// The base path the API is served at.
115    api_url_prefix: StringEnvVar,
116
117    /// Flag by enabling `/panic` endpoint if its set
118    /// Enabled is `YES_I_REALLY_WANT_TO_PANIC` env var is set
119    /// and equals to `"panic attack"`
120    is_panic_endpoint_enabled: bool,
121
122    /// The Config of the Persistent Cassandra DB.
123    cassandra_persistent_db: cassandra_db::EnvVars,
124
125    /// The Config of the Volatile Cassandra DB.
126    cassandra_volatile_db: cassandra_db::EnvVars,
127
128    /// The Chain Follower configuration
129    chain_follower: chain_follower::EnvVars,
130
131    /// The event db configuration
132    event_db: event_db::EnvVars,
133
134    /// The Catalyst Signed Documents configuration
135    signed_doc: signed_doc::EnvVars,
136
137    /// RBAC configuration.
138    rbac: rbac::EnvVars,
139
140    /// The Cardano assets caches configuration
141    cardano_assets_cache: cardano_assets_cache::EnvVars,
142
143    /// The Admin functionality configuration
144    admin: admin::EnvVars,
145
146    /// Internal API Access API Key
147    internal_api_key: Option<StringEnvVar>,
148
149    /// Tick every N seconds until config exists in db
150    #[allow(unused)]
151    check_config_tick: Duration,
152
153    /// Slot buffer used as overlap when purging Live Index data.
154    purge_backward_slot_buffer: u64,
155
156    /// Interval for determining if the service is live.
157    service_live_timeout_interval: Duration,
158
159    /// Threshold for determining if the service is live.
160    service_live_counter_threshold: u64,
161
162    /// Set to Log 404 not found.
163    log_not_found: Option<StringEnvVar>,
164
165    /// Telemetry should be enabled.
166    telemetry_enabled: Option<StringEnvVar>,
167}
168
169// Lazy initialization of all env vars which are not command line parameters.
170// All env vars used by the application should be listed here and all should have a
171// default. The default for all NON Secret values should be suitable for Production, and
172// NOT development. Secrets however should only be used with the default value in
173// development
174
175/// Handle to the mithril sync thread. One for each Network ONLY.
176static ENV_VARS: LazyLock<EnvVars> = LazyLock::new(|| {
177    // Support env vars in a `.env` file,  doesn't need to exist.
178    dotenv().ok();
179
180    let address = StringEnvVar::new("ADDRESS", ADDRESS_DEFAULT.to_string().into());
181    let address = SocketAddr::from_str(address.as_str())
182        .inspect_err(|err| {
183            error!(
184                error = ?err,
185                default_addr = ?ADDRESS_DEFAULT,
186                invalid_addr = ?address,
187                "Invalid binding address. Using default binding address value.",
188            );
189        })
190        .unwrap_or(ADDRESS_DEFAULT);
191
192    let purge_backward_slot_buffer = StringEnvVar::new_as_int(
193        "PURGE_BACKWARD_SLOT_BUFFER",
194        PURGE_BACKWARD_SLOT_BUFFER_DEFAULT,
195        0,
196        u64::MAX,
197    );
198
199    EnvVars {
200        github_repo_owner: StringEnvVar::new("GITHUB_REPO_OWNER", GITHUB_REPO_OWNER_DEFAULT.into()),
201        github_repo_name: StringEnvVar::new("GITHUB_REPO_NAME", GITHUB_REPO_NAME_DEFAULT.into()),
202        github_issue_template: StringEnvVar::new(
203            "GITHUB_ISSUE_TEMPLATE",
204            GITHUB_ISSUE_TEMPLATE_DEFAULT.into(),
205        ),
206        address,
207        server_name: StringEnvVar::new_optional("SERVER_NAME", false),
208        service_id: StringEnvVar::new("SERVICE_ID", calculate_service_uuid().into()),
209        client_id_key: StringEnvVar::new("CLIENT_ID_KEY", CLIENT_ID_KEY_DEFAULT.into()),
210        api_host_names: string_to_api_host_names(
211            &StringEnvVar::new_optional("API_HOST_NAMES", false)
212                .map(|v| v.as_string())
213                .unwrap_or_default(),
214        ),
215        api_url_prefix: StringEnvVar::new("API_URL_PREFIX", API_URL_PREFIX_DEFAULT.into()),
216        is_panic_endpoint_enabled: StringEnvVar::new_optional("YES_I_REALLY_WANT_TO_PANIC", false)
217            .is_some_and(|v| v.as_str() == "panic attack"),
218
219        cassandra_persistent_db: cassandra_db::EnvVars::new(
220            cassandra_db::PERSISTENT_URL_DEFAULT,
221            cassandra_db::PERSISTENT_NAMESPACE_DEFAULT,
222        ),
223        cassandra_volatile_db: cassandra_db::EnvVars::new(
224            cassandra_db::VOLATILE_URL_DEFAULT,
225            cassandra_db::VOLATILE_NAMESPACE_DEFAULT,
226        ),
227        chain_follower: chain_follower::EnvVars::new(),
228        event_db: event_db::EnvVars::new(),
229        signed_doc: signed_doc::EnvVars::new(),
230        rbac: rbac::EnvVars::new(),
231        cardano_assets_cache: cardano_assets_cache::EnvVars::new(),
232        admin: admin::EnvVars::new(),
233        internal_api_key: StringEnvVar::new_optional("INTERNAL_API_KEY", true),
234        check_config_tick: StringEnvVar::new_as_duration(
235            "CHECK_CONFIG_TICK",
236            CHECK_CONFIG_TICK_DEFAULT,
237        ),
238        purge_backward_slot_buffer,
239        service_live_timeout_interval: StringEnvVar::new_as_duration(
240            "SERVICE_LIVE_TIMEOUT_INTERVAL",
241            SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT,
242        ),
243        service_live_counter_threshold: StringEnvVar::new_as_int(
244            "SERVICE_LIVE_COUNTER_THRESHOLD",
245            SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT,
246            0,
247            u64::MAX,
248        ),
249        log_not_found: StringEnvVar::new_optional("LOG_NOT_FOUND", false),
250        telemetry_enabled: StringEnvVar::new_optional("TELEMETRY_ENABLED", false),
251    }
252});
253
254impl EnvVars {
255    /// Validate env vars in ways we couldn't when they were first loaded.
256    pub(crate) fn validate() -> anyhow::Result<()> {
257        let mut status = Ok(());
258
259        let url = ENV_VARS.event_db.url();
260        if let Err(error) = tokio_postgres::config::Config::from_str(url) {
261            error!(error=%error, url=url, "Invalid Postgres DB URL.");
262            status = Err(anyhow!("Environment Variable Validation Error."));
263        }
264
265        status
266    }
267}
268
269/// All Settings/Options for the Service.
270static SERVICE_SETTINGS: OnceLock<ServiceSettings> = OnceLock::new();
271
272/// Our Global Settings for this running service.
273pub(crate) struct Settings();
274
275impl Settings {
276    /// Initialize the settings data.
277    pub(crate) fn init(settings: ServiceSettings) -> anyhow::Result<Option<TelemetryGuard>> {
278        let log_level = settings.log_level;
279
280        if SERVICE_SETTINGS.set(settings).is_err() {
281            // We use println here, because logger not yet configured.
282            println!("Failed to initialize service settings. Called multiple times?");
283        }
284
285        let guard = if ENV_VARS.telemetry_enabled.is_some() {
286            let guard = telemetry::init(log_level)?;
287            Some(guard)
288        } else {
289            // Init the regular fmt logger.
290            logger::init(log_level);
291            None
292        };
293
294        log_build_info();
295
296        // Validate any settings we couldn't validate when loaded.
297        EnvVars::validate()?;
298        Ok(guard)
299    }
300
301    /// Get the current Event DB settings for this service.
302    pub(crate) fn event_db_settings() -> &'static event_db::EnvVars {
303        &ENV_VARS.event_db
304    }
305
306    /// Get the Persistent & Volatile Cassandra DB config for this service.
307    pub(crate) fn cassandra_db_cfg() -> (cassandra_db::EnvVars, cassandra_db::EnvVars) {
308        (
309            ENV_VARS.cassandra_persistent_db.clone(),
310            ENV_VARS.cassandra_volatile_db.clone(),
311        )
312    }
313
314    /// Get the configuration of the chain follower.
315    pub(crate) fn follower_cfg() -> chain_follower::EnvVars {
316        ENV_VARS.chain_follower.clone()
317    }
318
319    /// Get the configuration of the Catalyst Signed Documents.
320    pub(crate) fn signed_doc_cfg() -> signed_doc::EnvVars {
321        ENV_VARS.signed_doc.clone()
322    }
323
324    /// Returns the RBAC configuration.
325    pub fn rbac_cfg() -> &'static rbac::EnvVars {
326        &ENV_VARS.rbac
327    }
328
329    /// Get the configuration of the Cardano assets cache.
330    pub(crate) fn cardano_assets_cache() -> cardano_assets_cache::EnvVars {
331        ENV_VARS.cardano_assets_cache.clone()
332    }
333
334    /// Get the configuration of the Admin functionality.
335    pub(crate) fn admin_cfg() -> admin::EnvVars {
336        ENV_VARS.admin.clone()
337    }
338
339    /// Chain Follower network (The Blockchain network we are configured to use).
340    /// Note: Catalyst Gateway can ONLY follow one network at a time.
341    pub(crate) fn cardano_network() -> &'static Network {
342        ENV_VARS.chain_follower.chain()
343    }
344
345    /// The API Url prefix
346    pub(crate) fn api_url_prefix() -> &'static str {
347        ENV_VARS.api_url_prefix.as_str()
348    }
349
350    /// The Key used to anonymize client connections in the logs.
351    pub(crate) fn client_id_key() -> &'static str {
352        ENV_VARS.client_id_key.as_str()
353    }
354
355    /// The Service UUID
356    pub(crate) fn service_id() -> &'static str {
357        ENV_VARS.service_id.as_str()
358    }
359
360    /// Get a list of all host names to serve the API on.
361    ///
362    /// Used by the `OpenAPI` Documentation to point to the correct backend.
363    /// Take a list of [scheme://] + host names from the env var and turns it into
364    /// a lits of strings.
365    ///
366    /// Host names are taken from the `API_HOST_NAMES` environment variable.
367    /// If that is not set, returns an empty list.
368    pub(crate) fn api_host_names() -> &'static [String] {
369        &ENV_VARS.api_host_names
370    }
371
372    /// The socket address we are bound to.
373    pub(crate) fn bound_address() -> SocketAddr {
374        ENV_VARS.address
375    }
376
377    /// Get the server name to be used in the `Server` object of the `OpenAPI` Document.
378    pub(crate) fn server_name() -> Option<&'static str> {
379        ENV_VARS.server_name.as_ref().map(StringEnvVar::as_str)
380    }
381
382    /// Get the flag is the `/panic` should be enabled or not
383    pub(crate) fn is_panic_endpoint_enabled() -> bool {
384        ENV_VARS.is_panic_endpoint_enabled
385    }
386
387    /// Generate a github issue url with a given title
388    ///
389    /// ## Arguments
390    ///
391    /// * `title`: &str - the title to give the issue
392    ///
393    /// ## Returns
394    ///
395    /// * String - the url
396    ///
397    /// ## Example
398    ///
399    /// ```rust,no_run
400    /// # use cat_data_service::settings::generate_github_issue_url;
401    /// assert_eq!(
402    ///     generate_github_issue_url("Hello, World! How are you?"),
403    ///     "https://github.com/input-output-hk/catalyst-voices/issues/new?template=bug_report.yml&title=Hello%2C%20World%21%20How%20are%20you%3F"
404    /// );
405    /// ```
406    pub(crate) fn generate_github_issue_url(title: &str) -> Option<Url> {
407        let path = format!(
408            "https://github.com/{}/{}/issues/new",
409            ENV_VARS.github_repo_owner.as_str(),
410            ENV_VARS.github_repo_name.as_str()
411        );
412
413        match Url::parse_with_params(&path, &[
414            ("template", ENV_VARS.github_issue_template.as_str()),
415            ("title", title),
416        ]) {
417            Ok(url) => Some(url),
418            Err(e) => {
419                error!("Failed to generate github issue url {:?}", e.to_string());
420                None
421            },
422        }
423    }
424
425    /// Check a given key matches the internal API Key
426    pub(crate) fn check_internal_api_key(value: &str) -> bool {
427        if let Some(required_key) = ENV_VARS.internal_api_key.as_ref().map(StringEnvVar::as_str) {
428            value == required_key
429        } else {
430            false
431        }
432    }
433
434    /// Slot buffer used as overlap when purging Live Index data.
435    pub(crate) fn purge_backward_slot_buffer() -> Slot {
436        ENV_VARS.purge_backward_slot_buffer.into()
437    }
438
439    /// Duration in seconds used to determine if the system is live during checks.
440    pub(crate) fn service_live_timeout_interval() -> Duration {
441        ENV_VARS.service_live_timeout_interval
442    }
443
444    /// Value after which the service is considered NOT live.
445    pub(crate) fn service_live_counter_threshold() -> u64 {
446        ENV_VARS.service_live_counter_threshold
447    }
448
449    /// If set log the 404 not found, else do not log.
450    pub(crate) fn log_not_found() -> bool {
451        ENV_VARS.log_not_found.is_some()
452    }
453}
454
455/// Transform a string list of host names into a vec of host names.
456fn string_to_api_host_names(hosts: &str) -> Vec<String> {
457    /// Log an invalid hostname.
458    fn invalid_hostname(hostname: &str) -> String {
459        error!(hostname = hostname, "Invalid host name for API");
460        String::new()
461    }
462
463    let configured_hosts: Vec<String> = hosts
464        .split(',')
465        // filters out at the beginning all empty entries, because they all would be invalid and
466        // filtered out anyway
467        .filter(|s| !s.is_empty())
468        .map(|s| {
469            let url = Url::parse(s.trim());
470            match url {
471                Ok(url) => {
472                    // Get the scheme, and if its empty, use http
473                    let scheme = url.scheme();
474
475                    let port = url.port();
476
477                    // Rebuild the scheme + hostname
478                    match url.host() {
479                        Some(host) => {
480                            let host = host.to_string();
481                            if host.is_empty() {
482                                invalid_hostname(s)
483                            } else {
484                                match port {
485                                    Some(port) => {
486                                        format! {"{scheme}://{host}:{port}"}
487                                        // scheme.to_owned() + "://" + &host + ":" +
488                                        // &port.to_string()
489                                    },
490                                    None => {
491                                        format! {"{scheme}://{host}"}
492                                    },
493                                }
494                            }
495                        },
496                        None => invalid_hostname(s),
497                    }
498                },
499                Err(_) => invalid_hostname(s),
500            }
501        })
502        .filter(|s| !s.is_empty())
503        .collect();
504
505    configured_hosts
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn generate_github_issue_url_test() {
514        let title = "Hello, World! How are you?";
515        assert_eq!(
516            Settings::generate_github_issue_url(title)
517                .expect("Failed to generate url")
518                .as_str(),
519            "https://github.com/input-output-hk/catalyst-voices/issues/new?template=bug_report.yml&title=Hello%2C+World%21+How+are+you%3F"
520        );
521    }
522
523    #[test]
524    fn configured_hosts_default() {
525        let configured_hosts = Settings::api_host_names();
526        assert!(configured_hosts.is_empty());
527    }
528
529    #[test]
530    fn configured_hosts_set_multiple() {
531        let configured_hosts = string_to_api_host_names(
532            "http://api.prod.projectcatalyst.io , https://api.dev.projectcatalyst.io:1234",
533        );
534        assert_eq!(configured_hosts, vec![
535            "http://api.prod.projectcatalyst.io",
536            "https://api.dev.projectcatalyst.io:1234"
537        ]);
538    }
539
540    #[test]
541    fn configured_hosts_set_multiple_one_invalid() {
542        let configured_hosts =
543            string_to_api_host_names("not a hostname , https://api.dev.projectcatalyst.io:1234");
544        assert_eq!(configured_hosts, vec![
545            "https://api.dev.projectcatalyst.io:1234"
546        ]);
547    }
548
549    #[test]
550    fn configured_hosts_set_empty() {
551        let configured_hosts = string_to_api_host_names("");
552        assert!(configured_hosts.is_empty());
553    }
554
555    #[test]
556    fn configured_service_live_timeout_interval_default() {
557        let timeout_secs = Settings::service_live_timeout_interval();
558        assert_eq!(timeout_secs, SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT);
559    }
560
561    #[test]
562    fn configured_service_live_counter_threshold_default() {
563        let threshold = Settings::service_live_counter_threshold();
564        assert_eq!(threshold, SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT);
565    }
566}