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