Skip to main content

elasticctl_api/rules/
preview_execution.rs

1use super::BASE;
2use crate::model::Rule;
3use elasticctl_core::{Error, ErrorKind, Result, Transport, urlencode};
4use serde_json::{Value, json};
5
6#[derive(Debug, Clone, Default, PartialEq)]
7pub struct PreviewResult {
8    pub preview_id: Option<String>,
9    pub errors: Vec<String>,
10    pub warnings: Vec<String>,
11}
12
13pub async fn preview(
14    t: &Transport,
15    rule: &Rule,
16    invocation_count: u32,
17    timeframe_end: &str,
18) -> Result<PreviewResult> {
19    let mut body = rule.as_map().clone();
20    for k in [
21        "rule_id",
22        "id",
23        "immutable",
24        "rule_source",
25        "revision",
26        "version",
27    ] {
28        body.remove(k);
29    }
30    body.insert("invocationCount".into(), json!(invocation_count));
31    body.insert("timeframeEnd".into(), json!(timeframe_end));
32
33    let response = t
34        .post(&format!("{BASE}/preview"), Some(&Value::Object(body)))
35        .await?;
36
37    let collect = |key: &str| -> Vec<String> {
38        response["logs"]
39            .as_array()
40            .map(|logs| {
41                logs.iter()
42                    .filter_map(|l| l.get(key)?.as_array())
43                    .flatten()
44                    .filter_map(|v| v.as_str().map(str::to_owned))
45                    .collect()
46            })
47            .unwrap_or_default()
48    };
49
50    Ok(PreviewResult {
51        preview_id: response["previewId"].as_str().map(str::to_owned),
52        errors: collect("errors"),
53        warnings: collect("warnings"),
54    })
55}
56
57pub const PREVIEW_ALERTS_INDEX_PREFIX: &str = ".preview.alerts-security.alerts-";
58
59#[derive(Debug, Clone, Default, PartialEq)]
60pub struct PreviewHits {
61    pub total: u64,
62    pub sample: Vec<Value>,
63}
64
65pub async fn preview_hits(
66    t: &Transport,
67    space: &str,
68    preview_id: &str,
69    sample: usize,
70) -> Result<PreviewHits> {
71    let space = if space.is_empty() { "default" } else { space };
72    let index = urlencode(&format!("{PREVIEW_ALERTS_INDEX_PREFIX}{space}"));
73    let body = json!({
74        "size": sample,
75        "track_total_hits": true,
76        "query": {"term": {"kibana.alert.rule.uuid": preview_id}},
77        "sort": [{"@timestamp": {"order": "desc"}}]
78    });
79
80    let response = t
81        .post_absolute_es(&format!("/{index}/_search?ignore_unavailable=true"), &body)
82        .await?;
83
84    decode_preview_hits_checked(&response)
85}
86
87pub fn decode_preview_hits_checked(response: &Value) -> Result<PreviewHits> {
88    let map = response
89        .as_object()
90        .ok_or_else(|| preview_error("response", "must be a JSON object"))?;
91    let hits = map
92        .get("hits")
93        .and_then(Value::as_object)
94        .ok_or_else(|| preview_error("hits", "must be an object"))?;
95    let total = hits
96        .get("total")
97        .and_then(|total| total.get("value"))
98        .and_then(Value::as_u64)
99        .ok_or_else(|| preview_error("hits.total.value", "must be an unsigned integer"))?;
100    let sample = hits
101        .get("hits")
102        .and_then(Value::as_array)
103        .ok_or_else(|| preview_error("hits.hits", "must be an array"))?
104        .iter()
105        .map(|h| {
106            json!({
107                "_id": h.get("_id").cloned().unwrap_or(Value::Null),
108                "_source": h.get("_source").cloned().unwrap_or(Value::Null),
109            })
110        })
111        .collect();
112
113    Ok(PreviewHits { total, sample })
114}
115
116fn preview_error(field: &str, detail: impl std::fmt::Display) -> Error {
117    Error::new(
118        ErrorKind::Http,
119        format!("decoding preview-hits response field {field}: {detail}"),
120    )
121}
122
123pub fn decode_preview_hits(response: &Value) -> PreviewHits {
124    decode_preview_hits_checked(response).unwrap_or_default()
125}