mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-20 00:02:19 +00:00
fbf9f04e10
* fix: surface the real postgres error when data table migrations fail Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: address review nits on the data table migration error fix Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: name the exact grant a data table migration needs Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: quote both identifiers in the data table grant hint Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * feat: add a data table connection and privilege check to workspace settings Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: report data table privileges from the capability fields, not the grant list Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: read grant targets from the server and drop the public schema guess Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: render the search_path suggestion server-side and pin the granted database Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: key the connection check on request identity, not the data table name Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: declare the data table check schema field nullable and required Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
510 lines
17 KiB
Rust
510 lines
17 KiB
Rust
/*
|
|
* Author: Ruben Fiszel
|
|
* Copyright: Windmill Labs, Inc 2022
|
|
* This file and its contents are licensed under the AGPLv3 License.
|
|
* Please see the included NOTICE for copyright information and
|
|
* LICENSE-AGPL for a copy of the license.
|
|
*/
|
|
|
|
use std::panic::Location;
|
|
|
|
use axum::body::Body;
|
|
use axum::response::Response;
|
|
use axum::{response::IntoResponse, response::Json};
|
|
|
|
use hyper::StatusCode;
|
|
use sqlx::migrate::MigrateError;
|
|
use thiserror::Error;
|
|
use tokio::io;
|
|
|
|
pub type Result<T> = std::result::Result<T, Error>;
|
|
pub type JsonResult<T> = std::result::Result<Json<T>, Error>;
|
|
|
|
#[derive(Debug, Error)]
|
|
pub enum Error {
|
|
#[error("Bad gateway: {0}")]
|
|
BadGateway(String),
|
|
#[error("Bad config: {0}")]
|
|
BadConfig(String),
|
|
#[error("Connecting to database: {0}")]
|
|
ConnectingToDatabase(String),
|
|
#[error("Not found: {0}")]
|
|
NotFound(String),
|
|
#[error("Not authorized: {0}")]
|
|
NotAuthorized(String),
|
|
#[error("Metric not found: {0}")]
|
|
MetricNotFound(String),
|
|
#[error("Permission denied: {0}")]
|
|
PermissionDenied(String),
|
|
#[error("Require Admin privileges for {0}")]
|
|
RequireAdmin(String),
|
|
#[error("{0}")]
|
|
ExecutionErr(String),
|
|
#[error("{0}")]
|
|
ResultTooLarge(String),
|
|
#[error("IoErr: {error:#} @{location:#}")]
|
|
IoErr { error: io::Error, location: String },
|
|
#[error("Utf8Err: {error:#} @{location:#}")]
|
|
Utf8Err { error: std::string::FromUtf8Error, location: String },
|
|
#[error("UuidErr: {error:#} @{location:#}")]
|
|
UuidErr { error: uuid::Error, location: String },
|
|
#[error("SqlErr: {error:#} @{location:#}")]
|
|
SqlErr { error: sqlx::Error, location: String },
|
|
#[error("SerdeJson: {error:#} @{location:#}")]
|
|
SerdeJson { error: serde_json::Error, location: String },
|
|
#[error("Bad request: {0}")]
|
|
BadRequest(String),
|
|
#[error("Quota exceeded: {0}")]
|
|
QuotaExceeded(String),
|
|
#[error("Internal: {0}")]
|
|
InternalErr(String),
|
|
#[error("Internal: {message} @{location}")]
|
|
InternalErrLoc { message: String, location: String },
|
|
#[error("Internal: {0}: {1}")]
|
|
InternalErrAt(&'static Location<'static>, String),
|
|
#[error("HexErr: {error:#} @{location:#}")]
|
|
HexErr { error: hex::FromHexError, location: String },
|
|
#[error("Migrating database: {0}")]
|
|
DatabaseMigration(#[from] MigrateError),
|
|
#[error("Non-zero exit status for {0}: {1}")]
|
|
ExitStatus(String, i32),
|
|
#[error("ExecutionRawError: {0}")]
|
|
ExecutionRawError(Box<serde_json::value::RawValue>),
|
|
#[error("Error: {error:#} @{location:#}")]
|
|
Anyhow { error: anyhow::Error, location: String },
|
|
#[error("{}", format_json_err_message(.0))]
|
|
JsonErr(serde_json::Value),
|
|
#[error("{0}")]
|
|
AIError(String),
|
|
#[error("{0}")]
|
|
AlreadyCompleted(String),
|
|
#[error("WAC job suspended: {0}")]
|
|
WacSuspended(String),
|
|
#[error("Find python error: {0}")]
|
|
FindPythonError(String),
|
|
#[error("Problem with arguments: {0}")]
|
|
ArgumentErr(String),
|
|
#[error("{1}")]
|
|
Generic(StatusCode, String),
|
|
#[error("{feature} is unavailable due to some workers being behind. Do not use the feature or make sure all workers run at least {min_version}")]
|
|
WorkersAreBehind { feature: String, min_version: String },
|
|
#[error(
|
|
"Breaking change was introduced in v{version} ({feature}). Follow this migration guide: {guide_url}"
|
|
)]
|
|
MigrationNeeded { version: String, feature: String, guide_url: url::Url },
|
|
#[error("{0} is unavailable. It is possible for this worker to be behind.")]
|
|
FeatureUnavailable(String),
|
|
}
|
|
|
|
impl Error {
|
|
pub fn name(&self) -> &str {
|
|
match self {
|
|
Self::ExecutionErr(_) => "ExecutionErr",
|
|
Self::ResultTooLarge(_) => "ResultTooLarge",
|
|
Self::BadRequest(_) => "BadRequest",
|
|
Self::QuotaExceeded(_) => "QuotaExceeded",
|
|
Self::InternalErr(_) => "InternalErr",
|
|
Self::InternalErrLoc { .. } => "InternalErr",
|
|
Self::InternalErrAt(_, _) => "InternalErr",
|
|
Self::Anyhow { .. } => "Anyhow",
|
|
Self::JsonErr(_) => "JsonErr",
|
|
Self::AIError(_) => "AIError",
|
|
Self::AlreadyCompleted(_) => "AlreadyCompleted",
|
|
Self::WacSuspended(_) => "WacSuspended",
|
|
Self::FindPythonError(_) => "FindPythonError",
|
|
Self::ArgumentErr(_) => "ArgumentErr",
|
|
Self::Generic(_, _) => "Generic",
|
|
Self::IoErr { .. } => "IoErr",
|
|
Self::Utf8Err { .. } => "Utf8Err",
|
|
Self::UuidErr { .. } => "UuidErr",
|
|
Self::SqlErr { .. } => "SqlErr",
|
|
Self::SerdeJson { .. } => "SerdeJson",
|
|
Self::HexErr { .. } => "HexErr",
|
|
Self::DatabaseMigration(_) => "DatabaseMigration",
|
|
Self::ExitStatus(_, _) => "ExitStatus",
|
|
Self::ExecutionRawError(_) => "ExecutionRawError",
|
|
Self::BadGateway(_) => "BadGateway",
|
|
Self::BadConfig(_) => "BadConfig",
|
|
Self::ConnectingToDatabase(_) => "ConnectingToDatabase",
|
|
Self::NotFound(_) => "NotFound",
|
|
Self::NotAuthorized(_) => "NotAuthorized",
|
|
Self::MetricNotFound(_) => "MetricNotFound",
|
|
Self::PermissionDenied(_) => "PermissionDenied",
|
|
_ => "InternalErr",
|
|
}
|
|
}
|
|
}
|
|
|
|
fn prettify_location(location: &'static Location<'static>) -> String {
|
|
location
|
|
.to_string()
|
|
.split("/")
|
|
.last()
|
|
.unwrap_or("unknown")
|
|
.to_string()
|
|
}
|
|
impl From<anyhow::Error> for Error {
|
|
#[track_caller]
|
|
fn from(e: anyhow::Error) -> Self {
|
|
Self::Anyhow { error: e, location: prettify_location(std::panic::Location::caller()) }
|
|
}
|
|
}
|
|
|
|
impl From<sqlx::Error> for Error {
|
|
#[track_caller]
|
|
fn from(e: sqlx::Error) -> Self {
|
|
Self::SqlErr { error: e, location: prettify_location(std::panic::Location::caller()) }
|
|
}
|
|
}
|
|
|
|
impl From<uuid::Error> for Error {
|
|
#[track_caller]
|
|
fn from(e: uuid::Error) -> Self {
|
|
Self::UuidErr { error: e, location: prettify_location(std::panic::Location::caller()) }
|
|
}
|
|
}
|
|
|
|
impl From<std::string::FromUtf8Error> for Error {
|
|
#[track_caller]
|
|
fn from(e: std::string::FromUtf8Error) -> Self {
|
|
Self::Utf8Err { error: e, location: prettify_location(std::panic::Location::caller()) }
|
|
}
|
|
}
|
|
|
|
impl From<io::Error> for Error {
|
|
#[track_caller]
|
|
fn from(e: io::Error) -> Self {
|
|
Self::IoErr { error: e, location: prettify_location(std::panic::Location::caller()) }
|
|
}
|
|
}
|
|
|
|
impl From<hex::FromHexError> for Error {
|
|
#[track_caller]
|
|
fn from(e: hex::FromHexError) -> Self {
|
|
Self::HexErr { error: e, location: prettify_location(std::panic::Location::caller()) }
|
|
}
|
|
}
|
|
|
|
impl From<serde_json::Error> for Error {
|
|
#[track_caller]
|
|
fn from(e: serde_json::Error) -> Self {
|
|
Self::SerdeJson { error: e, location: prettify_location(std::panic::Location::caller()) }
|
|
}
|
|
}
|
|
|
|
impl From<url::ParseError> for Error {
|
|
#[track_caller]
|
|
fn from(e: url::ParseError) -> Self {
|
|
Self::ArgumentErr(format!("Cannot parse provided url. \ne: {e}"))
|
|
}
|
|
}
|
|
|
|
impl From<tokio::time::error::Elapsed> for Error {
|
|
fn from(value: tokio::time::error::Elapsed) -> Self {
|
|
Self::InternalErr(value.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<semver::Error> for Error {
|
|
fn from(e: semver::Error) -> Self {
|
|
Self::ArgumentErr(format!("Cannot parse provided semver: {e}"))
|
|
}
|
|
}
|
|
|
|
impl Error {
|
|
/// https://docs.rs/anyhow/1/anyhow/struct.Error.html#display-representations
|
|
pub fn alt(&self) -> String {
|
|
format!("{:#}", self)
|
|
}
|
|
|
|
pub fn dbg(&self) -> String {
|
|
format!("{:?}", self)
|
|
}
|
|
|
|
pub fn relocate_internal(self, loc: &'static Location<'static>) -> Self {
|
|
match self {
|
|
Self::InternalErrLoc { message, .. }
|
|
| Self::InternalErrAt(_, message)
|
|
| Self::InternalErr(message) => Self::InternalErrAt(loc, message),
|
|
_ => self,
|
|
}
|
|
}
|
|
|
|
#[track_caller]
|
|
pub fn internal_err<T: AsRef<str>>(msg: T) -> Self {
|
|
Self::InternalErrLoc {
|
|
message: msg.as_ref().to_string(),
|
|
location: prettify_location(std::panic::Location::caller()),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn relocate_internal(loc: &'static Location<'static>) -> impl FnOnce(Error) -> Error {
|
|
move |e| e.relocate_internal(loc)
|
|
}
|
|
|
|
pub fn to_anyhow<T: 'static + std::error::Error + Send + Sync>(e: T) -> anyhow::Error {
|
|
From::from(e)
|
|
}
|
|
|
|
/// Render a `tokio_postgres` error for a user-facing message.
|
|
///
|
|
/// The pinned rust-postgres build prints only the error *kind* in its `Display`
|
|
/// impl, so `format!("{e}")` on one of these yields the useless `db error` and
|
|
/// drops the Postgres message. Interpolate errors from a `tokio_postgres::Client`
|
|
/// through this instead.
|
|
pub fn pg_error_message(e: &tokio_postgres::Error) -> String {
|
|
match e.as_db_error() {
|
|
Some(db_err) => format_db_error(db_err.message(), db_err.detail(), db_err.hint()),
|
|
// Non-database failures (io, tls, protocol) keep their message in the cause.
|
|
None => error_source_chain(e),
|
|
}
|
|
}
|
|
|
|
fn format_db_error(message: &str, detail: Option<&str>, hint: Option<&str>) -> String {
|
|
let mut msg = message.to_string();
|
|
if let Some(detail) = detail {
|
|
msg.push_str(&format!(" ({detail})"));
|
|
}
|
|
if let Some(hint) = hint {
|
|
msg.push_str(&format!(". Hint: {hint}"));
|
|
}
|
|
msg
|
|
}
|
|
|
|
fn error_source_chain(e: &dyn std::error::Error) -> String {
|
|
let mut msg = e.to_string();
|
|
let mut source = e.source();
|
|
while let Some(cause) = source {
|
|
msg.push_str(&format!(": {cause}"));
|
|
source = cause.source();
|
|
}
|
|
msg
|
|
}
|
|
|
|
impl IntoResponse for Error {
|
|
fn into_response(self) -> axum::response::Response {
|
|
let status = match self {
|
|
Self::NotFound(_) => axum::http::StatusCode::NOT_FOUND,
|
|
Self::NotAuthorized(_) => axum::http::StatusCode::UNAUTHORIZED,
|
|
Self::RequireAdmin(_) | Self::PermissionDenied(_) => axum::http::StatusCode::FORBIDDEN,
|
|
Self::SqlErr { .. }
|
|
| Self::BadRequest(_)
|
|
| Self::AIError(_)
|
|
| Self::JsonErr(_)
|
|
| Self::QuotaExceeded(_) => axum::http::StatusCode::BAD_REQUEST,
|
|
Self::BadGateway(_) => axum::http::StatusCode::BAD_GATEWAY,
|
|
Self::Generic(status_code, _) => status_code,
|
|
_ => axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
|
};
|
|
|
|
let e = &self;
|
|
|
|
if matches!(status, axum::http::StatusCode::NOT_FOUND) {
|
|
tracing::warn!(message = e.to_string());
|
|
} else {
|
|
tracing::error!(message = e.to_string(), error = ?e);
|
|
};
|
|
|
|
let body = Body::from(e.to_string());
|
|
|
|
axum::response::Response::builder()
|
|
.header("Content-Type", "text/plain")
|
|
.status(status)
|
|
.body(body)
|
|
.unwrap()
|
|
}
|
|
}
|
|
|
|
/// Render a `JsonErr` payload as a readable message suitable for direct
|
|
/// display in a toast: surface the `error` field as the headline, append a
|
|
/// short summary of `details` (e.g. duplicate paths) when present, and fall
|
|
/// back to pretty JSON for unknown shapes. Avoids the Rust `Debug` output
|
|
/// (`Object { "error": String("..."), ... }`) that previously leaked to users.
|
|
fn format_json_err_message(v: &serde_json::Value) -> String {
|
|
if let Some(obj) = v.as_object() {
|
|
let headline = obj
|
|
.get("error")
|
|
.and_then(|e| e.as_str())
|
|
.map(|s| s.to_string());
|
|
let details_summary = obj.get("details").and_then(|d| {
|
|
let arr = d.as_array()?;
|
|
if arr.is_empty() {
|
|
return None;
|
|
}
|
|
let preview = arr
|
|
.iter()
|
|
.take(5)
|
|
.map(|item| match item {
|
|
serde_json::Value::Object(o) => {
|
|
let parts: Vec<String> = o
|
|
.iter()
|
|
.map(|(k, val)| match val {
|
|
serde_json::Value::String(s) => format!("{k}={s}"),
|
|
_ => format!("{k}={val}"),
|
|
})
|
|
.collect();
|
|
format!("- {}", parts.join(", "))
|
|
}
|
|
serde_json::Value::String(s) => format!("- {s}"),
|
|
other => format!("- {other}"),
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
let suffix = if arr.len() > 5 {
|
|
format!("\n... ({} more)", arr.len() - 5)
|
|
} else {
|
|
String::new()
|
|
};
|
|
Some(format!("{preview}{suffix}"))
|
|
});
|
|
|
|
match (headline, details_summary) {
|
|
(Some(h), Some(d)) => return format!("{h}\n{d}"),
|
|
(Some(h), None) => return h,
|
|
(None, Some(d)) => return d,
|
|
(None, None) => {}
|
|
}
|
|
}
|
|
serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string())
|
|
}
|
|
|
|
pub trait OrElseNotFound<T> {
|
|
fn or_else_not_found(self, s: impl ToString) -> Result<T>;
|
|
}
|
|
|
|
impl<T> OrElseNotFound<T> for Option<T> {
|
|
fn or_else_not_found(self, s: impl ToString) -> Result<T> {
|
|
self.ok_or_else(|| Error::NotFound(s.to_string()))
|
|
}
|
|
}
|
|
|
|
// Make our own error that wraps `anyhow::Error`.
|
|
pub struct AppError(anyhow::Error);
|
|
|
|
// Tell axum how to convert `AppError` into a response.
|
|
impl IntoResponse for AppError {
|
|
fn into_response(self) -> Response {
|
|
let body = Body::from(self.0.to_string());
|
|
tracing::error!(error = self.0.to_string());
|
|
axum::response::Response::builder()
|
|
.header("Content-Type", "text/plain")
|
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
|
.body(body)
|
|
.unwrap()
|
|
}
|
|
}
|
|
|
|
// This enables using `?` on functions that return `Result<_, anyhow::Error>` to turn them into
|
|
// `Result<_, AppError>`. That way you don't need to do that manually
|
|
impl<E> From<E> for AppError
|
|
where
|
|
E: Into<anyhow::Error>,
|
|
{
|
|
fn from(err: E) -> Self {
|
|
Self(err.into())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
#[test]
|
|
fn json_err_message_error_and_details() {
|
|
let v = json!({
|
|
"error": "Duplicate HTTP route paths detected",
|
|
"details": [
|
|
{ "route_path": "a", "workspace_id": "admins", "http_method": "post" },
|
|
{ "route_path": "a", "workspace_id": "starter", "http_method": "post" },
|
|
],
|
|
});
|
|
let rendered = Error::JsonErr(v).to_string();
|
|
assert_eq!(
|
|
rendered,
|
|
"Duplicate HTTP route paths detected\n\
|
|
- route_path=a, workspace_id=admins, http_method=post\n\
|
|
- route_path=a, workspace_id=starter, http_method=post"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn json_err_message_error_only() {
|
|
let v = json!({ "error": "Something went wrong" });
|
|
assert_eq!(Error::JsonErr(v).to_string(), "Something went wrong");
|
|
}
|
|
|
|
#[test]
|
|
fn json_err_message_truncates_long_details() {
|
|
let details: Vec<_> = (0..8).map(|i| json!({ "k": i })).collect();
|
|
let v = json!({ "error": "boom", "details": details });
|
|
let rendered = Error::JsonErr(v).to_string();
|
|
assert!(rendered.starts_with("boom\n- k=0\n- k=1\n- k=2\n- k=3\n- k=4"));
|
|
assert!(rendered.ends_with("... (3 more)"));
|
|
// Items beyond the cap aren't enumerated.
|
|
assert!(!rendered.contains("- k=5"));
|
|
}
|
|
|
|
#[test]
|
|
fn json_err_message_fallback_to_pretty_json() {
|
|
let v = json!([1, 2, 3]);
|
|
// Non-object payload falls back to pretty JSON instead of leaking
|
|
// Rust `Debug` syntax.
|
|
let rendered = Error::JsonErr(v).to_string();
|
|
assert_eq!(rendered, "[\n 1,\n 2,\n 3\n]");
|
|
}
|
|
|
|
#[test]
|
|
fn db_error_renders_message_with_detail_and_hint() {
|
|
assert_eq!(
|
|
super::format_db_error("permission denied for schema public", None, None),
|
|
"permission denied for schema public"
|
|
);
|
|
assert_eq!(
|
|
super::format_db_error("insert violates foreign key", Some("Key (id)=(1)"), None),
|
|
"insert violates foreign key (Key (id)=(1))"
|
|
);
|
|
assert_eq!(
|
|
super::format_db_error(
|
|
"column does not exist",
|
|
None,
|
|
Some("Perhaps you meant \"b\"")
|
|
),
|
|
"column does not exist. Hint: Perhaps you meant \"b\""
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn non_db_error_walks_the_source_chain() {
|
|
#[derive(Debug)]
|
|
struct Layer(&'static str, Option<Box<Layer>>);
|
|
impl std::fmt::Display for Layer {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.write_str(self.0)
|
|
}
|
|
}
|
|
impl std::error::Error for Layer {
|
|
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
|
self.1
|
|
.as_ref()
|
|
.map(|c| c.as_ref() as &(dyn std::error::Error + 'static))
|
|
}
|
|
}
|
|
|
|
// The pinned rust-postgres build renders only the kind, so everything
|
|
// actionable is in the causes: they must all reach the message.
|
|
let err = Layer(
|
|
"error connecting to server",
|
|
Some(Box::new(Layer(
|
|
"tcp connect error",
|
|
Some(Box::new(Layer("timed out", None))),
|
|
))),
|
|
);
|
|
assert_eq!(
|
|
super::error_source_chain(&err),
|
|
"error connecting to server: tcp connect error: timed out"
|
|
);
|
|
}
|
|
}
|