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 event_db;
27pub(crate) mod signed_doc;
28mod str_env_var;
29
30const ADDRESS_DEFAULT: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 3030);
32
33const GITHUB_REPO_OWNER_DEFAULT: &str = "input-output-hk";
35
36const GITHUB_REPO_NAME_DEFAULT: &str = "catalyst-voices";
38
39const GITHUB_ISSUE_TEMPLATE_DEFAULT: &str = "bug_report.yml";
41
42const CLIENT_ID_KEY_DEFAULT: &str = "3db5301e-40f2-47ed-ab11-55b37674631a";
44
45const API_URL_PREFIX_DEFAULT: &str = "/api";
47
48const CHECK_CONFIG_TICK_DEFAULT: Duration = Duration::from_secs(5);
50
51const PURGE_BACKWARD_SLOT_BUFFER_DEFAULT: u64 = 100;
53
54const SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT: Duration = Duration::from_secs(30);
57
58const SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT: u64 = 100;
61
62fn calculate_service_uuid() -> String {
65 let ip_addr: Vec<String> = vec![get_public_ipv4().to_string(), get_public_ipv6().to_string()];
66
67 generate_uuid_string_from_data("Catalyst-Gateway-Machine-UID", &ip_addr)
68}
69
70#[derive(Args, Clone)]
77#[clap(version = BUILD_INFO)]
78pub(crate) struct ServiceSettings {
79 #[clap(long, default_value = LOG_LEVEL_DEFAULT)]
81 pub(crate) log_level: LogLevel,
82}
83
84struct EnvVars {
86 github_repo_owner: StringEnvVar,
88
89 github_repo_name: StringEnvVar,
91
92 github_issue_template: StringEnvVar,
94
95 address: SocketAddr,
97
98 server_name: Option<StringEnvVar>,
100
101 service_id: StringEnvVar,
103
104 client_id_key: StringEnvVar,
106
107 api_host_names: Vec<String>,
109
110 api_url_prefix: StringEnvVar,
112
113 cassandra_persistent_db: cassandra_db::EnvVars,
115
116 cassandra_volatile_db: cassandra_db::EnvVars,
118
119 chain_follower: chain_follower::EnvVars,
121
122 event_db: event_db::EnvVars,
124
125 signed_doc: signed_doc::EnvVars,
127
128 internal_api_key: Option<StringEnvVar>,
130
131 #[allow(unused)]
133 check_config_tick: Duration,
134
135 purge_backward_slot_buffer: u64,
137
138 service_live_timeout_interval: Duration,
140
141 service_live_counter_threshold: u64,
143
144 log_not_found: Option<StringEnvVar>,
146}
147
148static ENV_VARS: LazyLock<EnvVars> = LazyLock::new(|| {
156 dotenv().ok();
158
159 let address = StringEnvVar::new("ADDRESS", ADDRESS_DEFAULT.to_string().into());
160 let address = SocketAddr::from_str(address.as_str())
161 .inspect(|err| {
162 error!(
163 "Invalid binding address {}, err: {err}. Using default binding address value {ADDRESS_DEFAULT}.",
164 address.as_str(),
165 );
166 }).unwrap_or(ADDRESS_DEFAULT);
167
168 let purge_backward_slot_buffer = StringEnvVar::new_as_int(
169 "PURGE_BACKWARD_SLOT_BUFFER",
170 PURGE_BACKWARD_SLOT_BUFFER_DEFAULT,
171 0,
172 u64::MAX,
173 );
174
175 EnvVars {
176 github_repo_owner: StringEnvVar::new("GITHUB_REPO_OWNER", GITHUB_REPO_OWNER_DEFAULT.into()),
177 github_repo_name: StringEnvVar::new("GITHUB_REPO_NAME", GITHUB_REPO_NAME_DEFAULT.into()),
178 github_issue_template: StringEnvVar::new(
179 "GITHUB_ISSUE_TEMPLATE",
180 GITHUB_ISSUE_TEMPLATE_DEFAULT.into(),
181 ),
182 address,
183 server_name: StringEnvVar::new_optional("SERVER_NAME", false),
184 service_id: StringEnvVar::new("SERVICE_ID", calculate_service_uuid().into()),
185 client_id_key: StringEnvVar::new("CLIENT_ID_KEY", CLIENT_ID_KEY_DEFAULT.into()),
186 api_host_names: string_to_api_host_names(
187 &StringEnvVar::new_optional("c", false)
188 .map(|v| v.as_string())
189 .unwrap_or_default(),
190 ),
191 api_url_prefix: StringEnvVar::new("API_URL_PREFIX", API_URL_PREFIX_DEFAULT.into()),
192
193 cassandra_persistent_db: cassandra_db::EnvVars::new(
194 cassandra_db::PERSISTENT_URL_DEFAULT,
195 cassandra_db::PERSISTENT_NAMESPACE_DEFAULT,
196 ),
197 cassandra_volatile_db: cassandra_db::EnvVars::new(
198 cassandra_db::VOLATILE_URL_DEFAULT,
199 cassandra_db::VOLATILE_NAMESPACE_DEFAULT,
200 ),
201 chain_follower: chain_follower::EnvVars::new(),
202 event_db: event_db::EnvVars::new(),
203 signed_doc: signed_doc::EnvVars::new(),
204 internal_api_key: StringEnvVar::new_optional("INTERNAL_API_KEY", true),
205 check_config_tick: StringEnvVar::new_as_duration(
206 "CHECK_CONFIG_TICK",
207 CHECK_CONFIG_TICK_DEFAULT,
208 ),
209 purge_backward_slot_buffer,
210 service_live_timeout_interval: StringEnvVar::new_as_duration(
211 "SERVICE_LIVE_TIMEOUT_INTERVAL",
212 SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT,
213 ),
214 service_live_counter_threshold: StringEnvVar::new_as_int(
215 "SERVICE_LIVE_COUNTER_THRESHOLD",
216 SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT,
217 0,
218 u64::MAX,
219 ),
220 log_not_found: StringEnvVar::new_optional("LOG_NOT_FOUND", false),
221 }
222});
223
224impl EnvVars {
225 pub(crate) fn validate() -> anyhow::Result<()> {
227 let mut status = Ok(());
228
229 let url = ENV_VARS.event_db.url.as_str();
230 if let Err(error) = tokio_postgres::config::Config::from_str(url) {
231 error!(error=%error, url=url, "Invalid Postgres DB URL.");
232 status = Err(anyhow!("Environment Variable Validation Error."));
233 }
234
235 status
236 }
237}
238
239static SERVICE_SETTINGS: OnceLock<ServiceSettings> = OnceLock::new();
241
242pub(crate) struct Settings();
244
245impl Settings {
246 pub(crate) fn init(settings: ServiceSettings) -> anyhow::Result<()> {
248 let log_level = settings.log_level;
249
250 if SERVICE_SETTINGS.set(settings).is_err() {
251 println!("Failed to initialize service settings. Called multiple times?");
253 }
254
255 logger::init(log_level);
257
258 log_build_info();
259
260 EnvVars::validate()
262 }
263
264 pub(crate) fn event_db_settings() -> (
266 &'static str,
267 Option<&'static str>,
268 Option<&'static str>,
269 u32,
270 u32,
271 u32,
272 u32,
273 ) {
274 let url = ENV_VARS.event_db.url.as_str();
275 let user = ENV_VARS
276 .event_db
277 .username
278 .as_ref()
279 .map(StringEnvVar::as_str);
280 let pass = ENV_VARS
281 .event_db
282 .password
283 .as_ref()
284 .map(StringEnvVar::as_str);
285
286 let max_connections = ENV_VARS.event_db.max_connections;
287
288 let max_lifetime = ENV_VARS.event_db.max_lifetime;
289
290 let min_idle = ENV_VARS.event_db.min_idle;
291
292 let connection_timeout = ENV_VARS.event_db.connection_timeout;
293
294 (
295 url,
296 user,
297 pass,
298 max_connections,
299 max_lifetime,
300 min_idle,
301 connection_timeout,
302 )
303 }
304
305 pub(crate) fn cassandra_db_cfg() -> (cassandra_db::EnvVars, cassandra_db::EnvVars) {
307 (
308 ENV_VARS.cassandra_persistent_db.clone(),
309 ENV_VARS.cassandra_volatile_db.clone(),
310 )
311 }
312
313 pub(crate) fn follower_cfg() -> chain_follower::EnvVars {
315 ENV_VARS.chain_follower.clone()
316 }
317
318 pub(crate) fn signed_doc_cfg() -> signed_doc::EnvVars {
320 ENV_VARS.signed_doc.clone()
321 }
322
323 pub(crate) fn cardano_network() -> Network {
326 ENV_VARS.chain_follower.chain
327 }
328
329 pub(crate) fn api_url_prefix() -> &'static str {
331 ENV_VARS.api_url_prefix.as_str()
332 }
333
334 pub(crate) fn client_id_key() -> &'static str {
336 ENV_VARS.client_id_key.as_str()
337 }
338
339 pub(crate) fn service_id() -> &'static str {
341 ENV_VARS.service_id.as_str()
342 }
343
344 pub(crate) fn api_host_names() -> &'static [String] {
353 &ENV_VARS.api_host_names
354 }
355
356 pub(crate) fn bound_address() -> SocketAddr {
358 ENV_VARS.address
359 }
360
361 pub(crate) fn server_name() -> Option<&'static str> {
363 ENV_VARS.server_name.as_ref().map(StringEnvVar::as_str)
364 }
365
366 pub(crate) fn generate_github_issue_url(title: &str) -> Option<Url> {
386 let path = format!(
387 "https://github.com/{}/{}/issues/new",
388 ENV_VARS.github_repo_owner.as_str(),
389 ENV_VARS.github_repo_name.as_str()
390 );
391
392 match Url::parse_with_params(&path, &[
393 ("template", ENV_VARS.github_issue_template.as_str()),
394 ("title", title),
395 ]) {
396 Ok(url) => Some(url),
397 Err(e) => {
398 error!("Failed to generate github issue url {:?}", e.to_string());
399 None
400 },
401 }
402 }
403
404 pub(crate) fn check_internal_api_key(value: &str) -> bool {
406 if let Some(required_key) = ENV_VARS.internal_api_key.as_ref().map(StringEnvVar::as_str) {
407 value == required_key
408 } else {
409 false
410 }
411 }
412
413 pub(crate) fn purge_backward_slot_buffer() -> Slot {
415 ENV_VARS.purge_backward_slot_buffer.into()
416 }
417
418 pub(crate) fn service_live_timeout_interval() -> Duration {
420 ENV_VARS.service_live_timeout_interval
421 }
422
423 pub(crate) fn service_live_counter_threshold() -> u64 {
425 ENV_VARS.service_live_counter_threshold
426 }
427
428 pub(crate) fn log_not_found() -> bool {
430 ENV_VARS.log_not_found.is_some()
431 }
432}
433
434fn string_to_api_host_names(hosts: &str) -> Vec<String> {
436 fn invalid_hostname(hostname: &str) -> String {
438 error!(hostname = hostname, "Invalid host name for API");
439 String::new()
440 }
441
442 let configured_hosts: Vec<String> = hosts
443 .split(',')
444 .filter(|s| !s.is_empty())
447 .map(|s| {
448 let url = Url::parse(s.trim());
449 match url {
450 Ok(url) => {
451 let scheme = url.scheme();
453
454 let port = url.port();
455
456 match url.host() {
458 Some(host) => {
459 let host = host.to_string();
460 if host.is_empty() {
461 invalid_hostname(s)
462 } else {
463 match port {
464 Some(port) => {
465 format! {"{scheme}://{host}:{port}"}
466 },
469 None => {
470 format! {"{scheme}://{host}"}
471 },
472 }
473 }
474 },
475 None => invalid_hostname(s),
476 }
477 },
478 Err(_) => invalid_hostname(s),
479 }
480 })
481 .filter(|s| !s.is_empty())
482 .collect();
483
484 configured_hosts
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490
491 #[test]
492 fn generate_github_issue_url_test() {
493 let title = "Hello, World! How are you?";
494 assert_eq!(
495 Settings::generate_github_issue_url(title).expect("Failed to generate url").as_str(),
496 "https://github.com/input-output-hk/catalyst-voices/issues/new?template=bug_report.yml&title=Hello%2C+World%21+How+are+you%3F"
497 );
498 }
499
500 #[test]
501 fn configured_hosts_default() {
502 let configured_hosts = Settings::api_host_names();
503 assert!(configured_hosts.is_empty());
504 }
505
506 #[test]
507 fn configured_hosts_set_multiple() {
508 let configured_hosts = string_to_api_host_names(
509 "http://api.prod.projectcatalyst.io , https://api.dev.projectcatalyst.io:1234",
510 );
511 assert_eq!(configured_hosts, vec![
512 "http://api.prod.projectcatalyst.io",
513 "https://api.dev.projectcatalyst.io:1234"
514 ]);
515 }
516
517 #[test]
518 fn configured_hosts_set_multiple_one_invalid() {
519 let configured_hosts =
520 string_to_api_host_names("not a hostname , https://api.dev.projectcatalyst.io:1234");
521 assert_eq!(configured_hosts, vec![
522 "https://api.dev.projectcatalyst.io:1234"
523 ]);
524 }
525
526 #[test]
527 fn configured_hosts_set_empty() {
528 let configured_hosts = string_to_api_host_names("");
529 assert!(configured_hosts.is_empty());
530 }
531
532 #[test]
533 fn configured_service_live_timeout_interval_default() {
534 let timeout_secs = Settings::service_live_timeout_interval();
535 assert_eq!(timeout_secs, SERVICE_LIVE_TIMEOUT_INTERVAL_DEFAULT);
536 }
537
538 #[test]
539 fn configured_service_live_counter_threshold_default() {
540 let threshold = Settings::service_live_counter_threshold();
541 assert_eq!(threshold, SERVICE_LIVE_COUNTER_THRESHOLD_DEFAULT);
542 }
543}