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