use std::fmt;
use std::time::Duration;
use crate::db::sqlite_pool_config;
const DEFAULT_CRAWLER_MAX_RETRIES: u32 = 3;
const DEFAULT_CRAWLER_BACKOFF_MS: u64 = 1000;
const DEFAULT_CRAWLER_COLLECTOR_CONCURRENCY: usize = 2;
const DEFAULT_CRAWLER_MONTH_CONCURRENCY: usize = 2;
const DEFAULT_BACKUP_INTERVAL_HOURS: u64 = 24;
const DEFAULT_META_RETENTION_DAYS: i64 = 30;
const DEFAULT_POSTGRES_MAX_CONNECTIONS: u32 = 10;
#[derive(Debug, Clone)]
pub struct CrawlerConfig {
pub max_retries: u32,
pub backoff_ms: u64,
pub collector_concurrency: usize,
pub month_concurrency: usize,
}
impl Default for CrawlerConfig {
fn default() -> Self {
Self {
max_retries: DEFAULT_CRAWLER_MAX_RETRIES,
backoff_ms: DEFAULT_CRAWLER_BACKOFF_MS,
collector_concurrency: DEFAULT_CRAWLER_COLLECTOR_CONCURRENCY,
month_concurrency: DEFAULT_CRAWLER_MONTH_CONCURRENCY,
}
}
}
impl CrawlerConfig {
pub fn from_env() -> Self {
Self {
max_retries: std::env::var("BGPKIT_BROKER_CRAWLER_MAX_RETRIES")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_CRAWLER_MAX_RETRIES),
backoff_ms: std::env::var("BGPKIT_BROKER_CRAWLER_BACKOFF_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_CRAWLER_BACKOFF_MS),
collector_concurrency: std::env::var("BGPKIT_BROKER_CRAWLER_COLLECTOR_CONCURRENCY")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_CRAWLER_COLLECTOR_CONCURRENCY),
month_concurrency: std::env::var("BGPKIT_BROKER_CRAWLER_MONTH_CONCURRENCY")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_CRAWLER_MONTH_CONCURRENCY),
}
}
}
impl fmt::Display for CrawlerConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"collector_concurrency={}, month_concurrency={}, max_retries={}, backoff_ms={}",
self.collector_concurrency, self.month_concurrency, self.max_retries, self.backoff_ms
)
}
}
#[derive(Debug, Clone)]
pub struct BackupConfig {
pub destination: Option<String>,
pub interval_hours: u64,
pub heartbeat_url: Option<String>,
}
impl Default for BackupConfig {
fn default() -> Self {
Self {
destination: None,
interval_hours: DEFAULT_BACKUP_INTERVAL_HOURS,
heartbeat_url: None,
}
}
}
impl BackupConfig {
pub fn from_env() -> Self {
Self {
destination: std::env::var("BGPKIT_BROKER_BACKUP_TO").ok(),
interval_hours: std::env::var("BGPKIT_BROKER_BACKUP_INTERVAL_HOURS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_BACKUP_INTERVAL_HOURS),
heartbeat_url: std::env::var("BGPKIT_BROKER_BACKUP_HEARTBEAT_URL").ok(),
}
}
pub fn is_enabled(&self) -> bool {
self.destination.is_some()
}
pub fn interval(&self) -> Duration {
Duration::from_secs(self.interval_hours * 60 * 60)
}
}
#[derive(Debug, Clone, Default)]
pub struct HeartbeatConfig {
pub general_url: Option<String>,
pub backup_url: Option<String>,
}
impl HeartbeatConfig {
pub fn from_env() -> Self {
Self {
general_url: std::env::var("BGPKIT_BROKER_HEARTBEAT_URL").ok(),
backup_url: std::env::var("BGPKIT_BROKER_BACKUP_HEARTBEAT_URL").ok(),
}
}
pub fn is_any_enabled(&self) -> bool {
self.general_url.is_some() || self.backup_url.is_some()
}
}
#[derive(Clone, PartialEq, Eq)]
pub enum DatabaseTarget {
Sqlite(String),
Postgres(String),
}
impl fmt::Debug for DatabaseTarget {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Sqlite(path) => formatter.debug_tuple("Sqlite").field(path).finish(),
Self::Postgres(_) => formatter
.debug_tuple("Postgres")
.field(&"<redacted>")
.finish(),
}
}
}
pub const DEFAULT_SQLITE_PATH: &str = "bgpkit-broker.sqlite3";
impl DatabaseTarget {
pub fn resolve(
database_path: Option<&str>,
environment_postgres_url: Option<&str>,
environment_sqlite_path: Option<&str>,
) -> Self {
if let Some(path) = non_empty(database_path) {
return postgres_url_from_path(path)
.map(Self::Postgres)
.unwrap_or_else(|| Self::Sqlite(path.to_string()));
}
if let Some(url) = non_empty(environment_postgres_url) {
return Self::Postgres(normalize_postgres_url(url));
}
Self::Sqlite(
non_empty(environment_sqlite_path)
.unwrap_or(DEFAULT_SQLITE_PATH)
.to_string(),
)
}
}
fn non_empty(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
fn postgres_url_from_path(path: &str) -> Option<String> {
let lowercase_path = path.to_ascii_lowercase();
(lowercase_path.starts_with("pg://")
|| lowercase_path.starts_with("postgres://")
|| lowercase_path.starts_with("postgresql://"))
.then(|| normalize_postgres_url(path))
}
fn normalize_postgres_url(url: &str) -> String {
url.get(..5)
.filter(|prefix| prefix.eq_ignore_ascii_case("pg://"))
.map(|_| format!("postgresql://{}", &url[5..]))
.unwrap_or_else(|| url.to_string())
}
#[derive(Clone)]
pub struct DatabaseConfig {
pub meta_retention_days: i64,
pub postgres_url: Option<String>,
pub sqlite_path: Option<String>,
pub postgres_max_connections: u32,
}
impl fmt::Debug for DatabaseConfig {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("DatabaseConfig")
.field("meta_retention_days", &self.meta_retention_days)
.field(
"postgres_url",
&self.postgres_url.as_ref().map(|_| "<redacted>"),
)
.field("sqlite_path", &self.sqlite_path)
.field("postgres_max_connections", &self.postgres_max_connections)
.finish()
}
}
impl Default for DatabaseConfig {
fn default() -> Self {
Self {
meta_retention_days: DEFAULT_META_RETENTION_DAYS,
postgres_url: None,
sqlite_path: None,
postgres_max_connections: DEFAULT_POSTGRES_MAX_CONNECTIONS,
}
}
}
impl DatabaseConfig {
pub fn from_env() -> Self {
Self {
meta_retention_days: std::env::var("BGPKIT_BROKER_META_RETENTION_DAYS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_META_RETENTION_DAYS),
postgres_url: std::env::var("BGPKIT_BROKER_POSTGRES_URL").ok(),
sqlite_path: std::env::var("BGPKIT_BROKER_SQLITE_PATH").ok(),
postgres_max_connections: std::env::var("BGPKIT_BROKER_POSTGRES_MAX_CONNECTIONS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(DEFAULT_POSTGRES_MAX_CONNECTIONS),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct BrokerConfig {
pub crawler: CrawlerConfig,
pub backup: BackupConfig,
pub heartbeat: HeartbeatConfig,
pub database: DatabaseConfig,
}
impl BrokerConfig {
pub fn new() -> Self {
Self::default()
}
pub fn from_env() -> Self {
Self {
crawler: CrawlerConfig::from_env(),
backup: BackupConfig::from_env(),
heartbeat: HeartbeatConfig::from_env(),
database: DatabaseConfig::from_env(),
}
}
pub fn display_summary(
&self,
do_update: bool,
do_api: bool,
update_interval: u64,
host: &str,
port: u16,
database_target: &DatabaseTarget,
) -> Vec<String> {
let mut lines = Vec::new();
lines.push("=== BGPKIT Broker Configuration ===".to_string());
lines.push(format!("Database backend: {:?}", database_target));
if do_update {
lines.push(format!(
"Periodic updates: ENABLED (interval: {} seconds)",
update_interval
));
lines.push(format!("Crawler config: {}", self.crawler));
} else {
lines.push("Periodic updates: DISABLED".to_string());
}
if do_api {
lines.push(format!("API service: ENABLED ({}:{})", host, port));
} else {
lines.push("API service: DISABLED".to_string());
}
if let Some(ref dest) = self.backup.destination {
let is_s3 = oneio::s3_url_parse(dest).is_ok();
let s3_ok = is_s3 && oneio::s3_env_check().is_ok();
if is_s3 && !s3_ok {
lines.push(format!(
"Backup: CONFIGURED to S3 ({}) every {} hours - WARNING: S3 env vars not set",
dest, self.backup.interval_hours
));
} else if is_s3 {
lines.push(format!(
"Backup: CONFIGURED to S3 ({}) every {} hours",
dest, self.backup.interval_hours
));
} else {
lines.push(format!(
"Backup: CONFIGURED to local path ({}) every {} hours",
dest, self.backup.interval_hours
));
}
} else {
lines.push("Backup: DISABLED".to_string());
}
let general = self.heartbeat.general_url.is_some();
let backup = self.heartbeat.backup_url.is_some();
match (general, backup) {
(true, true) => {
lines.push("Heartbeats: CONFIGURED (both general and backup)".to_string())
}
(true, false) => lines.push("Heartbeats: CONFIGURED (general only)".to_string()),
(false, true) => lines.push("Heartbeats: CONFIGURED (backup only)".to_string()),
(false, false) => lines.push("Heartbeats: DISABLED".to_string()),
}
let (sqlite_max_connections, sqlite_cache_size_kib) = sqlite_pool_config();
lines.push(format!(
"Database: meta_retention_days={}, sqlite_max_connections={}, sqlite_cache_size_kib={}",
self.database.meta_retention_days, sqlite_max_connections, sqlite_cache_size_kib
));
lines.push("=====================================".to_string());
lines
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = BrokerConfig::default();
assert_eq!(config.crawler.max_retries, 3);
assert_eq!(config.crawler.backoff_ms, 1000);
assert_eq!(config.crawler.collector_concurrency, 2);
assert_eq!(config.crawler.month_concurrency, 2);
assert_eq!(config.backup.interval_hours, 24);
assert_eq!(config.database.meta_retention_days, 30);
assert!(!config.backup.is_enabled());
}
#[test]
fn test_crawler_config_display() {
let config = CrawlerConfig::default();
let display = format!("{}", config);
assert!(display.contains("collector_concurrency=2"));
assert!(display.contains("month_concurrency=2"));
assert!(display.contains("max_retries=3"));
assert!(display.contains("backoff_ms=1000"));
}
#[test]
fn test_backup_interval() {
let config = BackupConfig {
destination: Some("test".to_string()),
interval_hours: 12,
heartbeat_url: None,
};
assert_eq!(config.interval(), Duration::from_secs(12 * 60 * 60));
}
#[test]
fn database_target_resolves_cli_path_and_environment_defaults() {
assert_eq!(
DatabaseTarget::resolve(None, None, None),
DatabaseTarget::Sqlite("bgpkit-broker.sqlite3".to_string())
);
assert_eq!(
DatabaseTarget::resolve(None, None, Some("/data/broker.sqlite3")),
DatabaseTarget::Sqlite("/data/broker.sqlite3".to_string())
);
assert_eq!(
DatabaseTarget::resolve(
Some("/tmp/override.sqlite3"),
Some("postgresql://broker@db/catalog"),
Some("/data/broker.sqlite3"),
),
DatabaseTarget::Sqlite("/tmp/override.sqlite3".to_string())
);
assert_eq!(
DatabaseTarget::resolve(
Some("pg://broker@db/catalog"),
None,
Some("/data/broker.sqlite3"),
),
DatabaseTarget::Postgres("postgresql://broker@db/catalog".to_string())
);
assert_eq!(
DatabaseTarget::resolve(
Some("postgresql://broker@db/catalog"),
None,
Some("/data/broker.sqlite3"),
),
DatabaseTarget::Postgres("postgresql://broker@db/catalog".to_string())
);
assert_eq!(
DatabaseTarget::resolve(
None,
Some("postgresql://broker@db/environment"),
Some("/data/broker.sqlite3"),
),
DatabaseTarget::Postgres("postgresql://broker@db/environment".to_string())
);
assert_eq!(
DatabaseTarget::resolve(None, Some(" "), Some(" ")),
DatabaseTarget::Sqlite("bgpkit-broker.sqlite3".to_string())
);
}
}