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 crate::config::scheme_anchor(url) {
33 Some(pos) => &url[pos..],
34 None => url,
35 };
36 after_scheme
38 .split(['/', '?', '#', ':'])
39 .next()
40 .unwrap_or("")
41}
42
43fn host_matches(host: &str, suffix: &str) -> bool {
47 host == suffix || host.ends_with(&format!(".{suffix}"))
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum Flavor {
52 SelfManaged,
53 ElasticCloudHosted,
54 Serverless,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum Feature {
61 Dashboards,
62 ExceptionLists,
63 PrebuiltRules,
64 RuleSourceScoping,
65}
66
67impl Feature {
68 fn label(self) -> &'static str {
69 match self {
70 Self::Dashboards => "dashboards",
71 Self::ExceptionLists => "exception lists",
72 Self::PrebuiltRules => "prebuilt rules",
73 Self::RuleSourceScoping => "rule source scoping",
74 }
75 }
76}
77
78impl Flavor {
79 pub fn as_str(&self) -> &'static str {
80 match self {
81 Self::SelfManaged => "self-managed",
82 Self::ElasticCloudHosted => "elastic-cloud-hosted",
83 Self::Serverless => "serverless",
84 }
85 }
86}
87
88#[derive(Debug, Clone)]
89pub struct Capabilities {
90 pub flavor: Flavor,
91 pub version: String,
92}
93
94fn numeric_version(version: &str) -> Option<Version> {
100 let numeric = version
101 .trim_start_matches(&['v', 'V'][..])
102 .split(&['-', '+'][..])
103 .next()
104 .unwrap_or_default();
105 Version::parse(numeric).ok()
106}
107
108impl Capabilities {
109 pub async fn probe(t: &Transport, kibana_url: &str) -> Result<Capabilities> {
110 let responded = t.get_with_headers("/api/status").await?;
111 Ok(Self::classify(
112 &responded.body,
113 responded.header(CLOUD_EDGE_HEADER).is_some(),
114 kibana_url,
115 ))
116 }
117
118 pub fn classify(status: &Value, cloud_edge: bool, kibana_url: &str) -> Capabilities {
127 let version = status["version"]["number"]
128 .as_str()
129 .unwrap_or("unknown")
130 .to_string();
131 let build_flavor = status["version"]["build_flavor"]
132 .as_str()
133 .unwrap_or("default");
134
135 let host = host_of(kibana_url)
137 .trim_end_matches('.')
138 .to_ascii_lowercase();
139 let cloud = cloud_edge
140 || ECH_SUFFIXES
141 .iter()
142 .any(|suffix| host_matches(&host, suffix));
143
144 let flavor = if build_flavor == "serverless" {
145 Flavor::Serverless
146 } else if cloud {
147 Flavor::ElasticCloudHosted
148 } else {
149 Flavor::SelfManaged
150 };
151
152 Capabilities { flavor, version }
153 }
154
155 pub fn require(&self, feature: &str, supported: bool) -> Result<()> {
158 if supported {
159 return Ok(());
160 }
161 Err(Error::new(
162 ErrorKind::Unsupported,
163 format!(
164 "{feature} is not available on {} deployments",
165 self.flavor.as_str()
166 ),
167 ))
168 }
169
170 pub fn require_feature(&self, feature: Feature) -> Result<()> {
173 let floor = Version::new(9, 5, 1);
174 let supported = numeric_version(&self.version).is_some_and(|version| version >= floor);
175 if supported {
176 return Ok(());
177 }
178 Err(Error::new(
179 ErrorKind::Unsupported,
180 format!(
181 "{} is not verified on {} {}; elasticctl requires Kibana {} or newer for this feature",
182 feature.label(),
183 self.flavor.as_str(),
184 self.version,
185 floor
186 ),
187 ))
188 }
189}
190
191pub async fn probe_spaces(t: &Transport) -> Option<Vec<String>> {
197 let body = t.get("/api/spaces/space").await.ok()?;
198 let spaces = body.as_array()?;
199 Some(
200 spaces
201 .iter()
202 .filter_map(|s| s.get("id")?.as_str().map(str::to_owned))
203 .collect(),
204 )
205}
206
207pub async fn probe_license_tier(t: &Transport, flavor: Flavor) -> Option<String> {
212 if flavor == Flavor::Serverless {
213 return None;
214 }
215 let body = t.get_absolute_es("/_license").await.ok()?;
216 body["license"]["type"].as_str().map(str::to_owned)
217}