use std::{
net::{IpAddr, Ipv4Addr, SocketAddr},
path::PathBuf,
str::FromStr,
sync::{LazyLock, OnceLock},
time::Duration,
};
use anyhow::anyhow;
use cardano_chain_follower::Network;
use clap::Args;
use dotenvy::dotenv;
use duration_string::DurationString;
use str_env_var::StringEnvVar;
use tracing::error;
use url::Url;
use crate::{
build_info::{log_build_info, BUILD_INFO},
logger::{self, LogLevel, LOG_LEVEL_DEFAULT},
service::utilities::net::{get_public_ipv4, get_public_ipv6},
utils::blake2b_hash::generate_uuid_string_from_data,
};
pub(crate) mod cassandra_db;
pub(crate) mod chain_follower;
mod str_env_var;
const ADDRESS_DEFAULT: &str = "0.0.0.0:3030";
const GITHUB_REPO_OWNER_DEFAULT: &str = "input-output-hk";
const GITHUB_REPO_NAME_DEFAULT: &str = "catalyst-voices";
const GITHUB_ISSUE_TEMPLATE_DEFAULT: &str = "bug_report.yml";
const CLIENT_ID_KEY_DEFAULT: &str = "3db5301e-40f2-47ed-ab11-55b37674631a";
const API_HOST_NAMES_DEFAULT: &str = "https://api.prod.projectcatalyst.io";
const API_URL_PREFIX_DEFAULT: &str = "/api";
const CHECK_CONFIG_TICK_DEFAULT: &str = "5s";
const EVENT_DB_URL_DEFAULT: &str =
"postgresql://postgres:postgres@localhost/catalyst_events?sslmode=disable";
fn calculate_service_uuid() -> String {
let ip_addr: Vec<String> = vec![get_public_ipv4().to_string(), get_public_ipv6().to_string()];
generate_uuid_string_from_data("Catalyst-Gateway-Machine-UID", &ip_addr)
}
#[derive(Args, Clone)]
#[clap(version = BUILD_INFO)]
pub(crate) struct ServiceSettings {
#[clap(long, default_value = LOG_LEVEL_DEFAULT)]
pub(crate) log_level: LogLevel,
#[clap(flatten)]
pub(crate) docs_settings: DocsSettings,
}
#[derive(Args, Clone)]
pub(crate) struct DocsSettings {
pub(crate) output: Option<PathBuf>,
#[clap(long, default_value = ADDRESS_DEFAULT, env = "ADDRESS")]
pub(crate) address: SocketAddr,
#[clap(long, env = "SERVER_NAME")]
pub(crate) server_name: Option<String>,
}
struct EnvVars {
github_repo_owner: StringEnvVar,
github_repo_name: StringEnvVar,
github_issue_template: StringEnvVar,
service_id: StringEnvVar,
client_id_key: StringEnvVar,
api_host_names: StringEnvVar,
api_url_prefix: StringEnvVar,
event_db_url: StringEnvVar,
event_db_username: Option<StringEnvVar>,
event_db_password: Option<StringEnvVar>,
cassandra_persistent_db: cassandra_db::EnvVars,
cassandra_volatile_db: cassandra_db::EnvVars,
chain_follower: chain_follower::EnvVars,
internal_api_key: Option<StringEnvVar>,
#[allow(unused)]
check_config_tick: Duration,
}
static ENV_VARS: LazyLock<EnvVars> = LazyLock::new(|| {
dotenv().ok();
let check_interval = StringEnvVar::new("CHECK_CONFIG_TICK", CHECK_CONFIG_TICK_DEFAULT.into());
let check_config_tick = match DurationString::try_from(check_interval.as_string()) {
Ok(duration) => duration.into(),
Err(error) => {
error!(
"Invalid Check Config Tick Duration: {} : {}. Defaulting to 5 seconds.",
check_interval.as_str(),
error
);
Duration::from_secs(5)
},
};
EnvVars {
github_repo_owner: StringEnvVar::new("GITHUB_REPO_OWNER", GITHUB_REPO_OWNER_DEFAULT.into()),
github_repo_name: StringEnvVar::new("GITHUB_REPO_NAME", GITHUB_REPO_NAME_DEFAULT.into()),
github_issue_template: StringEnvVar::new(
"GITHUB_ISSUE_TEMPLATE",
GITHUB_ISSUE_TEMPLATE_DEFAULT.into(),
),
service_id: StringEnvVar::new("SERVICE_ID", calculate_service_uuid().into()),
client_id_key: StringEnvVar::new("CLIENT_ID_KEY", CLIENT_ID_KEY_DEFAULT.into()),
api_host_names: StringEnvVar::new("API_HOST_NAMES", API_HOST_NAMES_DEFAULT.into()),
api_url_prefix: StringEnvVar::new("API_URL_PREFIX", API_URL_PREFIX_DEFAULT.into()),
event_db_url: StringEnvVar::new("EVENT_DB_URL", EVENT_DB_URL_DEFAULT.into()),
event_db_username: StringEnvVar::new_optional("EVENT_DB_USERNAME", false),
event_db_password: StringEnvVar::new_optional("EVENT_DB_PASSWORD", true),
cassandra_persistent_db: cassandra_db::EnvVars::new(
cassandra_db::PERSISTENT_URL_DEFAULT,
cassandra_db::PERSISTENT_NAMESPACE_DEFAULT,
),
cassandra_volatile_db: cassandra_db::EnvVars::new(
cassandra_db::VOLATILE_URL_DEFAULT,
cassandra_db::VOLATILE_NAMESPACE_DEFAULT,
),
chain_follower: chain_follower::EnvVars::new(),
internal_api_key: StringEnvVar::new_optional("INTERNAL_API_KEY", true),
check_config_tick,
}
});
impl EnvVars {
pub(crate) fn validate() -> anyhow::Result<()> {
let mut status = Ok(());
let url = ENV_VARS.event_db_url.as_str();
if let Err(error) = tokio_postgres::config::Config::from_str(url) {
error!(error=%error, url=url, "Invalid Postgres DB URL.");
status = Err(anyhow!("Environment Variable Validation Error."));
}
status
}
}
static SERVICE_SETTINGS: OnceLock<ServiceSettings> = OnceLock::new();
pub(crate) struct Settings();
impl Settings {
pub(crate) fn init(settings: ServiceSettings) -> anyhow::Result<()> {
let log_level = settings.log_level;
if SERVICE_SETTINGS.set(settings).is_err() {
println!("Failed to initialize service settings. Called multiple times?");
}
logger::init(log_level);
log_build_info();
EnvVars::validate()
}
pub(crate) fn event_db_settings() -> (&'static str, Option<&'static str>, Option<&'static str>)
{
let url = ENV_VARS.event_db_url.as_str();
let user = ENV_VARS
.event_db_username
.as_ref()
.map(StringEnvVar::as_str);
let pass = ENV_VARS
.event_db_password
.as_ref()
.map(StringEnvVar::as_str);
(url, user, pass)
}
pub(crate) fn cassandra_db_cfg() -> (cassandra_db::EnvVars, cassandra_db::EnvVars) {
(
ENV_VARS.cassandra_persistent_db.clone(),
ENV_VARS.cassandra_volatile_db.clone(),
)
}
pub(crate) fn follower_cfg() -> chain_follower::EnvVars {
ENV_VARS.chain_follower.clone()
}
pub(crate) fn cardano_network() -> Network {
ENV_VARS.chain_follower.chain
}
pub(crate) fn api_url_prefix() -> &'static str {
ENV_VARS.api_url_prefix.as_str()
}
pub(crate) fn client_id_key() -> &'static str {
ENV_VARS.client_id_key.as_str()
}
pub(crate) fn service_id() -> &'static str {
ENV_VARS.service_id.as_str()
}
pub(crate) fn api_host_names() -> Vec<String> {
if let Some(settings) = SERVICE_SETTINGS.get() {
let addr = settings.docs_settings.address;
string_to_api_host_names(&addr, ENV_VARS.api_host_names.as_str())
} else {
Vec::new()
}
}
pub(crate) fn bound_address() -> SocketAddr {
if let Some(settings) = SERVICE_SETTINGS.get() {
settings.docs_settings.address
} else {
SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080)
}
}
pub(crate) fn server_name() -> Option<String> {
if let Some(settings) = SERVICE_SETTINGS.get() {
settings.docs_settings.server_name.clone()
} else {
None
}
}
pub(crate) fn generate_github_issue_url(title: &str) -> Option<Url> {
let path = format!(
"https://github.com/{}/{}/issues/new",
ENV_VARS.github_repo_owner.as_str(),
ENV_VARS.github_repo_name.as_str()
);
match Url::parse_with_params(&path, &[
("template", ENV_VARS.github_issue_template.as_str()),
("title", title),
]) {
Ok(url) => Some(url),
Err(e) => {
error!("Failed to generate github issue url {:?}", e.to_string());
None
},
}
}
pub(crate) fn check_internal_api_key(value: &str) -> bool {
if let Some(required_key) = ENV_VARS.internal_api_key.as_ref().map(StringEnvVar::as_str) {
value == required_key
} else {
false
}
}
}
fn string_to_api_host_names(addr: &SocketAddr, hosts: &str) -> Vec<String> {
fn invalid_hostname(hostname: &str) -> String {
error!("Invalid host name for API: {}", hostname);
String::new()
}
let configured_hosts: Vec<String> = hosts
.split(',')
.map(|s| {
let url = Url::parse(s.trim());
match url {
Ok(url) => {
let scheme = url.scheme();
let port = url.port();
match url.host() {
Some(host) => {
let host = host.to_string();
if host.is_empty() {
invalid_hostname(s)
} else {
match port {
Some(port) => {
format! {"{scheme}://{host}:{port}"}
},
None => {
format! {"{scheme}://{host}"}
},
}
}
},
None => invalid_hostname(s),
}
},
Err(_) => invalid_hostname(s),
}
})
.filter(|s| !s.is_empty())
.collect();
if configured_hosts.is_empty() {
if match addr.ip() {
IpAddr::V4(ipv4) => ipv4.is_unspecified(),
IpAddr::V6(ipv6) => ipv6.is_unspecified(),
} {
let port = addr.port();
vec![format! {"http://localhost:{port}"}]
} else {
vec![format! {"http://{addr}"}]
}
} else {
configured_hosts
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_github_issue_url_test() {
let title = "Hello, World! How are you?";
assert_eq!(
Settings::generate_github_issue_url(title).expect("Failed to generate url").as_str(),
"https://github.com/input-output-hk/catalyst-voices/issues/new?template=bug_report.yml&title=Hello%2C+World%21+How+are+you%3F"
);
}
#[test]
fn configured_hosts_default() {
let configured_hosts = Settings::api_host_names();
assert_eq!(configured_hosts, Vec::<String>::new());
}
#[test]
fn configured_hosts_set_multiple() {
let configured_hosts = string_to_api_host_names(
&SocketAddr::from(([127, 0, 0, 1], 8080)),
"http://api.prod.projectcatalyst.io , https://api.dev.projectcatalyst.io:1234",
);
assert_eq!(configured_hosts, vec![
"http://api.prod.projectcatalyst.io",
"https://api.dev.projectcatalyst.io:1234"
]);
}
#[test]
fn configured_hosts_set_multiple_one_invalid() {
let configured_hosts = string_to_api_host_names(
&SocketAddr::from(([127, 0, 0, 1], 8080)),
"not a hostname , https://api.dev.projectcatalyst.io:1234",
);
assert_eq!(configured_hosts, vec![
"https://api.dev.projectcatalyst.io:1234"
]);
}
#[test]
fn configured_hosts_set_empty() {
let configured_hosts =
string_to_api_host_names(&SocketAddr::from(([127, 0, 0, 1], 8080)), "");
assert_eq!(configured_hosts, vec!["http://127.0.0.1:8080"]);
}
#[test]
fn configured_hosts_set_empty_undefined_address() {
let configured_hosts =
string_to_api_host_names(&SocketAddr::from(([0, 0, 0, 0], 7654)), "");
assert_eq!(configured_hosts, vec!["http://localhost:7654"]);
}
}