elasticctl_core/
capabilities.rs1use crate::error::{Error, ErrorKind, Result};
6use crate::transport::Transport;
7use serde_json::Value;
8
9const ECH_SUFFIXES: [&str; 4] = [
14 "elastic-cloud.com",
15 "found.io",
16 "cloud.es.io",
17 "elastic.cloud",
18];
19
20const CLOUD_EDGE_HEADER: &str = "x-found-handling-cluster";
23
24fn host_of(url: &str) -> &str {
29 let after_scheme = match url.rfind("://") {
30 Some(i) => &url[i + 3..],
31 None => url,
32 };
33 let host = after_scheme.split('/').next().unwrap_or("");
34 host.split(':').next().unwrap_or(host)
35}
36
37fn host_matches(host: &str, suffix: &str) -> bool {
41 host == suffix || host.ends_with(&format!(".{suffix}"))
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Flavor {
46 SelfManaged,
47 ElasticCloudHosted,
48 Serverless,
49}
50
51impl Flavor {
52 pub fn as_str(&self) -> &'static str {
53 match self {
54 Self::SelfManaged => "self-managed",
55 Self::ElasticCloudHosted => "elastic-cloud-hosted",
56 Self::Serverless => "serverless",
57 }
58 }
59}
60
61#[derive(Debug, Clone)]
62pub struct Capabilities {
63 pub flavor: Flavor,
64 pub version: String,
65}
66
67impl Capabilities {
68 pub async fn probe(t: &Transport, kibana_url: &str) -> Result<Capabilities> {
69 let responded = t.get_with_headers("/api/status").await?;
70 Ok(Self::classify(
71 &responded.body,
72 responded.header(CLOUD_EDGE_HEADER).is_some(),
73 kibana_url,
74 ))
75 }
76
77 pub fn classify(status: &Value, cloud_edge: bool, kibana_url: &str) -> Capabilities {
86 let version = status["version"]["number"]
87 .as_str()
88 .unwrap_or("unknown")
89 .to_string();
90 let build_flavor = status["version"]["build_flavor"]
91 .as_str()
92 .unwrap_or("default");
93
94 let cloud = cloud_edge
96 || ECH_SUFFIXES
97 .iter()
98 .any(|s| host_matches(host_of(kibana_url), s));
99
100 let flavor = if build_flavor == "serverless" {
101 Flavor::Serverless
102 } else if cloud {
103 Flavor::ElasticCloudHosted
104 } else {
105 Flavor::SelfManaged
106 };
107
108 Capabilities { flavor, version }
109 }
110
111 pub fn require(&self, feature: &str, supported: bool) -> Result<()> {
114 if supported {
115 return Ok(());
116 }
117 Err(Error::new(
118 ErrorKind::Unsupported,
119 format!(
120 "{feature} is not available on {} deployments",
121 self.flavor.as_str()
122 ),
123 ))
124 }
125}
126
127pub async fn probe_spaces(t: &Transport) -> Option<Vec<String>> {
133 let body = t.get("/api/spaces/space").await.ok()?;
134 let spaces = body.as_array()?;
135 Some(
136 spaces
137 .iter()
138 .filter_map(|s| s.get("id")?.as_str().map(str::to_owned))
139 .collect(),
140 )
141}
142
143pub async fn probe_license_tier(t: &Transport, flavor: Flavor) -> Option<String> {
148 if flavor == Flavor::Serverless {
149 return None;
150 }
151 let body = t.get_absolute_es("/_license").await.ok()?;
152 body["license"]["type"].as_str().map(str::to_owned)
153}