diff --git a/crates/rite-cli/src/main.rs b/crates/rite-cli/src/main.rs index 83186e4..15aa543 100644 --- a/crates/rite-cli/src/main.rs +++ b/crates/rite-cli/src/main.rs @@ -1,7 +1,10 @@ use std::{net::SocketAddr, path::PathBuf}; use clap::Parser; -use rite_server::{RiteConfig, app, configured_state, load_config, start_iris_subscription}; +use rite_server::{ + DiagnosticLevel, app, configured_state, load_config, start_iris_subscription, startup_summary, + validate, +}; #[derive(Parser)] #[command(name = "rite", about = "Minimal event-to-action runtime")] @@ -19,49 +22,28 @@ async fn main() -> Result<(), Box> { tracing_subscriber::fmt::init(); let cli = Cli::parse(); let config = load_config(&std::fs::read_to_string(cli.config)?)?; - let state = configured_state(&cli.github_webhook_secret, config.clone())?; - let listener = tokio::net::TcpListener::bind(cli.listen).await?; - for line in startup_summary(listener.local_addr()?, &config) { - tracing::info!("{line}"); + let diagnostics = validate(&config); + for diagnostic in &diagnostics { + match diagnostic.level { + DiagnosticLevel::Warning => { + tracing::warn!(message = %diagnostic.message, "Rite configuration warning"); + } + DiagnosticLevel::Error => { + tracing::error!(message = %diagnostic.message, "Rite configuration error"); + } + } + } + if diagnostics + .iter() + .any(|diagnostic| diagnostic.level == DiagnosticLevel::Error) + { + return Err("invalid Rite configuration".into()); } + let state = configured_state(&cli.github_webhook_secret, config.clone())?; + let listener = tokio::net::TcpListener::bind(cli.listen).await?; + tracing::info!("{}", startup_summary(&config)); start_iris_subscription(&state); axum::serve(listener, app(state)).await?; Ok(()) } - -/// Human-readable, secret-free startup inventory. -fn startup_summary(listen: SocketAddr, config: &RiteConfig) -> Vec { - let mut lines = vec![ - format!("Rite listening on {listen}"), - "source github enabled=true".into(), - ]; - if let Some(iris) = &config.sources.iris { - lines.push(format!("source iris enabled={}", iris.enabled)); - } - lines.extend(config.rites.iter().map(|handler| { - format!( - "handler name={} source={} action=http_post", - handler.name, handler.source - ) - })); - lines -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn startup_summary_is_secret_free() { - let config = load_config("[[rites]]\nname = \"forward\"\nsource = \"github\"\nmatch = { event_type = \"push\" }\naction = { type = \"http_post\", url = \"http://example.test\" }\n[sources.iris]\nenabled = true\nbase_url = \"http://iris.internal\"\n").expect("config"); - let lines = startup_summary("127.0.0.1:8080".parse().expect("address"), &config); - assert!( - lines - .iter() - .any(|line| line.contains("handler name=forward")) - ); - assert!(lines.iter().any(|line| line == "source iris enabled=true")); - assert!(!lines.iter().any(|line| line.contains("iris.internal"))); - } -} diff --git a/crates/rite-server/src/lib.rs b/crates/rite-server/src/lib.rs index 12f7700..8ca4e4a 100644 --- a/crates/rite-server/src/lib.rs +++ b/crates/rite-server/src/lib.rs @@ -15,6 +15,24 @@ use rite_core::{RiteAction, RiteHandler}; use rite_sources::{github::GitHubSource, iris::IrisSource}; use serde::Deserialize; +/// Severity emitted while checking a loaded configuration before the server starts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DiagnosticLevel { + /// Configuration is usable but likely accidental. + Warning, + /// Configuration cannot safely be served. + Error, +} + +/// A secret-free configuration diagnostic suitable for startup logging. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Diagnostic { + /// Severity that determines whether startup may continue. + pub level: DiagnosticLevel, + /// Human-readable explanation of the configuration problem. + pub message: String, +} + /// Server configuration loaded from TOML. #[derive(Debug, Clone, Default, Deserialize)] pub struct RiteConfig { @@ -91,6 +109,69 @@ pub fn load_config(input: &str) -> Result { toml::from_str(input) } +/// Validate configuration without performing network or filesystem I/O. +#[must_use] +pub fn validate(config: &RiteConfig) -> Vec { + let mut diagnostics = Vec::new(); + let configured_sources = [Some("github"), config.sources.iris.as_ref().map(|_| "iris")] + .into_iter() + .flatten() + .collect::>(); + + if config.rites.is_empty() { + diagnostics.push(Diagnostic { + level: DiagnosticLevel::Warning, + message: "no handlers configured; Rite will receive events but take no actions".into(), + }); + } + if let Some(iris) = &config.sources.iris + && iris.enabled + && iris.base_url.trim().is_empty() + { + diagnostics.push(Diagnostic { + level: DiagnosticLevel::Error, + message: "enabled source 'iris' has an empty base_url".into(), + }); + } + + let mut names = std::collections::BTreeSet::new(); + for handler in &config.rites { + if !configured_sources.contains(handler.source.as_str()) { + diagnostics.push(Diagnostic { + level: DiagnosticLevel::Error, + message: format!( + "handler '{}' references unknown source '{}'", + handler.name, handler.source + ), + }); + } + if !names.insert(handler.name.as_str()) { + diagnostics.push(Diagnostic { + level: DiagnosticLevel::Error, + message: format!("duplicate handler name '{}'", handler.name), + }); + } + } + diagnostics +} + +/// A secret-free configuration inventory for startup logs. +#[must_use] +pub fn startup_summary(config: &RiteConfig) -> String { + let source_count = 1 + usize::from(config.sources.iris.is_some()); + let enabled_count = 1 + usize::from( + config + .sources + .iris + .as_ref() + .is_some_and(|source| source.enabled), + ); + format!( + "rite: {source_count} sources ({enabled_count} enabled), {} handlers loaded", + config.rites.len() + ) +} + async fn health() -> &'static str { "ok" } @@ -186,6 +267,47 @@ mod tests { format!("sha256={}", hex::encode(mac.finalize().into_bytes())) } + #[test] + fn validation_reports_invalid_configuration() { + let config = load_config( + "[[rites]]\nname = \"duplicate\"\nsource = \"missing\"\naction = { type = \"http_post\", url = \"http://example.test\" }\n\n[[rites]]\nname = \"duplicate\"\nsource = \"github\"\naction = { type = \"http_post\", url = \"http://example.test\" }\n\n[sources.iris]\nenabled = true\nbase_url = \" \"\n", + ) + .expect("config parses"); + let diagnostics = validate(&config); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("unknown source")) + ); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("duplicate handler")) + ); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.message.contains("empty base_url")) + ); + assert!( + diagnostics + .iter() + .all(|diagnostic| diagnostic.level == DiagnosticLevel::Error) + ); + } + + #[test] + fn validation_warns_when_no_handlers_are_configured() { + let diagnostics = validate(&RiteConfig::default()); + assert_eq!(diagnostics.len(), 1); + assert_eq!(diagnostics[0].level, DiagnosticLevel::Warning); + assert!(diagnostics[0].message.contains("no handlers")); + assert_eq!( + startup_summary(&RiteConfig::default()), + "rite: 1 sources (1 enabled), 0 handlers loaded" + ); + } + #[tokio::test] async fn health_and_github_ingress_work() { let state = configured_state("secret", RiteConfig::default()).expect("valid state");