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