use crate::error::{Error, ErrorKind, Result};
use crate::transport::Transport;
use serde_json::Value;
const ECH_SUFFIXES: [&str; 4] = [
"elastic-cloud.com",
"found.io",
"cloud.es.io",
"elastic.cloud",
];
const CLOUD_EDGE_HEADER: &str = "x-found-handling-cluster";
fn host_of(url: &str) -> &str {
let after_scheme = match url.rfind("://") {
Some(i) => &url[i + 3..],
None => url,
};
let host = after_scheme.split('/').next().unwrap_or("");
host.split(':').next().unwrap_or(host)
}
fn host_matches(host: &str, suffix: &str) -> bool {
host == suffix || host.ends_with(&format!(".{suffix}"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Flavor {
SelfManaged,
ElasticCloudHosted,
Serverless,
}
impl Flavor {
pub fn as_str(&self) -> &'static str {
match self {
Self::SelfManaged => "self-managed",
Self::ElasticCloudHosted => "elastic-cloud-hosted",
Self::Serverless => "serverless",
}
}
}
#[derive(Debug, Clone)]
pub struct Capabilities {
pub flavor: Flavor,
pub version: String,
}
impl Capabilities {
pub async fn probe(t: &Transport, kibana_url: &str) -> Result<Capabilities> {
let responded = t.get_with_headers("/api/status").await?;
Ok(Self::classify(
&responded.body,
responded.header(CLOUD_EDGE_HEADER).is_some(),
kibana_url,
))
}
pub fn classify(status: &Value, cloud_edge: bool, kibana_url: &str) -> Capabilities {
let version = status["version"]["number"]
.as_str()
.unwrap_or("unknown")
.to_string();
let build_flavor = status["version"]["build_flavor"]
.as_str()
.unwrap_or("default");
let cloud = cloud_edge
|| ECH_SUFFIXES
.iter()
.any(|s| host_matches(host_of(kibana_url), s));
let flavor = if build_flavor == "serverless" {
Flavor::Serverless
} else if cloud {
Flavor::ElasticCloudHosted
} else {
Flavor::SelfManaged
};
Capabilities { flavor, version }
}
pub fn require(&self, feature: &str, supported: bool) -> Result<()> {
if supported {
return Ok(());
}
Err(Error::new(
ErrorKind::Unsupported,
format!(
"{feature} is not available on {} deployments",
self.flavor.as_str()
),
))
}
}
pub async fn probe_spaces(t: &Transport) -> Option<Vec<String>> {
let body = t.get("/api/spaces/space").await.ok()?;
let spaces = body.as_array()?;
Some(
spaces
.iter()
.filter_map(|s| s.get("id")?.as_str().map(str::to_owned))
.collect(),
)
}
pub async fn probe_license_tier(t: &Transport, flavor: Flavor) -> Option<String> {
if flavor == Flavor::Serverless {
return None;
}
let body = t.get_absolute_es("/_license").await.ok()?;
body["license"]["type"].as_str().map(str::to_owned)
}