elasticctl_core/
capabilities.rs1use crate::error::{Error, ErrorKind, Result};
2use crate::transport::Transport;
3use semver::Version;
4use serde_json::Value;
5
6const ECH_SUFFIXES: [&str; 4] = [
7 "elastic-cloud.com",
8 "found.io",
9 "cloud.es.io",
10 "elastic.cloud",
11];
12
13const CLOUD_EDGE_HEADER: &str = "x-found-handling-cluster";
14
15fn host_of(url: &str) -> &str {
16 let after_scheme = match crate::config::scheme_anchor(url) {
17 Some(pos) => &url[pos..],
18 None => url,
19 };
20 after_scheme
21 .split(['/', '?', '#', ':'])
22 .next()
23 .unwrap_or("")
24}
25
26fn host_matches(host: &str, suffix: &str) -> bool {
27 host == suffix || host.ends_with(&format!(".{suffix}"))
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Flavor {
32 SelfManaged,
33 ElasticCloudHosted,
34 Serverless,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Feature {
39 Dashboards,
40 ExceptionLists,
41 FleetPolicies,
42 PrebuiltRules,
43 RuleSourceScoping,
44}
45
46impl Feature {
47 fn label(self) -> &'static str {
48 match self {
49 Self::Dashboards => "dashboards",
50 Self::ExceptionLists => "exception lists",
51 Self::FleetPolicies => "fleet policies",
52 Self::PrebuiltRules => "prebuilt rules",
53 Self::RuleSourceScoping => "rule source scoping",
54 }
55 }
56}
57
58impl Flavor {
59 pub fn as_str(&self) -> &'static str {
60 match self {
61 Self::SelfManaged => "self-managed",
62 Self::ElasticCloudHosted => "elastic-cloud-hosted",
63 Self::Serverless => "serverless",
64 }
65 }
66}
67
68#[derive(Debug, Clone)]
69pub struct Capabilities {
70 pub flavor: Flavor,
71 pub version: String,
72}
73
74fn numeric_version(version: &str) -> Option<Version> {
75 let numeric = version
76 .trim_start_matches(&['v', 'V'][..])
77 .split(&['-', '+'][..])
78 .next()
79 .unwrap_or_default();
80 Version::parse(numeric).ok()
81}
82
83impl Capabilities {
84 pub async fn probe(t: &Transport, kibana_url: &str) -> Result<Capabilities> {
85 let responded = t.get_with_headers("/api/status").await?;
86 Ok(Self::classify(
87 &responded.body,
88 responded.header(CLOUD_EDGE_HEADER).is_some(),
89 kibana_url,
90 ))
91 }
92
93 pub fn classify(status: &Value, cloud_edge: bool, kibana_url: &str) -> Capabilities {
94 let version = status["version"]["number"]
95 .as_str()
96 .unwrap_or("unknown")
97 .to_string();
98 let build_flavor = status["version"]["build_flavor"]
99 .as_str()
100 .unwrap_or("default");
101
102 let host = host_of(kibana_url)
103 .trim_end_matches('.')
104 .to_ascii_lowercase();
105 let cloud = cloud_edge
106 || ECH_SUFFIXES
107 .iter()
108 .any(|suffix| host_matches(&host, suffix));
109
110 let flavor = if build_flavor == "serverless" {
111 Flavor::Serverless
112 } else if cloud {
113 Flavor::ElasticCloudHosted
114 } else {
115 Flavor::SelfManaged
116 };
117
118 Capabilities { flavor, version }
119 }
120
121 pub fn require(&self, feature: &str, supported: bool) -> Result<()> {
122 if supported {
123 return Ok(());
124 }
125 Err(Error::new(
126 ErrorKind::Unsupported,
127 format!(
128 "{feature} is not available on {} deployments",
129 self.flavor.as_str()
130 ),
131 ))
132 }
133
134 pub fn require_feature(&self, feature: Feature) -> Result<()> {
135 let floor = Version::new(9, 5, 1);
136 let supported = numeric_version(&self.version).is_some_and(|version| version >= floor);
137 if supported {
138 return Ok(());
139 }
140 Err(Error::new(
141 ErrorKind::Unsupported,
142 format!(
143 "{} is not verified on {} {}; elasticctl requires Kibana {} or newer for this feature",
144 feature.label(),
145 self.flavor.as_str(),
146 self.version,
147 floor
148 ),
149 ))
150 }
151}
152
153pub async fn probe_spaces(t: &Transport) -> Option<Vec<String>> {
154 let body = t.get("/api/spaces/space").await.ok()?;
155 let spaces = body.as_array()?;
156 Some(
157 spaces
158 .iter()
159 .filter_map(|s| s.get("id")?.as_str().map(str::to_owned))
160 .collect(),
161 )
162}
163
164pub async fn probe_license_tier(t: &Transport, flavor: Flavor) -> Option<String> {
165 if flavor == Flavor::Serverless {
166 return None;
167 }
168 let body = t.get_absolute_es("/_license").await.ok()?;
169 body["license"]["type"].as_str().map(str::to_owned)
170}