Skip to main content

elasticctl_api/rules/
preview_execution.rs

1//! Rule preview execution and matched alert retrieval.
2
3use super::BASE;
4use crate::model::Rule;
5use elasticctl_core::{Error, ErrorKind, Result, Transport, urlencode};
6use serde_json::{Value, json};
7
8#[derive(Debug, Clone, Default, PartialEq)]
9pub struct PreviewResult {
10    pub preview_id: Option<String>,
11    pub errors: Vec<String>,
12    pub warnings: Vec<String>,
13}
14
15/// Run a rule against historical data without writing alerts.
16///
17/// The API requires `invocationCount` and `timeframeEnd`. The `logs` array has
18/// errors and warnings for each simulated invocation.
19pub async fn preview(
20    t: &Transport,
21    rule: &Rule,
22    invocation_count: u32,
23    timeframe_end: &str,
24) -> Result<PreviewResult> {
25    let mut body = rule.as_map().clone();
26    // The preview API rejects fields that identify a saved rule.
27    for k in [
28        "rule_id",
29        "id",
30        "immutable",
31        "rule_source",
32        "revision",
33        "version",
34    ] {
35        body.remove(k);
36    }
37    body.insert("invocationCount".into(), json!(invocation_count));
38    body.insert("timeframeEnd".into(), json!(timeframe_end));
39
40    let response = t
41        .post(&format!("{BASE}/preview"), Some(&Value::Object(body)))
42        .await?;
43
44    let collect = |key: &str| -> Vec<String> {
45        response["logs"]
46            .as_array()
47            .map(|logs| {
48                logs.iter()
49                    .filter_map(|l| l.get(key)?.as_array())
50                    .flatten()
51                    .filter_map(|v| v.as_str().map(str::to_owned))
52                    .collect()
53            })
54            .unwrap_or_default()
55    };
56
57    Ok(PreviewResult {
58        preview_id: response["previewId"].as_str().map(str::to_owned),
59        errors: collect("errors"),
60        warnings: collect("warnings"),
61    })
62}
63
64/// Where a preview's alerts land. Kibana names the alias per space.
65///
66/// The `rules_preview_hits` fixtures record this alias and its filter field.
67/// A response alone cannot distinguish an empty result from a wrong field.
68pub const PREVIEW_ALERTS_INDEX_PREFIX: &str = ".preview.alerts-security.alerts-";
69
70#[derive(Debug, Clone, Default, PartialEq)]
71pub struct PreviewHits {
72    pub total: u64,
73    /// One entry for each returned document: `{"_id": ..., "_source": {...}}`.
74    /// Contains the matching alert document, not a projected subset.
75    pub sample: Vec<Value>,
76}
77
78/// Read back what a preview matched.
79///
80/// `rules/preview` returns a `previewId` but no hit count, so search the alerts
81/// it wrote. `ignore_unavailable=true` reports no preview index as zero hits,
82/// not a 404 error.
83pub async fn preview_hits(
84    t: &Transport,
85    space: &str,
86    preview_id: &str,
87    sample: usize,
88) -> Result<PreviewHits> {
89    let space = if space.is_empty() { "default" } else { space };
90    let index = urlencode(&format!("{PREVIEW_ALERTS_INDEX_PREFIX}{space}"));
91    let body = json!({
92        "size": sample,
93        // Count all matches, not only the default 10,000.
94        "track_total_hits": true,
95        "query": {"term": {"kibana.alert.rule.uuid": preview_id}},
96        "sort": [{"@timestamp": {"order": "desc"}}]
97    });
98
99    let response = t
100        .post_absolute_es(&format!("/{index}/_search?ignore_unavailable=true"), &body)
101        .await?;
102
103    decode_preview_hits_checked(&response)
104}
105
106/// Decode a preview-hits response, refusing a malformed success body.
107pub fn decode_preview_hits_checked(response: &Value) -> Result<PreviewHits> {
108    let map = response
109        .as_object()
110        .ok_or_else(|| preview_error("response", "must be a JSON object"))?;
111    let hits = map
112        .get("hits")
113        .and_then(Value::as_object)
114        .ok_or_else(|| preview_error("hits", "must be an object"))?;
115    let total = hits
116        .get("total")
117        .and_then(|total| total.get("value"))
118        .and_then(Value::as_u64)
119        .ok_or_else(|| preview_error("hits.total.value", "must be an unsigned integer"))?;
120    let sample = hits
121        .get("hits")
122        .and_then(Value::as_array)
123        .ok_or_else(|| preview_error("hits.hits", "must be an array"))?
124        .iter()
125        .map(|h| {
126            json!({
127                "_id": h.get("_id").cloned().unwrap_or(Value::Null),
128                "_source": h.get("_source").cloned().unwrap_or(Value::Null),
129            })
130        })
131        .collect();
132
133    Ok(PreviewHits { total, sample })
134}
135
136fn preview_error(field: &str, detail: impl std::fmt::Display) -> Error {
137    Error::new(
138        ErrorKind::Http,
139        format!("decoding preview-hits response field {field}: {detail}"),
140    )
141}
142
143/// Decode a preview-hits response, tolerating a malformed body as empty.
144///
145/// Fixtures and offline callers use this path; the live `preview_hits` uses the
146/// checked decoder so a malformed success body fails instead of reading as zero.
147pub fn decode_preview_hits(response: &Value) -> PreviewHits {
148    decode_preview_hits_checked(response).unwrap_or_default()
149}