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 serde_json::Value;
8
9/// Hostname suffixes used by Elastic Cloud Hosted deployments.
10///
11/// This is a fallback, not the primary signal; see `probe`. It identifies
12/// deployments reached through proxies that strip Cloud edge headers.
13const ECH_SUFFIXES: [&str; 4] = [
14    "elastic-cloud.com",
15    "found.io",
16    "cloud.es.io",
17    "elastic.cloud",
18];
19
20/// Sent by the Elastic Cloud edge proxy. Present on Hosted and Serverless;
21/// absent from unproxied stacks.
22const CLOUD_EDGE_HEADER: &str = "x-found-handling-cluster";
23
24/// Return the URL host without its port.
25///
26/// Matching the full URL would treat a suffix in its path or query as a
27/// deployment signal.
28fn host_of(url: &str) -> &str {
29    let after_scheme = match url.rfind("://") {
30        Some(i) => &url[i + 3..],
31        None => url,
32    };
33    let host = after_scheme.split('/').next().unwrap_or("");
34    host.split(':').next().unwrap_or(host)
35}
36
37/// Whether `host` equals `suffix` or is its subdomain.
38///
39/// A bare `ends_with` would match `notfound.io` as `found.io`.
40fn host_matches(host: &str, suffix: &str) -> bool {
41    host == suffix || host.ends_with(&format!(".{suffix}"))
42}
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum Flavor {
46    SelfManaged,
47    ElasticCloudHosted,
48    Serverless,
49}
50
51impl Flavor {
52    pub fn as_str(&self) -> &'static str {
53        match self {
54            Self::SelfManaged => "self-managed",
55            Self::ElasticCloudHosted => "elastic-cloud-hosted",
56            Self::Serverless => "serverless",
57        }
58    }
59}
60
61#[derive(Debug, Clone)]
62pub struct Capabilities {
63    pub flavor: Flavor,
64    pub version: String,
65}
66
67impl Capabilities {
68    pub async fn probe(t: &Transport, kibana_url: &str) -> Result<Capabilities> {
69        let responded = t.get_with_headers("/api/status").await?;
70        Ok(Self::classify(
71            &responded.body,
72            responded.header(CLOUD_EDGE_HEADER).is_some(),
73            kibana_url,
74        ))
75    }
76
77    /// Classify the flavor and version from one status response.
78    ///
79    /// This is separate from `probe` so recorded fixtures, not only mocks,
80    /// test the response shapes for each flavor.
81    ///
82    /// Test Serverless before the Cloud edge signal. Hosted and self-managed
83    /// stacks can both report `build_flavor: "traditional"`, while Serverless
84    /// sends the same edge header as Hosted.
85    pub fn classify(status: &Value, cloud_edge: bool, kibana_url: &str) -> Capabilities {
86        let version = status["version"]["number"]
87            .as_str()
88            .unwrap_or("unknown")
89            .to_string();
90        let build_flavor = status["version"]["build_flavor"]
91            .as_str()
92            .unwrap_or("default");
93
94        // `||` checks the hostname only when the edge header is absent.
95        let cloud = cloud_edge
96            || ECH_SUFFIXES
97                .iter()
98                .any(|s| host_matches(host_of(kibana_url), s));
99
100        let flavor = if build_flavor == "serverless" {
101            Flavor::Serverless
102        } else if cloud {
103            Flavor::ElasticCloudHosted
104        } else {
105            Flavor::SelfManaged
106        };
107
108        Capabilities { flavor, version }
109    }
110
111    /// Return an unsupported error that names the feature and deployment
112    /// flavor.
113    pub fn require(&self, feature: &str, supported: bool) -> Result<()> {
114        if supported {
115            return Ok(());
116        }
117        Err(Error::new(
118            ErrorKind::Unsupported,
119            format!(
120                "{feature} is not available on {} deployments",
121                self.flavor.as_str()
122            ),
123        ))
124    }
125}
126
127/// Return space IDs visible to this credential, or `None` when unavailable.
128///
129/// This is separate from `Capabilities::probe` because `doctor` and `config
130/// test` do not report spaces or license tiers. `None` means the spaces could
131/// not be determined; it never substitutes a configured space.
132pub async fn probe_spaces(t: &Transport) -> Option<Vec<String>> {
133    let body = t.get("/api/spaces/space").await.ok()?;
134    let spaces = body.as_array()?;
135    Some(
136        spaces
137            .iter()
138            .filter_map(|s| s.get("id")?.as_str().map(str::to_owned))
139            .collect(),
140    )
141}
142
143/// Return the license tier, or `None` when it is unavailable.
144///
145/// Serverless uses project tiers, so it never calls the license endpoint.
146/// Elsewhere, a failure leaves the tier unknown so `info` can continue.
147pub async fn probe_license_tier(t: &Transport, flavor: Flavor) -> Option<String> {
148    if flavor == Flavor::Serverless {
149        return None;
150    }
151    let body = t.get_absolute_es("/_license").await.ok()?;
152    body["license"]["type"].as_str().map(str::to_owned)
153}