1use 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
28const ADDRESS_DEFAULT: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 3030);
30
31const GITHUB_REPO_OWNER_DEFAULT: &str = "input-output-hk";
33
34const GITHUB_REPO_NAME_DEFAULT: &str = "catalyst-voices";
36
37const GITHUB_ISSUE_TEMPLATE_DEFAULT: &str = "bug_report.yml";
39
40const CLIENT_ID_KEY_DEFAULT: &str = "3db5301e-40f2-47ed-ab11-55b37674631a";
42
43const API_URL_PREFIX_DEFAULT: &str = "/api";
45
46const CHECK_CONFIG_TICK_DEFAULT: Duration = Duration::from_secs(5);
48
49const METRICS_MEMORY_INTERVAL_DEFAULT: Duration = Duration::from_secs(1);
51
52const METRICS_FOLLOWER_INTERVAL_DEFAULT: Duration = Duration::from_secs(1);
54
55const EVENT_DB_URL_DEFAULT: &str =
57 "postgresql://postgres:postgres@localhost/catalyst_events?sslmode=disable";
58
59const PURGE_SLOT_BUFFER_DEFAULT: u64 = 100;
61
62const SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT: Duration = Duration::from_secs(30);
65
66const SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT: u64 = 100;
69
70fn 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#[derive(Args, Clone)]
85#[clap(version = BUILD_INFO)]
86pub(crate) struct ServiceSettings {
87 #[clap(long, default_value = LOG_LEVEL_DEFAULT)]
89 pub(crate) log_level: LogLevel,
90}
91
92struct EnvVars {
94 github_repo_owner: StringEnvVar,
96
97 github_repo_name: StringEnvVar,
99
100 github_issue_template: StringEnvVar,
102
103 address: SocketAddr,
105
106 server_name: Option<StringEnvVar>,
108
109 service_id: StringEnvVar,
111
112 client_id_key: StringEnvVar,
114
115 api_host_names: Option<StringEnvVar>,
117
118 api_url_prefix: StringEnvVar,
120
121 event_db_url: StringEnvVar,
123
124 event_db_username: Option<StringEnvVar>,
126
127 event_db_password: Option<StringEnvVar>,
129
130 cassandra_persistent_db: cassandra_db::EnvVars,
132
133 cassandra_volatile_db: cassandra_db::EnvVars,
135
136 chain_follower: chain_follower::EnvVars,
138
139 internal_api_key: Option<StringEnvVar>,
141
142 #[allow(unused)]
144 check_config_tick: Duration,
145
146 purge_slot_buffer: u64,
148
149 metrics_memory_interval: Duration,
151
152 metrics_follower_interval: Duration,
154
155 service_live_timeout_interval: Duration,
157
158 service_live_counter_threshold: u64,
160}
161
162static ENV_VARS: LazyLock<EnvVars> = LazyLock::new(|| {
170 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 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
252static SERVICE_SETTINGS: OnceLock<ServiceSettings> = OnceLock::new();
254
255pub(crate) struct Settings();
257
258impl Settings {
259 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 println!("Failed to initialize service settings. Called multiple times?");
266 }
267
268 logger::init(log_level);
270
271 log_build_info();
272
273 EnvVars::validate()
275 }
276
277 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 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 pub(crate) fn follower_cfg() -> chain_follower::EnvVars {
303 ENV_VARS.chain_follower.clone()
304 }
305
306 pub(crate) fn cardano_network() -> Network {
309 ENV_VARS.chain_follower.chain
310 }
311
312 pub(crate) fn api_url_prefix() -> &'static str {
314 ENV_VARS.api_url_prefix.as_str()
315 }
316
317 pub(crate) fn client_id_key() -> &'static str {
319 ENV_VARS.client_id_key.as_str()
320 }
321
322 pub(crate) fn service_id() -> &'static str {
324 ENV_VARS.service_id.as_str()
325 }
326
327 pub(crate) fn metrics_memory_interval() -> Duration {
329 ENV_VARS.metrics_memory_interval
330 }
331
332 pub(crate) fn metrics_follower_interval() -> Duration {
334 ENV_VARS.metrics_follower_interval
335 }
336
337 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 pub(crate) fn bound_address() -> SocketAddr {
357 ENV_VARS.address
358 }
359
360 pub(crate) fn server_name() -> Option<&'static str> {
362 ENV_VARS.server_name.as_ref().map(StringEnvVar::as_str)
363 }
364
365 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 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 pub(crate) fn purge_slot_buffer() -> Slot {
414 ENV_VARS.purge_slot_buffer.into()
415 }
416
417 pub(crate) fn service_live_timeout_interval() -> Duration {
419 ENV_VARS.service_live_timeout_interval
420 }
421
422 pub(crate) fn service_live_counter_threshold() -> u64 {
424 ENV_VARS.service_live_counter_threshold
425 }
426}
427
428fn string_to_api_host_names(hosts: &str) -> Vec<String> {
430 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 let scheme = url.scheme();
444
445 let port = url.port();
446
447 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 },
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}