Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 23 additions & 41 deletions crates/rite-cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -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")]
Expand All @@ -19,49 +22,28 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
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<String> {
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")));
}
}
122 changes: 122 additions & 0 deletions crates/rite-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -91,6 +109,69 @@ pub fn load_config(input: &str) -> Result<RiteConfig, toml::de::Error> {
toml::from_str(input)
}

/// Validate configuration without performing network or filesystem I/O.
#[must_use]
pub fn validate(config: &RiteConfig) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let configured_sources = [Some("github"), config.sources.iris.as_ref().map(|_| "iris")]
.into_iter()
.flatten()
.collect::<std::collections::BTreeSet<_>>();

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"
}
Expand Down Expand Up @@ -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");
Expand Down
Loading