Skip to main content

elasticctl_core/
capabilities.rs

1//! Probe deployment capabilities at connection time.
2//!
3//! Commands can then report unsupported features before a 404 response.
4
5use crate::error::{Error, ErrorKind, Result};
6use crate::transport::Transport;
7use semver::Version;
8use serde_json::Value;
9
10/// Hostname suffixes used by Elastic Cloud Hosted deployments.
11///
12/// This is a fallback, not the primary signal; see `probe`. It identifies
13/// deployments reached through proxies that strip Cloud edge headers.
14const ECH_SUFFIXES: [&str; 4] = [
15    "elastic-cloud.com",
16    "found.io",
17    "cloud.es.io",
18    "elastic.cloud",
19];
20
21/// Sent by the Elastic Cloud edge proxy. Present on Hosted and Serverless;
22/// absent from unproxied stacks.
23const CLOUD_EDGE_HEADER: &str = "x-found-handling-cluster";
24
25/// Return the URL host without its port.
26///
27/// Matching the full URL would treat a suffix in its path or query as a
28/// deployment signal.
29fn host_of(url: &str) -> &str {
30    // The scheme is the first `://`; a later `://` in the path or query is not
31    // the scheme and must not be mistaken for it.
32    let after_scheme = match url.find("://") {
33        Some(i) => &url[i + 3..],
34        None => url,
35    };
36    let host = after_scheme.split('/').next().unwrap_or("");
37    host.split(':').next().unwrap_or(host)
38}
39
40/// Whether `host` equals `suffix` or is its subdomain.
41///
42/// A bare `ends_with` would match `notfound.io` as `found.io`.
43fn host_matches(host: &str, suffix: &str) -> bool {
44    host == suffix || host.ends_with(&format!(".{suffix}"))
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum Flavor {
49    SelfManaged,
50    ElasticCloudHosted,
51    Serverless,
52}
53
54/// Public feature areas whose availability depends on the measured stack
55/// contract rather than on the existence of one object.
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Feature {
58    ExceptionLists,
59    PrebuiltRules,
60    RuleSourceScoping,
61}
62
63impl Feature {
64    fn label(self) -> &'static str {
65        match self {
66            Self::ExceptionLists => "exception lists",
67            Self::PrebuiltRules => "prebuilt rules",
68            Self::RuleSourceScoping => "rule source scoping",
69        }
70    }
71}
72
73impl Flavor {
74    pub fn as_str(&self) -> &'static str {
75        match self {
76            Self::SelfManaged => "self-managed",
77            Self::ElasticCloudHosted => "elastic-cloud-hosted",
78            Self::Serverless => "serverless",
79        }
80    }
81}
82
83#[derive(Debug, Clone)]
84pub struct Capabilities {
85    pub flavor: Flavor,
86    pub version: String,
87}
88
89/// Parse the numeric `major.minor.patch` from a reported version string.
90///
91/// A leading `v` and any pre-release or build suffix are ignored, so a lab or
92/// snapshot build is not refused. A version with no numeric
93/// `major.minor.patch` is unreadable.
94fn numeric_version(version: &str) -> Option<Version> {
95    let numeric = version
96        .trim_start_matches(&['v', 'V'][..])
97        .split(&['-', '+'][..])
98        .next()
99        .unwrap_or_default();
100    Version::parse(numeric).ok()
101}
102
103impl Capabilities {
104    pub async fn probe(t: &Transport, kibana_url: &str) -> Result<Capabilities> {
105        let responded = t.get_with_headers("/api/status").await?;
106        Ok(Self::classify(
107            &responded.body,
108            responded.header(CLOUD_EDGE_HEADER).is_some(),
109            kibana_url,
110        ))
111    }
112
113    /// Classify the flavor and version from one status response.
114    ///
115    /// This is separate from `probe` so recorded fixtures, not only mocks,
116    /// test the response shapes for each flavor.
117    ///
118    /// Test Serverless before the Cloud edge signal. Hosted and self-managed
119    /// stacks can both report `build_flavor: "traditional"`, while Serverless
120    /// sends the same edge header as Hosted.
121    pub fn classify(status: &Value, cloud_edge: bool, kibana_url: &str) -> Capabilities {
122        let version = status["version"]["number"]
123            .as_str()
124            .unwrap_or("unknown")
125            .to_string();
126        let build_flavor = status["version"]["build_flavor"]
127            .as_str()
128            .unwrap_or("default");
129
130        // `||` checks the hostname only when the edge header is absent.
131        let host = host_of(kibana_url)
132            .trim_end_matches('.')
133            .to_ascii_lowercase();
134        let cloud = cloud_edge
135            || ECH_SUFFIXES
136                .iter()
137                .any(|suffix| host_matches(&host, suffix));
138
139        let flavor = if build_flavor == "serverless" {
140            Flavor::Serverless
141        } else if cloud {
142            Flavor::ElasticCloudHosted
143        } else {
144            Flavor::SelfManaged
145        };
146
147        Capabilities { flavor, version }
148    }
149
150    /// Return an unsupported error that names the feature and deployment
151    /// flavor.
152    pub fn require(&self, feature: &str, supported: bool) -> Result<()> {
153        if supported {
154            return Ok(());
155        }
156        Err(Error::new(
157            ErrorKind::Unsupported,
158            format!(
159                "{feature} is not available on {} deployments",
160                self.flavor.as_str()
161            ),
162        ))
163    }
164
165    /// Require a feature only on stack versions for which this client has
166    /// complete fixture evidence.
167    pub fn require_feature(&self, feature: Feature) -> Result<()> {
168        let floor = Version::new(9, 5, 1);
169        let supported = numeric_version(&self.version).is_some_and(|version| version >= floor);
170        if supported {
171            return Ok(());
172        }
173        Err(Error::new(
174            ErrorKind::Unsupported,
175            format!(
176                "{} is not verified on {} {}; elasticctl requires Kibana {} or newer for this feature",
177                feature.label(),
178                self.flavor.as_str(),
179                self.version,
180                floor
181            ),
182        ))
183    }
184}
185
186/// Return space IDs visible to this credential, or `None` when unavailable.
187///
188/// This is separate from `Capabilities::probe` because `doctor` and `config
189/// test` do not report spaces or license tiers. `None` means the spaces could
190/// not be determined; it never substitutes a configured space.
191pub async fn probe_spaces(t: &Transport) -> Option<Vec<String>> {
192    let body = t.get("/api/spaces/space").await.ok()?;
193    let spaces = body.as_array()?;
194    Some(
195        spaces
196            .iter()
197            .filter_map(|s| s.get("id")?.as_str().map(str::to_owned))
198            .collect(),
199    )
200}
201
202/// Return the license tier, or `None` when it is unavailable.
203///
204/// Serverless uses project tiers, so it never calls the license endpoint.
205/// Elsewhere, a failure leaves the tier unknown so `info` can continue.
206pub async fn probe_license_tier(t: &Transport, flavor: Flavor) -> Option<String> {
207    if flavor == Flavor::Serverless {
208        return None;
209    }
210    let body = t.get_absolute_es("/_license").await.ok()?;
211    body["license"]["type"].as_str().map(str::to_owned)
212}