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