blob: de3e92230c350ad3587d057a9324abf6e384a49c [file]
use crate::config::Environment;
use crate::error::{DorisError, DorisResult};
use serde_json::Value;
pub struct HttpConnection {
client: reqwest::Client,
base_url: String,
user: String,
password: String,
}
/// Outcome of probing a single HTTP endpoint.
#[derive(Debug, Clone)]
pub struct ProbeResult {
pub url: String,
pub status: ProbeStatus,
}
#[derive(Debug, Clone)]
pub enum ProbeStatus {
/// 2xx response — endpoint is reachable and auth succeeded.
Ok,
/// Non-2xx HTTP response — port is open, but endpoint/auth is off.
Http(u16),
/// Network/timeout/DNS failure — port is likely wrong or service down.
Unreachable(String),
}
impl ProbeStatus {
pub fn is_ok(&self) -> bool {
matches!(self, ProbeStatus::Ok)
}
pub fn short(&self) -> String {
match self {
ProbeStatus::Ok => "ok".to_string(),
ProbeStatus::Http(c) => format!("http {c}"),
ProbeStatus::Unreachable(e) => format!("unreachable: {e}"),
}
}
}
/// Aggregate HTTP health of the configured FE.
#[derive(Debug, Clone)]
pub struct HttpProbe {
pub rest_v2: ProbeResult,
pub legacy: ProbeResult,
}
impl HttpProbe {
pub fn any_ok(&self) -> bool {
self.rest_v2.status.is_ok() || self.legacy.status.is_ok()
}
}
impl HttpConnection {
pub fn new(env: &Environment) -> Self {
let mut builder = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(5));
if let Some(s5) = &env.socks5 {
// socks5h:// → DNS resolved on the proxy side (target hostnames may
// only exist inside the customer VPC in BYOC).
let proxy_url = format!("socks5h://{}:{}", s5.host, s5.port);
match reqwest::Proxy::all(&proxy_url) {
Ok(p) => {
builder = builder.proxy(p.basic_auth(&s5.user, &s5.pass));
}
Err(e) => {
tracing::warn!(
target = "doris::socks5",
"Invalid SOCKS5 proxy URL '{proxy_url}': {e} — HTTP will go direct"
);
}
}
}
HttpConnection {
client: builder.build().unwrap_or_default(),
base_url: format!("http://{}:{}", env.host, env.http_port),
user: env.user.clone(),
password: env.password.clone(),
}
}
/// Build a connection for a specific host:port, reusing env's credentials + socks5.
/// Used when fanning out to other FEs (via SHOW FRONTENDS) or probing alternative ports.
pub fn for_target(env: &Environment, host: &str, http_port: u16) -> Self {
let mut builder = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(5));
if let Some(s5) = &env.socks5 {
let proxy_url = format!("socks5h://{}:{}", s5.host, s5.port);
if let Ok(p) = reqwest::Proxy::all(&proxy_url) {
builder = builder.proxy(p.basic_auth(&s5.user, &s5.pass));
}
}
HttpConnection {
client: builder.build().unwrap_or_default(),
base_url: format!("http://{host}:{http_port}"),
user: env.user.clone(),
password: env.password.clone(),
}
}
/// Probe REST v2 + legacy HTTP endpoints with a short timeout.
/// Used by `auth add` / `auth status` to surface connectivity issues
/// early, rather than letting them explode later inside `profile get`.
pub async fn probe(&self) -> HttpProbe {
let rest_v2_url = format!(
"{}/rest/v2/manager/query/query_info?is_all_node=true",
self.base_url
);
let legacy_url = format!("{}/api/health", self.base_url);
let rest_v2 = self.probe_one(&rest_v2_url).await;
let legacy = self.probe_one(&legacy_url).await;
HttpProbe { rest_v2, legacy }
}
async fn probe_one(&self, url: &str) -> ProbeResult {
let resp = self
.client
.get(url)
.basic_auth(&self.user, Some(&self.password))
.timeout(std::time::Duration::from_secs(3))
.send()
.await;
let status = match resp {
Ok(r) => {
let code = r.status().as_u16();
if (200..300).contains(&code) {
ProbeStatus::Ok
} else {
ProbeStatus::Http(code)
}
}
Err(e) => ProbeStatus::Unreachable(short_err(&e)),
};
ProbeResult {
url: url.to_string(),
status,
}
}
}
/// Collapse reqwest's verbose error chain into one short line for probe output.
fn short_err(e: &reqwest::Error) -> String {
if e.is_timeout() {
"timeout".to_string()
} else if e.is_connect() {
"connection refused".to_string()
} else if e.is_request() {
"request failed".to_string()
} else {
e.to_string().chars().take(120).collect()
}
}
impl HttpConnection {
/// Fetch profile text via REST v2 Manager API (works on cloud port 8080).
/// Endpoint: GET /rest/v2/manager/query/profile/text/{query_id}?is_all_node=true
/// Response: {"msg":"success","code":0,"data":{"profile":"Summary:\n..."}}
pub async fn get_profile_text_v2(&self, query_id: &str) -> DorisResult<String> {
let url = format!(
"{}/rest/v2/manager/query/profile/text/{}?is_all_node=true",
self.base_url, query_id
);
let resp = self
.client
.get(&url)
.basic_auth(&self.user, Some(&self.password))
.send()
.await
.map_err(|e| {
DorisError::connection_with_source(
format!("REST v2 profile request failed: {url}"),
e,
)
})?;
let status = resp.status().as_u16();
if status != 200 {
let body = resp.text().await.unwrap_or_default();
return Err(DorisError::Http { status, body });
}
let json: Value = resp.json().await.map_err(|e| DorisError::Http {
status,
body: format!("Failed to parse JSON response: {e}"),
})?;
// Check response envelope
let code = json.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
if code != 0 {
let msg = json
.get("data")
.and_then(|v| v.as_str())
.or_else(|| json.get("msg").and_then(|v| v.as_str()))
.unwrap_or("Unknown error");
return Err(DorisError::Http {
status,
body: msg.to_string(),
});
}
// Extract profile text from data.profile
json.get("data")
.and_then(|d| d.get("profile"))
.and_then(|p| p.as_str())
.map(|s| s.to_string())
.ok_or_else(|| DorisError::Parse("No 'profile' field in REST v2 response".to_string()))
}
/// Fetch profile text from legacy FE HTTP API.
/// Endpoint: GET /api/profile/text/{query_id}
pub async fn get_profile_text(&self, query_id: &str) -> DorisResult<String> {
let url = format!("{}/api/profile/text/{}", self.base_url, query_id);
let resp = self
.client
.get(&url)
.basic_auth(&self.user, Some(&self.password))
.send()
.await
.map_err(|e| {
DorisError::connection_with_source(format!("HTTP request failed: {url}"), e)
})?;
let status = resp.status().as_u16();
let body = resp.text().await.map_err(|e| DorisError::Http {
status,
body: e.to_string(),
})?;
if status != 200 {
return Err(DorisError::Http { status, body });
}
Ok(body)
}
/// Fetch list of recent queries via REST v2 Manager API.
/// Endpoint: GET /rest/v2/manager/query/query_info?is_all_node=true
#[allow(dead_code)]
pub async fn get_query_list(&self) -> DorisResult<Value> {
let url = format!(
"{}/rest/v2/manager/query/query_info?is_all_node=true",
self.base_url
);
let resp = self
.client
.get(&url)
.basic_auth(&self.user, Some(&self.password))
.send()
.await
.map_err(|e| {
DorisError::connection_with_source(format!("REST v2 query list failed: {url}"), e)
})?;
let json: Value = resp.json().await.map_err(|e| DorisError::Http {
status: 0,
body: format!("Failed to parse JSON: {e}"),
})?;
Ok(json)
}
/// Test HTTP connectivity.
#[allow(dead_code)]
pub async fn ping(&self) -> DorisResult<u128> {
let start = std::time::Instant::now();
let url = format!("{}/api/health", self.base_url);
let _ = self
.client
.get(&url)
.basic_auth(&self.user, Some(&self.password))
.timeout(std::time::Duration::from_secs(5))
.send()
.await;
Ok(start.elapsed().as_millis())
}
}