elasticctl_core/
capabilities.rs1use crate::error::{Error, ErrorKind, Result};
6use crate::transport::Transport;
7use semver::Version;
8use serde_json::Value;
9
10const ECH_SUFFIXES: [&str; 4] = [
15 "elastic-cloud.com",
16 "found.io",
17 "cloud.es.io",
18 "elastic.cloud",
19];
20
21const CLOUD_EDGE_HEADER: &str = "x-found-handling-cluster";
24
25fn host_of(url: &str) -> &str {
30 let after_scheme = match url.find("://") {
33 Some(i) => &url[i + 3..],
34 None => url,
35 };
36 let host = after_scheme.split('/').next().unwrap_or("");
37 host.split(':').next().unwrap_or(host)
38}
39
40fn host_matches(host: &str, suffix: &str) -> bool {
44 host == suffix || host.ends_with(&format!(".{suffix}"))
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Flavor {
49 SelfManaged,
50 ElasticCloudHosted,
51 Serverless,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Feature {
58 ExceptionLists,
59 PrebuiltRules,
60 RuleSourceScoping,
61}
62
63impl Feature {
64 fn label(self) -> &'static str {
65 match self {
66 Self::ExceptionLists => "exception lists",
67 Self::PrebuiltRules => "prebuilt rules",
68 Self::RuleSourceScoping => "rule source scoping",
69 }
70 }
71}
72
73impl Flavor {
74 pub fn as_str(&self) -> &'static str {
75 match self {
76 Self::SelfManaged => "self-managed",
77 Self::ElasticCloudHosted => "elastic-cloud-hosted",
78 Self::Serverless => "serverless",
79 }
80 }
81}
82
83#[derive(Debug, Clone)]
84pub struct Capabilities {
85 pub flavor: Flavor,
86 pub version: String,
87}
88
89fn numeric_version(version: &str) -> Option<Version> {
95 let numeric = version
96 .trim_start_matches(&['v', 'V'][..])
97 .split(&['-', '+'][..])
98 .next()
99 .unwrap_or_default();
100 Version::parse(numeric).ok()
101}
102
103impl Capabilities {
104 pub async fn probe(t: &Transport, kibana_url: &str) -> Result<Capabilities> {
105 let responded = t.get_with_headers("/api/status").await?;
106 Ok(Self::classify(
107 &responded.body,
108 responded.header(CLOUD_EDGE_HEADER).is_some(),
109 kibana_url,
110 ))
111 }
112
113 pub fn classify(status: &Value, cloud_edge: bool, kibana_url: &str) -> Capabilities {
122 let version = status["version"]["number"]
123 .as_str()
124 .unwrap_or("unknown")
125 .to_string();
126 let build_flavor = status["version"]["build_flavor"]
127 .as_str()
128 .unwrap_or("default");
129
130 let host = host_of(kibana_url)
132 .trim_end_matches('.')
133 .to_ascii_lowercase();
134 let cloud = cloud_edge
135 || ECH_SUFFIXES
136 .iter()
137 .any(|suffix| host_matches(&host, suffix));
138
139 let flavor = if build_flavor == "serverless" {
140 Flavor::Serverless
141 } else if cloud {
142 Flavor::ElasticCloudHosted
143 } else {
144 Flavor::SelfManaged
145 };
146
147 Capabilities { flavor, version }
148 }
149
150 pub fn require(&self, feature: &str, supported: bool) -> Result<()> {
153 if supported {
154 return Ok(());
155 }
156 Err(Error::new(
157 ErrorKind::Unsupported,
158 format!(
159 "{feature} is not available on {} deployments",
160 self.flavor.as_str()
161 ),
162 ))
163 }
164
165 pub fn require_feature(&self, feature: Feature) -> Result<()> {
168 let floor = Version::new(9, 5, 1);
169 let supported = numeric_version(&self.version).is_some_and(|version| version >= floor);
170 if supported {
171 return Ok(());
172 }
173 Err(Error::new(
174 ErrorKind::Unsupported,
175 format!(
176 "{} is not verified on {} {}; elasticctl requires Kibana {} or newer for this feature",
177 feature.label(),
178 self.flavor.as_str(),
179 self.version,
180 floor
181 ),
182 ))
183 }
184}
185
186pub async fn probe_spaces(t: &Transport) -> Option<Vec<String>> {
192 let body = t.get("/api/spaces/space").await.ok()?;
193 let spaces = body.as_array()?;
194 Some(
195 spaces
196 .iter()
197 .filter_map(|s| s.get("id")?.as_str().map(str::to_owned))
198 .collect(),
199 )
200}
201
202pub async fn probe_license_tier(t: &Transport, flavor: Flavor) -> Option<String> {
207 if flavor == Flavor::Serverless {
208 return None;
209 }
210 let body = t.get_absolute_es("/_license").await.ok()?;
211 body["license"]["type"].as_str().map(str::to_owned)
212}