Skip to main content

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/// Build the synthetic alert file used by event triggers and clears.
14///
15/// Embedding the entity key in the file stem keeps the canopy ref stable
16/// across trigger and clear for the same logical entity (e.g. the same
17/// failing alert), and distinct across different entities.
18fn 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/// Internal event types that can trigger alerts
41#[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/// Context data for an event
60#[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/// Manages event-triggered alerts
107#[derive(Clone)]
108pub struct EventManager {
109	/// Alerts that listen for specific events
110	event_alerts: HashMap<EventType, Vec<(AlertDefinition, Vec<ResolvedTarget>)>>,
111	/// Default target for fallback alerts
112	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		// Separate event-based alerts from regular alerts
126		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	/// Trigger an event with the given context.
178	///
179	/// `entity_key` identifies the specific subject of the event (e.g. the
180	/// erroring alert's file path). It's embedded in the synthetic alert file
181	/// so canopy refs are stable across trigger and clear for the same
182	/// subject, and distinct across different subjects. Pass `None` for
183	/// events that have only one possible subject per host (e.g. the database).
184	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		// Check if there are explicit alerts for this event
202		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				// Merge event context
207				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			// No explicit alert, use default target with event-specific template
217			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	/// Send a clearing notification for an event.
253	///
254	/// Mirrors `trigger_event`, but calls `send_clear` on each target so
255	/// stateful sinks (canopy) flip the corresponding issue to `active=false`.
256	/// Non-stateful sinks (email, slack) return immediately — that's the same
257	/// behaviour as SQL-alert clears.
258	///
259	/// `entity_key` must match the value passed to the original
260	/// `trigger_event` so the canopy ref lines up.
261	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		// build_ref uses file_stem() to form the canopy ref. Verify that
457		// trigger and clear with the same entity key produce identical stems.
458		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		// And distinct entities produce distinct stems so canopy treats them
463		// as separate issues.
464		let other = synthetic_alert_file(&EventType::SourceError, Some("other-alert"));
465		assert_ne!(trigger.file_stem(), other.file_stem());
466	}
467}