1use std::{
3 net::{IpAddr, Ipv4Addr, SocketAddr},
4 str::FromStr,
5 sync::{LazyLock, OnceLock},
6 time::Duration,
7};
8
9use anyhow::anyhow;
10use cardano_chain_follower::{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 cardano_assets_cache;
25pub(crate) mod cassandra_db;
26pub(crate) mod chain_follower;
27pub(crate) mod event_db;
28pub(crate) mod rbac;
29pub(crate) mod signed_doc;
30mod str_env_var;
31
32const ADDRESS_DEFAULT: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 3030);
34
35const GITHUB_REPO_OWNER_DEFAULT: &str = "input-output-hk";
37
38const GITHUB_REPO_NAME_DEFAULT: &str = "catalyst-voices";
40
41const GITHUB_ISSUE_TEMPLATE_DEFAULT: &str = "bug_report.yml";
43
44const CLIENT_ID_KEY_DEFAULT: &str = "3db5301e-40f2-47ed-ab11-55b37674631a";
46
47const API_URL_PREFIX_DEFAULT: &str = "/api";
49
50const CHECK_CONFIG_TICK_DEFAULT: Duration = Duration::from_secs(5);
52
53const PURGE_BACKWARD_SLOT_BUFFER_DEFAULT: u64 = 100;
55
56const SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT: Duration = Duration::from_secs(30);
59
60const SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT: u64 = 100;
63
64fn calculate_service_uuid() -> String {
67 let ip_addr: Vec<String> = vec![get_public_ipv4().to_string(), get_public_ipv6().to_string()];
68
69 generate_uuid_string_from_data("Catalyst-Gateway-Machine-UID", &ip_addr)
70}
71
72#[derive(Args, Clone)]
79#[clap(version = BUILD_INFO)]
80pub(crate) struct ServiceSettings {
81 #[clap(long, default_value = LOG_LEVEL_DEFAULT)]
83 pub(crate) log_level: LogLevel,
84}
85
86struct EnvVars {
88 github_repo_owner: StringEnvVar,
90
91 github_repo_name: StringEnvVar,
93
94 github_issue_template: StringEnvVar,
96
97 address: SocketAddr,
99
100 server_name: Option<StringEnvVar>,
102
103 service_id: StringEnvVar,
105
106 client_id_key: StringEnvVar,
108
109 api_host_names: Vec<String>,
111
112 api_url_prefix: StringEnvVar,
114
115 is_panic_endpoint_enabled: bool,
119
120 cassandra_persistent_db: cassandra_db::EnvVars,
122
123 cassandra_volatile_db: cassandra_db::EnvVars,
125
126 chain_follower: chain_follower::EnvVars,
128
129 event_db: event_db::EnvVars,
131
132 signed_doc: signed_doc::EnvVars,
134
135 rbac: rbac::EnvVars,
137
138 cardano_assets_cache: cardano_assets_cache::EnvVars,
140
141 internal_api_key: Option<StringEnvVar>,
143
144 #[allow(unused)]
146 check_config_tick: Duration,
147
148 purge_backward_slot_buffer: u64,
150
151 service_live_timeout_interval: Duration,
153
154 service_live_counter_threshold: u64,
156
157 log_not_found: Option<StringEnvVar>,
159}
160
161static ENV_VARS: LazyLock<EnvVars> = LazyLock::new(|| {
169 dotenv().ok();
171
172 let address = StringEnvVar::new("ADDRESS", ADDRESS_DEFAULT.to_string().into());
173 let address = SocketAddr::from_str(address.as_str())
174 .inspect_err(|err| {
175 error!(
176 error = ?err,
177 default_addr = ?ADDRESS_DEFAULT,
178 invalid_addr = ?address,
179 "Invalid binding address. Using default binding address value.",
180 );
181 })
182 .unwrap_or(ADDRESS_DEFAULT);
183
184 let purge_backward_slot_buffer = StringEnvVar::new_as_int(
185 "PURGE_BACKWARD_SLOT_BUFFER",
186 PURGE_BACKWARD_SLOT_BUFFER_DEFAULT,
187 0,
188 u64::MAX,
189 );
190
191 EnvVars {
192 github_repo_owner: StringEnvVar::new("GITHUB_REPO_OWNER", GITHUB_REPO_OWNER_DEFAULT.into()),
193 github_repo_name: StringEnvVar::new("GITHUB_REPO_NAME", GITHUB_REPO_NAME_DEFAULT.into()),
194 github_issue_template: StringEnvVar::new(
195 "GITHUB_ISSUE_TEMPLATE",
196 GITHUB_ISSUE_TEMPLATE_DEFAULT.into(),
197 ),
198 address,
199 server_name: StringEnvVar::new_optional("SERVER_NAME", false),
200 service_id: StringEnvVar::new("SERVICE_ID", calculate_service_uuid().into()),
201 client_id_key: StringEnvVar::new("CLIENT_ID_KEY", CLIENT_ID_KEY_DEFAULT.into()),
202 api_host_names: string_to_api_host_names(
203 &StringEnvVar::new_optional("c", false)
204 .map(|v| v.as_string())
205 .unwrap_or_default(),
206 ),
207 api_url_prefix: StringEnvVar::new("API_URL_PREFIX", API_URL_PREFIX_DEFAULT.into()),
208 is_panic_endpoint_enabled: StringEnvVar::new_optional("YES_I_REALLY_WANT_TO_PANIC", false)
209 .is_some_and(|v| v.as_str() == "panic attack"),
210
211 cassandra_persistent_db: cassandra_db::EnvVars::new(
212 cassandra_db::PERSISTENT_URL_DEFAULT,
213 cassandra_db::PERSISTENT_NAMESPACE_DEFAULT,
214 ),
215 cassandra_volatile_db: cassandra_db::EnvVars::new(
216 cassandra_db::VOLATILE_URL_DEFAULT,
217 cassandra_db::VOLATILE_NAMESPACE_DEFAULT,
218 ),
219 chain_follower: chain_follower::EnvVars::new(),
220 event_db: event_db::EnvVars::new(),
221 signed_doc: signed_doc::EnvVars::new(),
222 rbac: rbac::EnvVars::new(),
223 cardano_assets_cache: cardano_assets_cache::EnvVars::new(),
224 internal_api_key: StringEnvVar::new_optional("INTERNAL_API_KEY", true),
225 check_config_tick: StringEnvVar::new_as_duration(
226 "CHECK_CONFIG_TICK",
227 CHECK_CONFIG_TICK_DEFAULT,
228 ),
229 purge_backward_slot_buffer,
230 service_live_timeout_interval: StringEnvVar::new_as_duration(
231 "SERVICE_LIVE_TIMEOUT_INTERVAL",
232 SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT,
233 ),
234 service_live_counter_threshold: StringEnvVar::new_as_int(
235 "SERVICE_LIVE_COUNTER_THRESHOLD",
236 SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT,
237 0,
238 u64::MAX,
239 ),
240 log_not_found: StringEnvVar::new_optional("LOG_NOT_FOUND", false),
241 }
242});
243
244impl EnvVars {
245 pub(crate) fn validate() -> anyhow::Result<()> {
247 let mut status = Ok(());
248
249 let url = ENV_VARS.event_db.url.as_str();
250 if let Err(error) = tokio_postgres::config::Config::from_str(url) {
251 error!(error=%error, url=url, "Invalid Postgres DB URL.");
252 status = Err(anyhow!("Environment Variable Validation Error."));
253 }
254
255 status
256 }
257}
258
259static SERVICE_SETTINGS: OnceLock<ServiceSettings> = OnceLock::new();
261
262pub(crate) struct Settings();
264
265impl Settings {
266 pub(crate) fn init(settings: ServiceSettings) -> anyhow::Result<()> {
268 let log_level = settings.log_level;
269
270 if SERVICE_SETTINGS.set(settings).is_err() {
271 println!("Failed to initialize service settings. Called multiple times?");
273 }
274
275 logger::init(log_level);
277
278 log_build_info();
279
280 EnvVars::validate()
282 }
283
284 pub(crate) fn event_db_settings() -> (
286 &'static str,
287 Option<&'static str>,
288 Option<&'static str>,
289 u32,
290 u32,
291 u32,
292 u32,
293 ) {
294 let url = ENV_VARS.event_db.url.as_str();
295 let user = ENV_VARS
296 .event_db
297 .username
298 .as_ref()
299 .map(StringEnvVar::as_str);
300 let pass = ENV_VARS
301 .event_db
302 .password
303 .as_ref()
304 .map(StringEnvVar::as_str);
305
306 let max_connections = ENV_VARS.event_db.max_connections;
307
308 let max_lifetime = ENV_VARS.event_db.max_lifetime;
309
310 let min_idle = ENV_VARS.event_db.min_idle;
311
312 let connection_timeout = ENV_VARS.event_db.connection_timeout;
313
314 (
315 url,
316 user,
317 pass,
318 max_connections,
319 max_lifetime,
320 min_idle,
321 connection_timeout,
322 )
323 }
324
325 pub(crate) fn cassandra_db_cfg() -> (cassandra_db::EnvVars, cassandra_db::EnvVars) {
327 (
328 ENV_VARS.cassandra_persistent_db.clone(),
329 ENV_VARS.cassandra_volatile_db.clone(),
330 )
331 }
332
333 pub(crate) fn follower_cfg() -> chain_follower::EnvVars {
335 ENV_VARS.chain_follower.clone()
336 }
337
338 pub(crate) fn signed_doc_cfg() -> signed_doc::EnvVars {
340 ENV_VARS.signed_doc.clone()
341 }
342
343 pub fn rbac_cfg() -> &'static rbac::EnvVars {
345 &ENV_VARS.rbac
346 }
347
348 pub(crate) fn cardano_assets_cache() -> cardano_assets_cache::EnvVars {
350 ENV_VARS.cardano_assets_cache.clone()
351 }
352
353 pub(crate) fn cardano_network() -> Network {
356 ENV_VARS.chain_follower.chain
357 }
358
359 pub(crate) fn api_url_prefix() -> &'static str {
361 ENV_VARS.api_url_prefix.as_str()
362 }
363
364 pub(crate) fn client_id_key() -> &'static str {
366 ENV_VARS.client_id_key.as_str()
367 }
368
369 pub(crate) fn service_id() -> &'static str {
371 ENV_VARS.service_id.as_str()
372 }
373
374 pub(crate) fn api_host_names() -> &'static [String] {
383 &ENV_VARS.api_host_names
384 }
385
386 pub(crate) fn bound_address() -> SocketAddr {
388 ENV_VARS.address
389 }
390
391 pub(crate) fn server_name() -> Option<&'static str> {
393 ENV_VARS.server_name.as_ref().map(StringEnvVar::as_str)
394 }
395
396 pub(crate) fn is_panic_endpoint_enabled() -> bool {
398 ENV_VARS.is_panic_endpoint_enabled
399 }
400
401 pub(crate) fn generate_github_issue_url(title: &str) -> Option<Url> {
421 let path = format!(
422 "https://github.com/{}/{}/issues/new",
423 ENV_VARS.github_repo_owner.as_str(),
424 ENV_VARS.github_repo_name.as_str()
425 );
426
427 match Url::parse_with_params(&path, &[
428 ("template", ENV_VARS.github_issue_template.as_str()),
429 ("title", title),
430 ]) {
431 Ok(url) => Some(url),
432 Err(e) => {
433 error!("Failed to generate github issue url {:?}", e.to_string());
434 None
435 },
436 }
437 }
438
439 pub(crate) fn check_internal_api_key(value: &str) -> bool {
441 if let Some(required_key) = ENV_VARS.internal_api_key.as_ref().map(StringEnvVar::as_str) {
442 value == required_key
443 } else {
444 false
445 }
446 }
447
448 pub(crate) fn purge_backward_slot_buffer() -> Slot {
450 ENV_VARS.purge_backward_slot_buffer.into()
451 }
452
453 pub(crate) fn service_live_timeout_interval() -> Duration {
455 ENV_VARS.service_live_timeout_interval
456 }
457
458 pub(crate) fn service_live_counter_threshold() -> u64 {
460 ENV_VARS.service_live_counter_threshold
461 }
462
463 pub(crate) fn log_not_found() -> bool {
465 ENV_VARS.log_not_found.is_some()
466 }
467}
468
469fn string_to_api_host_names(hosts: &str) -> Vec<String> {
471 fn invalid_hostname(hostname: &str) -> String {
473 error!(hostname = hostname, "Invalid host name for API");
474 String::new()
475 }
476
477 let configured_hosts: Vec<String> = hosts
478 .split(',')
479 .filter(|s| !s.is_empty())
482 .map(|s| {
483 let url = Url::parse(s.trim());
484 match url {
485 Ok(url) => {
486 let scheme = url.scheme();
488
489 let port = url.port();
490
491 match url.host() {
493 Some(host) => {
494 let host = host.to_string();
495 if host.is_empty() {
496 invalid_hostname(s)
497 } else {
498 match port {
499 Some(port) => {
500 format! {"{scheme}://{host}:{port}"}
501 },
504 None => {
505 format! {"{scheme}://{host}"}
506 },
507 }
508 }
509 },
510 None => invalid_hostname(s),
511 }
512 },
513 Err(_) => invalid_hostname(s),
514 }
515 })
516 .filter(|s| !s.is_empty())
517 .collect();
518
519 configured_hosts
520}
521
522#[cfg(test)]
523mod tests {
524 use super::*;
525
526 #[test]
527 fn generate_github_issue_url_test() {
528 let title = "Hello, World! How are you?";
529 assert_eq!(
530 Settings::generate_github_issue_url(title).expect("Failed to generate url").as_str(),
531 "https://github.com/input-output-hk/catalyst-voices/issues/new?template=bug_report.yml&title=Hello%2C+World%21+How+are+you%3F"
532 );
533 }
534
535 #[test]
536 fn configured_hosts_default() {
537 let configured_hosts = Settings::api_host_names();
538 assert!(configured_hosts.is_empty());
539 }
540
541 #[test]
542 fn configured_hosts_set_multiple() {
543 let configured_hosts = string_to_api_host_names(
544 "http://api.prod.projectcatalyst.io , https://api.dev.projectcatalyst.io:1234",
545 );
546 assert_eq!(configured_hosts, vec![
547 "http://api.prod.projectcatalyst.io",
548 "https://api.dev.projectcatalyst.io:1234"
549 ]);
550 }
551
552 #[test]
553 fn configured_hosts_set_multiple_one_invalid() {
554 let configured_hosts =
555 string_to_api_host_names("not a hostname , https://api.dev.projectcatalyst.io:1234");
556 assert_eq!(configured_hosts, vec![
557 "https://api.dev.projectcatalyst.io:1234"
558 ]);
559 }
560
561 #[test]
562 fn configured_hosts_set_empty() {
563 let configured_hosts = string_to_api_host_names("");
564 assert!(configured_hosts.is_empty());
565 }
566
567 #[test]
568 fn configured_service_live_timeout_interval_default() {
569 let timeout_secs = Settings::service_live_timeout_interval();
570 assert_eq!(timeout_secs, SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT);
571 }
572
573 #[test]
574 fn configured_service_live_counter_threshold_default() {
575 let threshold = Settings::service_live_counter_threshold();
576 assert_eq!(threshold, SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT);
577 }
578}