Skip to main content

elasticctl_api/
health.rs

1use crate::rules::{self, RuleFilter};
2use elasticctl_core::capabilities::{probe_license_tier, probe_spaces};
3use elasticctl_core::{Capabilities, Error, ErrorKind, Result, Transport};
4use serde::Serialize;
5use serde_json::Value;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
8#[serde(rename_all = "lowercase")]
9pub enum Status {
10    Ok,
11    Warn,
12    Fail,
13}
14
15#[derive(Debug, Clone, PartialEq, Serialize)]
16pub struct DoctorCheck {
17    #[serde(rename = "check")]
18    pub name: String,
19    #[serde(rename = "status")]
20    pub status: Status,
21    #[serde(rename = "message")]
22    pub detail: String,
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize)]
26pub struct DoctorReport {
27    pub checks: Vec<DoctorCheck>,
28    pub ok: bool,
29}
30
31impl DoctorReport {
32    pub fn from_checks(checks: Vec<DoctorCheck>) -> DoctorReport {
33        let ok = checks.iter().all(|c| c.status != Status::Fail);
34        DoctorReport { checks, ok }
35    }
36}
37
38#[derive(Debug, Clone, PartialEq)]
39pub struct InfoReport {
40    pub version: String,
41    pub flavor: String,
42    pub license: Option<String>,
43    pub spaces: Option<Vec<String>>,
44}
45
46pub fn check(name: &str, status: Status, message: impl Into<String>) -> DoctorCheck {
47    DoctorCheck {
48        name: name.into(),
49        status,
50        detail: message.into(),
51    }
52}
53
54fn key_scope_check(realm: &str) -> DoctorCheck {
55    match realm {
56        "_es_api_key" => check(
57            "key_scope",
58            Status::Ok,
59            "project-scoped Elasticsearch API key",
60        ),
61        "_cloud_api_key" => check(
62            "key_scope",
63            Status::Warn,
64            "Organization-level API key: not supported. MCP planning refuses it, and \
65             rule changes may fail on some deployments. Create a project- or \
66             deployment-scoped Elasticsearch API key in Kibana under Management > API keys.",
67        ),
68        other => check(
69            "key_scope",
70            Status::Ok,
71            format!("authenticated via the '{other}' realm, not an Elasticsearch API key"),
72        ),
73    }
74}
75
76const MAX_IDENTITY_CHARS: usize = 12;
77
78fn short_identity(value: &str) -> String {
79    if value.chars().count() <= MAX_IDENTITY_CHARS {
80        return value.to_string();
81    }
82    let head: String = value.chars().take(6).collect();
83    format!("{head}...")
84}
85
86async fn identity(t: &Transport) -> Result<(String, String)> {
87    let body = t.get_absolute_es("/_security/_authenticate").await?;
88    decode_identity(&body)
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct CredentialIdentity {
93    pub username: String,
94    pub realm_type: String,
95    pub api_key_id: Option<String>,
96}
97
98pub async fn credential_identity(t: &Transport) -> Result<CredentialIdentity> {
99    let body = t.get_absolute_es("/_security/_authenticate").await?;
100    let (username, realm_type) = decode_identity(&body)?;
101    let api_key_id = match body.get("api_key") {
102        None | Some(Value::Null) => None,
103        Some(Value::Object(key)) => match key.get("id") {
104            None => None,
105            Some(id) => Some(
106                id.as_str()
107                    .filter(|s| !s.is_empty())
108                    .ok_or_else(|| identity_error("api_key.id", "must be a non-empty string"))?
109                    .to_string(),
110            ),
111        },
112        Some(_) => return Err(identity_error("api_key", "must be an object")),
113    };
114    Ok(CredentialIdentity {
115        username,
116        realm_type,
117        api_key_id,
118    })
119}
120
121fn decode_identity(body: &Value) -> Result<(String, String)> {
122    let username = body
123        .get("username")
124        .and_then(Value::as_str)
125        .filter(|value| !value.is_empty())
126        .ok_or_else(|| identity_error("username", "must be a non-empty string"))?
127        .to_string();
128    let realm = body
129        .get("authentication_realm")
130        .and_then(|realm| realm.get("type"))
131        .and_then(Value::as_str)
132        .filter(|value| !value.is_empty())
133        .ok_or_else(|| identity_error("authentication_realm.type", "must be a non-empty string"))?
134        .to_string();
135    Ok((username, realm))
136}
137
138fn identity_error(field: &str, detail: impl std::fmt::Display) -> Error {
139    Error::new(
140        ErrorKind::Http,
141        format!("decoding identity response field {field}: {detail}"),
142    )
143}
144
145async fn value_list_index_check(t: &Transport) -> DoctorCheck {
146    match crate::exceptions::value_lists_bootstrapped(t).await {
147        Ok(true) => check(
148            "value_list_index",
149            Status::Ok,
150            "value-list data streams are bootstrapped",
151        ),
152        Ok(false) => check(
153            "value_list_index",
154            Status::Warn,
155            "value-list data streams are not bootstrapped; an exception entry of type \
156             'list' cannot work until elasticctl value-lists init runs \
157             POST /api/security_solution/initialize with the create-list-indices flow",
158        ),
159        Err(e) => check("value_list_index", Status::Fail, e.message),
160    }
161}
162
163pub async fn doctor(t: &Transport) -> Result<DoctorReport> {
164    let mut checks = Vec::new();
165
166    let caps = Capabilities::probe(t, t.kibana_url()).await;
167    match &caps {
168        Ok(_) => checks.push(check("connectivity", Status::Ok, t.kibana_url())),
169        Err(e) => checks.push(check("connectivity", Status::Fail, e.message.clone())),
170    }
171
172    if let Ok(c) = &caps {
173        checks.push(check(
174            "flavor",
175            Status::Ok,
176            format!("{} {}", c.flavor.as_str(), c.version),
177        ));
178
179        match identity(t).await {
180            Ok((username, realm)) => {
181                checks.push(check(
182                    "auth",
183                    Status::Ok,
184                    format!("{} via {realm}", short_identity(&username)),
185                ));
186                checks.push(key_scope_check(&realm));
187            }
188            Err(e) => checks.push(check("auth", Status::Fail, e.message)),
189        }
190
191        match rules::find_page(t, &RuleFilter::default(), 1, 1).await {
192            Ok((_, total)) => checks.push(check(
193                "rules_access",
194                Status::Ok,
195                format!("{total} rules visible"),
196            )),
197            Err(e) => checks.push(check("rules_access", Status::Fail, e.message)),
198        }
199
200        checks.push(value_list_index_check(t).await);
201    }
202
203    Ok(DoctorReport::from_checks(checks))
204}
205
206pub async fn info(t: &Transport) -> Result<InfoReport> {
207    let caps = Capabilities::probe(t, t.kibana_url()).await?;
208    let spaces = probe_spaces(t).await;
209    let license = probe_license_tier(t, caps.flavor).await;
210
211    Ok(InfoReport {
212        version: caps.version,
213        flavor: caps.flavor.as_str().to_string(),
214        license,
215        spaces,
216    })
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn status_serializes_to_the_rendered_lowercase_strings() {
225        assert_eq!(
226            serde_json::to_value(Status::Ok).unwrap(),
227            serde_json::json!("ok")
228        );
229        assert_eq!(
230            serde_json::to_value(Status::Warn).unwrap(),
231            serde_json::json!("warn")
232        );
233        assert_eq!(
234            serde_json::to_value(Status::Fail).unwrap(),
235            serde_json::json!("fail")
236        );
237    }
238
239    #[test]
240    fn key_scope_check_reports_ok_for_a_project_scoped_es_api_key() {
241        let c = key_scope_check("_es_api_key");
242        assert_eq!(c.status, Status::Ok);
243        assert!(c.detail.contains("project-scoped"));
244    }
245
246    #[test]
247    fn key_scope_check_warns_for_an_organization_cloud_api_key() {
248        let c = key_scope_check("_cloud_api_key");
249        assert_eq!(c.status, Status::Warn);
250        assert_eq!(
251            c.detail,
252            "Organization-level API key: not supported. MCP planning refuses it, and rule changes may fail on some deployments. Create a project- or deployment-scoped Elasticsearch API key in Kibana under Management > API keys."
253        );
254    }
255
256    #[test]
257    fn key_scope_check_names_an_unrecognized_realm_rather_than_calling_it_an_api_key() {
258        let c = key_scope_check("native");
259        assert_eq!(c.status, Status::Ok);
260        assert!(
261            c.detail.contains("native"),
262            "message must name the realm: {}",
263            c.detail
264        );
265        assert!(
266            !c.detail.contains("project-scoped Elasticsearch API key"),
267            "must not claim a non-API-key realm is a project-scoped API key: {}",
268            c.detail
269        );
270    }
271
272    #[test]
273    fn key_scope_check_names_an_unclassifiable_realm() {
274        let c = key_scope_check("unknown");
275        assert_eq!(c.status, Status::Ok);
276        assert!(c.detail.contains("unknown"));
277    }
278
279    #[test]
280    fn a_key_id_is_truncated_in_the_auth_check() {
281        let full = "2XTe9p8BLjNicQlhfc9W";
282        let short = short_identity(full);
283        assert_eq!(short, "2XTe9p...");
284        assert!(
285            !full.starts_with(&short),
286            "sanity: the id must be shortened"
287        );
288    }
289
290    #[test]
291    fn a_human_username_is_left_readable() {
292        assert_eq!(short_identity("elastic"), "elastic");
293        assert_eq!(short_identity("admin"), "admin");
294        assert_eq!(short_identity("unknown"), "unknown");
295    }
296
297    #[test]
298    fn truncation_never_splits_a_multibyte_character() {
299        let s = short_identity("ααααααααααααααααα");
300        assert!(s.ends_with("..."));
301        assert_eq!(s.chars().count(), 9);
302    }
303
304    #[test]
305    fn doctor_check_serializes_to_the_rendered_key_names() {
306        let c = check("config", Status::Ok, "profile 'default'");
307        let v = serde_json::to_value(&c).unwrap();
308        assert_eq!(v["check"], "config");
309        assert_eq!(v["status"], "ok");
310        assert_eq!(v["message"], "profile 'default'");
311        assert_eq!(
312            v.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>()),
313            Some(vec![
314                "check".to_string(),
315                "status".to_string(),
316                "message".to_string()
317            ]),
318            "key order is the rendered JSON order"
319        );
320    }
321
322    #[test]
323    fn from_checks_derives_ok_from_the_checks() {
324        assert!(
325            DoctorReport::from_checks(vec![
326                check("connectivity", Status::Ok, "ok"),
327                check("key_scope", Status::Warn, "caution"),
328            ])
329            .ok,
330            "a warning must not fail the report"
331        );
332        assert!(!DoctorReport::from_checks(vec![check("config", Status::Fail, "fail")]).ok);
333    }
334}