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.rfind("://") {
31 Some(i) => &url[i + 3..],
32 None => url,
33 };
34 let host = after_scheme.split('/').next().unwrap_or("");
35 host.split(':').next().unwrap_or(host)
36}
37
38fn host_matches(host: &str, suffix: &str) -> bool {
42 host == suffix || host.ends_with(&format!(".{suffix}"))
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Flavor {
47 SelfManaged,
48 ElasticCloudHosted,
49 Serverless,
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum Feature {
56 ExceptionLists,
57 PrebuiltRules,
58 RuleSourceScoping,
59}
60
61impl Feature {
62 fn label(self) -> &'static str {
63 match self {
64 Self::ExceptionLists => "exception lists",
65 Self::PrebuiltRules => "prebuilt rules",
66 Self::RuleSourceScoping => "rule source scoping",
67 }
68 }
69}
70
71impl Flavor {
72 pub fn as_str(&self) -> &'static str {
73 match self {
74 Self::SelfManaged => "self-managed",
75 Self::ElasticCloudHosted => "elastic-cloud-hosted",
76 Self::Serverless => "serverless",
77 }
78 }
79}
80
81#[derive(Debug, Clone)]
82pub struct Capabilities {
83 pub flavor: Flavor,
84 pub version: String,
85}
86
87impl Capabilities {
88 pub async fn probe(t: &Transport, kibana_url: &str) -> Result<Capabilities> {
89 let responded = t.get_with_headers("/api/status").await?;
90 Ok(Self::classify(
91 &responded.body,
92 responded.header(CLOUD_EDGE_HEADER).is_some(),
93 kibana_url,
94 ))
95 }
96
97 pub fn classify(status: &Value, cloud_edge: bool, kibana_url: &str) -> Capabilities {
106 let version = status["version"]["number"]
107 .as_str()
108 .unwrap_or("unknown")
109 .to_string();
110 let build_flavor = status["version"]["build_flavor"]
111 .as_str()
112 .unwrap_or("default");
113
114 let host = host_of(kibana_url)
116 .trim_end_matches('.')
117 .to_ascii_lowercase();
118 let cloud = cloud_edge
119 || ECH_SUFFIXES
120 .iter()
121 .any(|suffix| host_matches(&host, suffix));
122
123 let flavor = if build_flavor == "serverless" {
124 Flavor::Serverless
125 } else if cloud {
126 Flavor::ElasticCloudHosted
127 } else {
128 Flavor::SelfManaged
129 };
130
131 Capabilities { flavor, version }
132 }
133
134 pub fn require(&self, feature: &str, supported: bool) -> Result<()> {
137 if supported {
138 return Ok(());
139 }
140 Err(Error::new(
141 ErrorKind::Unsupported,
142 format!(
143 "{feature} is not available on {} deployments",
144 self.flavor.as_str()
145 ),
146 ))
147 }
148
149 pub fn require_feature(&self, feature: Feature) -> Result<()> {
152 let floor = Version::new(9, 5, 1);
153 let supported = Version::parse(&self.version).is_ok_and(|version| version >= floor);
154 if supported {
155 return Ok(());
156 }
157 Err(Error::new(
158 ErrorKind::Unsupported,
159 format!(
160 "{} is not verified on {} {}; elasticctl requires Kibana {} or newer for this feature",
161 feature.label(),
162 self.flavor.as_str(),
163 self.version,
164 floor
165 ),
166 ))
167 }
168}
169
170pub async fn probe_spaces(t: &Transport) -> Option<Vec<String>> {
176 let body = t.get("/api/spaces/space").await.ok()?;
177 let spaces = body.as_array()?;
178 Some(
179 spaces
180 .iter()
181 .filter_map(|s| s.get("id")?.as_str().map(str::to_owned))
182 .collect(),
183 )
184}
185
186pub async fn probe_license_tier(t: &Transport, flavor: Flavor) -> Option<String> {
191 if flavor == Flavor::Serverless {
192 return None;
193 }
194 let body = t.get_absolute_es("/_license").await.ok()?;
195 body["license"]["type"].as_str().map(str::to_owned)
196}