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;
26pub(crate) mod signed_doc;
27mod str_env_var;
28
29const ADDRESS_DEFAULT: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 3030);
31
32const GITHUB_REPO_OWNER_DEFAULT: &str = "input-output-hk";
34
35const GITHUB_REPO_NAME_DEFAULT: &str = "catalyst-voices";
37
38const GITHUB_ISSUE_TEMPLATE_DEFAULT: &str = "bug_report.yml";
40
41const CLIENT_ID_KEY_DEFAULT: &str = "3db5301e-40f2-47ed-ab11-55b37674631a";
43
44const API_URL_PREFIX_DEFAULT: &str = "/api";
46
47const CHECK_CONFIG_TICK_DEFAULT: Duration = Duration::from_secs(5);
49
50const METRICS_MEMORY_INTERVAL_DEFAULT: Duration = Duration::from_secs(1);
52
53const METRICS_FOLLOWER_INTERVAL_DEFAULT: Duration = Duration::from_secs(1);
55
56const EVENT_DB_URL_DEFAULT: &str =
58 "postgresql://postgres:postgres@localhost/catalyst_events?sslmode=disable";
59
60const PURGE_BACKWARD_SLOT_BUFFER_DEFAULT: u64 = 100;
62
63const SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT: Duration = Duration::from_secs(30);
66
67const SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT: u64 = 100;
70
71fn 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#[derive(Args, Clone)]
86#[clap(version = BUILD_INFO)]
87pub(crate) struct ServiceSettings {
88 #[clap(long, default_value = LOG_LEVEL_DEFAULT)]
90 pub(crate) log_level: LogLevel,
91}
92
93struct EnvVars {
95 github_repo_owner: StringEnvVar,
97
98 github_repo_name: StringEnvVar,
100
101 github_issue_template: StringEnvVar,
103
104 address: SocketAddr,
106
107 server_name: Option<StringEnvVar>,
109
110 service_id: StringEnvVar,
112
113 client_id_key: StringEnvVar,
115
116 api_host_names: Option<StringEnvVar>,
118
119 api_url_prefix: StringEnvVar,
121
122 event_db_url: StringEnvVar,
124
125 event_db_username: Option<StringEnvVar>,
127
128 event_db_password: Option<StringEnvVar>,
130
131 cassandra_persistent_db: cassandra_db::EnvVars,
133
134 cassandra_volatile_db: cassandra_db::EnvVars,
136
137 chain_follower: chain_follower::EnvVars,
139
140 signed_doc: signed_doc::EnvVars,
142
143 internal_api_key: Option<StringEnvVar>,
145
146 #[allow(unused)]
148 check_config_tick: Duration,
149
150 purge_backward_slot_buffer: u64,
152
153 metrics_memory_interval: Duration,
155
156 metrics_follower_interval: Duration,
158
159 service_live_timeout_interval: Duration,
161
162 service_live_counter_threshold: u64,
164}
165
166static ENV_VARS: LazyLock<EnvVars> = LazyLock::new(|| {
174 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 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
261static SERVICE_SETTINGS: OnceLock<ServiceSettings> = OnceLock::new();
263
264pub(crate) struct Settings();
266
267impl Settings {
268 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 println!("Failed to initialize service settings. Called multiple times?");
275 }
276
277 logger::init(log_level);
279
280 log_build_info();
281
282 EnvVars::validate()
284 }
285
286 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 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 pub(crate) fn follower_cfg() -> chain_follower::EnvVars {
312 ENV_VARS.chain_follower.clone()
313 }
314
315 pub(crate) fn signed_doc_cfg() -> signed_doc::EnvVars {
317 ENV_VARS.signed_doc.clone()
318 }
319
320 pub(crate) fn cardano_network() -> Network {
323 ENV_VARS.chain_follower.chain
324 }
325
326 pub(crate) fn api_url_prefix() -> &'static str {
328 ENV_VARS.api_url_prefix.as_str()
329 }
330
331 pub(crate) fn client_id_key() -> &'static str {
333 ENV_VARS.client_id_key.as_str()
334 }
335
336 pub(crate) fn service_id() -> &'static str {
338 ENV_VARS.service_id.as_str()
339 }
340
341 pub(crate) fn metrics_memory_interval() -> Duration {
343 ENV_VARS.metrics_memory_interval
344 }
345
346 pub(crate) fn metrics_follower_interval() -> Duration {
348 ENV_VARS.metrics_follower_interval
349 }
350
351 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 pub(crate) fn bound_address() -> SocketAddr {
371 ENV_VARS.address
372 }
373
374 pub(crate) fn server_name() -> Option<&'static str> {
376 ENV_VARS.server_name.as_ref().map(StringEnvVar::as_str)
377 }
378
379 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 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 pub(crate) fn purge_backward_slot_buffer() -> Slot {
428 ENV_VARS.purge_backward_slot_buffer.into()
429 }
430
431 pub(crate) fn service_live_timeout_interval() -> Duration {
433 ENV_VARS.service_live_timeout_interval
434 }
435
436 pub(crate) fn service_live_counter_threshold() -> u64 {
438 ENV_VARS.service_live_counter_threshold
439 }
440}
441
442fn string_to_api_host_names(hosts: &str) -> Vec<String> {
444 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 let scheme = url.scheme();
458
459 let port = url.port();
460
461 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 },
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}