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 {
86 match realm {
87 "_es_api_key" => check(
88 "key_scope",
89 Status::Ok,
90 "project-scoped Elasticsearch API key",
91 ),
92 "_cloud_api_key" => check(
93 "key_scope",
94 Status::Warn,
95 "Organization-level API key: reads and deletes work, but enabling a \
96 rule will fail. Create a project-scoped Elasticsearch API key in \
97 Kibana under Management > API keys.",
98 ),
99 other => check(
100 "key_scope",
101 Status::Ok,
102 format!("authenticated via the '{other}' realm, not an Elasticsearch API key"),
103 ),
104 }
105}
106
107const MAX_IDENTITY_CHARS: usize = 12;
112
113fn short_identity(value: &str) -> String {
114 if value.chars().count() <= MAX_IDENTITY_CHARS {
115 return value.to_string();
116 }
117 let head: String = value.chars().take(6).collect();
120 format!("{head}...")
121}
122
123async fn identity(t: &Transport) -> Result<(String, String)> {
125 let body = t.get_absolute_es("/_security/_authenticate").await?;
126 decode_identity(&body)
127}
128
129fn decode_identity(body: &Value) -> Result<(String, String)> {
134 let username = body
135 .get("username")
136 .and_then(Value::as_str)
137 .filter(|value| !value.is_empty())
138 .ok_or_else(|| identity_error("username", "must be a non-empty string"))?
139 .to_string();
140 let realm = body
141 .get("authentication_realm")
142 .and_then(|realm| realm.get("type"))
143 .and_then(Value::as_str)
144 .filter(|value| !value.is_empty())
145 .ok_or_else(|| identity_error("authentication_realm.type", "must be a non-empty string"))?
146 .to_string();
147 Ok((username, realm))
148}
149
150fn identity_error(field: &str, detail: impl std::fmt::Display) -> Error {
151 Error::new(
152 ErrorKind::Http,
153 format!("decoding identity response field {field}: {detail}"),
154 )
155}
156
157async fn value_list_index_check(t: &Transport) -> DoctorCheck {
164 match crate::exceptions::value_lists_bootstrapped(t).await {
165 Ok(true) => check(
166 "value_list_index",
167 Status::Ok,
168 "value-list data streams are bootstrapped",
169 ),
170 Ok(false) => check(
171 "value_list_index",
172 Status::Warn,
173 "value-list data streams are not bootstrapped; an exception entry of type \
174 'list' cannot work until POST /api/lists/index runs",
175 ),
176 Err(e) => check("value_list_index", Status::Fail, e.message),
177 }
178}
179
180pub async fn doctor(t: &Transport) -> Result<DoctorReport> {
188 let mut checks = Vec::new();
189
190 let caps = Capabilities::probe(t, t.kibana_url()).await;
191 match &caps {
192 Ok(_) => checks.push(check("connectivity", Status::Ok, t.kibana_url())),
193 Err(e) => checks.push(check("connectivity", Status::Fail, e.message.clone())),
194 }
195
196 if let Ok(c) = &caps {
197 checks.push(check(
198 "flavor",
199 Status::Ok,
200 format!("{} {}", c.flavor.as_str(), c.version),
201 ));
202
203 match identity(t).await {
204 Ok((username, realm)) => {
205 checks.push(check(
206 "auth",
207 Status::Ok,
208 format!("{} via {realm}", short_identity(&username)),
209 ));
210 checks.push(key_scope_check(&realm));
211 }
212 Err(e) => checks.push(check("auth", Status::Fail, e.message)),
213 }
214
215 match rules::find_page(t, &RuleFilter::default(), 1, 1).await {
216 Ok((_, total)) => checks.push(check(
217 "rules_access",
218 Status::Ok,
219 format!("{total} rules visible"),
220 )),
221 Err(e) => checks.push(check("rules_access", Status::Fail, e.message)),
222 }
223
224 checks.push(value_list_index_check(t).await);
225 }
226
227 Ok(DoctorReport::from_checks(checks))
228}
229
230pub async fn info(t: &Transport) -> Result<InfoReport> {
236 let caps = Capabilities::probe(t, t.kibana_url()).await?;
237 let spaces = probe_spaces(t).await;
238 let license = probe_license_tier(t, caps.flavor).await;
239
240 Ok(InfoReport {
241 version: caps.version,
242 flavor: caps.flavor.as_str().to_string(),
243 license,
244 spaces,
245 })
246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251
252 #[test]
253 fn status_serializes_to_the_rendered_lowercase_strings() {
254 assert_eq!(
255 serde_json::to_value(Status::Ok).unwrap(),
256 serde_json::json!("ok")
257 );
258 assert_eq!(
259 serde_json::to_value(Status::Warn).unwrap(),
260 serde_json::json!("warn")
261 );
262 assert_eq!(
263 serde_json::to_value(Status::Fail).unwrap(),
264 serde_json::json!("fail")
265 );
266 }
267
268 #[test]
269 fn key_scope_check_reports_ok_for_a_project_scoped_es_api_key() {
270 let c = key_scope_check("_es_api_key");
271 assert_eq!(c.status, Status::Ok);
272 assert!(c.detail.contains("project-scoped"));
273 }
274
275 #[test]
276 fn key_scope_check_warns_for_an_organization_cloud_api_key() {
277 let c = key_scope_check("_cloud_api_key");
278 assert_eq!(c.status, Status::Warn);
279 assert!(c.detail.contains("Organization-level"));
280 }
281
282 #[test]
283 fn key_scope_check_names_an_unrecognized_realm_rather_than_calling_it_an_api_key() {
284 let c = key_scope_check("native");
285 assert_eq!(c.status, Status::Ok);
286 assert!(
287 c.detail.contains("native"),
288 "message must name the realm: {}",
289 c.detail
290 );
291 assert!(
292 !c.detail.contains("project-scoped Elasticsearch API key"),
293 "must not claim a non-API-key realm is a project-scoped API key: {}",
294 c.detail
295 );
296 }
297
298 #[test]
299 fn key_scope_check_names_an_unclassifiable_realm() {
300 let c = key_scope_check("unknown");
303 assert_eq!(c.status, Status::Ok);
304 assert!(c.detail.contains("unknown"));
305 }
306
307 #[test]
308 fn a_key_id_is_truncated_in_the_auth_check() {
309 let full = "2XTe9p8BLjNicQlhfc9W";
312 let short = short_identity(full);
313 assert_eq!(short, "2XTe9p...");
314 assert!(
315 !full.starts_with(&short),
316 "sanity: the id must be shortened"
317 );
318 }
319
320 #[test]
321 fn a_human_username_is_left_readable() {
322 assert_eq!(short_identity("elastic"), "elastic");
324 assert_eq!(short_identity("admin"), "admin");
325 assert_eq!(short_identity("unknown"), "unknown");
326 }
327
328 #[test]
329 fn truncation_never_splits_a_multibyte_character() {
330 let s = short_identity("ααααααααααααααααα");
331 assert!(s.ends_with("..."));
332 assert_eq!(s.chars().count(), 9);
333 }
334
335 #[test]
336 fn doctor_check_serializes_to_the_rendered_key_names() {
337 let c = check("config", Status::Ok, "profile 'default'");
338 let v = serde_json::to_value(&c).unwrap();
339 assert_eq!(v["check"], "config");
340 assert_eq!(v["status"], "ok");
341 assert_eq!(v["message"], "profile 'default'");
342 assert_eq!(
343 v.as_object().map(|o| o.keys().cloned().collect::<Vec<_>>()),
344 Some(vec![
345 "check".to_string(),
346 "status".to_string(),
347 "message".to_string()
348 ]),
349 "key order is the rendered JSON order"
350 );
351 }
352
353 #[test]
354 fn from_checks_derives_ok_from_the_checks() {
355 assert!(
356 DoctorReport::from_checks(vec![
357 check("connectivity", Status::Ok, "ok"),
358 check("key_scope", Status::Warn, "caution"),
359 ])
360 .ok,
361 "a warning must not fail the report"
362 );
363 assert!(!DoctorReport::from_checks(vec![check("config", Status::Fail, "fail")]).ok);
364 }
365}