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 utils::blake2b_hash::generate_uuid_string_from_data,
22};
23
24pub(crate) mod admin;
25pub(crate) mod cardano_assets_cache;
26pub(crate) mod cassandra_db;
27pub(crate) mod chain_follower;
28pub(crate) mod event_db;
29pub(crate) mod rbac;
30pub(crate) mod signed_doc;
31mod str_env_var;
32
33const ADDRESS_DEFAULT: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 3030);
35
36const GITHUB_REPO_OWNER_DEFAULT: &str = "input-output-hk";
38
39const GITHUB_REPO_NAME_DEFAULT: &str = "catalyst-voices";
41
42const GITHUB_ISSUE_TEMPLATE_DEFAULT: &str = "bug_report.yml";
44
45const CLIENT_ID_KEY_DEFAULT: &str = "3db5301e-40f2-47ed-ab11-55b37674631a";
47
48const API_URL_PREFIX_DEFAULT: &str = "/api";
50
51const CHECK_CONFIG_TICK_DEFAULT: Duration = Duration::from_secs(5);
53
54const PURGE_BACKWARD_SLOT_BUFFER_DEFAULT: u64 = 100;
56
57const SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT: Duration = Duration::from_secs(30);
60
61const SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT: u64 = 100;
64
65fn calculate_service_uuid() -> String {
68 let ip_addr: Vec<String> = vec![get_public_ipv4().to_string(), get_public_ipv6().to_string()];
69
70 generate_uuid_string_from_data("Catalyst-Gateway-Machine-UID", &ip_addr)
71}
72
73#[derive(Args, Clone)]
80#[clap(version = BUILD_INFO)]
81pub(crate) struct ServiceSettings {
82 #[clap(long, default_value = LOG_LEVEL_DEFAULT)]
84 pub(crate) log_level: LogLevel,
85}
86
87struct EnvVars {
89 github_repo_owner: StringEnvVar,
91
92 github_repo_name: StringEnvVar,
94
95 github_issue_template: StringEnvVar,
97
98 address: SocketAddr,
100
101 server_name: Option<StringEnvVar>,
103
104 service_id: StringEnvVar,
106
107 client_id_key: StringEnvVar,
109
110 api_host_names: Vec<String>,
112
113 api_url_prefix: StringEnvVar,
115
116 is_panic_endpoint_enabled: bool,
120
121 cassandra_persistent_db: cassandra_db::EnvVars,
123
124 cassandra_volatile_db: cassandra_db::EnvVars,
126
127 chain_follower: chain_follower::EnvVars,
129
130 event_db: event_db::EnvVars,
132
133 signed_doc: signed_doc::EnvVars,
135
136 rbac: rbac::EnvVars,
138
139 cardano_assets_cache: cardano_assets_cache::EnvVars,
141
142 admin: admin::EnvVars,
144
145 internal_api_key: Option<StringEnvVar>,
147
148 #[allow(unused)]
150 check_config_tick: Duration,
151
152 purge_backward_slot_buffer: u64,
154
155 service_live_timeout_interval: Duration,
157
158 service_live_counter_threshold: u64,
160
161 log_not_found: Option<StringEnvVar>,
163}
164
165static ENV_VARS: LazyLock<EnvVars> = LazyLock::new(|| {
173 dotenv().ok();
175
176 let address = StringEnvVar::new("ADDRESS", ADDRESS_DEFAULT.to_string().into());
177 let address = SocketAddr::from_str(address.as_str())
178 .inspect_err(|err| {
179 error!(
180 error = ?err,
181 default_addr = ?ADDRESS_DEFAULT,
182 invalid_addr = ?address,
183 "Invalid binding address. Using default binding address value.",
184 );
185 })
186 .unwrap_or(ADDRESS_DEFAULT);
187
188 let purge_backward_slot_buffer = StringEnvVar::new_as_int(
189 "PURGE_BACKWARD_SLOT_BUFFER",
190 PURGE_BACKWARD_SLOT_BUFFER_DEFAULT,
191 0,
192 u64::MAX,
193 );
194
195 EnvVars {
196 github_repo_owner: StringEnvVar::new("GITHUB_REPO_OWNER", GITHUB_REPO_OWNER_DEFAULT.into()),
197 github_repo_name: StringEnvVar::new("GITHUB_REPO_NAME", GITHUB_REPO_NAME_DEFAULT.into()),
198 github_issue_template: StringEnvVar::new(
199 "GITHUB_ISSUE_TEMPLATE",
200 GITHUB_ISSUE_TEMPLATE_DEFAULT.into(),
201 ),
202 address,
203 server_name: StringEnvVar::new_optional("SERVER_NAME", false),
204 service_id: StringEnvVar::new("SERVICE_ID", calculate_service_uuid().into()),
205 client_id_key: StringEnvVar::new("CLIENT_ID_KEY", CLIENT_ID_KEY_DEFAULT.into()),
206 api_host_names: string_to_api_host_names(
207 &StringEnvVar::new_optional("API_HOST_NAMES", false)
208 .map(|v| v.as_string())
209 .unwrap_or_default(),
210 ),
211 api_url_prefix: StringEnvVar::new("API_URL_PREFIX", API_URL_PREFIX_DEFAULT.into()),
212 is_panic_endpoint_enabled: StringEnvVar::new_optional("YES_I_REALLY_WANT_TO_PANIC", false)
213 .is_some_and(|v| v.as_str() == "panic attack"),
214
215 cassandra_persistent_db: cassandra_db::EnvVars::new(
216 cassandra_db::PERSISTENT_URL_DEFAULT,
217 cassandra_db::PERSISTENT_NAMESPACE_DEFAULT,
218 ),
219 cassandra_volatile_db: cassandra_db::EnvVars::new(
220 cassandra_db::VOLATILE_URL_DEFAULT,
221 cassandra_db::VOLATILE_NAMESPACE_DEFAULT,
222 ),
223 chain_follower: chain_follower::EnvVars::new(),
224 event_db: event_db::EnvVars::new(),
225 signed_doc: signed_doc::EnvVars::new(),
226 rbac: rbac::EnvVars::new(),
227 cardano_assets_cache: cardano_assets_cache::EnvVars::new(),
228 admin: admin::EnvVars::new(),
229 internal_api_key: StringEnvVar::new_optional("INTERNAL_API_KEY", true),
230 check_config_tick: StringEnvVar::new_as_duration(
231 "CHECK_CONFIG_TICK",
232 CHECK_CONFIG_TICK_DEFAULT,
233 ),
234 purge_backward_slot_buffer,
235 service_live_timeout_interval: StringEnvVar::new_as_duration(
236 "SERVICE_LIVE_TIMEOUT_INTERVAL",
237 SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT,
238 ),
239 service_live_counter_threshold: StringEnvVar::new_as_int(
240 "SERVICE_LIVE_COUNTER_THRESHOLD",
241 SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT,
242 0,
243 u64::MAX,
244 ),
245 log_not_found: StringEnvVar::new_optional("LOG_NOT_FOUND", false),
246 }
247});
248
249impl EnvVars {
250 pub(crate) fn validate() -> anyhow::Result<()> {
252 let mut status = Ok(());
253
254 let url = ENV_VARS.event_db.url();
255 if let Err(error) = tokio_postgres::config::Config::from_str(url) {
256 error!(error=%error, url=url, "Invalid Postgres DB URL.");
257 status = Err(anyhow!("Environment Variable Validation Error."));
258 }
259
260 status
261 }
262}
263
264static SERVICE_SETTINGS: OnceLock<ServiceSettings> = OnceLock::new();
266
267pub(crate) struct Settings();
269
270impl Settings {
271 pub(crate) fn init(settings: ServiceSettings) -> anyhow::Result<()> {
273 let log_level = settings.log_level;
274
275 if SERVICE_SETTINGS.set(settings).is_err() {
276 println!("Failed to initialize service settings. Called multiple times?");
278 }
279
280 logger::init(log_level);
282
283 log_build_info();
284
285 EnvVars::validate()
287 }
288
289 pub(crate) fn event_db_settings() -> &'static event_db::EnvVars {
291 &ENV_VARS.event_db
292 }
293
294 pub(crate) fn cassandra_db_cfg() -> (cassandra_db::EnvVars, cassandra_db::EnvVars) {
296 (
297 ENV_VARS.cassandra_persistent_db.clone(),
298 ENV_VARS.cassandra_volatile_db.clone(),
299 )
300 }
301
302 pub(crate) fn follower_cfg() -> chain_follower::EnvVars {
304 ENV_VARS.chain_follower.clone()
305 }
306
307 pub(crate) fn signed_doc_cfg() -> signed_doc::EnvVars {
309 ENV_VARS.signed_doc.clone()
310 }
311
312 pub fn rbac_cfg() -> &'static rbac::EnvVars {
314 &ENV_VARS.rbac
315 }
316
317 pub(crate) fn cardano_assets_cache() -> cardano_assets_cache::EnvVars {
319 ENV_VARS.cardano_assets_cache.clone()
320 }
321
322 pub(crate) fn admin_cfg() -> admin::EnvVars {
324 ENV_VARS.admin.clone()
325 }
326
327 pub(crate) fn cardano_network() -> &'static Network {
330 ENV_VARS.chain_follower.chain()
331 }
332
333 pub(crate) fn api_url_prefix() -> &'static str {
335 ENV_VARS.api_url_prefix.as_str()
336 }
337
338 pub(crate) fn client_id_key() -> &'static str {
340 ENV_VARS.client_id_key.as_str()
341 }
342
343 pub(crate) fn service_id() -> &'static str {
345 ENV_VARS.service_id.as_str()
346 }
347
348 pub(crate) fn api_host_names() -> &'static [String] {
357 &ENV_VARS.api_host_names
358 }
359
360 pub(crate) fn bound_address() -> SocketAddr {
362 ENV_VARS.address
363 }
364
365 pub(crate) fn server_name() -> Option<&'static str> {
367 ENV_VARS.server_name.as_ref().map(StringEnvVar::as_str)
368 }
369
370 pub(crate) fn is_panic_endpoint_enabled() -> bool {
372 ENV_VARS.is_panic_endpoint_enabled
373 }
374
375 pub(crate) fn generate_github_issue_url(title: &str) -> Option<Url> {
395 let path = format!(
396 "https://github.com/{}/{}/issues/new",
397 ENV_VARS.github_repo_owner.as_str(),
398 ENV_VARS.github_repo_name.as_str()
399 );
400
401 match Url::parse_with_params(&path, &[
402 ("template", ENV_VARS.github_issue_template.as_str()),
403 ("title", title),
404 ]) {
405 Ok(url) => Some(url),
406 Err(e) => {
407 error!("Failed to generate github issue url {:?}", e.to_string());
408 None
409 },
410 }
411 }
412
413 pub(crate) fn check_internal_api_key(value: &str) -> bool {
415 if let Some(required_key) = ENV_VARS.internal_api_key.as_ref().map(StringEnvVar::as_str) {
416 value == required_key
417 } else {
418 false
419 }
420 }
421
422 pub(crate) fn purge_backward_slot_buffer() -> Slot {
424 ENV_VARS.purge_backward_slot_buffer.into()
425 }
426
427 pub(crate) fn service_live_timeout_interval() -> Duration {
429 ENV_VARS.service_live_timeout_interval
430 }
431
432 pub(crate) fn service_live_counter_threshold() -> u64 {
434 ENV_VARS.service_live_counter_threshold
435 }
436
437 pub(crate) fn log_not_found() -> bool {
439 ENV_VARS.log_not_found.is_some()
440 }
441}
442
443fn string_to_api_host_names(hosts: &str) -> Vec<String> {
445 fn invalid_hostname(hostname: &str) -> String {
447 error!(hostname = hostname, "Invalid host name for API");
448 String::new()
449 }
450
451 let configured_hosts: Vec<String> = hosts
452 .split(',')
453 .filter(|s| !s.is_empty())
456 .map(|s| {
457 let url = Url::parse(s.trim());
458 match url {
459 Ok(url) => {
460 let scheme = url.scheme();
462
463 let port = url.port();
464
465 match url.host() {
467 Some(host) => {
468 let host = host.to_string();
469 if host.is_empty() {
470 invalid_hostname(s)
471 } else {
472 match port {
473 Some(port) => {
474 format! {"{scheme}://{host}:{port}"}
475 },
478 None => {
479 format! {"{scheme}://{host}"}
480 },
481 }
482 }
483 },
484 None => invalid_hostname(s),
485 }
486 },
487 Err(_) => invalid_hostname(s),
488 }
489 })
490 .filter(|s| !s.is_empty())
491 .collect();
492
493 configured_hosts
494}
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499
500 #[test]
501 fn generate_github_issue_url_test() {
502 let title = "Hello, World! How are you?";
503 assert_eq!(
504 Settings::generate_github_issue_url(title)
505 .expect("Failed to generate url")
506 .as_str(),
507 "https://github.com/input-output-hk/catalyst-voices/issues/new?template=bug_report.yml&title=Hello%2C+World%21+How+are+you%3F"
508 );
509 }
510
511 #[test]
512 fn configured_hosts_default() {
513 let configured_hosts = Settings::api_host_names();
514 assert!(configured_hosts.is_empty());
515 }
516
517 #[test]
518 fn configured_hosts_set_multiple() {
519 let configured_hosts = string_to_api_host_names(
520 "http://api.prod.projectcatalyst.io , https://api.dev.projectcatalyst.io:1234",
521 );
522 assert_eq!(configured_hosts, vec![
523 "http://api.prod.projectcatalyst.io",
524 "https://api.dev.projectcatalyst.io:1234"
525 ]);
526 }
527
528 #[test]
529 fn configured_hosts_set_multiple_one_invalid() {
530 let configured_hosts =
531 string_to_api_host_names("not a hostname , https://api.dev.projectcatalyst.io:1234");
532 assert_eq!(configured_hosts, vec![
533 "https://api.dev.projectcatalyst.io:1234"
534 ]);
535 }
536
537 #[test]
538 fn configured_hosts_set_empty() {
539 let configured_hosts = string_to_api_host_names("");
540 assert!(configured_hosts.is_empty());
541 }
542
543 #[test]
544 fn configured_service_live_timeout_interval_default() {
545 let timeout_secs = Settings::service_live_timeout_interval();
546 assert_eq!(timeout_secs, SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT);
547 }
548
549 #[test]
550 fn configured_service_live_counter_threshold_default() {
551 let threshold = Settings::service_live_counter_threshold();
552 assert_eq!(threshold, SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT);
553 }
554}