1use std::{
2 collections::{HashMap, HashSet},
3 path::PathBuf,
4 sync::Arc,
5 time::Duration,
6};
7
8use jiff::Timestamp;
9use miette::Result;
10use tokio::{
11 sync::{Mutex, Notify, RwLock},
12 task::JoinHandle,
13 time::{interval, sleep},
14};
15use tracing::{debug, error, info, warn};
16
17use crate::{
18 EmailConfig, LogError,
19 alert::{AlertDefinition, InternalContext, TicketSource},
20 events::{EventContext, EventManager, EventType},
21 glob_resolver::{GlobResolver, ResolvedPaths},
22 loader::{LoadedAlerts, load_alerts_from_paths},
23 metrics,
24 state_file::{PersistedAlertState, PersistedState},
25 targets::ResolvedTarget,
26};
27
28#[derive(Debug, Clone)]
29pub struct AlertState {
30 pub definition: AlertDefinition,
31 pub resolved_targets: Vec<ResolvedTarget>,
32 pub triggered_at: Option<Timestamp>,
33 pub last_sent_at: Option<Timestamp>,
34 pub last_output: Option<String>,
35 pub paused_until: Option<Timestamp>,
36 pub source_was_erroring: bool,
41}
42
43impl AlertState {
44 pub fn new(definition: AlertDefinition, resolved_targets: Vec<ResolvedTarget>) -> Self {
45 Self {
46 definition,
47 resolved_targets,
48 triggered_at: None,
49 last_sent_at: None,
50 last_output: None,
51 paused_until: None,
52 source_was_erroring: false,
53 }
54 }
55
56 pub fn preserve_state_from(&mut self, old_state: &AlertState) {
57 self.triggered_at = old_state.triggered_at;
58 self.last_sent_at = old_state.last_sent_at;
59 self.last_output = old_state.last_output.clone();
60 self.paused_until = old_state.paused_until;
61 self.source_was_erroring = old_state.source_was_erroring;
62 }
63
64 pub fn hydrate_from_persisted(&mut self, entry: &PersistedAlertState) {
65 self.triggered_at = entry.triggered_at;
66 self.last_sent_at = entry.last_sent_at;
67 self.last_output = entry.last_output.clone();
68 self.paused_until = entry.paused_until;
69 self.source_was_erroring = entry.source_was_erroring;
70 }
71
72 pub fn to_persisted(&self) -> PersistedAlertState {
73 PersistedAlertState {
74 triggered_at: self.triggered_at,
75 last_sent_at: self.last_sent_at,
76 last_output: self.last_output.clone(),
77 paused_until: self.paused_until,
78 source_was_erroring: self.source_was_erroring,
79 }
80 }
81}
82
83pub struct Scheduler {
84 glob_resolver: GlobResolver,
85 resolved_paths: Arc<RwLock<ResolvedPaths>>,
86 ctx: Arc<InternalContext>,
87 email: Option<EmailConfig>,
88 dry_run: bool,
89 alerts: Arc<RwLock<HashMap<PathBuf, Arc<RwLock<AlertState>>>>>,
90 tasks: Arc<RwLock<HashMap<PathBuf, JoinHandle<()>>>>,
91 event_manager: Arc<RwLock<Option<EventManager>>>,
92 external_targets:
93 Arc<RwLock<std::collections::HashMap<String, Vec<crate::targets::ExternalTarget>>>>,
94 state_dirty: Arc<Notify>,
95 pending_hydration: Arc<Mutex<Option<PersistedState>>>,
96 last_definition_error_files: Arc<RwLock<HashSet<PathBuf>>>,
100 database_was_down: Arc<RwLock<bool>>,
106}
107
108impl Scheduler {
109 pub fn new(
110 alert_globs: Vec<String>,
111 ctx: Arc<InternalContext>,
112 email: Option<EmailConfig>,
113 dry_run: bool,
114 ) -> Self {
115 let glob_resolver = GlobResolver::new(alert_globs);
116 Self {
117 glob_resolver,
118 resolved_paths: Arc::new(RwLock::new(ResolvedPaths {
119 dirs: Vec::new(),
120 files: Vec::new(),
121 })),
122 ctx,
123 email,
124 dry_run,
125 alerts: Arc::new(RwLock::new(HashMap::new())),
126 tasks: Arc::new(RwLock::new(HashMap::new())),
127 event_manager: Arc::new(RwLock::new(None)),
128 external_targets: Arc::new(RwLock::new(HashMap::new())),
129 state_dirty: Arc::new(Notify::new()),
130 pending_hydration: Arc::new(Mutex::new(None)),
131 last_definition_error_files: Arc::new(RwLock::new(HashSet::new())),
132 database_was_down: Arc::new(RwLock::new(false)),
133 }
134 }
135
136 pub async fn database_was_down(&self) -> bool {
138 *self.database_was_down.read().await
139 }
140
141 pub async fn set_database_was_down(&self, value: bool) {
143 *self.database_was_down.write().await = value;
144 self.state_dirty.notify_one();
145 }
146
147 pub fn state_dirty(&self) -> Arc<Notify> {
149 self.state_dirty.clone()
150 }
151
152 pub async fn set_pending_hydration(&self, state: PersistedState) {
158 *self.pending_hydration.lock().await = Some(state);
159 }
160
161 pub fn get_event_manager(&self) -> Arc<RwLock<Option<EventManager>>> {
162 self.event_manager.clone()
163 }
164
165 pub async fn get_loaded_alerts(&self) -> Vec<PathBuf> {
166 let alerts = self.alerts.read().await;
167 let mut files: Vec<PathBuf> = alerts.keys().cloned().collect();
168 files.sort();
169 files
170 }
171
172 pub async fn get_alert_states(&self) -> HashMap<PathBuf, AlertState> {
173 let alerts = self.alerts.read().await;
174 let mut states = HashMap::new();
175 for (path, state_lock) in alerts.iter() {
176 let state = state_lock.read().await;
177 states.insert(path.clone(), state.clone());
178 }
179 states
180 }
181
182 pub async fn pause_alert(&self, path: &PathBuf, until: Timestamp) -> Result<bool> {
183 let alerts = self.alerts.read().await;
184 if let Some(alert_state) = alerts.get(path) {
185 let mut state = alert_state.write().await;
186 state.paused_until = Some(until);
187 info!(?path, until = %until, "paused alert");
188 drop(state);
189 self.state_dirty.notify_one();
190 Ok(true)
191 } else {
192 Ok(false)
193 }
194 }
195
196 pub async fn snapshot_for_persistence(&self) -> PersistedState {
198 let alerts = self.alerts.read().await;
199 let mut out = HashMap::with_capacity(alerts.len());
200 for (path, state_lock) in alerts.iter() {
201 let state = state_lock.read().await;
202 out.insert(path.clone(), state.to_persisted());
203 }
204 PersistedState {
205 saved_at: Some(Timestamp::now()),
206 alerts: out,
207 database_was_down: *self.database_was_down.read().await,
208 definition_error_files: self.last_definition_error_files.read().await.clone(),
209 }
210 }
211
212 pub async fn get_external_targets(
213 &self,
214 ) -> std::collections::HashMap<String, Vec<crate::targets::ExternalTarget>> {
215 self.external_targets.read().await.clone()
216 }
217
218 pub async fn load_and_schedule_alerts(&self) -> Result<()> {
219 info!("resolving glob patterns and loading alerts");
220
221 let hydration = self.pending_hydration.lock().await.take();
225 if let Some(ref h) = hydration {
226 *self.database_was_down.write().await = h.database_was_down;
227 *self.last_definition_error_files.write().await = h.definition_error_files.clone();
228 }
229
230 let resolved = self.glob_resolver.resolve()?;
231 debug!(
232 dirs = resolved.dirs.len(),
233 files = resolved.files.len(),
234 "resolved paths from globs"
235 );
236
237 let canopy_available = self.ctx.canopy_client.is_some();
238 let LoadedAlerts {
239 alerts,
240 external_targets,
241 definition_errors,
242 } = load_alerts_from_paths(&resolved, canopy_available)?;
243
244 *self.resolved_paths.write().await = resolved;
246
247 let (event_alerts, regular_alerts): (Vec<_>, Vec<_>) = alerts
249 .into_iter()
250 .partition(|(alert, _)| matches!(alert.source, TicketSource::Event { .. }));
251
252 *self.external_targets.write().await = external_targets.clone();
254
255 let event_manager = EventManager::new(event_alerts, &external_targets);
257 *self.event_manager.write().await = Some(event_manager.clone());
258
259 if !definition_errors.is_empty() {
261 info!(
262 count = definition_errors.len(),
263 "triggering definition-error events for failed alerts"
264 );
265 }
266 let new_error_files: HashSet<PathBuf> =
267 definition_errors.iter().map(|e| e.file.clone()).collect();
268 for def_err in definition_errors {
269 info!(
270 file = %def_err.file.display(),
271 "triggering definition-error event"
272 );
273 let entity_key = def_err.file.display().to_string();
274 let event_context = EventContext::DefinitionError {
275 alert_file: entity_key.clone(),
276 error_message: def_err.error.clone(),
277 };
278 if let Err(err) = event_manager
279 .trigger_event(
280 EventType::DefinitionError,
281 &self.ctx,
282 self.email.as_ref(),
283 self.dry_run,
284 event_context,
285 Some(&entity_key),
286 )
287 .await
288 {
289 error!(
290 "failed to trigger definition-error event: {}",
291 LogError(&err)
292 );
293 }
294 }
295
296 let mut last_def_errors = self.last_definition_error_files.write().await;
299 for recovered in last_def_errors.difference(&new_error_files) {
300 info!(
301 file = %recovered.display(),
302 "clearing definition-error event (file now loads cleanly)"
303 );
304 let entity_key = recovered.display().to_string();
305 if let Err(err) = event_manager
306 .trigger_clear(
307 EventType::DefinitionError,
308 &self.ctx,
309 self.dry_run,
310 Some(&entity_key),
311 )
312 .await
313 {
314 error!("failed to clear definition-error event: {}", LogError(&err));
315 }
316 }
317 *last_def_errors = new_error_files;
318 drop(last_def_errors);
319
320 if regular_alerts.is_empty() {
321 warn!("no regular alerts found");
322 return Ok(());
323 }
324
325 info!(count = regular_alerts.len(), "scheduling regular alerts");
326
327 let old_alerts = self.alerts.read().await.clone();
329
330 let mut hydrated_count = 0usize;
334
335 let mut new_alerts = HashMap::new();
336 let mut tasks = HashMap::new();
337
338 for (definition, resolved_targets) in regular_alerts {
339 let file = definition.file.clone();
340
341 let mut new_state = AlertState::new(definition.clone(), resolved_targets.clone());
343
344 if let Some(old_alert_lock) = old_alerts.get(&file) {
345 let old_state = old_alert_lock.read().await;
346 new_state.preserve_state_from(&old_state);
347 } else if let Some(entry) = hydration.as_ref().and_then(|h| h.alerts.get(&file)) {
348 new_state.hydrate_from_persisted(entry);
349 hydrated_count += 1;
350 }
351
352 let state_lock = Arc::new(RwLock::new(new_state));
353 let task = self.spawn_alert_task(state_lock.clone());
354
355 new_alerts.insert(file.clone(), state_lock);
356 tasks.insert(file, task);
357 }
358
359 if hydrated_count > 0 {
360 info!(count = hydrated_count, "hydrated alert state from disk");
361 }
362
363 *self.alerts.write().await = new_alerts;
365 *self.tasks.write().await = tasks;
366
367 metrics::set_alerts_loaded(self.alerts.read().await.len());
369
370 Ok(())
371 }
372
373 pub async fn execute_all_alerts_once(&self) -> Result<()> {
374 info!("executing all alerts once");
375
376 let resolved = self.glob_resolver.resolve()?;
377 let canopy_available = self.ctx.canopy_client.is_some();
378 let LoadedAlerts {
379 alerts,
380 external_targets,
381 definition_errors,
382 } = load_alerts_from_paths(&resolved, canopy_available)?;
383
384 let (event_alerts, regular_alerts): (Vec<_>, Vec<_>) = alerts
386 .into_iter()
387 .partition(|(alert, _)| matches!(alert.source, TicketSource::Event { .. }));
388
389 *self.external_targets.write().await = external_targets.clone();
391
392 let event_manager = EventManager::new(event_alerts, &external_targets);
394 *self.event_manager.write().await = Some(event_manager.clone());
395
396 if !definition_errors.is_empty() {
398 info!(
399 count = definition_errors.len(),
400 "triggering definition-error events for failed alerts"
401 );
402 }
403 for def_err in definition_errors {
404 info!(
405 file = %def_err.file.display(),
406 "triggering definition-error event"
407 );
408 let entity_key = def_err.file.display().to_string();
409 let event_context = EventContext::DefinitionError {
410 alert_file: entity_key.clone(),
411 error_message: def_err.error.clone(),
412 };
413 if let Err(err) = event_manager
414 .trigger_event(
415 EventType::DefinitionError,
416 &self.ctx,
417 self.email.as_ref(),
418 self.dry_run,
419 event_context,
420 Some(&entity_key),
421 )
422 .await
423 {
424 error!(
425 "failed to trigger definition-error event: {}",
426 LogError(&err)
427 );
428 }
429 }
430
431 if regular_alerts.is_empty() {
432 warn!("no regular alerts found");
433 return Ok(());
434 }
435
436 info!(count = regular_alerts.len(), "executing alerts");
437
438 for (alert, resolved_targets) in regular_alerts {
439 let ctx = self.ctx.clone();
440 let email = self.email.clone();
441 let dry_run = self.dry_run;
442 let file = alert.file.clone();
443
444 debug!(?file, "executing alert");
445 if let Err(err) = alert
446 .execute(ctx, email.as_ref(), dry_run, &resolved_targets)
447 .await
448 {
449 error!(?file, "error executing alert: {}", LogError(&err));
450 }
451 }
452
453 Ok(())
454 }
455
456 pub async fn check_and_reload_if_paths_changed(&self) -> Result<()> {
457 debug!("checking if resolved paths have changed");
458
459 let new_resolved = self.glob_resolver.resolve()?;
460 let old_resolved = self.resolved_paths.read().await;
461
462 if new_resolved.differs_from(&old_resolved) {
463 drop(old_resolved); info!("resolved paths have changed, reloading alerts");
465 self.reload_alerts().await?;
466 }
467
468 Ok(())
469 }
470
471 pub async fn get_resolved_paths(&self) -> Vec<PathBuf> {
472 let resolved = self.resolved_paths.read().await;
473 resolved
474 .all_paths()
475 .iter()
476 .map(|p| p.to_path_buf())
477 .collect()
478 }
479
480 pub async fn reload_alerts(&self) -> Result<()> {
481 info!("reloading alerts");
482
483 {
485 let mut tasks = self.tasks.write().await;
486 for (path, handle) in tasks.drain() {
487 debug!(?path, "cancelling alert task");
488 handle.abort();
489 }
490 }
491
492 self.load_and_schedule_alerts().await
494 }
495
496 fn spawn_alert_task(&self, alert_state: Arc<RwLock<AlertState>>) -> JoinHandle<()> {
497 fn serialize_context_for_comparison(
498 context: &tera::Context,
499 when_changed: &crate::alert::WhenChanged,
500 ) -> String {
501 use crate::alert::WhenChanged;
502
503 let rows = match context.get("rows") {
505 Some(value) => value,
506 None => return String::new(),
507 };
508
509 let rows_array = match rows.as_array() {
511 Some(arr) => arr,
512 None => return serde_json::to_string(rows).unwrap_or_default(),
513 };
514
515 match when_changed {
516 WhenChanged::Boolean(true) => {
517 serde_json::to_string(rows).unwrap_or_default()
519 }
520 WhenChanged::Boolean(false) => {
521 String::new()
523 }
524 WhenChanged::Detailed(config) => {
525 let filtered_rows: Vec<serde_json::Map<String, serde_json::Value>> = rows_array
527 .iter()
528 .filter_map(|row| {
529 let obj = row.as_object()?;
530 let mut filtered = serde_json::Map::new();
531
532 for (key, value) in obj {
533 let include = if !config.only.is_empty() {
534 config.only.contains(key)
536 } else if !config.except.is_empty() {
537 !config.except.contains(key)
539 } else {
540 true
542 };
543
544 if include {
545 filtered.insert(key.clone(), value.clone());
546 }
547 }
548
549 Some(filtered)
550 })
551 .collect();
552
553 serde_json::to_string(&filtered_rows).unwrap_or_default()
554 }
555 }
556 }
557
558 let ctx = self.ctx.clone();
559 let email = self.email.clone();
560 let dry_run = self.dry_run;
561 let event_manager = self.event_manager.clone();
562 let state_dirty = self.state_dirty.clone();
563
564 tokio::spawn(async move {
565 let (file, interval_duration) = {
567 let state = alert_state.read().await;
568 (
569 state.definition.file.clone(),
570 state.definition.interval_duration,
571 )
572 };
573 debug!(?file, ?interval_duration, "starting alert task");
574
575 let jitter = Duration::from_millis(rand::random::<u64>() % 5000);
577 sleep(jitter).await;
578
579 let mut ticker = interval(interval_duration);
580 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
581
582 loop {
583 ticker.tick().await;
584 metrics::record_activity();
585
586 let is_paused = {
588 let state = alert_state.read().await;
589 if let Some(until) = state.paused_until {
590 let now = Timestamp::now();
591 now < until
592 } else {
593 false
594 }
595 };
596
597 if is_paused {
598 debug!(?file, "alert is paused, skipping execution");
599 continue;
600 }
601
602 debug!(?file, "executing alert");
603
604 let (
606 alert,
607 resolved_targets,
608 was_triggered,
609 was_source_erroring,
610 always_send,
611 when_changed,
612 ) = {
613 let state = alert_state.read().await;
614 (
615 state.definition.clone(),
616 state.resolved_targets.clone(),
617 state.triggered_at.is_some(),
618 state.source_was_erroring,
619 state.definition.always_send.clone(),
620 state.definition.when_changed.clone(),
621 )
622 };
623
624 let now = jiff::Timestamp::now();
626 let mut tera_ctx = crate::templates::build_context(&alert, now);
627 let not_before = now - alert.interval_duration;
628
629 let mut state_changed = false;
630
631 let is_triggering = match alert
632 .read_sources(&ctx.pg_pool, not_before, &mut tera_ctx, was_triggered)
633 .await
634 {
635 Ok(flow) => {
636 if was_source_erroring {
638 info!(?file, "source recovered, clearing source-error event");
639 if let Some(ref event_mgr) = *event_manager.read().await {
640 let entity_key = file.display().to_string();
641 if let Err(event_err) = event_mgr
642 .trigger_clear(
643 EventType::SourceError,
644 &ctx,
645 dry_run,
646 Some(&entity_key),
647 )
648 .await
649 {
650 error!(
651 "failed to clear source_error event: {}",
652 LogError(&event_err)
653 );
654 }
655 }
656 let mut state = alert_state.write().await;
657 state.source_was_erroring = false;
658 state_changed = true;
659 }
660 flow.is_continue()
661 }
662 Err(err) => {
663 error!(?file, "error reading sources: {}", LogError(&err));
664 metrics::inc_alerts_failed();
665
666 if let Some(ref event_mgr) = *event_manager.read().await {
668 let entity_key = file.display().to_string();
669 let event_context = EventContext::SourceError {
670 alert_file: entity_key.clone(),
671 error_message: format!("{err:?}"),
672 };
673 if let Err(event_err) = event_mgr
674 .trigger_event(
675 EventType::SourceError,
676 &ctx,
677 email.as_ref(),
678 dry_run,
679 event_context,
680 Some(&entity_key),
681 )
682 .await
683 {
684 error!(
685 "failed to trigger source_error event: {}",
686 LogError(&event_err)
687 );
688 }
689 }
690 if !was_source_erroring {
691 let mut state = alert_state.write().await;
692 state.source_was_erroring = true;
693 state_dirty.notify_one();
694 }
695 continue;
696 }
697 };
698
699 if is_triggering {
700 let mut state = alert_state.write().await;
702
703 let mut should_send = match &always_send {
704 crate::alert::AlwaysSend::Boolean(true) => true,
705 crate::alert::AlwaysSend::Boolean(false) => !was_triggered,
706 crate::alert::AlwaysSend::Timed(config) => {
707 match state.last_sent_at {
709 Some(last_sent_time) => {
710 let now = Timestamp::now();
711 let elapsed = now.duration_since(last_sent_time);
712 if let Ok(after_duration) =
713 jiff::SignedDuration::try_from(config.after_duration)
714 {
715 elapsed >= after_duration
716 } else {
717 false
718 }
719 }
720 None => true, }
722 }
723 };
724
725 if should_send
727 && !matches!(when_changed, crate::alert::WhenChanged::Boolean(false))
728 {
729 let current_output =
730 serialize_context_for_comparison(&tera_ctx, &when_changed);
731
732 let output_changed = match &state.last_output {
733 Some(prev_output) => prev_output != ¤t_output,
734 None => true, };
736
737 if output_changed {
738 debug!(?file, "output changed, will send");
739 state.last_output = Some(current_output);
740 state_changed = true;
741 } else {
742 debug!(?file, "output unchanged, skipping");
743 should_send = false;
744 }
745 }
746
747 if should_send {
748 debug!(?file, "alert triggered, sending notifications");
749
750 for target in &resolved_targets {
752 if let Err(err) = target
753 .send(&alert, &mut tera_ctx, email.as_ref(), &ctx, dry_run)
754 .await
755 {
756 error!("sending: {}", LogError(&err));
757 }
758 }
759
760 metrics::inc_alerts_sent();
761
762 state.last_sent_at = Some(Timestamp::now());
764 state_changed = true;
765 } else {
766 debug!(?file, "alert still triggered, not sending (already sent)");
767 }
768
769 if !was_triggered {
771 state.triggered_at = Some(Timestamp::now());
772 state_changed = true;
773 }
774 } else {
775 if was_triggered {
777 info!(?file, "alert is no longer triggering, sending clear");
778 let all_cleared =
779 send_clear_to_targets(&resolved_targets, &alert, &ctx, dry_run).await;
780 if all_cleared {
781 let mut state = alert_state.write().await;
782 state.triggered_at = None;
783 state.last_sent_at = None;
784
785 if !matches!(when_changed, crate::alert::WhenChanged::Boolean(false)) {
787 state.last_output = None;
788 }
789 state_changed = true;
790 } else {
791 warn!(
792 ?file,
793 "send_clear failed for one or more targets; will retry on next tick"
794 );
795 }
796 }
797 }
798
799 if state_changed {
800 state_dirty.notify_one();
801 }
802 }
803 })
804 }
805
806 pub async fn shutdown(&self) {
807 info!("shutting down scheduler");
808 let mut tasks = self.tasks.write().await;
809 for (path, handle) in tasks.drain() {
810 debug!(?path, "cancelling alert task");
811 handle.abort();
812 }
813 }
814}
815
816async fn send_clear_to_targets(
824 targets: &[ResolvedTarget],
825 alert: &AlertDefinition,
826 ctx: &InternalContext,
827 dry_run: bool,
828) -> bool {
829 let mut all_ok = true;
830 for target in targets {
831 if let Err(err) = target.send_clear(alert, ctx, dry_run).await {
832 error!("sending clear: {}", LogError(&err));
833 all_ok = false;
834 }
835 }
836 all_ok
837}
838
839#[cfg(test)]
840mod tests {
841 use super::*;
842 use crate::{
843 canopy::{DEFAULT_CANOPY_URL, Severity},
844 targets::{CanopyConfig, TargetCanopy, TargetConnection, TargetEmail},
845 };
846
847 async fn test_internal_context() -> InternalContext {
848 let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for tests");
849 let pg_pool = bestool_postgres::pool::create_pool(&db_url, "bestool-alertd-test")
850 .await
851 .unwrap();
852 InternalContext {
853 pg_pool,
854 http_client: reqwest::Client::new(),
855 canopy_client: None,
856 }
857 }
858
859 fn email_target() -> ResolvedTarget {
860 ResolvedTarget {
861 target_id: "ops".into(),
862 subject: None,
863 template: "body".into(),
864 conn: TargetConnection::Email(TargetEmail {
865 addresses: vec!["ops@example.com".into()],
866 }),
867 }
868 }
869
870 fn canopy_target() -> ResolvedTarget {
871 ResolvedTarget {
872 target_id: "default".into(),
873 subject: None,
874 template: "body".into(),
875 conn: TargetConnection::Canopy(TargetCanopy {
876 canopy: CanopyConfig {
877 url: DEFAULT_CANOPY_URL.parse().unwrap(),
878 source: "test".into(),
879 severity: Some(Severity::Error),
880 },
881 }),
882 }
883 }
884
885 fn test_alert() -> AlertDefinition {
886 AlertDefinition {
887 file: "test.yml".into(),
888 ..Default::default()
889 }
890 }
891
892 #[tokio::test]
893 async fn send_clear_to_targets_returns_true_for_non_stateful_only() {
894 let ctx = test_internal_context().await;
895 let targets = vec![email_target()];
896 assert!(send_clear_to_targets(&targets, &test_alert(), &ctx, false).await);
897 }
898
899 #[tokio::test]
900 async fn send_clear_to_targets_returns_false_when_canopy_lacks_client() {
901 let ctx = test_internal_context().await;
905 let targets = vec![canopy_target()];
906 assert!(!send_clear_to_targets(&targets, &test_alert(), &ctx, false).await);
907 }
908
909 #[tokio::test]
910 async fn send_clear_to_targets_reports_failure_even_when_only_one_target_fails() {
911 let ctx = test_internal_context().await;
915 let targets = vec![email_target(), canopy_target()];
916 assert!(!send_clear_to_targets(&targets, &test_alert(), &ctx, false).await);
917 }
918
919 #[tokio::test]
920 async fn send_clear_to_targets_in_dry_run_succeeds_regardless_of_canopy_client() {
921 let ctx = test_internal_context().await;
923 let targets = vec![canopy_target()];
924 assert!(send_clear_to_targets(&targets, &test_alert(), &ctx, true).await);
925 }
926}