Skip to main content

dol/
alerts.rs

1//! Alert evaluation engine.
2//!
3//! Evaluates DOL alert rules against live metric samples. Supports duration
4//! guards (e.g. "for 2m") and actions: `print`, `webhook` (HTTP POST), and
5//! `restart` (container restart via bollard).
6//!
7//! # Example
8//!
9//! ```ignore
10//! let rule = parser::parse(r#"alert when cpu > 80% for 2m then print "High""#)?;
11//! let mut evaluator = AlertEvaluator::new();
12//! let events = evaluator.evaluate_samples(&rule, &samples, Instant::now())?;
13//! ```
14
15use std::{
16    collections::{BTreeMap, HashMap},
17    sync::OnceLock,
18    time::{Duration as StdDuration, Instant},
19};
20
21use serde::Serialize;
22use serde_json::{Number, Value as JsonValue};
23use thiserror::Error;
24
25use crate::{
26    ast::{AlertAction, AlertRule, Duration, DurationUnit},
27    docker::MetricSample,
28    eval::{self, EvalError},
29    metrics::{MetricsCollector, MetricsError},
30};
31
32// ── Global alert timeout configuration ─────────────────────────────────────
33
34/// Global cache for alert timeout values (set once at startup from config).
35static ALERT_TIMEOUTS: OnceLock<AlertTimeouts> = OnceLock::new();
36
37struct AlertTimeouts {
38    webhook: StdDuration,
39    restart: StdDuration,
40}
41
42/// Initialise the global alert timeout cache. Should be called once at startup.
43pub fn init_alert_timeouts(webhook_secs: u64, restart_secs: u64) {
44    let _ = ALERT_TIMEOUTS.set(AlertTimeouts {
45        webhook: StdDuration::from_secs(webhook_secs.max(1)),
46        restart: StdDuration::from_secs(restart_secs.max(1)),
47    });
48}
49
50fn webhook_timeout() -> StdDuration {
51    ALERT_TIMEOUTS
52        .get()
53        .map_or_else(|| StdDuration::from_secs(10), |t| t.webhook)
54}
55
56fn restart_timeout() -> StdDuration {
57    ALERT_TIMEOUTS
58        .get()
59        .map_or_else(|| StdDuration::from_secs(30), |t| t.restart)
60}
61
62#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
63pub struct AlertEvent {
64    pub container_id: String,
65    pub container_name: String,
66    pub message: String,
67    pub action: AlertActionPlan,
68}
69
70#[derive(Debug, Clone, Eq, PartialEq, Serialize)]
71pub enum AlertActionPlan {
72    Print {
73        message: String,
74    },
75    Webhook {
76        url: String,
77        executed: bool,
78    },
79    Restart {
80        target: String,
81        executed: bool,
82    },
83    Slack {
84        url: String,
85        executed: bool,
86    },
87    Discord {
88        url: String,
89        executed: bool,
90    },
91    Email {
92        to: String,
93        subject: String,
94        executed: bool,
95    },
96}
97
98#[derive(Debug, Error)]
99pub enum AlertError {
100    #[error("{0}")]
101    Metrics(#[from] MetricsError),
102    #[error("{0}")]
103    Eval(#[from] EvalError),
104    #[error("HTTP request failed: {0}")]
105    Http(String),
106    #[error("Docker restart failed: {0}")]
107    Restart(String),
108}
109
110#[derive(Debug, Default)]
111pub struct AlertEvaluator {
112    active_since: HashMap<String, Instant>,
113}
114
115impl AlertEvaluator {
116    #[must_use]
117    pub fn new() -> Self {
118        Self::default()
119    }
120
121    pub fn evaluate_samples(
122        &mut self,
123        rule: &AlertRule,
124        samples: &[MetricSample],
125        now: Instant,
126    ) -> Result<Vec<AlertEvent>, AlertError> {
127        let mut events = Vec::new();
128
129        for sample in samples {
130            let key = sample_key(sample);
131            let row = sample_fields(sample);
132            let matches = eval::evaluate_expression(&row, &rule.condition)?;
133
134            if !matches {
135                self.active_since.remove(&key);
136                continue;
137            }
138
139            let since = *self.active_since.entry(key).or_insert(now);
140            let elapsed = now.saturating_duration_since(since);
141            let required = rule.duration.map(duration_to_std).unwrap_or_default();
142
143            if elapsed >= required {
144                events.push(alert_event(rule, sample));
145            }
146        }
147
148        Ok(events)
149    }
150}
151
152pub async fn evaluate_alert_once<C>(
153    rule: &AlertRule,
154    collector: &C,
155) -> Result<Vec<AlertEvent>, AlertError>
156where
157    C: MetricsCollector + ?Sized,
158{
159    let mut evaluator = AlertEvaluator::new();
160    let samples = collector.collect().await?;
161    evaluator.evaluate_samples(rule, &samples, Instant::now())
162}
163
164#[must_use]
165pub fn render_alert_event(event: &AlertEvent) -> String {
166    match &event.action {
167        AlertActionPlan::Print { message } => {
168            format!(
169                "{} [{}]: {}",
170                event.container_name, event.container_id, message
171            )
172        }
173        AlertActionPlan::Webhook { url, executed } => {
174            if *executed {
175                format!(
176                    "{} [{}]: POSTED alert to {}",
177                    event.container_name, event.container_id, url
178                )
179            } else {
180                format!(
181                    "{} [{}]: FAILED to POST alert to {}",
182                    event.container_name, event.container_id, url
183                )
184            }
185        }
186        AlertActionPlan::Restart { target, executed } => {
187            if *executed {
188                format!(
189                    "{} [{}]: RESTARTED {}",
190                    event.container_name, event.container_id, target
191                )
192            } else {
193                format!(
194                    "{} [{}]: FAILED to restart {}",
195                    event.container_name, event.container_id, target
196                )
197            }
198        }
199        AlertActionPlan::Slack { url, executed } => {
200            if *executed {
201                format!(
202                    "{} [{}]: POSTED Slack alert to {}",
203                    event.container_name, event.container_id, url
204                )
205            } else {
206                format!(
207                    "{} [{}]: FAILED to POST Slack alert to {}",
208                    event.container_name, event.container_id, url
209                )
210            }
211        }
212        AlertActionPlan::Discord { url, executed } => {
213            if *executed {
214                format!(
215                    "{} [{}]: POSTED Discord alert to {}",
216                    event.container_name, event.container_id, url
217                )
218            } else {
219                format!(
220                    "{} [{}]: FAILED to POST Discord alert to {}",
221                    event.container_name, event.container_id, url
222                )
223            }
224        }
225        AlertActionPlan::Email {
226            to,
227            subject,
228            executed,
229        } => {
230            if *executed {
231                format!(
232                    "{} [{}]: SENT email \"{}\" to {}",
233                    event.container_name, event.container_id, subject, to
234                )
235            } else {
236                format!(
237                    "{} [{}]: FAILED to send email \"{}\" to {}",
238                    event.container_name, event.container_id, subject, to
239                )
240            }
241        }
242    }
243}
244
245#[must_use]
246pub const fn duration_to_std(duration: Duration) -> StdDuration {
247    let seconds = match duration.unit {
248        DurationUnit::Seconds => duration.value,
249        DurationUnit::Minutes => duration.value * 60,
250        DurationUnit::Hours => duration.value * 60 * 60,
251        DurationUnit::Days => duration.value * 60 * 60 * 24,
252    };
253    StdDuration::from_secs(seconds)
254}
255
256fn alert_event(rule: &AlertRule, sample: &MetricSample) -> AlertEvent {
257    let action = match &rule.action {
258        AlertAction::Print(message) => AlertActionPlan::Print {
259            message: message.clone(),
260        },
261        AlertAction::Webhook(url) => {
262            let executed = execute_webhook(url);
263            AlertActionPlan::Webhook {
264                url: url.clone(),
265                executed,
266            }
267        }
268        AlertAction::Restart(target) => {
269            let target_str = format!("{:?} {}", target.kind, target.value);
270            let executed = execute_restart(&target_str);
271            AlertActionPlan::Restart {
272                target: target_str,
273                executed,
274            }
275        }
276        AlertAction::Slack(url) => {
277            let executed = execute_slack(url, &sample_key(sample));
278            AlertActionPlan::Slack {
279                url: url.clone(),
280                executed,
281            }
282        }
283        AlertAction::Discord(url) => {
284            let executed = execute_discord(url, &sample_key(sample));
285            AlertActionPlan::Discord {
286                url: url.clone(),
287                executed,
288            }
289        }
290        AlertAction::Email { to, subject } => {
291            let executed = execute_email(to, subject, &sample_key(sample));
292            AlertActionPlan::Email {
293                to: to.clone(),
294                subject: subject.clone(),
295                executed,
296            }
297        }
298    };
299    let message = render_action_message(&action);
300
301    AlertEvent {
302        container_id: sample.container_id.clone(),
303        container_name: sample.container_name.clone(),
304        message,
305        action,
306    }
307}
308
309fn execute_webhook(url: &str) -> bool {
310    execute_webhook_payload(url, &serde_json::json!({}))
311}
312
313fn execute_slack(url: &str, container: &str) -> bool {
314    let message = format!(
315        "[DockQL Alert] Container `{}` triggered an alert",
316        container
317    );
318    let payload = serde_json::json!({
319        "text": message,
320        "blocks": [
321            {
322                "type": "header",
323                "text": {
324                    "type": "plain_text",
325                    "text": "DockQL Alert"
326                }
327            },
328            {
329                "type": "section",
330                "fields": [
331                    {
332                        "type": "mrkdwn",
333                        "text": format!("*Container:* {}", container)
334                    },
335                    {
336                        "type": "mrkdwn",
337                        "text": format!("*Time:* {}", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"))
338                    }
339                ]
340            }
341        ]
342    });
343    execute_webhook_payload(url, &payload)
344}
345
346fn execute_discord(url: &str, container: &str) -> bool {
347    let payload = serde_json::json!({
348        "content": format!("DockQL Alert for `{}`", container),
349        "embeds": [
350            {
351                "title": "DockQL Alert",
352                "description": format!("Container **{}** triggered an alert", container),
353                "color": 0xFF0000,
354                "fields": [
355                    {
356                        "name": "Container",
357                        "value": container,
358                        "inline": true
359                    },
360                    {
361                        "name": "Time",
362                        "value": chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
363                        "inline": true
364                    }
365                ],
366                "footer": {
367                    "text": "DockQL"
368                },
369                "timestamp": chrono::Utc::now().to_rfc3339()
370            }
371        ]
372    });
373    execute_webhook_payload(url, &payload)
374}
375
376fn execute_email(to: &str, subject: &str, container: &str) -> bool {
377    use lettre::{
378        AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
379        transport::smtp::authentication::Credentials,
380    };
381
382    let body = format!(
383        "DockQL Alert\n\nContainer: {}\nSubject: {}\nTime: {}\n\nThis is an automated alert from DockQL.\n",
384        container,
385        subject,
386        chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
387    );
388
389    let email = match Message::builder()
390        .from(
391            "DockQL <noreply@dockql>"
392                .parse()
393                .expect("hardcoded DockQL noreply email address"),
394        )
395        .to(to.parse().expect("invalid recipient email address"))
396        .subject(subject)
397        .body(body)
398    {
399        Ok(msg) => msg,
400        Err(e) => {
401            eprintln!("Failed to build email: {e}");
402            return false;
403        }
404    };
405
406    // Try config file first, then environment variables, then defaults
407    let cfg = crate::config::DolConfig::load();
408    let smtp_host = cfg
409        .smtp_host
410        .clone()
411        .or_else(|| std::env::var("DOCKQL_SMTP_HOST").ok())
412        .unwrap_or_else(|| "localhost".to_owned());
413    let smtp_port: u16 = cfg
414        .smtp_port
415        .or_else(|| {
416            std::env::var("DOCKQL_SMTP_PORT")
417                .ok()
418                .and_then(|p| p.parse().ok())
419        })
420        .unwrap_or(25);
421    let smtp_user = cfg
422        .smtp_user
423        .clone()
424        .or_else(|| std::env::var("DOCKQL_SMTP_USER").ok());
425    let smtp_pass = cfg
426        .smtp_pass
427        .clone()
428        .or_else(|| std::env::var("DOCKQL_SMTP_PASS").ok());
429
430    async fn do_email(
431        email: Message,
432        host: String,
433        port: u16,
434        user: Option<String>,
435        pass: Option<String>,
436    ) -> Result<bool, String> {
437        let creds = match (user, pass) {
438            (Some(u), Some(p)) => Some(Credentials::new(u, p)),
439            _ => None,
440        };
441
442        let mailer = match creds {
443            Some(c) => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)
444                .map_err(|e| format!("SMTP relay setup: {e}"))?
445                .port(port)
446                .credentials(c)
447                .build(),
448            None => AsyncSmtpTransport::<Tokio1Executor>::relay(&host)
449                .map_err(|e| format!("SMTP relay setup: {e}"))?
450                .port(port)
451                .build(),
452        };
453
454        tokio::time::timeout(std::time::Duration::from_secs(30), async {
455            mailer
456                .send(email)
457                .await
458                .map_err(|e| format!("SMTP send: {e}"))?;
459            Ok(true)
460        })
461        .await
462        .map_err(|_| "SMTP send timed out".to_owned())?
463    }
464
465    match std::thread::spawn(move || {
466        let rt = match tokio::runtime::Runtime::new() {
467            Ok(rt) => rt,
468            Err(e) => {
469                eprintln!("Failed to create runtime for email: {e}");
470                return Ok(false);
471            }
472        };
473        rt.block_on(do_email(email, smtp_host, smtp_port, smtp_user, smtp_pass))
474    })
475    .join()
476    {
477        Ok(Ok(success)) => success,
478        Ok(Err(e)) => {
479            eprintln!("Email send failed: {e}");
480            false
481        }
482        Err(_) => {
483            eprintln!("Email thread panicked");
484            false
485        }
486    }
487}
488
489/// POST a JSON payload to a webhook URL.
490fn execute_webhook_payload(url: &str, payload: &serde_json::Value) -> bool {
491    async fn do_post(
492        url: &str,
493        payload: &serde_json::Value,
494        timeout: StdDuration,
495    ) -> Result<bool, String> {
496        let client = reqwest::Client::builder()
497            .timeout(timeout)
498            .build()
499            .map_err(|e| format!("reqwest client: {e}"))?;
500        let resp = client
501            .post(url)
502            .json(payload)
503            .send()
504            .await
505            .map_err(|e| format!("request failed: {e}"))?;
506        Ok(resp.status().is_success())
507    }
508
509    let timeout = webhook_timeout();
510    let url = url.to_owned();
511    let payload = payload.clone();
512    match std::thread::spawn(move || match tokio::runtime::Runtime::new() {
513        Ok(rt) => rt.block_on(do_post(&url, &payload, timeout)),
514        Err(e) => {
515            eprintln!("Failed to create runtime: {e}");
516            Ok(false)
517        }
518    })
519    .join()
520    {
521        Ok(Ok(success)) => success,
522        Ok(Err(e)) => {
523            eprintln!("Webhook POST failed: {e}");
524            false
525        }
526        Err(_) => {
527            eprintln!("Webhook thread panicked");
528            false
529        }
530    }
531}
532
533fn execute_restart(target: &str) -> bool {
534    async fn do_restart(target: &str, timeout: StdDuration) -> Result<bool, String> {
535        let docker = bollard::Docker::connect_with_local_defaults()
536            .map_err(|e| format!("bollard connect: {e}"))?;
537        tokio::time::timeout(
538            timeout,
539            docker.restart_container(
540                target,
541                None::<bollard::query_parameters::RestartContainerOptions>,
542            ),
543        )
544        .await
545        .map_err(|_| format!("restart timed out after {}s", timeout.as_secs()))?
546        .map_err(|e| format!("restart failed: {e}"))?;
547        Ok(true)
548    }
549
550    let timeout = restart_timeout();
551    let target = target.to_owned();
552    match std::thread::spawn(move || match tokio::runtime::Runtime::new() {
553        Ok(rt) => rt.block_on(do_restart(&target, timeout)),
554        Err(e) => {
555            eprintln!("Failed to create runtime for restart: {e}");
556            Ok(false)
557        }
558    })
559    .join()
560    {
561        Ok(Ok(success)) => success,
562        Ok(Err(e)) => {
563            eprintln!("Container restart failed: {e}");
564            false
565        }
566        Err(_) => {
567            eprintln!("Restart thread panicked");
568            false
569        }
570    }
571}
572
573fn render_action_message(action: &AlertActionPlan) -> String {
574    match action {
575        AlertActionPlan::Print { message } => message.clone(),
576        AlertActionPlan::Webhook { url, executed } => {
577            if *executed {
578                format!("webhook alert sent to {url}")
579            } else {
580                format!("webhook alert FAILED for {url}")
581            }
582        }
583        AlertActionPlan::Restart { target, executed } => {
584            if *executed {
585                format!("restarted {target}")
586            } else {
587                format!("restart FAILED for {target}")
588            }
589        }
590        AlertActionPlan::Slack { url, executed } => {
591            if *executed {
592                format!("Slack alert sent to {url}")
593            } else {
594                format!("Slack alert FAILED for {url}")
595            }
596        }
597        AlertActionPlan::Discord { url, executed } => {
598            if *executed {
599                format!("Discord alert sent to {url}")
600            } else {
601                format!("Discord alert FAILED for {url}")
602            }
603        }
604        AlertActionPlan::Email {
605            to,
606            subject,
607            executed,
608        } => {
609            if *executed {
610                format!("email \"{subject}\" sent to {to}")
611            } else {
612                format!("email \"{subject}\" FAILED for {to}")
613            }
614        }
615    }
616}
617
618fn sample_key(sample: &MetricSample) -> String {
619    if sample.container_id.is_empty() {
620        sample.container_name.clone()
621    } else {
622        sample.container_id.clone()
623    }
624}
625
626fn sample_fields(sample: &MetricSample) -> BTreeMap<String, JsonValue> {
627    BTreeMap::from([
628        (
629            "container_id".to_owned(),
630            JsonValue::String(sample.container_id.clone()),
631        ),
632        (
633            "container_name".to_owned(),
634            JsonValue::String(sample.container_name.clone()),
635        ),
636        (
637            "name".to_owned(),
638            JsonValue::String(sample.container_name.clone()),
639        ),
640        (
641            "timestamp".to_owned(),
642            JsonValue::String(sample.timestamp.clone()),
643        ),
644        ("cpu".to_owned(), json_option_f64(sample.cpu_percent)),
645        (
646            "memory".to_owned(),
647            json_option_u64(sample.memory_usage_bytes),
648        ),
649        (
650            "memory_limit".to_owned(),
651            json_option_u64(sample.memory_limit_bytes),
652        ),
653        (
654            "network_rx".to_owned(),
655            json_option_u64(sample.network_rx_bytes),
656        ),
657        (
658            "network_tx".to_owned(),
659            json_option_u64(sample.network_tx_bytes),
660        ),
661        (
662            "disk_read".to_owned(),
663            json_option_u64(sample.disk_read_bytes),
664        ),
665        (
666            "disk_write".to_owned(),
667            json_option_u64(sample.disk_write_bytes),
668        ),
669    ])
670}
671
672fn json_option_f64(value: Option<f64>) -> JsonValue {
673    value
674        .and_then(Number::from_f64)
675        .map_or(JsonValue::Null, JsonValue::Number)
676}
677
678fn json_option_u64(value: Option<u64>) -> JsonValue {
679    value
680        .map(Number::from)
681        .map_or(JsonValue::Null, JsonValue::Number)
682}
683
684#[cfg(test)]
685mod tests {
686    use std::time::Duration as StdDuration;
687
688    use crate::{ast::DurationUnit, metrics::MockMetricsCollector, parser};
689
690    /// 121 seconds — just past the 2-minute duration guard used in tests.
691    const JUST_PAST_TWO_MINUTES: StdDuration = StdDuration::from_secs(121);
692    /// 60 seconds — used when checking that a recovered condition resets the guard.
693    const ONE_MINUTE: StdDuration = StdDuration::from_secs(60);
694
695    use super::*;
696
697    #[test]
698    fn fires_print_alert_without_duration() {
699        let rule = alert_rule("alert when cpu > 80% then print \"High CPU\"");
700        let collector = MockMetricsCollector {
701            samples: vec![sample("api", 87.5)],
702        };
703
704        let rt = tokio::runtime::Runtime::new().expect("runtime");
705        let events = rt
706            .block_on(evaluate_alert_once(&rule, &collector))
707            .expect("alert should evaluate");
708
709        assert_eq!(events.len(), 1);
710        assert_eq!(
711            events[0].action,
712            AlertActionPlan::Print {
713                message: "High CPU".to_owned()
714            }
715        );
716    }
717
718    #[test]
719    fn honors_duration_guard() {
720        let mut rule = alert_rule("alert when cpu > 80% for 2m then print \"High CPU\"");
721        rule.duration = Some(Duration {
722            value: 2,
723            unit: DurationUnit::Minutes,
724        });
725        let mut evaluator = AlertEvaluator::new();
726        let samples = vec![sample("api", 90.0)];
727        let start = Instant::now();
728
729        let first = evaluator
730            .evaluate_samples(&rule, &samples, start)
731            .expect("first evaluation should pass");
732        let second = evaluator
733            .evaluate_samples(&rule, &samples, start + JUST_PAST_TWO_MINUTES)
734            .expect("second evaluation should pass");
735
736        assert!(first.is_empty());
737        assert_eq!(second.len(), 1);
738    }
739
740    #[test]
741    fn clears_duration_guard_when_condition_recovers() {
742        let rule = alert_rule("alert when cpu > 80% for 2m then print \"High CPU\"");
743        let mut evaluator = AlertEvaluator::new();
744        let start = Instant::now();
745
746        evaluator
747            .evaluate_samples(&rule, &[sample("api", 90.0)], start)
748            .expect("evaluation should pass");
749        evaluator
750            .evaluate_samples(&rule, &[sample("api", 20.0)], start + ONE_MINUTE)
751            .expect("evaluation should pass");
752        let events = evaluator
753            .evaluate_samples(&rule, &[sample("api", 90.0)], start + JUST_PAST_TWO_MINUTES)
754            .expect("evaluation should pass");
755
756        assert!(events.is_empty());
757    }
758
759    #[test]
760    fn plans_webhook_and_restart_as_actions() {
761        let webhook = alert_rule("alert when cpu > 80% then webhook \"http://localhost/hook\"");
762        let restart = alert_rule("alert when cpu > 80% then restart container api");
763        let collector = MockMetricsCollector {
764            samples: vec![sample("api", 90.0)],
765        };
766
767        let rt = tokio::runtime::Runtime::new().expect("runtime");
768        let webhook_events = rt
769            .block_on(evaluate_alert_once(&webhook, &collector))
770            .expect("webhook");
771        let restart_events = rt
772            .block_on(evaluate_alert_once(&restart, &collector))
773            .expect("restart");
774
775        assert!(matches!(
776            webhook_events[0].action,
777            AlertActionPlan::Webhook { .. }
778        ));
779        assert!(matches!(
780            restart_events[0].action,
781            AlertActionPlan::Restart { .. }
782        ));
783    }
784
785    #[test]
786    fn plans_slack_discord_email_as_actions() {
787        let slack =
788            alert_rule(r#"alert when cpu > 80% then slack "https://hooks.slack.com/services/xxx""#);
789        let discord = alert_rule(
790            r#"alert when cpu > 80% then discord "https://discord.com/api/webhooks/xxx""#,
791        );
792        let email = alert_rule(r#"alert when cpu > 80% then email "admin@example.com""#);
793        let email_with_subject =
794            alert_rule(r#"alert when cpu > 80% then email "admin@example.com" subject "High CPU""#);
795        let collector = MockMetricsCollector {
796            samples: vec![sample("api", 90.0)],
797        };
798
799        let rt = tokio::runtime::Runtime::new().expect("runtime");
800        let slack_events = rt
801            .block_on(evaluate_alert_once(&slack, &collector))
802            .expect("slack");
803        let discord_events = rt
804            .block_on(evaluate_alert_once(&discord, &collector))
805            .expect("discord");
806        let email_events = rt
807            .block_on(evaluate_alert_once(&email, &collector))
808            .expect("email");
809        let email_subject_events = rt
810            .block_on(evaluate_alert_once(&email_with_subject, &collector))
811            .expect("email with subject");
812
813        assert!(
814            matches!(slack_events[0].action, AlertActionPlan::Slack { .. }),
815            "expected Slack action"
816        );
817        assert!(
818            matches!(discord_events[0].action, AlertActionPlan::Discord { .. }),
819            "expected Discord action"
820        );
821        assert!(
822            matches!(email_events[0].action, AlertActionPlan::Email { .. }),
823            "expected Email action"
824        );
825        assert!(
826            matches!(
827                email_subject_events[0].action,
828                AlertActionPlan::Email { .. }
829            ),
830            "expected Email action with custom subject"
831        );
832
833        // Verify email with custom subject preserves the subject
834        if let AlertActionPlan::Email {
835            ref subject,
836            executed,
837            ..
838        } = email_subject_events[0].action
839        {
840            assert_eq!(subject, "High CPU");
841            assert!(!executed, "email should not actually send in tests");
842        }
843    }
844
845    fn alert_rule(source: &str) -> AlertRule {
846        let parsed = parser::parse(source).expect("alert should parse");
847        let crate::ast::Query::Alert(rule) = parsed.query else {
848            panic!("expected alert rule");
849        };
850        rule
851    }
852
853    fn sample(name: &str, cpu: f64) -> MetricSample {
854        MetricSample {
855            container_id: format!("{name}-id"),
856            container_name: name.to_owned(),
857            timestamp: "2026-05-31T02:00:00Z".to_owned(),
858            cpu_percent: Some(cpu),
859            memory_usage_bytes: Some(128),
860            memory_limit_bytes: Some(1024),
861            network_rx_bytes: Some(1),
862            network_tx_bytes: Some(2),
863            disk_read_bytes: Some(3),
864            disk_write_bytes: Some(4),
865        }
866    }
867}