use anyhow::Context;
use reqwest::header::{HeaderMap, HeaderValue};
use super::wire::format_request_as_curl;
use crate::openapi_client::types::{AuthTokenRequest, ValidateTokenRequest};
pub const DEFAULT_API_TIMEOUT_SECS: u64 = 300;
#[derive(Debug)]
pub struct AuthServerUnreachable {
pub url: String,
}
impl std::fmt::Display for AuthServerUnreachable {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"cannot reach manta server at {} for authentication. \
Is the server running, and is `manta_server_url` in your \
config correct?",
self.url,
)
}
}
impl std::error::Error for AuthServerUnreachable {}
#[derive(Debug)]
pub struct SiteNotFound {
pub site: String,
}
impl std::fmt::Display for SiteNotFound {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"site '{}' is not configured on the manta server. \
Check the `site` value in your `cli.toml`.",
self.site,
)
}
}
impl std::error::Error for SiteNotFound {}
pub trait OpenApiResultExt<T> {
fn into_anyhow(self) -> anyhow::Result<T>;
}
impl<T, E> OpenApiResultExt<T>
for Result<progenitor_client::ResponseValue<T>, progenitor_client::Error<E>>
where
E: std::fmt::Debug + serde::Serialize,
{
fn into_anyhow(self) -> anyhow::Result<T> {
match self {
Ok(rv) => Ok(rv.into_inner()),
Err(progenitor_client::Error::ErrorResponse(rv)) => {
let status = rv.status();
let inner = rv.into_inner();
let raw_msg = serde_json::to_value(&inner)
.ok()
.and_then(|v| {
v.get("error").and_then(|e| e.as_str()).map(String::from)
})
.unwrap_or_else(|| format!("{inner:?}"));
let msg = categorise_server_error(status.as_u16(), &raw_msg);
Err(anyhow::anyhow!("HTTP {}: {msg}", status.as_u16()))
}
Err(progenitor_client::Error::CommunicationError(rqe)) => {
Err(anyhow::anyhow!("{}", categorise_transport_error(&rqe)))
}
Err(e) => Err(anyhow::anyhow!("{}", e)),
}
}
}
fn categorise_transport_error(rqe: &reqwest::Error) -> String {
if rqe.is_timeout() {
if rqe.is_connect() {
format!(
"CLI -> manta-server connect timed out. \
The CLI gave up before the TCP/TLS handshake completed. \
Likely causes: manta-server unreachable, wrong `manta_server_url` \
in `cli.toml`, or a firewall dropping the SYN. \
Underlying: {rqe}"
)
} else {
format!(
"CLI -> manta-server request timed out. \
The CLI gave up before manta-server sent response headers. \
This is the CLI-side `request_timeout_secs` in `cli.toml` \
(default 300 s). manta-server may still be working on the \
request — bump that value if you're hitting it on a heavy \
call against a busy site. Underlying: {rqe}"
)
}
} else if rqe.is_connect() {
format!(
"CLI could not connect to manta-server. \
Check `manta_server_url` in `cli.toml` and confirm the server \
is reachable from this host. Underlying: {rqe}"
)
} else {
format!("CLI transport error talking to manta-server: {rqe}")
}
}
fn categorise_server_error(status: u16, body: &str) -> String {
if status == 408 {
return format!(
"manta-server per-route request timeout fired. \
The handler took longer than `request_timeout_secs` in \
`server.toml` (default 600 s). The upstream call may still \
be running on the server. Original body: {body}"
);
}
if body.contains("operation timed out")
|| body.contains("Connect timed out")
|| body.contains("manta-server -> CSM")
{
return format!(
"manta-server's outbound call to CSM timed out (csm-rs \
reqwest timeout). This is not the CLI or manta-server's own \
timeout — CSM itself did not respond. Original body: {body}"
);
}
body.to_string()
}
#[cfg(test)]
mod into_anyhow_tests {
use super::*;
#[test]
fn categorise_server_408_explains_route_timeout() {
let msg = categorise_server_error(408, "Request Timeout");
assert!(msg.contains("per-route request timeout"));
assert!(msg.contains("server.toml"));
assert!(msg.contains("Request Timeout"));
}
#[test]
fn categorise_server_500_with_operation_timed_out_explains_csm_hop() {
let msg = categorise_server_error(
500,
"ERROR - http client: error sending request for url (...): operation timed out",
);
assert!(msg.contains("manta-server's outbound call to CSM"));
assert!(msg.contains("csm-rs"));
}
#[test]
fn categorise_server_500_passes_unknown_bodies_through() {
let body = "Internal error: something else";
assert_eq!(categorise_server_error(500, body), body);
}
#[test]
fn categorise_server_404_passes_through() {
let body = "Not found: image abcd-1234";
assert_eq!(categorise_server_error(404, body), body);
}
}
#[derive(Debug)]
pub struct MantaClient {
pub openapi: crate::openapi_client::Client,
pub site_name: String,
pub raw: reqwest::Client,
pub token: Option<String>,
pub base_url: String,
}
impl MantaClient {
pub fn new(server_url: &str, site_name: &str) -> anyhow::Result<Self> {
Self::new_with_timeout(server_url, site_name, None, None)
}
pub fn from_app_ctx(
ctx: &crate::common::app_context::AppContext<'_>,
token: Option<&str>,
) -> anyhow::Result<Self> {
Self::new_with_timeout(
ctx.manta_server_url,
ctx.require_site()?,
ctx.request_timeout_secs,
token,
)
}
pub fn new_with_timeout(
server_url: &str,
site_name: &str,
timeout_secs: Option<u64>,
token: Option<&str>,
) -> anyhow::Result<Self> {
let normalized = if server_url.starts_with("http://")
|| server_url.starts_with("https://")
{
server_url.to_owned()
} else {
format!("http://{server_url}")
};
let base_url = format!("{}/api/v1", normalized.trim_end_matches('/'));
let mut default_headers = HeaderMap::new();
if let Some(t) = token {
let mut bearer = HeaderValue::from_str(&format!("Bearer {t}"))
.context("token contained non-ASCII characters; cannot build Authorization header")?;
bearer.set_sensitive(true);
default_headers.insert(reqwest::header::AUTHORIZATION, bearer);
}
let mut raw_builder =
reqwest::Client::builder().default_headers(default_headers.clone());
if let Some(secs) = timeout_secs {
raw_builder = raw_builder.timeout(std::time::Duration::from_secs(secs));
}
let raw = raw_builder.build().context("Failed to build HTTP client")?;
let api_timeout_secs = timeout_secs.unwrap_or(DEFAULT_API_TIMEOUT_SECS);
let openapi_inner = reqwest::Client::builder()
.default_headers(default_headers)
.timeout(std::time::Duration::from_secs(api_timeout_secs))
.build()
.context("Failed to build OpenAPI HTTP client")?;
let openapi =
crate::openapi_client::Client::new_with_client(&base_url, openapi_inner);
Ok(Self {
openapi,
site_name: site_name.to_owned(),
raw,
token: token.map(str::to_owned),
base_url,
})
}
pub fn site_name(&self) -> &str {
&self.site_name
}
pub fn base_url(&self) -> &str {
&self.base_url
}
fn auth_base_url(&self) -> String {
self.base_url.trim_end_matches("/api/v1").to_string()
}
fn map_auth_error<E: std::fmt::Debug>(
&self,
err: progenitor_client::Error<E>,
) -> anyhow::Error
where
progenitor_client::Error<E>: std::fmt::Display,
{
let status = match &err {
progenitor_client::Error::ErrorResponse(rv) => Some(rv.status()),
progenitor_client::Error::UnexpectedResponse(resp) => Some(resp.status()),
_ => None,
};
if status == Some(reqwest::StatusCode::NOT_FOUND) {
return anyhow::anyhow!("{err}").context(SiteNotFound {
site: self.site_name.clone(),
});
}
let unreachable = matches!(
&err,
progenitor_client::Error::CommunicationError(e) if e.is_connect() || e.is_timeout()
);
let message = format!("{err}");
if unreachable {
anyhow::anyhow!(message).context(AuthServerUnreachable {
url: self.auth_base_url(),
})
} else {
anyhow::anyhow!(message)
}
}
pub(crate) async fn validate_token(&self, token: &str) -> anyhow::Result<()> {
self
.openapi
.auth_validate(
self.site_name(),
&ValidateTokenRequest {
token: token.to_owned(),
},
)
.await
.map(|_| ())
.map_err(|e| self.map_auth_error(e))
}
pub(crate) async fn exchange_credentials(
&self,
username: &str,
password: &str,
) -> anyhow::Result<String> {
let resp = self
.openapi
.auth_token(
self.site_name(),
&AuthTokenRequest {
username: username.to_owned(),
password: password.to_owned(),
},
)
.await
.map_err(|e| self.map_auth_error(e))?;
Ok(resp.into_inner().token)
}
pub(super) fn log_request_as_curl(builder: &reqwest::RequestBuilder) {
if !tracing::enabled!(tracing::Level::DEBUG) {
return;
}
let Some(cloned) = builder.try_clone() else {
return;
};
let Ok(req) = cloned.build() else {
return;
};
tracing::debug!(
"curl equivalent (secrets replaced with <REDACTED>):\n{}",
format_request_as_curl(&req)
);
}
pub(super) fn unreachable_server_msg(&self) -> String {
let server_url = self.base_url.trim_end_matches("/api/v1");
format!(
"cannot reach manta server at {server_url}. Is the server \
running, and is `manta_server_url` in your config correct?"
)
}
}
pub(super) fn unwrap_error_body(body: &str) -> String {
#[derive(serde::Deserialize)]
struct ServerErrorBody {
error: String,
}
serde_json::from_str::<ServerErrorBody>(body)
.map(|e| e.error)
.unwrap_or_else(|_| body.to_string())
}
#[cfg(test)]
mod tests {
use super::unwrap_error_body;
#[test]
fn unwrap_error_body_extracts_error_field_from_standard_body() {
let body = r#"{"error":"Can't access HSM group 'compute-2'."}"#;
assert_eq!(
unwrap_error_body(body),
"Can't access HSM group 'compute-2'."
);
}
#[test]
fn unwrap_error_body_falls_back_to_raw_for_non_json() {
let body = "Method Not Allowed";
assert_eq!(unwrap_error_body(body), "Method Not Allowed");
}
#[test]
fn unwrap_error_body_falls_back_to_raw_for_json_without_error_field() {
let body = r#"{"detail":"something"}"#;
assert_eq!(unwrap_error_body(body), body);
}
#[test]
fn unwrap_error_body_falls_back_to_raw_for_empty_body() {
assert_eq!(unwrap_error_body(""), "");
}
}