Skip to main content

elasticctl_core/
capabilities.rs

1use 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}