Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion console-web/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,7 +428,7 @@ export interface EncryptionInfoResponse {

export interface UpdateEncryptionRequest {
enabled: boolean
backend?: string
backend?: "local" | "vault"
vault?: {
endpoint: string
}
Expand Down
194 changes: 192 additions & 2 deletions src/console/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,32 @@

use axum::{
Json,
extract::rejection::JsonRejection,
http::StatusCode,
response::{IntoResponse, Response},
};
use snafu::Snafu;

use crate::console::models::common::{ConsoleErrorDetails, ConsoleErrorResponse};

pub(crate) const JSON_REJECTION_MESSAGE_MAX_BYTES: usize = 1024;
const JSON_REJECTION_TRUNCATION_SUFFIX: &str = "... [truncated]";

fn bounded_json_rejection_message(mut message: String) -> String {
if message.len() <= JSON_REJECTION_MESSAGE_MAX_BYTES {
return message;
}

let mut truncate_at =
JSON_REJECTION_MESSAGE_MAX_BYTES.saturating_sub(JSON_REJECTION_TRUNCATION_SUFFIX.len());
while !message.is_char_boundary(truncate_at) {
truncate_at -= 1;
}
message.truncate(truncate_at);
message.push_str(JSON_REJECTION_TRUNCATION_SUFFIX);
message
}

/// Console HTTP API error type
#[derive(Debug, Snafu)]
#[snafu(visibility(pub))]
Expand All @@ -37,6 +56,18 @@ pub enum Error {
#[snafu(display("Bad request: {}", message))]
BadRequest { message: String },

#[snafu(display("Invalid JSON syntax: {}", message))]
JsonSyntax { message: String },

#[snafu(display("Invalid JSON data: {}", message))]
JsonData { message: String },

#[snafu(display("Unsupported JSON media type: {}", message))]
UnsupportedMediaType { message: String },

#[snafu(display("Request body rejected with status {}: {}", status, message))]
RequestBody { status: StatusCode, message: String },

#[snafu(display("Conflict: {}", message))]
Conflict { message: String },

Expand Down Expand Up @@ -65,9 +96,23 @@ pub enum Error {
Json { source: serde_json::Error },
}

/// Map `kube::Error` to a console error (403 -> Forbidden, 404 -> NotFound, 409 -> Conflict).
/// Map Kubernetes API status errors to the corresponding Console error contract.
pub fn map_kube_error(e: kube::Error, not_found_resource: impl Into<String>) -> Error {
match &e {
kube::Error::Api(ae) if ae.code == 401 => Error::Unauthorized {
message: if ae.message.trim().is_empty() {
"Kubernetes API authentication required".to_string()
} else {
ae.message.clone()
},
},
kube::Error::Api(ae) if matches!(ae.code, 400 | 422) => Error::BadRequest {
message: if ae.message.trim().is_empty() {
"Kubernetes API rejected the request".to_string()
} else {
ae.message.clone()
},
},
kube::Error::Api(ae) if ae.code == 403 => Error::Forbidden {
message: if ae.message.is_empty() {
"Kubernetes API access denied".to_string()
Expand All @@ -79,13 +124,36 @@ pub fn map_kube_error(e: kube::Error, not_found_resource: impl Into<String>) ->
resource: not_found_resource.into(),
},
kube::Error::Api(ae) if ae.code == 409 => Error::Conflict {
message: "Resource was modified by another request, please retry".to_string(),
message: if ae.message.trim().is_empty() {
"Kubernetes API reported a resource conflict".to_string()
} else {
ae.message.clone()
},
},
_ => Error::KubeApi { source: e },
}
}

impl Error {
/// Convert Axum's JSON extraction failures into the Console error contract.
pub(crate) fn from_json_rejection(rejection: JsonRejection) -> Self {
match rejection {
JsonRejection::JsonSyntaxError(error) => Self::JsonSyntax {
message: bounded_json_rejection_message(error.body_text()),
},
JsonRejection::JsonDataError(error) => Self::JsonData {
message: bounded_json_rejection_message(error.body_text()),
},
JsonRejection::MissingJsonContentType(error) => Self::UnsupportedMediaType {
message: bounded_json_rejection_message(error.body_text()),
},
rejection => Self::RequestBody {
status: rejection.status(),
message: bounded_json_rejection_message(rejection.body_text()),
},
}
}

fn log_if_server_error(&self) {
match self {
Error::InternalServer { message } => {
Expand Down Expand Up @@ -173,6 +241,38 @@ impl Error {
Vec::new(),
None,
),
Error::JsonSyntax { message } => (
StatusCode::BAD_REQUEST,
"BadRequest".to_string(),
"InvalidJsonSyntax".to_string(),
message,
Vec::new(),
None,
),
Error::JsonData { message } => (
StatusCode::UNPROCESSABLE_ENTITY,
"UnprocessableEntity".to_string(),
"InvalidJsonData".to_string(),
message,
Vec::new(),
None,
),
Error::UnsupportedMediaType { message } => (
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"UnsupportedMediaType".to_string(),
"UnsupportedJsonContentType".to_string(),
message,
Vec::new(),
None,
),
Error::RequestBody { status, message } => (
status,
"RequestBodyError".to_string(),
"InvalidRequestBody".to_string(),
message,
Vec::new(),
None,
),
Error::Conflict { message } => (
StatusCode::CONFLICT,
"Conflict".to_string(),
Expand Down Expand Up @@ -261,6 +361,43 @@ mod tests {
use crate::console::models::common::ConsoleErrorDetails;
use serde_json::json;

fn kube_api_error(code: u16, message: &str) -> kube::Error {
kube::Error::Api(kube::error::ErrorResponse {
status: "Failure".to_string(),
message: message.to_string(),
reason: String::new(),
code,
})
}

#[test]
fn json_rejection_message_preserves_text_at_the_limit() {
let message = "x".repeat(JSON_REJECTION_MESSAGE_MAX_BYTES);

assert_eq!(bounded_json_rejection_message(message.clone()), message);
}

#[test]
fn json_rejection_message_is_truncated_to_the_byte_limit() {
let message = "x".repeat(JSON_REJECTION_MESSAGE_MAX_BYTES + 1);

let bounded = bounded_json_rejection_message(message);

assert_eq!(bounded.len(), JSON_REJECTION_MESSAGE_MAX_BYTES);
assert!(bounded.ends_with(JSON_REJECTION_TRUNCATION_SUFFIX));
}

#[test]
fn json_rejection_message_truncates_on_a_utf8_boundary() {
let message = "界".repeat(JSON_REJECTION_MESSAGE_MAX_BYTES);

let bounded = bounded_json_rejection_message(message);

assert!(bounded.len() <= JSON_REJECTION_MESSAGE_MAX_BYTES);
assert!(bounded.starts_with('界'));
assert!(bounded.ends_with(JSON_REJECTION_TRUNCATION_SUFFIX));
}

#[test]
fn bad_request_maps_to_stable_error_contract() -> std::result::Result<(), serde_json::Error> {
let (status, response) = Error::BadRequest {
Expand Down Expand Up @@ -331,4 +468,57 @@ mod tests {
);
Ok(())
}

#[test]
fn kubernetes_unprocessable_entity_maps_to_bad_request() {
let error = map_kube_error(
kube_api_error(422, "metadata.labels: Invalid value"),
"Tenant",
);

assert!(matches!(
error,
Error::BadRequest { message } if message == "metadata.labels: Invalid value"
));
}

#[test]
fn kubernetes_unauthorized_maps_to_unauthorized() {
let server_message = map_kube_error(
kube_api_error(401, "Unauthorized: bearer token has expired"),
"Tenant",
);
assert!(matches!(
server_message,
Error::Unauthorized { message }
if message == "Unauthorized: bearer token has expired"
));

let fallback = map_kube_error(kube_api_error(401, ""), "Tenant");
assert!(matches!(
fallback,
Error::Unauthorized { message }
if message == "Kubernetes API authentication required"
));
}

#[test]
fn kubernetes_conflict_preserves_server_message_or_uses_fallback() {
let server_message = map_kube_error(
kube_api_error(409, "tenants.rustfs.com \"logs\" already exists"),
"Tenant",
);
assert!(matches!(
server_message,
Error::Conflict { message }
if message == "tenants.rustfs.com \"logs\" already exists"
));

let fallback = map_kube_error(kube_api_error(409, ""), "Tenant");
assert!(matches!(
fallback,
Error::Conflict { message }
if message == "Kubernetes API reported a resource conflict"
));
}
}
3 changes: 2 additions & 1 deletion src/console/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use snafu::ResultExt;

use crate::console::{
error::{self, Error, Result},
json::ConsoleJson,
models::auth::{LoginRequest, LoginResponse, SessionResponse},
state::{AppState, Claims, SESSION_TTL_SECONDS},
};
Expand All @@ -30,7 +31,7 @@ use crate::types::v1alpha1::tenant::Tenant;
// -d "{\"token\": \"$TOKEN\"}"
pub async fn login(
State(state): State<AppState>,
Json(req): Json<LoginRequest>,
ConsoleJson(req): ConsoleJson<LoginRequest>,
) -> Result<impl IntoResponse> {
tracing::info!("Console login attempt");

Expand Down
3 changes: 2 additions & 1 deletion src/console/handlers/cluster.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

use crate::console::{
error::{self, Error, Result},
json::ConsoleJson,
models::cluster::*,
state::Claims,
};
Expand Down Expand Up @@ -146,7 +147,7 @@ pub async fn list_namespaces(
/// Create a namespace by name.
pub async fn create_namespace(
Extension(claims): Extension<Claims>,
Json(req): Json<CreateNamespaceRequest>,
ConsoleJson(req): ConsoleJson<CreateNamespaceRequest>,
) -> Result<Json<NamespaceItem>> {
let client = create_client(&claims).await?;
let api: Api<corev1::Namespace> = Api::all(client);
Expand Down
Loading
Loading