From 207ff07060fa755f884ad38d2966490f539496f9 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 11 Apr 2026 13:43:20 -0400 Subject: [PATCH 1/2] feat(trogon-gateway): use shared runtime config crate Signed-off-by: Yordis Prieto --- rsworkspace/crates/trogon-gateway/Cargo.toml | 1 + rsworkspace/crates/trogon-gateway/src/cli.rs | 10 +- .../crates/trogon-gateway/src/config.rs | 123 +++++------- rsworkspace/crates/trogon-gateway/src/main.rs | 7 +- .../crates/trogon-service-config/Cargo.toml | 15 ++ .../crates/trogon-service-config/src/lib.rs | 184 ++++++++++++++++++ 6 files changed, 260 insertions(+), 80 deletions(-) create mode 100644 rsworkspace/crates/trogon-service-config/Cargo.toml create mode 100644 rsworkspace/crates/trogon-service-config/src/lib.rs diff --git a/rsworkspace/crates/trogon-gateway/Cargo.toml b/rsworkspace/crates/trogon-gateway/Cargo.toml index 0ae7faab7..b6b389400 100644 --- a/rsworkspace/crates/trogon-gateway/Cargo.toml +++ b/rsworkspace/crates/trogon-gateway/Cargo.toml @@ -20,6 +20,7 @@ serde = { workspace = true } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } trogon-nats = { workspace = true } +trogon-service-config = { workspace = true } trogon-source-discord = { workspace = true } trogon-source-github = { workspace = true } trogon-source-gitlab = { workspace = true } diff --git a/rsworkspace/crates/trogon-gateway/src/cli.rs b/rsworkspace/crates/trogon-gateway/src/cli.rs index f4e252d4e..8cc6012fe 100644 --- a/rsworkspace/crates/trogon-gateway/src/cli.rs +++ b/rsworkspace/crates/trogon-gateway/src/cli.rs @@ -1,16 +1,16 @@ -use std::path::PathBuf; +use trogon_service_config::RuntimeConfigArgs; #[derive(clap::Parser, Clone)] #[command(name = "trogon-gateway", about = "Unified gateway ingestion binary")] pub struct Cli { + #[command(flatten)] + pub runtime: RuntimeConfigArgs, + #[command(subcommand)] pub command: Command, } #[derive(clap::Subcommand, Clone)] pub enum Command { - Serve { - #[arg(long, short)] - config: Option, - }, + Serve, } diff --git a/rsworkspace/crates/trogon-gateway/src/config.rs b/rsworkspace/crates/trogon-gateway/src/config.rs index 0269b5f5d..32b0cfed4 100644 --- a/rsworkspace/crates/trogon-gateway/src/config.rs +++ b/rsworkspace/crates/trogon-gateway/src/config.rs @@ -2,8 +2,11 @@ use std::fmt; use std::path::Path; use confique::Config; +#[cfg(test)] +use trogon_nats::NatsAuth; use trogon_nats::jetstream::StreamMaxAge; -use trogon_nats::{NatsAuth, NatsToken, SubjectTokenViolation}; +use trogon_nats::{NatsToken, SubjectTokenViolation}; +use trogon_service_config::{NatsArgs, NatsConfigSection, load_config, resolve_nats}; use trogon_source_discord::config::DiscordBotToken; use trogon_source_github::config::GitHubWebhookSecret; use trogon_source_gitlab::config::GitLabWebhookSecret; @@ -164,7 +167,7 @@ struct GatewayConfig { #[config(nested)] http_server: HttpServerConfig, #[config(nested)] - nats: NatsConfig, + nats: NatsConfigSection, #[config(nested)] sources: SourcesConfig, } @@ -175,22 +178,6 @@ struct HttpServerConfig { port: u16, } -#[derive(Config)] -struct NatsConfig { - #[config(env = "NATS_URL", default = "localhost:4222")] - url: String, - #[config(env = "NATS_CREDS")] - creds: Option, - #[config(env = "NATS_NKEY")] - nkey: Option, - #[config(env = "NATS_USER")] - user: Option, - #[config(env = "NATS_PASSWORD")] - password: Option, - #[config(env = "NATS_TOKEN")] - token: Option, -} - #[derive(Config)] struct SourcesConfig { #[config(nested)] @@ -358,17 +345,21 @@ impl ResolvedConfig { } } +#[cfg(test)] pub fn load(config_path: Option<&Path>) -> Result { - let mut builder = GatewayConfig::builder(); - if let Some(path) = config_path { - builder = builder.file(path); - } - let cfg = builder.env().load().map_err(ConfigError::Load)?; - resolve(cfg) + load_with_overrides(config_path, &NatsArgs::default()) +} + +pub fn load_with_overrides( + config_path: Option<&Path>, + nats_overrides: &NatsArgs, +) -> Result { + let cfg = load_config::(config_path).map_err(ConfigError::Load)?; + resolve(cfg, nats_overrides) } -fn resolve(cfg: GatewayConfig) -> Result { - let nats = resolve_nats(&cfg.nats); +fn resolve(cfg: GatewayConfig, nats_overrides: &NatsArgs) -> Result { + let nats = resolve_nats(&cfg.nats, nats_overrides); let mut errors = Vec::new(); let github = resolve_github(cfg.sources.github, &mut errors); @@ -398,38 +389,6 @@ fn resolve(cfg: GatewayConfig) -> Result { }) } -fn non_empty(opt: &Option) -> Option<&String> { - opt.as_ref().filter(|s| !s.is_empty()) -} - -fn resolve_nats(section: &NatsConfig) -> trogon_nats::NatsConfig { - let auth = if let Some(creds) = non_empty(§ion.creds) { - NatsAuth::Credentials(creds.clone().into()) - } else if let Some(nkey) = non_empty(§ion.nkey) { - NatsAuth::NKey(nkey.clone()) - } else if let (Some(user), Some(password)) = - (non_empty(§ion.user), non_empty(§ion.password)) - { - NatsAuth::UserPassword { - user: user.clone(), - password: password.clone(), - } - } else if let Some(token) = non_empty(§ion.token) { - NatsAuth::Token(token.clone()) - } else { - NatsAuth::None - }; - - let servers: Vec = section - .url - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(); - - trogon_nats::NatsConfig::new(servers, auth) -} - fn resolve_github( section: GithubConfig, errors: &mut Vec, @@ -1721,21 +1680,47 @@ token = "mytoken" } #[test] - fn non_empty_filters_none() { - let val: Option = None; - assert!(non_empty(&val).is_none()); - } + fn load_with_overrides_prefers_cli_nats_values() { + let toml = r#" +[nats] +url = "file1:4222,file2:4222" +token = "file-token" +"#; + let f = write_toml(toml); + let cfg = load_with_overrides( + Some(f.path()), + &NatsArgs { + nats_url: Some("override:4222".to_string()), + nats_token: Some("override-token".to_string()), + ..Default::default() + }, + ) + .expect("load failed"); - #[test] - fn non_empty_filters_empty_string() { - let val = Some(String::new()); - assert!(non_empty(&val).is_none()); + assert_eq!(cfg.nats.servers, vec!["override:4222"]); + assert!(matches!(cfg.nats.auth, NatsAuth::Token(ref token) if token == "override-token")); } #[test] - fn non_empty_passes_through_nonempty() { - let val = Some("hello".to_string()); - assert_eq!(non_empty(&val), Some(&"hello".to_string())); + fn load_with_overrides_keeps_auth_priority() { + let toml = r#" +[nats] +token = "file-token" +"#; + let f = write_toml(toml); + let cfg = load_with_overrides( + Some(f.path()), + &NatsArgs { + nats_creds: Some("/path/to/override.creds".to_string()), + nats_token: Some("override-token".to_string()), + ..Default::default() + }, + ) + .expect("load failed"); + + assert!( + matches!(cfg.nats.auth, NatsAuth::Credentials(ref path) if path == std::path::Path::new("/path/to/override.creds")) + ); } #[test] diff --git a/rsworkspace/crates/trogon-gateway/src/main.rs b/rsworkspace/crates/trogon-gateway/src/main.rs index 496d2cb11..2ba2ffd21 100644 --- a/rsworkspace/crates/trogon-gateway/src/main.rs +++ b/rsworkspace/crates/trogon-gateway/src/main.rs @@ -41,12 +41,7 @@ type SourceResult = (&'static str, Result<(), String>); #[tokio::main] async fn main() -> Result<(), Box> { let cli = CliArgs::::new().parse_args(); - - let config_path = match cli.command { - cli::Command::Serve { ref config } => config.as_deref(), - }; - - let resolved = config::load(config_path)?; + let resolved = config::load_with_overrides(cli.runtime.config.as_deref(), &cli.runtime.nats)?; if !resolved.has_any_source() { return Err("no sources configured — provide a config file or set source env vars".into()); diff --git a/rsworkspace/crates/trogon-service-config/Cargo.toml b/rsworkspace/crates/trogon-service-config/Cargo.toml new file mode 100644 index 000000000..c74e1c372 --- /dev/null +++ b/rsworkspace/crates/trogon-service-config/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "trogon-service-config" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +clap = { workspace = true, features = ["derive"] } +confique = { workspace = true } +trogon-nats = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/rsworkspace/crates/trogon-service-config/src/lib.rs b/rsworkspace/crates/trogon-service-config/src/lib.rs new file mode 100644 index 000000000..db2efb473 --- /dev/null +++ b/rsworkspace/crates/trogon-service-config/src/lib.rs @@ -0,0 +1,184 @@ +use std::path::{Path, PathBuf}; + +use clap::Args; +use confique::Config; +use trogon_nats::{NatsAuth, NatsConfig}; + +#[derive(Args, Clone, Debug, Default)] +pub struct RuntimeConfigArgs { + #[arg(long, short, global = true)] + pub config: Option, + + #[command(flatten)] + pub nats: NatsArgs, +} + +#[derive(Args, Clone, Debug, Default)] +pub struct NatsArgs { + #[arg(long, global = true)] + pub nats_url: Option, + #[arg(long, global = true)] + pub nats_creds: Option, + #[arg(long, global = true)] + pub nats_nkey: Option, + #[arg(long, global = true)] + pub nats_user: Option, + #[arg(long, global = true)] + pub nats_password: Option, + #[arg(long, global = true)] + pub nats_token: Option, +} + +#[derive(Config, Clone, Debug)] +pub struct NatsConfigSection { + #[config(env = "NATS_URL", default = "localhost:4222")] + pub url: String, + #[config(env = "NATS_CREDS")] + pub creds: Option, + #[config(env = "NATS_NKEY")] + pub nkey: Option, + #[config(env = "NATS_USER")] + pub user: Option, + #[config(env = "NATS_PASSWORD")] + pub password: Option, + #[config(env = "NATS_TOKEN")] + pub token: Option, +} + +pub fn load_config(config_path: Option<&Path>) -> Result { + let mut builder = T::builder(); + if let Some(path) = config_path { + builder = builder.file(path); + } + builder.env().load() +} + +pub fn resolve_nats(section: &NatsConfigSection, overrides: &NatsArgs) -> NatsConfig { + let auth = if let Some(creds) = + first_non_empty(overrides.nats_creds.as_ref(), section.creds.as_ref()) + { + NatsAuth::Credentials(creds.clone().into()) + } else if let Some(nkey) = first_non_empty(overrides.nats_nkey.as_ref(), section.nkey.as_ref()) + { + NatsAuth::NKey(nkey.clone()) + } else if let (Some(user), Some(password)) = ( + first_non_empty(overrides.nats_user.as_ref(), section.user.as_ref()), + first_non_empty(overrides.nats_password.as_ref(), section.password.as_ref()), + ) { + NatsAuth::UserPassword { + user: user.clone(), + password: password.clone(), + } + } else if let Some(token) = + first_non_empty(overrides.nats_token.as_ref(), section.token.as_ref()) + { + NatsAuth::Token(token.clone()) + } else { + NatsAuth::None + }; + + let raw_url = first_non_empty(overrides.nats_url.as_ref(), Some(§ion.url)) + .map(|value| value.as_str()) + .unwrap_or("localhost:4222"); + + let servers = raw_url + .split(',') + .map(str::trim) + .filter(|server| !server.is_empty()) + .map(ToOwned::to_owned) + .collect(); + + NatsConfig::new(servers, auth) +} + +fn first_non_empty<'a>( + primary: Option<&'a String>, + fallback: Option<&'a String>, +) -> Option<&'a String> { + primary + .filter(|value| !value.is_empty()) + .or_else(|| fallback.filter(|value| !value.is_empty())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Config)] + struct DummyConfig { + #[config(env = "DUMMY_VALUE", default = "default")] + value: String, + } + + #[test] + fn resolve_nats_uses_section_when_no_overrides() { + let section = NatsConfigSection { + url: "host1:4222, host2:4222".to_string(), + creds: None, + nkey: None, + user: None, + password: None, + token: Some("section-token".to_string()), + }; + + let resolved = resolve_nats(§ion, &NatsArgs::default()); + + assert_eq!(resolved.servers, vec!["host1:4222", "host2:4222"]); + assert!(matches!(resolved.auth, NatsAuth::Token(ref token) if token == "section-token")); + } + + #[test] + fn resolve_nats_prefers_cli_overrides() { + let section = NatsConfigSection { + url: "host1:4222".to_string(), + creds: None, + nkey: None, + user: None, + password: None, + token: Some("section-token".to_string()), + }; + let overrides = NatsArgs { + nats_url: Some("override1:4222,override2:4222".to_string()), + nats_token: Some("override-token".to_string()), + ..Default::default() + }; + + let resolved = resolve_nats(§ion, &overrides); + + assert_eq!(resolved.servers, vec!["override1:4222", "override2:4222"]); + assert!(matches!(resolved.auth, NatsAuth::Token(ref token) if token == "override-token")); + } + + #[test] + fn resolve_nats_keeps_auth_priority_with_overrides() { + let section = NatsConfigSection { + url: "host1:4222".to_string(), + creds: None, + nkey: None, + user: None, + password: None, + token: Some("section-token".to_string()), + }; + let overrides = NatsArgs { + nats_creds: Some("/tmp/nats.creds".to_string()), + nats_token: Some("override-token".to_string()), + ..Default::default() + }; + + let resolved = resolve_nats(§ion, &overrides); + + assert!( + matches!(resolved.auth, NatsAuth::Credentials(ref path) if path == Path::new("/tmp/nats.creds")) + ); + } + + #[test] + fn load_config_reads_optional_file() { + let file = tempfile::Builder::new().suffix(".toml").tempfile().unwrap(); + std::fs::write(file.path(), "value = 'from-file'\n").unwrap(); + + let loaded: DummyConfig = load_config(Some(file.path())).unwrap(); + + assert_eq!(loaded.value, "from-file"); + } +} From 223ce933564175c744e848175402cae937494370 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 11 Apr 2026 14:02:07 -0400 Subject: [PATCH 2/2] fix(ci): register trogon-service-config workspace dependency Signed-off-by: Yordis Prieto --- rsworkspace/Cargo.lock | 11 +++++++++++ rsworkspace/Cargo.toml | 1 + 2 files changed, 12 insertions(+) diff --git a/rsworkspace/Cargo.lock b/rsworkspace/Cargo.lock index c85356649..999dc8cbf 100644 --- a/rsworkspace/Cargo.lock +++ b/rsworkspace/Cargo.lock @@ -3461,6 +3461,7 @@ dependencies = [ "tokio", "tracing", "trogon-nats", + "trogon-service-config", "trogon-source-discord", "trogon-source-github", "trogon-source-gitlab", @@ -3489,6 +3490,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "trogon-service-config" +version = "0.1.0" +dependencies = [ + "clap", + "confique", + "tempfile", + "trogon-nats", +] + [[package]] name = "trogon-source-discord" version = "0.1.0" diff --git a/rsworkspace/Cargo.toml b/rsworkspace/Cargo.toml index 43ca27cec..328d45e84 100644 --- a/rsworkspace/Cargo.toml +++ b/rsworkspace/Cargo.toml @@ -14,6 +14,7 @@ all = "deny" acp-nats = { path = "crates/acp-nats" } acp-telemetry = { path = "crates/acp-telemetry" } trogon-nats = { path = "crates/trogon-nats" } +trogon-service-config = { path = "crates/trogon-service-config" } trogon-source-discord = { path = "crates/trogon-source-discord" } trogon-source-github = { path = "crates/trogon-source-github" } trogon-source-gitlab = { path = "crates/trogon-source-gitlab" }