use std::collections::HashMap;
use miette::Result;
use tera::Context as TeraCtx;
use tracing::{debug, error, info, warn};
use crate::{
LogError,
alert::{AlertDefinition, InternalContext},
targets::{ExternalTarget, ResolvedTarget, determine_default_target},
};
fn synthetic_alert_file(event_type: &EventType, entity_key: Option<&str>) -> std::path::PathBuf {
match entity_key {
Some(key) => format!("[internal:{}:{}]", event_type.as_str(), key).into(),
None => format!("[internal:{}]", event_type.as_str()).into(),
}
}
fn synthetic_alert(event_type: &EventType, entity_key: Option<&str>) -> AlertDefinition {
AlertDefinition {
file: synthetic_alert_file(event_type, entity_key),
enabled: true,
interval: "0 seconds".to_string(),
interval_duration: std::time::Duration::from_secs(0),
always_send: crate::alert::AlwaysSend::Boolean(false),
when_changed: crate::alert::WhenChanged::default(),
send: Vec::new(),
source: crate::alert::TicketSource::Event {
event: event_type.clone(),
},
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum EventType {
SourceError,
DefinitionError,
Http,
DatabaseDown,
}
impl EventType {
pub fn as_str(&self) -> &'static str {
match self {
Self::SourceError => "source-error",
Self::DefinitionError => "definition-error",
Self::Http => "http",
Self::DatabaseDown => "database-down",
}
}
}
#[derive(Debug, Clone)]
pub enum EventContext {
SourceError {
alert_file: String,
error_message: String,
},
DefinitionError {
alert_file: String,
error_message: String,
},
Http {
message: String,
subject: Option<String>,
custom: serde_json::Value,
},
DatabaseDown {
database_url: String,
error_message: String,
},
}
impl EventContext {
pub fn to_tera_context(&self) -> TeraCtx {
let mut ctx = TeraCtx::new();
match self {
Self::SourceError {
alert_file,
error_message,
} => {
ctx.insert("alert_file", alert_file);
ctx.insert("error_message", error_message);
}
Self::DefinitionError {
alert_file,
error_message,
} => {
ctx.insert("alert_file", alert_file);
ctx.insert("error_message", error_message);
}
Self::Http {
message,
subject,
custom,
} => {
ctx.insert("message", message);
ctx.insert("subject", subject.as_deref().unwrap_or("Custom alert"));
if let serde_json::Value::Object(map) = custom {
for (key, value) in map {
ctx.insert(key, value);
}
}
}
Self::DatabaseDown {
database_url,
error_message,
} => {
ctx.insert("database_url", database_url);
ctx.insert("error_message", error_message);
}
}
ctx
}
}
#[derive(Clone)]
pub struct EventManager {
event_alerts: HashMap<EventType, Vec<(AlertDefinition, Vec<ResolvedTarget>)>>,
default_target: Option<ResolvedTarget>,
}
impl EventManager {
pub fn new(
alerts: Vec<(AlertDefinition, Vec<ResolvedTarget>)>,
external_targets: &HashMap<String, Vec<ExternalTarget>>,
) -> Self {
let mut event_alerts: HashMap<EventType, Vec<(AlertDefinition, Vec<ResolvedTarget>)>> =
HashMap::new();
debug!(total_alerts = alerts.len(), "initializing event manager");
for (alert, targets) in alerts {
if let crate::alert::TicketSource::Event { event } = &alert.source {
debug!(
file = ?alert.file,
event = event.as_str(),
targets = targets.len(),
"registered event alert"
);
event_alerts
.entry(event.clone())
.or_default()
.push((alert, targets));
}
}
info!(
event_types = ?event_alerts.keys().collect::<Vec<_>>(),
total_event_alerts = event_alerts.values().map(|v| v.len()).sum::<usize>(),
"event manager initialized"
);
let default_target = determine_default_target(external_targets).map(|t| ResolvedTarget {
target_id: t.id.clone(),
subject: None,
template: String::new(),
conn: t.conn.clone(),
});
if let Some(ref target) = default_target {
let target_desc = match &target.conn {
crate::targets::TargetConnection::Email(email) => email
.addresses
.first()
.cloned()
.unwrap_or_else(|| "unknown".into()),
crate::targets::TargetConnection::Slack(slack) => {
format!("slack:{}", slack.webhook.host_str().unwrap_or("unknown"))
}
crate::targets::TargetConnection::Canopy(_) => "canopy".to_string(),
};
info!(
from = %target_desc,
"determined default target for fallback alerts"
);
}
Self {
event_alerts,
default_target,
}
}
pub async fn trigger_event(
&self,
event_type: EventType,
ctx: &InternalContext,
email: Option<&crate::EmailConfig>,
dry_run: bool,
event_context: EventContext,
entity_key: Option<&str>,
) -> Result<()> {
info!(
event = event_type.as_str(),
entity_key,
has_alerts = self.event_alerts.contains_key(&event_type),
has_default_target = self.default_target.is_some(),
"triggering event"
);
if let Some(alerts) = self.event_alerts.get(&event_type) {
info!(count = alerts.len(), "executing event alerts");
for (alert, targets) in alerts {
let mut tera_ctx = crate::templates::build_context(alert, jiff::Timestamp::now());
tera_ctx.extend(event_context.to_tera_context());
for target in targets {
if let Err(err) = target.send(alert, &mut tera_ctx, email, ctx, dry_run).await {
error!(file = ?alert.file, "failed to send event alert: {}", LogError(&err));
}
}
}
} else if let Some(ref default_target) = self.default_target {
info!(
event = event_type.as_str(),
"using default target for event (no explicit alert configured)"
);
let (subject_template, body_template) = default_event_template(&event_type);
let default_target_for_event = ResolvedTarget {
target_id: default_target.target_id.clone(),
subject: Some(subject_template),
template: body_template,
conn: default_target.conn.clone(),
};
let alert = synthetic_alert(&event_type, entity_key);
let mut tera_ctx = crate::templates::build_context(&alert, jiff::Timestamp::now());
tera_ctx.extend(event_context.to_tera_context());
if let Err(err) = default_target_for_event
.send(&alert, &mut tera_ctx, email, ctx, dry_run)
.await
{
error!("failed to send default event alert: {}", LogError(&err));
}
} else {
warn!(
event = event_type.as_str(),
"no alerts or default target for event, skipping notification"
);
}
Ok(())
}
pub async fn trigger_clear(
&self,
event_type: EventType,
ctx: &InternalContext,
dry_run: bool,
entity_key: Option<&str>,
) -> Result<()> {
info!(
event = event_type.as_str(),
entity_key,
has_alerts = self.event_alerts.contains_key(&event_type),
has_default_target = self.default_target.is_some(),
"clearing event"
);
if let Some(alerts) = self.event_alerts.get(&event_type) {
info!(count = alerts.len(), "clearing event alerts");
for (alert, targets) in alerts {
for target in targets {
if let Err(err) = target.send_clear(alert, ctx, dry_run).await {
error!(file = ?alert.file, "failed to clear event alert: {}", LogError(&err));
}
}
}
} else if let Some(ref default_target) = self.default_target {
info!(
event = event_type.as_str(),
"clearing default target for event"
);
let default_target_for_event = ResolvedTarget {
target_id: default_target.target_id.clone(),
subject: None,
template: String::new(),
conn: default_target.conn.clone(),
};
let alert = synthetic_alert(&event_type, entity_key);
if let Err(err) = default_target_for_event
.send_clear(&alert, ctx, dry_run)
.await
{
error!("failed to clear default event alert: {}", LogError(&err));
}
} else {
debug!(
event = event_type.as_str(),
"no alerts or default target for event, skipping clear"
);
}
Ok(())
}
}
fn default_event_template(event_type: &EventType) -> (String, String) {
match event_type {
EventType::SourceError => (
"[bestool-alertd] {{ hostname }}: Failed alert: {{ alert_file }}".to_string(),
"<pre>{{ error_message }}</pre>".to_string(),
),
EventType::DefinitionError => (
"[bestool-alertd] {{ hostname }}: Invalid alert definition: {{ alert_file }}"
.to_string(),
"<pre>{{ error_message }}</pre>".to_string(),
),
EventType::Http => (
"[bestool-alertd] {{ hostname }}: {{ subject }}".to_string(),
"{{ message }}".to_string(),
),
EventType::DatabaseDown => (
"[bestool-alertd] {{ hostname }}: Database unreachable".to_string(),
"The PostgreSQL database is unreachable.\n\n\
Database URL: {{ database_url }}\n\
Error: <pre>{{ error_message }}</pre>\n\n\
All SQL-based alerts are non-functional until the database is restored."
.to_string(),
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_event_type_parsing() {
let yaml = "source-error";
let event: EventType = serde_yaml::from_str(yaml).unwrap();
assert_eq!(event, EventType::SourceError);
}
#[test]
fn test_event_type_as_str() {
assert_eq!(EventType::SourceError.as_str(), "source-error");
assert_eq!(EventType::DefinitionError.as_str(), "definition-error");
assert_eq!(EventType::Http.as_str(), "http");
assert_eq!(EventType::DatabaseDown.as_str(), "database-down");
}
#[test]
fn test_event_type_serialization() {
let event = EventType::SourceError;
let yaml = serde_yaml::to_string(&event).unwrap();
assert!(yaml.contains("source-error"));
}
#[test]
fn test_event_context_to_tera_source_error() {
let ctx = EventContext::SourceError {
alert_file: "/etc/alerts/test.yml".to_string(),
error_message: "Something went wrong".to_string(),
};
let tera_ctx = ctx.to_tera_context();
assert_eq!(
tera_ctx.get("alert_file").unwrap().as_str().unwrap(),
"/etc/alerts/test.yml"
);
assert_eq!(
tera_ctx.get("error_message").unwrap().as_str().unwrap(),
"Something went wrong"
);
}
#[test]
fn test_event_context_to_tera_http() {
let ctx = EventContext::Http {
message: "Test message".to_string(),
subject: Some("Test subject".to_string()),
custom: serde_json::json!({"extra": "data"}),
};
let tera_ctx = ctx.to_tera_context();
assert_eq!(
tera_ctx.get("message").unwrap().as_str().unwrap(),
"Test message"
);
assert_eq!(
tera_ctx.get("subject").unwrap().as_str().unwrap(),
"Test subject"
);
assert_eq!(tera_ctx.get("extra").unwrap().as_str().unwrap(), "data");
}
#[test]
fn test_event_type_database_down() {
let yaml = "database-down";
let event: EventType = serde_yaml::from_str(yaml).unwrap();
assert_eq!(event, EventType::DatabaseDown);
}
#[test]
fn test_event_context_to_tera_database_down() {
let ctx = EventContext::DatabaseDown {
database_url: "postgresql://localhost/mydb".to_string(),
error_message: "connection refused".to_string(),
};
let tera_ctx = ctx.to_tera_context();
assert_eq!(
tera_ctx.get("database_url").unwrap().as_str().unwrap(),
"postgresql://localhost/mydb"
);
assert_eq!(
tera_ctx.get("error_message").unwrap().as_str().unwrap(),
"connection refused"
);
}
#[test]
fn test_event_context_http_default_subject() {
let ctx = EventContext::Http {
message: "Test message".to_string(),
subject: None,
custom: serde_json::json!({}),
};
let tera_ctx = ctx.to_tera_context();
assert_eq!(
tera_ctx.get("subject").unwrap().as_str().unwrap(),
"Custom alert"
);
}
#[test]
fn test_event_type_definition_error() {
let yaml = "definition-error";
let event: EventType = serde_yaml::from_str(yaml).unwrap();
assert_eq!(event, EventType::DefinitionError);
}
#[test]
fn test_event_context_to_tera_definition_error() {
let ctx = EventContext::DefinitionError {
alert_file: "/etc/alerts/broken.yml".to_string(),
error_message: "Invalid YAML syntax".to_string(),
};
let tera_ctx = ctx.to_tera_context();
assert_eq!(
tera_ctx.get("alert_file").unwrap().as_str().unwrap(),
"/etc/alerts/broken.yml"
);
assert_eq!(
tera_ctx.get("error_message").unwrap().as_str().unwrap(),
"Invalid YAML syntax"
);
}
#[test]
fn synthetic_alert_file_without_entity_key() {
let path = synthetic_alert_file(&EventType::DatabaseDown, None);
assert_eq!(path.to_string_lossy(), "[internal:database-down]");
}
#[test]
fn synthetic_alert_file_with_entity_key() {
let path = synthetic_alert_file(&EventType::SourceError, Some("disk-full.yml"));
assert_eq!(
path.to_string_lossy(),
"[internal:source-error:disk-full.yml]"
);
}
#[test]
fn synthetic_alert_file_distinguishes_entities() {
let a = synthetic_alert_file(&EventType::DefinitionError, Some("a.yml"));
let b = synthetic_alert_file(&EventType::DefinitionError, Some("b.yml"));
assert_ne!(a, b);
}
#[test]
fn synthetic_alert_file_stem_includes_entity_for_canopy_ref() {
let trigger = synthetic_alert_file(&EventType::SourceError, Some("my-alert"));
let clear = synthetic_alert_file(&EventType::SourceError, Some("my-alert"));
assert_eq!(trigger.file_stem(), clear.file_stem());
let other = synthetic_alert_file(&EventType::SourceError, Some("other-alert"));
assert_ne!(trigger.file_stem(), other.file_stem());
}
}