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