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