Skip to main content

elasticctl_api/
health.rs

1//! Health orchestration: `doctor` and `info`.
2//!
3//! `doctor` reads the stack and reports every check it can, so a broken
4//! configuration surfaces as a failed check rather than an error envelope.
5//! The checks that read the operator's local configuration live in `-cli`,
6//! which has the `Context`; these functions take a `&Transport` and report
7//! only what the stack says.
8
9use crate::rules::{self, RuleFilter};
10use elasticctl_core::capabilities::{probe_license_tier, probe_spaces};
11use elasticctl_core::{Capabilities, Error, ErrorKind, Result, Transport};
12use serde::Serialize;
13use serde_json::Value;
14
15/// The outcome of one `doctor` check.
16///
17/// Serialized lowercase (`ok`, `warn`, `fail`). `warn` passes the report but
18/// carries a caution, so it is distinct from `ok` even though both leave
19/// [`DoctorReport::ok`] true.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Status {
23    Ok,
24    Warn,
25    Fail,
26}
27
28/// One `doctor` check.
29///
30/// Field order is the serialized JSON key order and is contractual: the root
31/// `Cargo.toml` enables `serde_json`'s `preserve_order`, so reordering these
32/// fields would silently change rendered output.
33#[derive(Debug, Clone, PartialEq, Serialize)]
34pub struct DoctorCheck {
35    #[serde(rename = "check")]
36    pub name: String,
37    #[serde(rename = "status")]
38    pub status: Status,
39    #[serde(rename = "message")]
40    pub detail: String,
41}
42
43/// The report `doctor` renders.
44#[derive(Debug, Clone, PartialEq, Serialize)]
45pub struct DoctorReport {
46    pub checks: Vec<DoctorCheck>,
47    pub ok: bool,
48}
49
50impl DoctorReport {
51    /// Build a report from its checks, deriving `ok` from them.
52    ///
53    /// This is the single place `ok` is derived, so a check with a misspelled
54    /// status cannot silently report a broken stack as healthy.
55    pub fn from_checks(checks: Vec<DoctorCheck>) -> DoctorReport {
56        let ok = checks.iter().all(|c| c.status != Status::Fail);
57        DoctorReport { checks, ok }
58    }
59}
60
61/// The stack-derived half of `info`'s report. The caller prepends the
62/// profile fields (`elasticctl_version`, `profile`, `kibana_url`, `space`)
63/// it alone can supply.
64#[derive(Debug, Clone, PartialEq)]
65pub struct InfoReport {
66    pub version: String,
67    pub flavor: String,
68    pub license: Option<String>,
69    pub spaces: Option<Vec<String>>,
70}
71
72/// Construct a check. Public so `-cli` builds its configuration checks with
73/// the same shape as the stack checks.
74pub fn check(name: &str, status: Status, message: impl Into<String>) -> DoctorCheck {
75    DoctorCheck {
76        name: name.into(),
77        status,
78        detail: message.into(),
79    }
80}
81
82/// `_es_api_key` is scoped; `_cloud_api_key` is an unsupported organization
83/// key. Other realms are named without claiming they are API keys.
84fn key_scope_check(realm: &str) -> DoctorCheck {
85    match realm {
86        "_es_api_key" => check(
87            "key_scope",
88            Status::Ok,
89            "project-scoped Elasticsearch API key",
90        ),
91        "_cloud_api_key" => check(
92            "key_scope",
93            Status::Warn,
94            "Organization-level API key: not supported. MCP planning refuses it, and \
95             rule changes may fail on some deployments. Create a project- or \
96             deployment-scoped Elasticsearch API key in Kibana under Management > API keys.",
97        ),
98        other => check(
99            "key_scope",
100            Status::Ok,
101            format!("authenticated via the '{other}' realm, not an Elasticsearch API key"),
102        ),
103    }
104}
105
106/// Identities longer than this are truncated in output.
107///
108/// API keys authenticate as their key IDs. Truncate those IDs because
109/// `config show` redacts them, while short usernames remain readable.
110const MAX_IDENTITY_CHARS: usize = 12;
111
112fn short_identity(value: &str) -> String {
113    if value.chars().count() <= MAX_IDENTITY_CHARS {
114        return value.to_string();
115    }
116    // By characters, not bytes: a byte slice can split a multibyte character
117    // and panic.
118    let head: String = value.chars().take(6).collect();
119    format!("{head}...")
120}
121
122/// Reads the username and authentication realm from Elasticsearch.
123async fn identity(t: &Transport) -> Result<(String, String)> {
124    let body = t.get_absolute_es("/_security/_authenticate").await?;
125    decode_identity(&body)
126}
127
128/// The authenticated username, realm, and optional API key identifier.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct CredentialIdentity {
131    /// The authenticated username.
132    pub username: String,
133    /// The authentication realm type.
134    pub realm_type: String,
135    /// The API key ID, when supplied.
136    pub api_key_id: Option<String>,
137}
138
139/// Read the credential identity from Elasticsearch.
140pub async fn credential_identity(t: &Transport) -> Result<CredentialIdentity> {
141    let body = t.get_absolute_es("/_security/_authenticate").await?;
142    let (username, realm_type) = decode_identity(&body)?;
143    let api_key_id = match body.get("api_key") {
144        None | Some(Value::Null) => None,
145        Some(Value::Object(key)) => match key.get("id") {
146            None => None,
147            Some(id) => Some(
148                id.as_str()
149                    .filter(|s| !s.is_empty())
150                    .ok_or_else(|| identity_error("api_key.id", "must be a non-empty string"))?
151                    .to_string(),
152            ),
153        },
154        Some(_) => return Err(identity_error("api_key", "must be an object")),
155    };
156    Ok(CredentialIdentity {
157        username,
158        realm_type,
159        api_key_id,
160    })
161}
162
163/// Decode an `_authenticate` response, refusing a malformed success body.
164///
165/// A missing or mistyped `username` or `authentication_realm.type` must fail
166/// the auth check rather than read as an "unknown" realm.
167fn decode_identity(body: &Value) -> Result<(String, String)> {
168    let username = body
169        .get("username")
170        .and_then(Value::as_str)
171        .filter(|value| !value.is_empty())
172        .ok_or_else(|| identity_error("username", "must be a non-empty string"))?
173        .to_string();
174    let realm = body
175        .get("authentication_realm")
176        .and_then(|realm| realm.get("type"))
177        .and_then(Value::as_str)
178        .filter(|value| !value.is_empty())
179        .ok_or_else(|| identity_error("authentication_realm.type", "must be a non-empty string"))?
180        .to_string();
181    Ok((username, realm))
182}
183
184fn identity_error(field: &str, detail: impl std::fmt::Display) -> Error {
185    Error::new(
186        ErrorKind::Http,
187        format!("decoding identity response field {field}: {detail}"),
188    )
189}
190
191/// The value-list data streams (`/api/lists/index`) bootstrapping check.
192///
193/// A 404 is the absent case, not an error (spec 7.7): the route answering 404
194/// is how it says the data streams do not exist, and an exception entry of
195/// type `list` cannot work until they are created. Absence is a warning, not a
196/// failure — a stack with no value-list-backed exceptions never needs them.
197async fn value_list_index_check(t: &Transport) -> DoctorCheck {
198    match crate::exceptions::value_lists_bootstrapped(t).await {
199        Ok(true) => check(
200            "value_list_index",
201            Status::Ok,
202            "value-list data streams are bootstrapped",
203        ),
204        Ok(false) => check(
205            "value_list_index",
206            Status::Warn,
207            "value-list data streams are not bootstrapped; an exception entry of type \
208             'list' cannot work until POST /api/lists/index runs",
209        ),
210        Err(e) => check("value_list_index", Status::Fail, e.message),
211    }
212}
213
214/// Run the stack-reading checks.
215///
216/// The connectivity check gates the rest: without a capability probe there is
217/// no flavor, no realm, and no rule access to report. The realm identifies
218/// unsupported organization keys, which may fail for rule changes (spec 7.2).
219pub async fn doctor(t: &Transport) -> Result<DoctorReport> {
220    let mut checks = Vec::new();
221
222    let caps = Capabilities::probe(t, t.kibana_url()).await;
223    match &caps {
224        Ok(_) => checks.push(check("connectivity", Status::Ok, t.kibana_url())),
225        Err(e) => checks.push(check("connectivity", Status::Fail, e.message.clone())),
226    }
227
228    if let Ok(c) = &caps {
229        checks.push(check(
230            "flavor",
231            Status::Ok,
232            format!("{} {}", c.flavor.as_str(), c.version),
233        ));
234
235        match identity(t).await {
236            Ok((username, realm)) => {
237                checks.push(check(
238                    "auth",
239                    Status::Ok,
240                    format!("{} via {realm}", short_identity(&username)),
241                ));
242                checks.push(key_scope_check(&realm));
243            }
244            Err(e) => checks.push(check("auth", Status::Fail, e.message)),
245        }
246
247        match rules::find_page(t, &RuleFilter::default(), 1, 1).await {
248            Ok((_, total)) => checks.push(check(
249                "rules_access",
250                Status::Ok,
251                format!("{total} rules visible"),
252            )),
253            Err(e) => checks.push(check("rules_access", Status::Fail, e.message)),
254        }
255
256        checks.push(value_list_index_check(t).await);
257    }
258
259    Ok(DoctorReport::from_checks(checks))
260}
261
262/// Probe the stack values only `info` reports.
263///
264/// Spaces and license tier each cost a request, so they are not part of the
265/// capability probe every command pays for. `None` means the value could not
266/// be determined; Serverless has no license tier.
267pub async fn info(t: &Transport) -> Result<InfoReport> {
268    let caps = Capabilities::probe(t, t.kibana_url()).await?;
269    let spaces = probe_spaces(t).await;
270    let license = probe_license_tier(t, caps.flavor).await;
271
272    Ok(InfoReport {
273        version: caps.version,
274        flavor: caps.flavor.as_str().to_string(),
275        license,
276        spaces,
277    })
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn status_serializes_to_the_rendered_lowercase_strings() {
286        assert_eq!(
287            serde_json::to_value(Status::Ok).unwrap(),
288            serde_json::json!("ok")
289        );
290        assert_eq!(
291            serde_json::to_value(Status::Warn).unwrap(),
292            serde_json::json!("warn")
293        );
294        assert_eq!(
295            serde_json::to_value(Status::Fail).unwrap(),
296            serde_json::json!("fail")
297        );
298    }
299
300    #[test]
301    fn key_scope_check_reports_ok_for_a_project_scoped_es_api_key() {
302        let c = key_scope_check("_es_api_key");
303        assert_eq!(c.status, Status::Ok);
304        assert!(c.detail.contains("project-scoped"));
305    }
306
307    #[test]
308    fn key_scope_check_warns_for_an_organization_cloud_api_key() {
309        let c = key_scope_check("_cloud_api_key");
310        assert_eq!(c.status, Status::Warn);
311        assert_eq!(
312            c.detail,
313            "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."
314        );
315    }
316
317    #[test]
318    fn key_scope_check_names_an_unrecognized_realm_rather_than_calling_it_an_api_key() {
319        let c = key_scope_check("native");
320        assert_eq!(c.status, Status::Ok);
321        assert!(
322            c.detail.contains("native"),
323            "message must name the realm: {}",
324            c.detail
325        );
326        assert!(
327            !c.detail.contains("project-scoped Elasticsearch API key"),
328            "must not claim a non-API-key realm is a project-scoped API key: {}",
329            c.detail
330        );
331    }
332
333    #[test]
334    fn key_scope_check_names_an_unclassifiable_realm() {
335        // A realm string that is neither API-key type is reported by name, not
336        // claimed as an API key.
337        let c = key_scope_check("unknown");
338        assert_eq!(c.status, Status::Ok);
339        assert!(c.detail.contains("unknown"));
340    }
341
342    #[test]
343    fn a_key_id_is_truncated_in_the_auth_check() {
344        // An API key authenticates as its key ID. `config show` redacts that
345        // identifier, so do not write it in full to stdout.
346        let full = "2XTe9p8BLjNicQlhfc9W";
347        let short = short_identity(full);
348        assert_eq!(short, "2XTe9p...");
349        assert!(
350            !full.starts_with(&short),
351            "sanity: the id must be shortened"
352        );
353    }
354
355    #[test]
356    fn a_human_username_is_left_readable() {
357        // Keep common usernames readable.
358        assert_eq!(short_identity("elastic"), "elastic");
359        assert_eq!(short_identity("admin"), "admin");
360        assert_eq!(short_identity("unknown"), "unknown");
361    }
362
363    #[test]
364    fn truncation_never_splits_a_multibyte_character() {
365        let s = short_identity("ααααααααααααααααα");
366        assert!(s.ends_with("..."));
367        assert_eq!(s.chars().count(), 9);
368    }
369
370    #[test]
371    fn doctor_check_serializes_to_the_rendered_key_names() {
372        let c = check("config", Status::Ok, "profile 'default'");
373        let v = serde_json::to_value(&c).unwrap();
374        assert_eq!(v["check"], "config");
375        assert_eq!(v["status"], "ok");
376        assert_eq!(v["message"], "profile 'default'");
377        assert_eq!(
378            v.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>()),
379            Some(vec![
380                "check".to_string(),
381                "status".to_string(),
382                "message".to_string()
383            ]),
384            "key order is the rendered JSON order"
385        );
386    }
387
388    #[test]
389    fn from_checks_derives_ok_from_the_checks() {
390        assert!(
391            DoctorReport::from_checks(vec![
392                check("connectivity", Status::Ok, "ok"),
393                check("key_scope", Status::Warn, "caution"),
394            ])
395            .ok,
396            "a warning must not fail the report"
397        );
398        assert!(!DoctorReport::from_checks(vec![check("config", Status::Fail, "fail")]).ok);
399    }
400}