bestool_alertd/
events.rs

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/// Internal event types that can trigger alerts
14#[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/// Context data for an event
33#[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/// Manages event-triggered alerts
87#[derive(Clone)]
88pub struct EventManager {
89	/// Alerts that listen for specific events
90	event_alerts: HashMap<EventType, Vec<(AlertDefinition, Vec<ResolvedTarget>)>>,
91	/// Default target for fallback alerts
92	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		// Separate event-based alerts from regular alerts
106		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	/// Trigger an event with the given context
151	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		// Check if there are explicit alerts for this event
167		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				// Merge event context
172				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			// No explicit alert, use default target with event-specific template
182			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			// Create a synthetic alert for the default notification
210			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}