1use std::collections::HashMap;
2
3use miette::Result;
4use tera::Context as TeraCtx;
5use tracing::{debug, error, info, warn};
6
7use crate::{
8 LogError,
9 alert::{AlertDefinition, InternalContext},
10 targets::{ExternalTarget, ResolvedTarget, determine_default_target},
11};
12
13#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize)]
15#[serde(rename_all = "kebab-case")]
16pub enum EventType {
17 SourceError,
18 DefinitionError,
19 Http,
20}
21
22impl EventType {
23 pub fn as_str(&self) -> &'static str {
24 match self {
25 Self::SourceError => "source-error",
26 Self::DefinitionError => "definition-error",
27 Self::Http => "http",
28 }
29 }
30}
31
32#[derive(Debug, Clone)]
34pub enum EventContext {
35 SourceError {
36 alert_file: String,
37 error_message: String,
38 },
39 DefinitionError {
40 alert_file: String,
41 error_message: String,
42 },
43 Http {
44 message: String,
45 subject: Option<String>,
46 custom: serde_json::Value,
47 },
48}
49
50impl EventContext {
51 pub fn to_tera_context(&self) -> TeraCtx {
52 let mut ctx = TeraCtx::new();
53 match self {
54 Self::SourceError {
55 alert_file,
56 error_message,
57 } => {
58 ctx.insert("alert_file", alert_file);
59 ctx.insert("error_message", error_message);
60 }
61 Self::DefinitionError {
62 alert_file,
63 error_message,
64 } => {
65 ctx.insert("alert_file", alert_file);
66 ctx.insert("error_message", error_message);
67 }
68 Self::Http {
69 message,
70 subject,
71 custom,
72 } => {
73 ctx.insert("message", message);
74 ctx.insert("subject", subject.as_deref().unwrap_or("Custom alert"));
75 if let serde_json::Value::Object(map) = custom {
76 for (key, value) in map {
77 ctx.insert(key, value);
78 }
79 }
80 }
81 }
82 ctx
83 }
84}
85
86#[derive(Clone)]
88pub struct EventManager {
89 event_alerts: HashMap<EventType, Vec<(AlertDefinition, Vec<ResolvedTarget>)>>,
91 default_target: Option<ResolvedTarget>,
93}
94
95impl EventManager {
96 pub fn new(
97 alerts: Vec<(AlertDefinition, Vec<ResolvedTarget>)>,
98 external_targets: &HashMap<String, Vec<ExternalTarget>>,
99 ) -> Self {
100 let mut event_alerts: HashMap<EventType, Vec<(AlertDefinition, Vec<ResolvedTarget>)>> =
101 HashMap::new();
102
103 debug!(total_alerts = alerts.len(), "initializing event manager");
104
105 for (alert, targets) in alerts {
107 if let crate::alert::TicketSource::Event { event } = &alert.source {
108 debug!(
109 file = ?alert.file,
110 event = event.as_str(),
111 targets = targets.len(),
112 "registered event alert"
113 );
114 event_alerts
115 .entry(event.clone())
116 .or_default()
117 .push((alert, targets));
118 }
119 }
120
121 info!(
122 event_types = ?event_alerts.keys().collect::<Vec<_>>(),
123 total_event_alerts = event_alerts.values().map(|v| v.len()).sum::<usize>(),
124 "event manager initialized"
125 );
126
127 let default_target = determine_default_target(external_targets).map(|t| ResolvedTarget {
128 subject: None,
129 template: String::new(),
130 conn: t.conn.clone(),
131 });
132 if let Some(ref target) = default_target {
133 info!(
134 from = target
135 .conn
136 .addresses
137 .first()
138 .map(|s| s.as_str())
139 .unwrap_or("unknown"),
140 "determined default target for fallback alerts"
141 );
142 }
143
144 Self {
145 event_alerts,
146 default_target,
147 }
148 }
149
150 pub async fn trigger_event(
152 &self,
153 event_type: EventType,
154 _ctx: &InternalContext,
155 email: Option<&crate::EmailConfig>,
156 dry_run: bool,
157 event_context: EventContext,
158 ) -> Result<()> {
159 info!(
160 event = event_type.as_str(),
161 has_alerts = self.event_alerts.contains_key(&event_type),
162 has_default_target = self.default_target.is_some(),
163 "triggering event"
164 );
165
166 if let Some(alerts) = self.event_alerts.get(&event_type) {
168 info!(count = alerts.len(), "executing event alerts");
169 for (alert, targets) in alerts {
170 let mut tera_ctx = crate::templates::build_context(alert, chrono::Utc::now());
171 tera_ctx.extend(event_context.to_tera_context());
173
174 for target in targets {
175 if let Err(err) = target.send(alert, &mut tera_ctx, email, dry_run).await {
176 error!(file = ?alert.file, "failed to send event alert: {}", LogError(&err));
177 }
178 }
179 }
180 } else if let Some(ref default_target) = self.default_target {
181 info!(
183 event = event_type.as_str(),
184 "using default target for event (no explicit alert configured)"
185 );
186
187 let (subject_template, body_template) = match event_type {
188 EventType::SourceError => (
189 "[bestool-alertd] {{ hostname }}: Failed alert: {{ alert_file }}".to_string(),
190 "<pre>{{ error_message }}</pre>".to_string(),
191 ),
192 EventType::DefinitionError => (
193 "[bestool-alertd] {{ hostname }}: Invalid alert definition: {{ alert_file }}"
194 .to_string(),
195 "<pre>{{ error_message }}</pre>".to_string(),
196 ),
197 EventType::Http => (
198 "[bestool-alertd] {{ hostname }}: {{ subject }}".to_string(),
199 "{{ message }}".to_string(),
200 ),
201 };
202
203 let default_target_for_event = ResolvedTarget {
204 subject: Some(subject_template),
205 template: body_template,
206 conn: default_target.conn.clone(),
207 };
208
209 let synthetic_alert = AlertDefinition {
211 file: format!("[internal:{}]", event_type.as_str()).into(),
212 enabled: true,
213 interval: "0 seconds".to_string(),
214 interval_duration: std::time::Duration::from_secs(0),
215 always_send: crate::alert::AlwaysSend::Boolean(false),
216 when_changed: crate::alert::WhenChanged::default(),
217 send: Vec::new(),
218 source: crate::alert::TicketSource::Event {
219 event: event_type.clone(),
220 },
221 };
222
223 let mut tera_ctx =
224 crate::templates::build_context(&synthetic_alert, chrono::Utc::now());
225 tera_ctx.extend(event_context.to_tera_context());
226
227 if let Err(err) = default_target_for_event
228 .send(&synthetic_alert, &mut tera_ctx, email, dry_run)
229 .await
230 {
231 error!("failed to send default event alert: {}", LogError(&err));
232 }
233 } else {
234 warn!(
235 event = event_type.as_str(),
236 "no alerts or default target for event, skipping notification"
237 );
238 }
239
240 Ok(())
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn test_event_type_parsing() {
250 let yaml = "source-error";
251 let event: EventType = serde_yaml::from_str(yaml).unwrap();
252 assert_eq!(event, EventType::SourceError);
253 }
254
255 #[test]
256 fn test_event_type_as_str() {
257 assert_eq!(EventType::SourceError.as_str(), "source-error");
258 assert_eq!(EventType::DefinitionError.as_str(), "definition-error");
259 assert_eq!(EventType::Http.as_str(), "http");
260 }
261
262 #[test]
263 fn test_event_type_serialization() {
264 let event = EventType::SourceError;
265 let yaml = serde_yaml::to_string(&event).unwrap();
266 assert!(yaml.contains("source-error"));
267 }
268
269 #[test]
270 fn test_event_context_to_tera_source_error() {
271 let ctx = EventContext::SourceError {
272 alert_file: "/etc/alerts/test.yml".to_string(),
273 error_message: "Something went wrong".to_string(),
274 };
275
276 let tera_ctx = ctx.to_tera_context();
277 assert_eq!(
278 tera_ctx.get("alert_file").unwrap().as_str().unwrap(),
279 "/etc/alerts/test.yml"
280 );
281 assert_eq!(
282 tera_ctx.get("error_message").unwrap().as_str().unwrap(),
283 "Something went wrong"
284 );
285 }
286
287 #[test]
288 fn test_event_context_to_tera_http() {
289 let ctx = EventContext::Http {
290 message: "Test message".to_string(),
291 subject: Some("Test subject".to_string()),
292 custom: serde_json::json!({"extra": "data"}),
293 };
294
295 let tera_ctx = ctx.to_tera_context();
296 assert_eq!(
297 tera_ctx.get("message").unwrap().as_str().unwrap(),
298 "Test message"
299 );
300 assert_eq!(
301 tera_ctx.get("subject").unwrap().as_str().unwrap(),
302 "Test subject"
303 );
304 assert_eq!(tera_ctx.get("extra").unwrap().as_str().unwrap(), "data");
305 }
306
307 #[test]
308 fn test_event_context_http_default_subject() {
309 let ctx = EventContext::Http {
310 message: "Test message".to_string(),
311 subject: None,
312 custom: serde_json::json!({}),
313 };
314
315 let tera_ctx = ctx.to_tera_context();
316 assert_eq!(
317 tera_ctx.get("subject").unwrap().as_str().unwrap(),
318 "Custom alert"
319 );
320 }
321
322 #[test]
323 fn test_event_type_definition_error() {
324 let yaml = "definition-error";
325 let event: EventType = serde_yaml::from_str(yaml).unwrap();
326 assert_eq!(event, EventType::DefinitionError);
327 }
328
329 #[test]
330 fn test_event_context_to_tera_definition_error() {
331 let ctx = EventContext::DefinitionError {
332 alert_file: "/etc/alerts/broken.yml".to_string(),
333 error_message: "Invalid YAML syntax".to_string(),
334 };
335
336 let tera_ctx = ctx.to_tera_context();
337 assert_eq!(
338 tera_ctx.get("alert_file").unwrap().as_str().unwrap(),
339 "/etc/alerts/broken.yml"
340 );
341 assert_eq!(
342 tera_ctx.get("error_message").unwrap().as_str().unwrap(),
343 "Invalid YAML syntax"
344 );
345 }
346}