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::{BUILD_INFO, log_build_info},
19 logger::{self, LOG_LEVEL_DEFAULT, LogLevel},
20 service::utilities::net::{get_public_ipv4, get_public_ipv6},
21 telemetry::{self, TelemetryGuard},
22 utils::blake2b_hash::generate_uuid_string_from_data,
23};
24
25pub(crate) mod admin;
26pub(crate) mod cardano_assets_cache;
27pub(crate) mod cassandra_db;
28pub(crate) mod chain_follower;
29pub(crate) mod event_db;
30pub(crate) mod rbac;
31pub(crate) mod signed_doc;
32mod str_env_var;
33
34const ADDRESS_DEFAULT: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 3030);
36
37const GITHUB_REPO_OWNER_DEFAULT: &str = "input-output-hk";
39
40const GITHUB_REPO_NAME_DEFAULT: &str = "catalyst-voices";
42
43const GITHUB_ISSUE_TEMPLATE_DEFAULT: &str = "bug_report.yml";
45
46const CLIENT_ID_KEY_DEFAULT: &str = "3db5301e-40f2-47ed-ab11-55b37674631a";
48
49const API_URL_PREFIX_DEFAULT: &str = "/api";
51
52const CHECK_CONFIG_TICK_DEFAULT: Duration = Duration::from_secs(5);
54
55const PURGE_BACKWARD_SLOT_BUFFER_DEFAULT: u64 = 100;
57
58const SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT: Duration = Duration::from_secs(30);
61
62const SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT: u64 = 100;
65
66fn calculate_service_uuid() -> String {
69 let ip_addr: Vec<String> = vec![get_public_ipv4().to_string(), get_public_ipv6().to_string()];
70
71 generate_uuid_string_from_data("Catalyst-Gateway-Machine-UID", &ip_addr)
72}
73
74#[derive(Args, Clone)]
81#[clap(version = BUILD_INFO)]
82pub(crate) struct ServiceSettings {
83 #[clap(long, default_value = LOG_LEVEL_DEFAULT)]
85 pub(crate) log_level: LogLevel,
86}
87
88struct EnvVars {
90 github_repo_owner: StringEnvVar,
92
93 github_repo_name: StringEnvVar,
95
96 github_issue_template: StringEnvVar,
98
99 address: SocketAddr,
101
102 server_name: Option<StringEnvVar>,
104
105 service_id: StringEnvVar,
107
108 client_id_key: StringEnvVar,
110
111 api_host_names: Vec<String>,
113
114 api_url_prefix: StringEnvVar,
116
117 is_panic_endpoint_enabled: bool,
121
122 cassandra_persistent_db: cassandra_db::EnvVars,
124
125 cassandra_volatile_db: cassandra_db::EnvVars,
127
128 chain_follower: chain_follower::EnvVars,
130
131 event_db: event_db::EnvVars,
133
134 signed_doc: signed_doc::EnvVars,
136
137 rbac: rbac::EnvVars,
139
140 cardano_assets_cache: cardano_assets_cache::EnvVars,
142
143 admin: admin::EnvVars,
145
146 internal_api_key: Option<StringEnvVar>,
148
149 #[allow(unused)]
151 check_config_tick: Duration,
152
153 purge_backward_slot_buffer: u64,
155
156 service_live_timeout_interval: Duration,
158
159 service_live_counter_threshold: u64,
161
162 log_not_found: Option<StringEnvVar>,
164
165 telemetry_enabled: Option<StringEnvVar>,
167}
168
169static ENV_VARS: LazyLock<EnvVars> = LazyLock::new(|| {
177 dotenv().ok();
179
180 let address = StringEnvVar::new("ADDRESS", ADDRESS_DEFAULT.to_string().into());
181 let address = SocketAddr::from_str(address.as_str())
182 .inspect_err(|err| {
183 error!(
184 error = ?err,
185 default_addr = ?ADDRESS_DEFAULT,
186 invalid_addr = ?address,
187 "Invalid binding address. Using default binding address value.",
188 );
189 })
190 .unwrap_or(ADDRESS_DEFAULT);
191
192 let purge_backward_slot_buffer = StringEnvVar::new_as_int(
193 "PURGE_BACKWARD_SLOT_BUFFER",
194 PURGE_BACKWARD_SLOT_BUFFER_DEFAULT,
195 0,
196 u64::MAX,
197 );
198
199 EnvVars {
200 github_repo_owner: StringEnvVar::new("GITHUB_REPO_OWNER", GITHUB_REPO_OWNER_DEFAULT.into()),
201 github_repo_name: StringEnvVar::new("GITHUB_REPO_NAME", GITHUB_REPO_NAME_DEFAULT.into()),
202 github_issue_template: StringEnvVar::new(
203 "GITHUB_ISSUE_TEMPLATE",
204 GITHUB_ISSUE_TEMPLATE_DEFAULT.into(),
205 ),
206 address,
207 server_name: StringEnvVar::new_optional("SERVER_NAME", false),
208 service_id: StringEnvVar::new("SERVICE_ID", calculate_service_uuid().into()),
209 client_id_key: StringEnvVar::new("CLIENT_ID_KEY", CLIENT_ID_KEY_DEFAULT.into()),
210 api_host_names: string_to_api_host_names(
211 &StringEnvVar::new_optional("API_HOST_NAMES", false)
212 .map(|v| v.as_string())
213 .unwrap_or_default(),
214 ),
215 api_url_prefix: StringEnvVar::new("API_URL_PREFIX", API_URL_PREFIX_DEFAULT.into()),
216 is_panic_endpoint_enabled: StringEnvVar::new_optional("YES_I_REALLY_WANT_TO_PANIC", false)
217 .is_some_and(|v| v.as_str() == "panic attack"),
218
219 cassandra_persistent_db: cassandra_db::EnvVars::new(
220 cassandra_db::PERSISTENT_URL_DEFAULT,
221 cassandra_db::PERSISTENT_NAMESPACE_DEFAULT,
222 ),
223 cassandra_volatile_db: cassandra_db::EnvVars::new(
224 cassandra_db::VOLATILE_URL_DEFAULT,
225 cassandra_db::VOLATILE_NAMESPACE_DEFAULT,
226 ),
227 chain_follower: chain_follower::EnvVars::new(),
228 event_db: event_db::EnvVars::new(),
229 signed_doc: signed_doc::EnvVars::new(),
230 rbac: rbac::EnvVars::new(),
231 cardano_assets_cache: cardano_assets_cache::EnvVars::new(),
232 admin: admin::EnvVars::new(),
233 internal_api_key: StringEnvVar::new_optional("INTERNAL_API_KEY", true),
234 check_config_tick: StringEnvVar::new_as_duration(
235 "CHECK_CONFIG_TICK",
236 CHECK_CONFIG_TICK_DEFAULT,
237 ),
238 purge_backward_slot_buffer,
239 service_live_timeout_interval: StringEnvVar::new_as_duration(
240 "SERVICE_LIVE_TIMEOUT_INTERVAL",
241 SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT,
242 ),
243 service_live_counter_threshold: StringEnvVar::new_as_int(
244 "SERVICE_LIVE_COUNTER_THRESHOLD",
245 SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT,
246 0,
247 u64::MAX,
248 ),
249 log_not_found: StringEnvVar::new_optional("LOG_NOT_FOUND", false),
250 telemetry_enabled: StringEnvVar::new_optional("TELEMETRY_ENABLED", false),
251 }
252});
253
254impl EnvVars {
255 pub(crate) fn validate() -> anyhow::Result<()> {
257 let mut status = Ok(());
258
259 let url = ENV_VARS.event_db.url();
260 if let Err(error) = tokio_postgres::config::Config::from_str(url) {
261 error!(error=%error, url=url, "Invalid Postgres DB URL.");
262 status = Err(anyhow!("Environment Variable Validation Error."));
263 }
264
265 status
266 }
267}
268
269static SERVICE_SETTINGS: OnceLock<ServiceSettings> = OnceLock::new();
271
272pub(crate) struct Settings();
274
275impl Settings {
276 pub(crate) fn init(settings: ServiceSettings) -> anyhow::Result<Option<TelemetryGuard>> {
278 let log_level = settings.log_level;
279
280 if SERVICE_SETTINGS.set(settings).is_err() {
281 println!("Failed to initialize service settings. Called multiple times?");
283 }
284
285 let guard = if ENV_VARS.telemetry_enabled.is_some() {
286 let guard = telemetry::init(log_level)?;
287 Some(guard)
288 } else {
289 logger::init(log_level);
291 None
292 };
293
294 log_build_info();
295
296 EnvVars::validate()?;
298 Ok(guard)
299 }
300
301 pub(crate) fn event_db_settings() -> &'static event_db::EnvVars {
303 &ENV_VARS.event_db
304 }
305
306 pub(crate) fn cassandra_db_cfg() -> (cassandra_db::EnvVars, cassandra_db::EnvVars) {
308 (
309 ENV_VARS.cassandra_persistent_db.clone(),
310 ENV_VARS.cassandra_volatile_db.clone(),
311 )
312 }
313
314 pub(crate) fn follower_cfg() -> chain_follower::EnvVars {
316 ENV_VARS.chain_follower.clone()
317 }
318
319 pub(crate) fn signed_doc_cfg() -> signed_doc::EnvVars {
321 ENV_VARS.signed_doc.clone()
322 }
323
324 pub fn rbac_cfg() -> &'static rbac::EnvVars {
326 &ENV_VARS.rbac
327 }
328
329 pub(crate) fn cardano_assets_cache() -> cardano_assets_cache::EnvVars {
331 ENV_VARS.cardano_assets_cache.clone()
332 }
333
334 pub(crate) fn admin_cfg() -> admin::EnvVars {
336 ENV_VARS.admin.clone()
337 }
338
339 pub(crate) fn cardano_network() -> &'static Network {
342 ENV_VARS.chain_follower.chain()
343 }
344
345 pub(crate) fn api_url_prefix() -> &'static str {
347 ENV_VARS.api_url_prefix.as_str()
348 }
349
350 pub(crate) fn client_id_key() -> &'static str {
352 ENV_VARS.client_id_key.as_str()
353 }
354
355 pub(crate) fn service_id() -> &'static str {
357 ENV_VARS.service_id.as_str()
358 }
359
360 pub(crate) fn api_host_names() -> &'static [String] {
369 &ENV_VARS.api_host_names
370 }
371
372 pub(crate) fn bound_address() -> SocketAddr {
374 ENV_VARS.address
375 }
376
377 pub(crate) fn server_name() -> Option<&'static str> {
379 ENV_VARS.server_name.as_ref().map(StringEnvVar::as_str)
380 }
381
382 pub(crate) fn is_panic_endpoint_enabled() -> bool {
384 ENV_VARS.is_panic_endpoint_enabled
385 }
386
387 pub(crate) fn generate_github_issue_url(title: &str) -> Option<Url> {
407 let path = format!(
408 "https://github.com/{}/{}/issues/new",
409 ENV_VARS.github_repo_owner.as_str(),
410 ENV_VARS.github_repo_name.as_str()
411 );
412
413 match Url::parse_with_params(&path, &[
414 ("template", ENV_VARS.github_issue_template.as_str()),
415 ("title", title),
416 ]) {
417 Ok(url) => Some(url),
418 Err(e) => {
419 error!("Failed to generate github issue url {:?}", e.to_string());
420 None
421 },
422 }
423 }
424
425 pub(crate) fn check_internal_api_key(value: &str) -> bool {
427 if let Some(required_key) = ENV_VARS.internal_api_key.as_ref().map(StringEnvVar::as_str) {
428 value == required_key
429 } else {
430 false
431 }
432 }
433
434 pub(crate) fn purge_backward_slot_buffer() -> Slot {
436 ENV_VARS.purge_backward_slot_buffer.into()
437 }
438
439 pub(crate) fn service_live_timeout_interval() -> Duration {
441 ENV_VARS.service_live_timeout_interval
442 }
443
444 pub(crate) fn service_live_counter_threshold() -> u64 {
446 ENV_VARS.service_live_counter_threshold
447 }
448
449 pub(crate) fn log_not_found() -> bool {
451 ENV_VARS.log_not_found.is_some()
452 }
453}
454
455fn string_to_api_host_names(hosts: &str) -> Vec<String> {
457 fn invalid_hostname(hostname: &str) -> String {
459 error!(hostname = hostname, "Invalid host name for API");
460 String::new()
461 }
462
463 let configured_hosts: Vec<String> = hosts
464 .split(',')
465 .filter(|s| !s.is_empty())
468 .map(|s| {
469 let url = Url::parse(s.trim());
470 match url {
471 Ok(url) => {
472 let scheme = url.scheme();
474
475 let port = url.port();
476
477 match url.host() {
479 Some(host) => {
480 let host = host.to_string();
481 if host.is_empty() {
482 invalid_hostname(s)
483 } else {
484 match port {
485 Some(port) => {
486 format! {"{scheme}://{host}:{port}"}
487 },
490 None => {
491 format! {"{scheme}://{host}"}
492 },
493 }
494 }
495 },
496 None => invalid_hostname(s),
497 }
498 },
499 Err(_) => invalid_hostname(s),
500 }
501 })
502 .filter(|s| !s.is_empty())
503 .collect();
504
505 configured_hosts
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn generate_github_issue_url_test() {
514 let title = "Hello, World! How are you?";
515 assert_eq!(
516 Settings::generate_github_issue_url(title)
517 .expect("Failed to generate url")
518 .as_str(),
519 "https://github.com/input-output-hk/catalyst-voices/issues/new?template=bug_report.yml&title=Hello%2C+World%21+How+are+you%3F"
520 );
521 }
522
523 #[test]
524 fn configured_hosts_default() {
525 let configured_hosts = Settings::api_host_names();
526 assert!(configured_hosts.is_empty());
527 }
528
529 #[test]
530 fn configured_hosts_set_multiple() {
531 let configured_hosts = string_to_api_host_names(
532 "http://api.prod.projectcatalyst.io , https://api.dev.projectcatalyst.io:1234",
533 );
534 assert_eq!(configured_hosts, vec![
535 "http://api.prod.projectcatalyst.io",
536 "https://api.dev.projectcatalyst.io:1234"
537 ]);
538 }
539
540 #[test]
541 fn configured_hosts_set_multiple_one_invalid() {
542 let configured_hosts =
543 string_to_api_host_names("not a hostname , https://api.dev.projectcatalyst.io:1234");
544 assert_eq!(configured_hosts, vec![
545 "https://api.dev.projectcatalyst.io:1234"
546 ]);
547 }
548
549 #[test]
550 fn configured_hosts_set_empty() {
551 let configured_hosts = string_to_api_host_names("");
552 assert!(configured_hosts.is_empty());
553 }
554
555 #[test]
556 fn configured_service_live_timeout_interval_default() {
557 let timeout_secs = Settings::service_live_timeout_interval();
558 assert_eq!(timeout_secs, SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT);
559 }
560
561 #[test]
562 fn configured_service_live_counter_threshold_default() {
563 let threshold = Settings::service_live_counter_threshold();
564 assert_eq!(threshold, SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT);
565 }
566}