use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::Serialize;
use super::auth::AdminAuthError;
use crate::backends::control_plane::ControlPlaneError;
use crate::desired_state::{
CanonicalError, ExpectedRevision, IdempotencyKey, InvalidIdempotencyKey, ResourceRef,
RevisionId, ValidationError,
};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AdminError {
#[error("administrative authentication failed")]
Unauthenticated(#[source] AdminAuthError),
#[error("administrative authorization failed")]
Forbidden(#[source] AdminAuthError),
#[error("the identity provider could not be consulted")]
IdentityProviderUnavailable,
#[error("this deployment is stateless; /admin/v1 administers durable state in stateful mode")]
StatefulModeRequired,
#[error("an administrative mutation requires an `Idempotency-Key` header")]
IdempotencyKeyRequired,
#[error("the `Idempotency-Key` header is not a usable key: {0}")]
IdempotencyKeyInvalid(#[source] InvalidIdempotencyKey),
#[error("idempotency key `{key}` already published revision {published} with other state")]
IdempotencyKeyReused {
key: IdempotencyKey,
published: RevisionId,
},
#[error("an administrative mutation requires an `X-Axond-Expected-Revision` header")]
ExpectedRevisionRequired,
#[error("the `X-Axond-Expected-Revision` header is neither `empty` nor a revision id")]
ExpectedRevisionInvalid,
#[error("expected {expected} to be current, but the newest is {actual:?}")]
RevisionConflict {
expected: ExpectedRevision,
actual: Option<RevisionId>,
},
#[error("the candidate revision is not valid desired state: {rule}")]
ValidationFailed {
rule: &'static str,
reference: Option<ResourceRef>,
detail: String,
},
#[error("{reference} is already published with different content; publish a new version")]
ImmutableResourceVersion { reference: ResourceRef },
#[error("revision {0} is not retained")]
RevisionNotFound(RevisionId),
#[error("stored control-plane state is unreadable")]
RevisionUnreadable {
revision: Option<RevisionId>,
detail: String,
},
#[error("stored revision {revision} is not compatible with this build")]
RevisionIncompatible {
revision: RevisionId,
detail: String,
},
#[error("stored revision {revision} exceeds what this build reads")]
RevisionTooLarge {
revision: RevisionId,
detail: String,
},
#[error("the control plane is unavailable")]
ControlPlaneUnavailable { detail: String },
#[error("the control plane refused the operation")]
ControlPlaneDenied { detail: String },
#[error("the {noun} name `{name}` is already taken")]
NameTaken {
noun: &'static str,
name: String,
detail: String,
},
#[error("the audit summary is empty, too long, or not printable")]
AuditSummaryInvalid,
#[error("the `X-Axond-Dry-Run` header must be `true` or `false`")]
DryRunInvalid,
#[error("a history request may ask for at most {max} revisions")]
HistoryLimitInvalid { max: u32 },
#[error("the request body is not a valid `{schema}` document: {detail}")]
RequestInvalid {
schema: &'static str,
detail: String,
},
#[error("the request body exceeds the {limit}-byte administrative limit")]
RequestTooLarge { limit: usize },
#[error("no such /admin/v1 route")]
RouteNotFound,
#[error("that method is not allowed on this /admin/v1 route")]
MethodNotAllowed,
}
impl AdminError {
pub const CODES: &'static [&'static str] = &[
"admin_unauthenticated",
"admin_forbidden",
"identity_provider_unavailable",
"stateful_mode_required",
"idempotency_key_required",
"idempotency_key_invalid",
"idempotency_key_reused",
"expected_revision_required",
"expected_revision_invalid",
"revision_conflict",
"validation_failed",
"immutable_resource_version",
"revision_not_found",
"revision_unreadable",
"revision_incompatible",
"revision_too_large",
"control_plane_unavailable",
"control_plane_denied",
"name_taken",
"audit_summary_invalid",
"dry_run_invalid",
"history_limit_invalid",
"admin_request_invalid",
"admin_request_too_large",
"admin_route_not_found",
"admin_method_not_allowed",
];
pub const fn code(&self) -> &'static str {
match self {
Self::Unauthenticated(_) => "admin_unauthenticated",
Self::Forbidden(_) => "admin_forbidden",
Self::IdentityProviderUnavailable => "identity_provider_unavailable",
Self::StatefulModeRequired => "stateful_mode_required",
Self::IdempotencyKeyRequired => "idempotency_key_required",
Self::IdempotencyKeyInvalid(_) => "idempotency_key_invalid",
Self::IdempotencyKeyReused { .. } => "idempotency_key_reused",
Self::ExpectedRevisionRequired => "expected_revision_required",
Self::ExpectedRevisionInvalid => "expected_revision_invalid",
Self::RevisionConflict { .. } => "revision_conflict",
Self::ValidationFailed { .. } => "validation_failed",
Self::ImmutableResourceVersion { .. } => "immutable_resource_version",
Self::RevisionNotFound(_) => "revision_not_found",
Self::RevisionUnreadable { .. } => "revision_unreadable",
Self::RevisionIncompatible { .. } => "revision_incompatible",
Self::RevisionTooLarge { .. } => "revision_too_large",
Self::ControlPlaneUnavailable { .. } => "control_plane_unavailable",
Self::ControlPlaneDenied { .. } => "control_plane_denied",
Self::NameTaken { .. } => "name_taken",
Self::AuditSummaryInvalid => "audit_summary_invalid",
Self::DryRunInvalid => "dry_run_invalid",
Self::HistoryLimitInvalid { .. } => "history_limit_invalid",
Self::RequestInvalid { .. } => "admin_request_invalid",
Self::RequestTooLarge { .. } => "admin_request_too_large",
Self::RouteNotFound => "admin_route_not_found",
Self::MethodNotAllowed => "admin_method_not_allowed",
}
}
pub const fn status(&self) -> StatusCode {
match self {
Self::Unauthenticated(_) => StatusCode::UNAUTHORIZED,
Self::Forbidden(_) => StatusCode::FORBIDDEN,
Self::ExpectedRevisionRequired => StatusCode::PRECONDITION_REQUIRED,
Self::IdempotencyKeyRequired
| Self::IdempotencyKeyInvalid(_)
| Self::ExpectedRevisionInvalid
| Self::ValidationFailed { .. }
| Self::AuditSummaryInvalid
| Self::DryRunInvalid
| Self::HistoryLimitInvalid { .. }
| Self::RequestInvalid { .. } => StatusCode::BAD_REQUEST,
Self::RevisionConflict { .. }
| Self::IdempotencyKeyReused { .. }
| Self::NameTaken { .. }
| Self::ImmutableResourceVersion { .. } => StatusCode::CONFLICT,
Self::RequestTooLarge { .. } => StatusCode::PAYLOAD_TOO_LARGE,
Self::RevisionNotFound(_) | Self::RouteNotFound => StatusCode::NOT_FOUND,
Self::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
Self::StatefulModeRequired => StatusCode::NOT_IMPLEMENTED,
Self::ControlPlaneUnavailable { .. } | Self::IdentityProviderUnavailable => {
StatusCode::SERVICE_UNAVAILABLE
}
Self::RevisionUnreadable { .. }
| Self::RevisionIncompatible { .. }
| Self::RevisionTooLarge { .. }
| Self::ControlPlaneDenied { .. } => StatusCode::INTERNAL_SERVER_ERROR,
}
}
pub const fn retryable(&self) -> bool {
matches!(
self,
Self::ControlPlaneUnavailable { .. } | Self::IdentityProviderUnavailable
)
}
pub fn operator_detail(&self) -> Option<&str> {
match self {
Self::ValidationFailed { detail, .. }
| Self::RevisionUnreadable { detail, .. }
| Self::RevisionIncompatible { detail, .. }
| Self::RevisionTooLarge { detail, .. }
| Self::ControlPlaneUnavailable { detail }
| Self::ControlPlaneDenied { detail }
| Self::NameTaken { detail, .. }
| Self::RequestInvalid { detail, .. } => Some(detail),
_ => None,
}
}
pub const fn rule(&self) -> Option<&'static str> {
match self {
Self::ValidationFailed { rule, .. } => Some(rule),
_ => None,
}
}
pub const fn revision(&self) -> Option<RevisionId> {
match self {
Self::RevisionNotFound(revision)
| Self::RevisionIncompatible { revision, .. }
| Self::RevisionTooLarge { revision, .. } => Some(*revision),
Self::IdempotencyKeyReused { published, .. } => Some(*published),
Self::RevisionUnreadable { revision, .. } => *revision,
Self::RevisionConflict { actual, .. } => *actual,
_ => None,
}
}
pub const fn reference(&self) -> Option<ResourceRef> {
match self {
Self::ImmutableResourceVersion { reference } => Some(*reference),
Self::ValidationFailed { reference, .. } => *reference,
_ => None,
}
}
pub fn envelope(&self) -> AdminErrorEnvelope {
AdminErrorEnvelope {
error: AdminErrorBody {
code: self.code(),
message: self.to_string(),
retryable: self.retryable(),
rule: self.rule(),
resource: self.reference().map(|reference| reference.to_string()),
revision: self.revision().map(|revision| revision.to_string()),
},
}
}
pub fn from_control_plane(error: ControlPlaneError) -> Self {
match error {
ControlPlaneError::Unavailable { backend, message } => Self::ControlPlaneUnavailable {
detail: format!("{backend}: {message}"),
},
ControlPlaneError::Conflict { expected, actual } => {
Self::RevisionConflict { expected, actual }
}
ControlPlaneError::RevisionNotFound(revision) => Self::RevisionNotFound(revision),
ControlPlaneError::Invalid(error) => Self::from(error),
ControlPlaneError::ImmutableResourceVersion { reference } => {
Self::ImmutableResourceVersion { reference }
}
ControlPlaneError::IdempotencyKeyReused { key, published } => {
Self::IdempotencyKeyReused { key, published }
}
ControlPlaneError::Denied { backend, message } => Self::ControlPlaneDenied {
detail: format!("{backend}: {message}"),
},
ControlPlaneError::NameTaken { noun, name, holder } => Self::NameTaken {
noun,
detail: holder.map_or_else(
|| format!("the {noun} name `{name}` is already projected"),
|constraint| {
format!("the {noun} name `{name}` violates the unique index {constraint}")
},
),
name,
},
ControlPlaneError::Corrupt { revision, source } => Self::RevisionUnreadable {
revision: Some(revision),
detail: source.to_string(),
},
ControlPlaneError::CorruptStorage { detail } => Self::RevisionUnreadable {
revision: None,
detail,
},
ControlPlaneError::Incompatible { revision, source } => Self::RevisionIncompatible {
revision,
detail: source.to_string(),
},
ControlPlaneError::TooLarge { revision, limit } => Self::RevisionTooLarge {
revision,
detail: limit.to_string(),
},
}
}
}
fn validation_rule(error: &ValidationError) -> (&'static str, Option<ResourceRef>) {
match error {
ValidationError::Empty => ("empty_revision", None),
ValidationError::DuplicateResourceVersion { reference } => {
("duplicate_resource_version", Some(*reference))
}
ValidationError::MultipleVersions { first, .. } => ("multiple_versions", Some(*first)),
ValidationError::VersionNotAdvanced { proposed, .. } => {
("version_not_advanced", Some(*proposed))
}
ValidationError::DuplicateSlug { first, .. } => ("duplicate_slug", Some(*first)),
ValidationError::ScopeMismatch { reference, .. } => ("scope_mismatch", Some(*reference)),
ValidationError::DanglingResourceReference { from, .. } => {
("dangling_resource_reference", Some(*from))
}
ValidationError::DanglingBlobReference { from, .. } => {
("dangling_blob_reference", Some(*from))
}
ValidationError::UnreferencedBlob { .. } => ("unreferenced_blob", None),
ValidationError::PinnedSnapshotWithdrawn { enablement, .. } => {
("pinned_snapshot_withdrawn", Some(*enablement))
}
ValidationError::CrossTenantReference { from, .. } => {
("cross_tenant_reference", Some(*from))
}
ValidationError::TenantScopedDependency { from, .. } => {
("tenant_scoped_dependency", Some(*from))
}
ValidationError::Tenancy(_) => ("tenancy", None),
ValidationError::Credential(_) => ("provider_credential", None),
ValidationError::CredentialTransition(_) => ("credential_transition", None),
ValidationError::Policy(policy) => ("policy", Some(policy.reference())),
ValidationError::Provider(_) => ("provider_connection", None),
ValidationError::Model(_) => ("model_contract", None),
ValidationError::Pricing(pricing) => ("price_book", Some(pricing.reference())),
ValidationError::AuditMutationMismatch { .. } => ("audit_mutation_mismatch", None),
ValidationError::Canonical(_) => ("not_canonical", None),
}
}
impl From<ValidationError> for AdminError {
fn from(error: ValidationError) -> Self {
let (rule, reference) = validation_rule(&error);
Self::ValidationFailed {
rule,
reference,
detail: error.to_string(),
}
}
}
impl From<CanonicalError> for AdminError {
fn from(error: CanonicalError) -> Self {
Self::from(ValidationError::from(error))
}
}
impl From<ControlPlaneError> for AdminError {
fn from(error: ControlPlaneError) -> Self {
Self::from_control_plane(error)
}
}
impl From<AdminAuthError> for AdminError {
fn from(error: AdminAuthError) -> Self {
if error.is_unavailable() {
Self::IdentityProviderUnavailable
} else if error.is_authorization() {
Self::Forbidden(error)
} else {
Self::Unauthenticated(error)
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AdminErrorEnvelope {
pub error: AdminErrorBody,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct AdminErrorBody {
#[serde(rename = "type")]
pub code: &'static str,
pub message: String,
pub retryable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub rule: Option<&'static str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub resource: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub revision: Option<String>,
}
impl IntoResponse for AdminError {
fn into_response(self) -> Response {
(self.status(), Json(self.envelope())).into_response()
}
}