Skip to main content

elasticctl_api/
prebuilt.rs

1//! Prebuilt-rule status and installation (spec 4.6).
2//!
3//! `status` reads the public prepackaged status route and adds the customized
4//! count from one filtered `_find`. `install` is one verb because
5//! `PUT .../rules/prepackaged` installs missing rules and updates outdated ones
6//! in one request. The route takes no selection.
7
8use crate::ops::MutationPlan;
9use crate::rules::{self, RuleFilter, RuleSource};
10use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport};
11use serde::{Deserialize, Serialize};
12use serde_json::Value;
13
14const PREPACKAGED_STATUS: &str = "/api/detection_engine/rules/prepackaged/_status";
15const PREPACKAGED: &str = "/api/detection_engine/rules/prepackaged";
16
17#[derive(Deserialize)]
18struct StatusWire {
19    rules_installed: u64,
20    rules_custom_installed: u64,
21    rules_not_installed: u64,
22    rules_not_updated: u64,
23    timelines_installed: u64,
24    timelines_not_installed: u64,
25    timelines_not_updated: u64,
26}
27
28#[derive(Deserialize)]
29struct InstallOutcomeWire {
30    rules_installed: u64,
31    rules_updated: u64,
32    timelines_installed: u64,
33    timelines_updated: u64,
34}
35
36/// The report `rules prebuilt status` renders.
37#[derive(Debug, Clone, PartialEq, Serialize)]
38pub struct PrebuiltStatus {
39    pub installed: u64,
40    pub not_installed: u64,
41    pub not_updated: u64,
42    pub custom_installed: u64,
43    /// Prebuilt rules edited on the stack. Costs one extra `_find`. Spec 4.6.
44    pub customized: u64,
45    pub timelines_installed: u64,
46    pub timelines_not_installed: u64,
47    pub timelines_not_updated: u64,
48}
49
50/// The report an `install` apply renders.
51#[derive(Debug, Clone, PartialEq, Serialize)]
52pub struct PrebuiltInstallOutcome {
53    pub applied: bool,
54    pub rules_installed: u64,
55    pub rules_updated: u64,
56    pub timelines_installed: u64,
57    pub timelines_updated: u64,
58}
59
60pub async fn status(t: &Transport) -> Result<PrebuiltStatus> {
61    t.require_feature(Feature::PrebuiltRules).await?;
62    let body = t.get(PREPACKAGED_STATUS).await?;
63    let mut status = decode_status(body, 0)?;
64    status.customized = customized_count(t).await?;
65    Ok(status)
66}
67
68fn decode_status(body: Value, customized: u64) -> Result<PrebuiltStatus> {
69    validate_counters(
70        &body,
71        PREPACKAGED_STATUS,
72        &[
73            "rules_installed",
74            "rules_custom_installed",
75            "rules_not_installed",
76            "rules_not_updated",
77            "timelines_installed",
78            "timelines_not_installed",
79            "timelines_not_updated",
80        ],
81    )?;
82    let body: StatusWire = serde_json::from_value(body)
83        .map_err(|error| response_decode_error(PREPACKAGED_STATUS, error))?;
84    Ok(PrebuiltStatus {
85        installed: body.rules_installed,
86        not_installed: body.rules_not_installed,
87        not_updated: body.rules_not_updated,
88        custom_installed: body.rules_custom_installed,
89        customized,
90        timelines_installed: body.timelines_installed,
91        timelines_not_installed: body.timelines_not_installed,
92        timelines_not_updated: body.timelines_not_updated,
93    })
94}
95
96/// The number of prebuilt rules edited on the stack. Read `total` only: a
97/// prebuilt rule edited in the Kibana UI is invisible to a custom-scoped
98/// mirror, and an unrecorded edit is exactly what a detection engineer needs
99/// to see (spec 4.6).
100async fn customized_count(t: &Transport) -> Result<u64> {
101    let filter = RuleFilter {
102        source: RuleSource::Customized,
103        ..Default::default()
104    };
105    let (_, total) = rules::find_page(t, &filter, 1, 1).await?;
106    Ok(total)
107}
108
109pub async fn plan_install(t: &Transport) -> Result<(MutationPlan, PrebuiltStatus)> {
110    let s = status(t).await?;
111
112    // The preview is client-computed from `_status`, not from a server dry
113    // run: `PUT .../prepackaged` has no `dry_run` parameter. Every other
114    // guarded path in this codebase previews server-side; this is the one
115    // that cannot, so a "nothing to do" status would hide real updates. Name
116    // both counts always, even when one is zero.
117    let plan = MutationPlan {
118        preview_action: format!(
119            "Install {} missing and update {} outdated prebuilt rule(s)",
120            s.not_installed, s.not_updated
121        ),
122        preview_details: vec![
123            format!("{} missing rule(s) to install", s.not_installed),
124            format!("{} outdated rule(s) to update", s.not_updated),
125        ],
126        // The route takes no selection, so there are no object identities.
127        targets: Vec::new(),
128    };
129
130    Ok((plan, s))
131}
132
133pub async fn apply_install(t: &Transport) -> Result<PrebuiltInstallOutcome> {
134    t.require_feature(Feature::PrebuiltRules).await?;
135    // The route takes no selection, so the body is empty. `Transport::put`
136    // always sends one, and `null` is the empty JSON body.
137    let body = t.put(PREPACKAGED, &Value::Null).await?;
138    decode_install_outcome(body)
139}
140
141fn decode_install_outcome(body: Value) -> Result<PrebuiltInstallOutcome> {
142    validate_counters(
143        &body,
144        PREPACKAGED,
145        &[
146            "rules_installed",
147            "rules_updated",
148            "timelines_installed",
149            "timelines_updated",
150        ],
151    )?;
152    let body: InstallOutcomeWire =
153        serde_json::from_value(body).map_err(|error| response_decode_error(PREPACKAGED, error))?;
154    Ok(PrebuiltInstallOutcome {
155        applied: true,
156        rules_installed: body.rules_installed,
157        rules_updated: body.rules_updated,
158        timelines_installed: body.timelines_installed,
159        timelines_updated: body.timelines_updated,
160    })
161}
162
163fn response_decode_error(endpoint: &str, error: serde_json::Error) -> Error {
164    Error::new(
165        ErrorKind::Http,
166        format!("invalid prebuilt response from {endpoint}: {error}"),
167    )
168}
169
170fn validate_counters(body: &Value, endpoint: &str, fields: &[&str]) -> Result<()> {
171    let object = body.as_object().ok_or_else(|| {
172        Error::new(
173            ErrorKind::Http,
174            format!("invalid prebuilt response from {endpoint}: expected an object"),
175        )
176    })?;
177    for field in fields {
178        if object.get(*field).and_then(Value::as_u64).is_none() {
179            return Err(Error::new(
180                ErrorKind::Http,
181                format!(
182                    "invalid prebuilt response from {endpoint}: field `{field}` must be a non-negative integer"
183                ),
184            ));
185        }
186    }
187    Ok(())
188}