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