1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Status {
23 Ok,
24 Warn,
25 Fail,
26}
27
28#[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#[derive(Debug, Clone, PartialEq, Serialize)]
45pub struct DoctorReport {
46 pub checks: Vec<DoctorCheck>,
47 pub ok: bool,
48}
49
50impl DoctorReport {
51 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#[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
72pub 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
82fn 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
106const 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 let head: String = value.chars().take(6).collect();
119 format!("{head}...")
120}
121
122async fn identity(t: &Transport) -> Result<(String, String)> {
124 let body = t.get_absolute_es("/_security/_authenticate").await?;
125 decode_identity(&body)
126}
127
128#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct CredentialIdentity {
131 pub username: String,
133 pub realm_type: String,
135 pub api_key_id: Option<String>,
137}
138
139pub 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
163fn 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
191async 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
214pub 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
262pub 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 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 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 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}