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