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