Skip to main content

bestool_alertd/
targets.rs

1use std::collections::HashMap;
2
3use miette::Result;
4
5use crate::{
6	EmailConfig,
7	alert::{AlertDefinition, InternalContext},
8	templates::{load_templates, render_alert},
9};
10
11pub mod canopy;
12mod default;
13mod email;
14mod slack;
15
16pub use canopy::{CanopyConfig, TargetCanopy};
17pub use default::determine_default_target;
18pub use email::TargetEmail;
19pub use slack::TargetSlack;
20
21#[derive(serde::Deserialize, Debug, Clone)]
22#[serde(rename_all = "snake_case")]
23#[serde(untagged)]
24pub enum SendTarget {
25	// New format: just id, subject, template
26	Simple {
27		id: String,
28		subject: Option<String>,
29		template: String,
30	},
31	// Old format: target: external, id, subject, template
32	External {
33		target: String, // Should be "external" but we ignore the value
34		id: String,
35		subject: Option<String>,
36		template: String,
37	},
38}
39
40impl SendTarget {
41	pub fn id(&self) -> &str {
42		match self {
43			Self::Simple { id, .. } => id,
44			Self::External { id, .. } => id,
45		}
46	}
47
48	pub fn subject(&self) -> &Option<String> {
49		match self {
50			Self::Simple { subject, .. } => subject,
51			Self::External { subject, .. } => subject,
52		}
53	}
54
55	pub fn template(&self) -> &str {
56		match self {
57			Self::Simple { template, .. } => template,
58			Self::External { template, .. } => template,
59		}
60	}
61
62	pub fn resolve_external(
63		&self,
64		external_targets: &HashMap<String, Vec<ExternalTarget>>,
65	) -> Vec<ResolvedTarget> {
66		external_targets
67			.get(self.id())
68			.map(|exts| {
69				exts.iter()
70					.map(|ext| ResolvedTarget {
71						target_id: ext.id.clone(),
72						subject: self.subject().clone(),
73						template: self.template().to_string(),
74						conn: ext.conn.clone(),
75					})
76					.collect()
77			})
78			.unwrap_or_default()
79	}
80}
81
82#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
83#[serde(untagged)]
84pub enum TargetConnection {
85	Slack(TargetSlack),
86	Email(TargetEmail),
87	Canopy(TargetCanopy),
88}
89
90#[derive(Debug, Clone)]
91pub struct ResolvedTarget {
92	pub target_id: String,
93	pub subject: Option<String>,
94	pub template: String,
95	pub conn: TargetConnection,
96}
97
98impl ResolvedTarget {
99	pub async fn send(
100		&self,
101		alert: &AlertDefinition,
102		tera_ctx: &mut tera::Context,
103		email: Option<&EmailConfig>,
104		ctx: &InternalContext,
105		dry_run: bool,
106	) -> Result<()> {
107		let tera = load_templates(&self.subject, &self.template)?;
108		let (subject, body) = render_alert(&tera, tera_ctx)?;
109
110		match &self.conn {
111			TargetConnection::Email(target) => {
112				target.send(alert, email, &subject, &body, dry_run).await
113			}
114			TargetConnection::Slack(target) => {
115				target
116					.send(
117						&ctx.http_client,
118						slack::SlackSendParams {
119							alert,
120							subject: &subject,
121							body: &body,
122							tera: &tera,
123							tera_ctx,
124							dry_run,
125						},
126					)
127					.await
128			}
129			TargetConnection::Canopy(target) => {
130				target
131					.send(
132						ctx.canopy_client.as_deref(),
133						alert,
134						&self.target_id,
135						&subject,
136						&body,
137						dry_run,
138					)
139					.await
140			}
141		}
142	}
143
144	/// Send a "cleared" notification for stateful targets (canopy).
145	///
146	/// Non-stateful targets (email, slack) return Ok immediately.
147	pub async fn send_clear(
148		&self,
149		alert: &AlertDefinition,
150		ctx: &InternalContext,
151		dry_run: bool,
152	) -> Result<()> {
153		match &self.conn {
154			TargetConnection::Canopy(target) => {
155				target
156					.send_clear(
157						ctx.canopy_client.as_deref(),
158						alert,
159						&self.target_id,
160						dry_run,
161					)
162					.await
163			}
164			_ => Ok(()),
165		}
166	}
167}
168
169#[derive(serde::Deserialize, Debug)]
170pub struct AlertTargets {
171	pub targets: Vec<ExternalTarget>,
172}
173
174#[derive(serde::Deserialize, serde::Serialize, Clone, Debug)]
175#[serde(rename_all = "snake_case")]
176pub struct ExternalTarget {
177	pub id: String,
178	#[serde(flatten)]
179	pub conn: TargetConnection,
180}
181
182#[cfg(test)]
183mod tests {
184	use super::*;
185
186	#[test]
187	fn test_send_target_simple_format() {
188		let yaml = r#"
189id: test-target
190subject: Test Subject
191template: Test template
192"#;
193		let target: SendTarget = serde_yaml::from_str(yaml).unwrap();
194		assert_eq!(target.id(), "test-target");
195		assert_eq!(target.subject(), &Some("Test Subject".to_string()));
196		assert_eq!(target.template(), "Test template");
197	}
198
199	#[test]
200	fn test_send_target_external_format() {
201		let yaml = r#"
202target: external
203id: test-target
204subject: Test Subject
205template: Test template
206"#;
207		let target: SendTarget = serde_yaml::from_str(yaml).unwrap();
208		assert_eq!(target.id(), "test-target");
209		assert_eq!(target.subject(), &Some("Test Subject".to_string()));
210		assert_eq!(target.template(), "Test template");
211	}
212
213	#[test]
214	fn test_send_target_without_subject() {
215		let yaml = r#"
216id: test-target
217template: Test template
218"#;
219		let target: SendTarget = serde_yaml::from_str(yaml).unwrap();
220		assert_eq!(target.id(), "test-target");
221		assert_eq!(target.subject(), &None);
222		assert_eq!(target.template(), "Test template");
223	}
224
225	#[test]
226	fn test_external_target_email() {
227		let yaml = r#"
228id: ops-team
229addresses:
230  - ops@example.com
231  - oncall@example.com
232"#;
233		let target: ExternalTarget = serde_yaml::from_str(yaml).unwrap();
234		assert_eq!(target.id, "ops-team");
235		assert!(matches!(target.conn, TargetConnection::Email(_)));
236		if let TargetConnection::Email(email) = &target.conn {
237			assert_eq!(email.addresses.len(), 2);
238			assert_eq!(email.addresses[0], "ops@example.com");
239		}
240	}
241
242	#[test]
243	fn test_external_target_slack() {
244		let yaml = r#"
245id: slack-alerts
246webhook: https://hooks.example.com/services/T00/B00/xxx
247"#;
248		let target: ExternalTarget = serde_yaml::from_str(yaml).unwrap();
249		assert_eq!(target.id, "slack-alerts");
250		assert!(matches!(target.conn, TargetConnection::Slack(_)));
251		if let TargetConnection::Slack(slack) = &target.conn {
252			assert_eq!(
253				slack.webhook.as_str(),
254				"https://hooks.example.com/services/T00/B00/xxx"
255			);
256		}
257	}
258
259	#[test]
260	fn test_external_target_slack_with_fields() {
261		let yaml = r#"
262id: slack-custom
263webhook: https://hooks.example.com/services/T00/B00/xxx
264fields:
265  - name: text
266    field: body
267  - name: server
268    value: production
269"#;
270		let target: ExternalTarget = serde_yaml::from_str(yaml).unwrap();
271		assert_eq!(target.id, "slack-custom");
272		assert!(matches!(target.conn, TargetConnection::Slack(_)));
273		if let TargetConnection::Slack(slack) = &target.conn {
274			assert_eq!(slack.fields.len(), 2);
275		}
276	}
277
278	#[test]
279	fn test_alert_targets_mixed() {
280		let yaml = r#"
281targets:
282  - id: email-team
283    addresses:
284      - team@example.com
285  - id: slack-channel
286    webhook: https://hooks.example.com/services/T00/B00/xxx
287"#;
288		let targets: AlertTargets = serde_yaml::from_str(yaml).unwrap();
289		assert_eq!(targets.targets.len(), 2);
290		assert!(matches!(
291			targets.targets[0].conn,
292			TargetConnection::Email(_)
293		));
294		assert!(matches!(
295			targets.targets[1].conn,
296			TargetConnection::Slack(_)
297		));
298	}
299
300	#[test]
301	fn test_resolve_email_target() {
302		let mut external_targets = HashMap::new();
303		external_targets.insert(
304			"ops".to_string(),
305			vec![ExternalTarget {
306				id: "ops".to_string(),
307				conn: TargetConnection::Email(TargetEmail {
308					addresses: vec!["ops@example.com".to_string()],
309				}),
310			}],
311		);
312
313		let send = SendTarget::Simple {
314			id: "ops".to_string(),
315			subject: Some("Test".to_string()),
316			template: "Body".to_string(),
317		};
318
319		let resolved = send.resolve_external(&external_targets);
320		assert_eq!(resolved.len(), 1);
321		assert!(matches!(resolved[0].conn, TargetConnection::Email(_)));
322	}
323
324	#[test]
325	fn test_resolve_slack_target() {
326		let mut external_targets = HashMap::new();
327		external_targets.insert(
328			"slack".to_string(),
329			vec![ExternalTarget {
330				id: "slack".to_string(),
331				conn: TargetConnection::Slack(TargetSlack {
332					webhook: "https://hooks.example.com/services/T00/B00/xxx"
333						.parse()
334						.unwrap(),
335					fields: slack::SlackField::default_set(),
336				}),
337			}],
338		);
339
340		let send = SendTarget::Simple {
341			id: "slack".to_string(),
342			subject: Some("Test".to_string()),
343			template: "Body".to_string(),
344		};
345
346		let resolved = send.resolve_external(&external_targets);
347		assert_eq!(resolved.len(), 1);
348		assert!(matches!(resolved[0].conn, TargetConnection::Slack(_)));
349	}
350
351	#[test]
352	fn test_resolve_mixed_targets_same_id() {
353		let mut external_targets = HashMap::new();
354		external_targets.insert(
355			"all".to_string(),
356			vec![
357				ExternalTarget {
358					id: "all".to_string(),
359					conn: TargetConnection::Email(TargetEmail {
360						addresses: vec!["team@example.com".to_string()],
361					}),
362				},
363				ExternalTarget {
364					id: "all".to_string(),
365					conn: TargetConnection::Slack(TargetSlack {
366						webhook: "https://hooks.example.com/services/T00/B00/xxx"
367							.parse()
368							.unwrap(),
369						fields: slack::SlackField::default_set(),
370					}),
371				},
372			],
373		);
374
375		let send = SendTarget::Simple {
376			id: "all".to_string(),
377			subject: Some("Test".to_string()),
378			template: "Body".to_string(),
379		};
380
381		let resolved = send.resolve_external(&external_targets);
382		assert_eq!(resolved.len(), 2);
383		assert!(matches!(resolved[0].conn, TargetConnection::Email(_)));
384		assert!(matches!(resolved[1].conn, TargetConnection::Slack(_)));
385	}
386}