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
13fn synthetic_alert_file(event_type: &EventType, entity_key: Option<&str>) -> std::path::PathBuf {
19 match entity_key {
20 Some(key) => format!("[internal:{}:{}]", event_type.as_str(), key).into(),
21 None => format!("[internal:{}]", event_type.as_str()).into(),
22 }
23}
24
25fn synthetic_alert(event_type: &EventType, entity_key: Option<&str>) -> AlertDefinition {
26 AlertDefinition {
27 file: synthetic_alert_file(event_type, entity_key),
28 enabled: true,
29 interval: "0 seconds".to_string(),
30 interval_duration: std::time::Duration::from_secs(0),
31 always_send: crate::alert::AlwaysSend::Boolean(false),
32 when_changed: crate::alert::WhenChanged::default(),
33 send: Vec::new(),
34 source: crate::alert::TicketSource::Event {
35 event: event_type.clone(),
36 },
37 }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, serde::Serialize)]
42#[serde(rename_all = "kebab-case")]
43pub enum EventType {
44 SourceError,
45 DefinitionError,
46 DatabaseDown,
47}
48
49impl EventType {
50 pub fn as_str(&self) -> &'static str {
51 match self {
52 Self::SourceError => "source-error",
53 Self::DefinitionError => "definition-error",
54 Self::DatabaseDown => "database-down",
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
61pub enum EventContext {
62 SourceError {
63 alert_file: String,
64 error_message: String,
65 },
66 DefinitionError {
67 alert_file: String,
68 error_message: String,
69 },
70 DatabaseDown {
71 database_url: String,
72 error_message: String,
73 },
74}
75
76impl EventContext {
77 pub fn to_tera_context(&self) -> TeraCtx {
78 let mut ctx = TeraCtx::new();
79 match self {
80 Self::SourceError {
81 alert_file,
82 error_message,
83 } => {
84 ctx.insert("alert_file", alert_file);
85 ctx.insert("error_message", error_message);
86 }
87 Self::DefinitionError {
88 alert_file,
89 error_message,
90 } => {
91 ctx.insert("alert_file", alert_file);
92 ctx.insert("error_message", error_message);
93 }
94 Self::DatabaseDown {
95 database_url,
96 error_message,
97 } => {
98 ctx.insert("database_url", database_url);
99 ctx.insert("error_message", error_message);
100 }
101 }
102 ctx
103 }
104}
105
106#[derive(Clone)]
108pub struct EventManager {
109 event_alerts: HashMap<EventType, Vec<(AlertDefinition, Vec<ResolvedTarget>)>>,
111 default_target: Option<ResolvedTarget>,
113}
114
115impl EventManager {
116 pub fn new(
117 alerts: Vec<(AlertDefinition, Vec<ResolvedTarget>)>,
118 external_targets: &HashMap<String, Vec<ExternalTarget>>,
119 ) -> Self {
120 let mut event_alerts: HashMap<EventType, Vec<(AlertDefinition, Vec<ResolvedTarget>)>> =
121 HashMap::new();
122
123 debug!(total_alerts = alerts.len(), "initializing event manager");
124
125 for (alert, targets) in alerts {
127 if let crate::alert::TicketSource::Event { event } = &alert.source {
128 debug!(
129 file = ?alert.file,
130 event = event.as_str(),
131 targets = targets.len(),
132 "registered event alert"
133 );
134 event_alerts
135 .entry(event.clone())
136 .or_default()
137 .push((alert, targets));
138 }
139 }
140
141 info!(
142 event_types = ?event_alerts.keys().collect::<Vec<_>>(),
143 total_event_alerts = event_alerts.values().map(|v| v.len()).sum::<usize>(),
144 "event manager initialized"
145 );
146
147 let default_target = determine_default_target(external_targets).map(|t| ResolvedTarget {
148 target_id: t.id.clone(),
149 subject: None,
150 template: String::new(),
151 conn: t.conn.clone(),
152 });
153 if let Some(ref target) = default_target {
154 let target_desc = match &target.conn {
155 crate::targets::TargetConnection::Email(email) => email
156 .addresses
157 .first()
158 .cloned()
159 .unwrap_or_else(|| "unknown".into()),
160 crate::targets::TargetConnection::Slack(slack) => {
161 format!("slack:{}", slack.webhook.host_str().unwrap_or("unknown"))
162 }
163 crate::targets::TargetConnection::Canopy(_) => "canopy".to_string(),
164 };
165 info!(
166 from = %target_desc,
167 "determined default target for fallback alerts"
168 );
169 }
170
171 Self {
172 event_alerts,
173 default_target,
174 }
175 }
176
177 pub async fn trigger_event(
185 &self,
186 event_type: EventType,
187 ctx: &InternalContext,
188 email: Option<&crate::EmailConfig>,
189 dry_run: bool,
190 event_context: EventContext,
191 entity_key: Option<&str>,
192 ) -> Result<()> {
193 info!(
194 event = event_type.as_str(),
195 entity_key,
196 has_alerts = self.event_alerts.contains_key(&event_type),
197 has_default_target = self.default_target.is_some(),
198 "triggering event"
199 );
200
201 if let Some(alerts) = self.event_alerts.get(&event_type) {
203 info!(count = alerts.len(), "executing event alerts");
204 for (alert, targets) in alerts {
205 let mut tera_ctx = crate::templates::build_context(alert, jiff::Timestamp::now());
206 tera_ctx.extend(event_context.to_tera_context());
208
209 for target in targets {
210 if let Err(err) = target.send(alert, &mut tera_ctx, email, ctx, dry_run).await {
211 error!(file = ?alert.file, "failed to send event alert: {}", LogError(&err));
212 }
213 }
214 }
215 } else if let Some(ref default_target) = self.default_target {
216 info!(
218 event = event_type.as_str(),
219 "using default target for event (no explicit alert configured)"
220 );
221
222 let (subject_template, body_template) = default_event_template(&event_type);
223
224 let default_target_for_event = ResolvedTarget {
225 target_id: default_target.target_id.clone(),
226 subject: Some(subject_template),
227 template: body_template,
228 conn: default_target.conn.clone(),
229 };
230
231 let alert = synthetic_alert(&event_type, entity_key);
232
233 let mut tera_ctx = crate::templates::build_context(&alert, jiff::Timestamp::now());
234 tera_ctx.extend(event_context.to_tera_context());
235
236 if let Err(err) = default_target_for_event
237 .send(&alert, &mut tera_ctx, email, ctx, dry_run)
238 .await
239 {
240 error!("failed to send default event alert: {}", LogError(&err));
241 }
242 } else {
243 warn!(
244 event = event_type.as_str(),
245 "no alerts or default target for event, skipping notification"
246 );
247 }
248
249 Ok(())
250 }
251
252 pub async fn trigger_clear(
262 &self,
263 event_type: EventType,
264 ctx: &InternalContext,
265 dry_run: bool,
266 entity_key: Option<&str>,
267 ) -> Result<()> {
268 info!(
269 event = event_type.as_str(),
270 entity_key,
271 has_alerts = self.event_alerts.contains_key(&event_type),
272 has_default_target = self.default_target.is_some(),
273 "clearing event"
274 );
275
276 if let Some(alerts) = self.event_alerts.get(&event_type) {
277 info!(count = alerts.len(), "clearing event alerts");
278 for (alert, targets) in alerts {
279 for target in targets {
280 if let Err(err) = target.send_clear(alert, ctx, dry_run).await {
281 error!(file = ?alert.file, "failed to clear event alert: {}", LogError(&err));
282 }
283 }
284 }
285 } else if let Some(ref default_target) = self.default_target {
286 info!(
287 event = event_type.as_str(),
288 "clearing default target for event"
289 );
290
291 let default_target_for_event = ResolvedTarget {
292 target_id: default_target.target_id.clone(),
293 subject: None,
294 template: String::new(),
295 conn: default_target.conn.clone(),
296 };
297
298 let alert = synthetic_alert(&event_type, entity_key);
299
300 if let Err(err) = default_target_for_event
301 .send_clear(&alert, ctx, dry_run)
302 .await
303 {
304 error!("failed to clear default event alert: {}", LogError(&err));
305 }
306 } else {
307 debug!(
308 event = event_type.as_str(),
309 "no alerts or default target for event, skipping clear"
310 );
311 }
312
313 Ok(())
314 }
315}
316
317fn default_event_template(event_type: &EventType) -> (String, String) {
318 match event_type {
319 EventType::SourceError => (
320 "[bestool-alertd] {{ hostname }}: Failed alert: {{ alert_file }}".to_string(),
321 "<pre>{{ error_message }}</pre>".to_string(),
322 ),
323 EventType::DefinitionError => (
324 "[bestool-alertd] {{ hostname }}: Invalid alert definition: {{ alert_file }}"
325 .to_string(),
326 "<pre>{{ error_message }}</pre>".to_string(),
327 ),
328 EventType::DatabaseDown => (
329 "[bestool-alertd] {{ hostname }}: Database unreachable".to_string(),
330 "The PostgreSQL database is unreachable.\n\n\
331 Database URL: {{ database_url }}\n\
332 Error: <pre>{{ error_message }}</pre>\n\n\
333 All SQL-based alerts are non-functional until the database is restored."
334 .to_string(),
335 ),
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn test_event_type_parsing() {
345 let yaml = "source-error";
346 let event: EventType = serde_yaml::from_str(yaml).unwrap();
347 assert_eq!(event, EventType::SourceError);
348 }
349
350 #[test]
351 fn test_event_type_as_str() {
352 assert_eq!(EventType::SourceError.as_str(), "source-error");
353 assert_eq!(EventType::DefinitionError.as_str(), "definition-error");
354 assert_eq!(EventType::DatabaseDown.as_str(), "database-down");
355 }
356
357 #[test]
358 fn test_event_type_serialization() {
359 let event = EventType::SourceError;
360 let yaml = serde_yaml::to_string(&event).unwrap();
361 assert!(yaml.contains("source-error"));
362 }
363
364 #[test]
365 fn test_event_context_to_tera_source_error() {
366 let ctx = EventContext::SourceError {
367 alert_file: "/etc/alerts/test.yml".to_string(),
368 error_message: "Something went wrong".to_string(),
369 };
370
371 let tera_ctx = ctx.to_tera_context();
372 assert_eq!(
373 tera_ctx.get("alert_file").unwrap().as_str().unwrap(),
374 "/etc/alerts/test.yml"
375 );
376 assert_eq!(
377 tera_ctx.get("error_message").unwrap().as_str().unwrap(),
378 "Something went wrong"
379 );
380 }
381
382 #[test]
383 fn test_event_type_database_down() {
384 let yaml = "database-down";
385 let event: EventType = serde_yaml::from_str(yaml).unwrap();
386 assert_eq!(event, EventType::DatabaseDown);
387 }
388
389 #[test]
390 fn test_event_context_to_tera_database_down() {
391 let ctx = EventContext::DatabaseDown {
392 database_url: "postgresql://localhost/mydb".to_string(),
393 error_message: "connection refused".to_string(),
394 };
395
396 let tera_ctx = ctx.to_tera_context();
397 assert_eq!(
398 tera_ctx.get("database_url").unwrap().as_str().unwrap(),
399 "postgresql://localhost/mydb"
400 );
401 assert_eq!(
402 tera_ctx.get("error_message").unwrap().as_str().unwrap(),
403 "connection refused"
404 );
405 }
406
407 #[test]
408 fn test_event_type_definition_error() {
409 let yaml = "definition-error";
410 let event: EventType = serde_yaml::from_str(yaml).unwrap();
411 assert_eq!(event, EventType::DefinitionError);
412 }
413
414 #[test]
415 fn test_event_context_to_tera_definition_error() {
416 let ctx = EventContext::DefinitionError {
417 alert_file: "/etc/alerts/broken.yml".to_string(),
418 error_message: "Invalid YAML syntax".to_string(),
419 };
420
421 let tera_ctx = ctx.to_tera_context();
422 assert_eq!(
423 tera_ctx.get("alert_file").unwrap().as_str().unwrap(),
424 "/etc/alerts/broken.yml"
425 );
426 assert_eq!(
427 tera_ctx.get("error_message").unwrap().as_str().unwrap(),
428 "Invalid YAML syntax"
429 );
430 }
431
432 #[test]
433 fn synthetic_alert_file_without_entity_key() {
434 let path = synthetic_alert_file(&EventType::DatabaseDown, None);
435 assert_eq!(path.to_string_lossy(), "[internal:database-down]");
436 }
437
438 #[test]
439 fn synthetic_alert_file_with_entity_key() {
440 let path = synthetic_alert_file(&EventType::SourceError, Some("disk-full.yml"));
441 assert_eq!(
442 path.to_string_lossy(),
443 "[internal:source-error:disk-full.yml]"
444 );
445 }
446
447 #[test]
448 fn synthetic_alert_file_distinguishes_entities() {
449 let a = synthetic_alert_file(&EventType::DefinitionError, Some("a.yml"));
450 let b = synthetic_alert_file(&EventType::DefinitionError, Some("b.yml"));
451 assert_ne!(a, b);
452 }
453
454 #[test]
455 fn synthetic_alert_file_stem_includes_entity_for_canopy_ref() {
456 let trigger = synthetic_alert_file(&EventType::SourceError, Some("my-alert"));
459 let clear = synthetic_alert_file(&EventType::SourceError, Some("my-alert"));
460 assert_eq!(trigger.file_stem(), clear.file_stem());
461
462 let other = synthetic_alert_file(&EventType::SourceError, Some("other-alert"));
465 assert_ne!(trigger.file_stem(), other.file_stem());
466 }
467}