cloudfoundry/cf-k8s-controllers

View on GitHub
api/errors/errors.go

Summary

Maintainability
A
0 mins
Test Coverage
package errors

import (
    "errors"
    "fmt"
    "net/http"
    "reflect"
    "strings"

    "code.cloudfoundry.org/korifi/controllers/webhooks/validation"
    "github.com/go-logr/logr"
    k8serrors "k8s.io/apimachinery/pkg/api/errors"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

type ApiError interface {
    Detail() string
    Title() string
    Code() int
    HttpStatus() int
    Unwrap() error
    Error() string
}

// LogAndReturn logs api errors at the info level and other errors at the
// error level since api errors are expected recoverable conditions.
// It returns the error for convenience.
func LogAndReturn(logger logr.Logger, err error, msg string, keysAndValues ...interface{}) error {
    var apiError ApiError
    if errors.As(err, &apiError) {
        keysAndValues = append(keysAndValues, "reason", err)
        logger.Info(msg, keysAndValues...)
    } else {
        logger.Error(err, msg, keysAndValues...)
    }

    return err
}

type apiError struct {
    cause             error
    detail            string
    title             string
    code              int
    httpStatus        int
    additionalDetails map[string]string
}

func (e apiError) Error() string {
    if e.cause == nil {
        return "unknown"
    }

    return e.cause.Error()
}

func (e apiError) Unwrap() error {
    return e.cause
}

func (e apiError) Detail() string {
    detail := e.detail
    for k, v := range e.additionalDetails {
        detail += fmt.Sprintf(" %s=%q", k, v)
    }
    return detail
}

func (e apiError) Title() string {
    return e.title
}

func (e apiError) Code() int {
    return e.code
}

func (e apiError) HttpStatus() int {
    return e.httpStatus
}

func toKeyValues(s ...string) map[string]string {
    result := map[string]string{}

    for i := 0; i < len(s); i += 2 {
        key := s[i]
        val := ""
        if i+1 < len(s) {
            val = s[i+1]
        }
        result[key] = val
    }

    return result
}

type UnprocessableEntityError struct {
    apiError
}

func NewUnprocessableEntityError(cause error, detail string) UnprocessableEntityError {
    return UnprocessableEntityError{
        apiError{
            cause:      cause,
            title:      "CF-UnprocessableEntity",
            detail:     detail,
            code:       10008,
            httpStatus: http.StatusUnprocessableEntity,
        },
    }
}

type MessageParseError struct {
    apiError
}

func NewMessageParseError(cause error) MessageParseError {
    return MessageParseError{
        apiError{
            cause:      cause,
            title:      "CF-MessageParseError",
            detail:     "Request invalid due to parse error: invalid request body",
            code:       1001,
            httpStatus: http.StatusBadRequest,
        },
    }
}

// UnknownError is a generic wrapper over an error Korifi cannot recover from.
// Unknown errors should be only used by the presentation layer to present such
// an error to the user. Other components (handlers, repositories, etc.) should
// simply return the incoming error, it would be mapped to `UnknownError` by
// the presentation layer
type UnknownError struct {
    apiError
}

// NewUnknownError creates an UnknownError. One should generally not create
// unknown errors as generic errors are automatically presented as unknown
// errors to the user
func NewUnknownError(cause error) UnknownError {
    return UnknownError{
        apiError{
            cause:      cause,
            title:      "UnknownError",
            detail:     "An unknown error occurred.",
            code:       10001,
            httpStatus: http.StatusInternalServerError,
        },
    }
}

type NotFoundError struct {
    apiError
}

func NewNotFoundError(cause error, resourceType string, additionalDetails ...string) NotFoundError {
    return NotFoundError{
        apiError{
            cause:             cause,
            title:             "CF-ResourceNotFound",
            detail:            fmt.Sprintf("%s not found. Ensure it exists and you have access to it.", resourceType),
            additionalDetails: toKeyValues(additionalDetails...),
            code:              10010,
            httpStatus:        http.StatusNotFound,
        },
    }
}

type EndpointNotFoundError struct {
    apiError
}

func NewEndpointNotFoundError() EndpointNotFoundError {
    return EndpointNotFoundError{
        apiError{
            title:      "CF-NotFound",
            detail:     "Unknown request",
            code:       10000,
            httpStatus: http.StatusNotFound,
        },
    }
}

type InvalidAuthError struct {
    apiError
}

func NewInvalidAuthError(cause error) InvalidAuthError {
    return InvalidAuthError{
        apiError{
            cause:      cause,
            title:      "CF-InvalidAuthToken",
            detail:     "Invalid Auth Token",
            code:       1000,
            httpStatus: http.StatusUnauthorized,
        },
    }
}

type NotAuthenticatedError struct {
    apiError
}

func NewNotAuthenticatedError(cause error) NotAuthenticatedError {
    return NotAuthenticatedError{
        apiError{
            cause:      cause,
            title:      "CF-NotAuthenticated",
            detail:     "Authentication error",
            code:       10002,
            httpStatus: http.StatusUnauthorized,
        },
    }
}

type ForbiddenError struct {
    apiError
    resourceType string
}

func (e ForbiddenError) ResourceType() string {
    return e.resourceType
}

func NewForbiddenError(cause error, resourceType string) ForbiddenError {
    return ForbiddenError{
        apiError: apiError{
            cause:      cause,
            title:      "CF-NotAuthorized",
            detail:     "You are not authorized to perform the requested action",
            code:       10003,
            httpStatus: http.StatusForbidden,
        },
        resourceType: resourceType,
    }
}

type BadQueryParamValueError struct {
    apiError
}

func NewBadQueryParamValueError(key string, validValues ...string) BadQueryParamValueError {
    return BadQueryParamValueError{
        apiError: apiError{
            title:      "CF-BadQueryParameter",
            detail:     fmt.Sprintf("The query parameter is invalid: %s can only be: %s", key, quotedCommaSeparatedList(validValues)),
            code:       10005,
            httpStatus: http.StatusBadRequest,
        },
    }
}

type UnknownKeyError struct {
    apiError
}

func NewUnknownKeyError(cause error, validKeys []string) UnknownKeyError {
    return UnknownKeyError{
        apiError: apiError{
            cause:      cause,
            title:      "CF-BadQueryParameter",
            detail:     fmt.Sprintf("The query parameter is invalid: Valid parameters are: %s", quotedCommaSeparatedList(validKeys)),
            code:       10005,
            httpStatus: http.StatusBadRequest,
        },
    }
}

func quotedCommaSeparatedList(in []string) string {
    var out []string
    for _, i := range in {
        out = append(out, fmt.Sprintf("'%s'", i))
    }
    return strings.Join(out, ", ")
}

type UniquenessError struct {
    apiError
}

func NewUniquenessError(cause error, detail string) UniquenessError {
    return UniquenessError{
        apiError: apiError{
            cause:      cause,
            title:      "CF-UniquenessError",
            detail:     detail,
            code:       10016,
            httpStatus: http.StatusUnprocessableEntity,
        },
    }
}

type InvalidRequestError struct {
    apiError
}

func NewInvalidRequestError(cause error, detail string) InvalidRequestError {
    return InvalidRequestError{
        apiError: apiError{
            cause:      cause,
            title:      "CF-InvalidRequest",
            detail:     detail,
            code:       10004,
            httpStatus: http.StatusBadRequest,
        },
    }
}

type PackageBitsAlreadyUploadedError struct {
    apiError
}

func NewPackageBitsAlreadyUploadedError(cause error) PackageBitsAlreadyUploadedError {
    return PackageBitsAlreadyUploadedError{
        apiError: apiError{
            cause:      cause,
            title:      "CF-PackageBitsAlreadyUploaded",
            detail:     "Bits may be uploaded only once. Create a new package to upload different bits.",
            code:       150004,
            httpStatus: http.StatusBadRequest,
        },
    }
}

type BlobstoreUnavailableError struct {
    apiError
}

func NewBlobstoreUnavailableError(cause error) BlobstoreUnavailableError {
    return BlobstoreUnavailableError{
        apiError: apiError{
            cause:      cause,
            title:      "CF-BlobstoreUnavailable",
            detail:     "Error uploading source package to the container registry",
            code:       150006,
            httpStatus: http.StatusBadGateway,
        },
    }
}

type ResourceNotReadyError struct {
    apiError
}

func NewResourceNotReadyError(cause error) ResourceNotReadyError {
    return ResourceNotReadyError{
        apiError: apiError{
            cause:      cause,
            title:      "CF-ResourceNotReady",
            detail:     cause.Error(),
            code:       420000,
            httpStatus: http.StatusInternalServerError,
        },
    }
}

type RollingDeployNotSupportedError struct {
    apiError
}

func NewRollingDeployNotSupportedError(runnerName string) RollingDeployNotSupportedError {
    detail := fmt.Sprintf("The configured runner '%s' does not support rolling deploys", runnerName)
    return RollingDeployNotSupportedError{
        apiError: apiError{
            title:      "CF-RollingDeployNotSupported",
            detail:     detail,
            code:       42000,
            httpStatus: http.StatusBadRequest,
        },
    }
}

func FromK8sError(err error, resourceType string) error {
    if webhookValidationError, ok := validation.WebhookErrorToValidationError(err); ok {
        return NewUnprocessableEntityError(err, webhookValidationError.GetMessage())
    }

    switch {
    case k8serrors.IsUnauthorized(err):
        return NewInvalidAuthError(err)
    case k8serrors.IsNotFound(err):
        return NewNotFoundError(err, resourceType)
    case k8serrors.IsForbidden(err):
        return NewForbiddenError(err, resourceType)
    case k8serrors.IsInvalid(err):
        cause, ok := k8serrors.StatusCause(err, metav1.CauseTypeFieldValueInvalid)
        if ok {
            return NewUnprocessableEntityError(err, fmt.Sprintf("%s is invalid: %s", cause.Field, cause.Message))
        }
        return NewUnprocessableEntityError(err, resourceType)
    default:
        return err
    }
}

func AsUnprocessableEntity(err error, detail string, errTypes ...ApiError) error {
    if err == nil {
        return nil
    }

    for i := range errTypes {
        // At this point in time the errors in the errType array are downgraded
        // to `ApiError`. This means that pointers to api errors that only
        // embed `apiError` are assignable to each other. Therefore `errors.As`
        // would return `true` and would change the initial value type of the
        // array entry. That is why we need to get the "desiredType" first and
        // compare it to the type that has been set by `errors.As`
        desiredErrType := reflect.ValueOf(errTypes[i]).Type()

        if !errors.As(err, &errTypes[i]) {
            continue
        }

        asErrType := reflect.ValueOf(errTypes[i]).Type()
        if asErrType != desiredErrType {
            continue
        }

        return NewUnprocessableEntityError(errTypes[i].Unwrap(), detail)
    }

    return err
}

func ForbiddenAsNotFound(err error) error {
    var forbiddenErr ForbiddenError
    if errors.As(err, &forbiddenErr) {
        return NewNotFoundError(forbiddenErr.Unwrap(), forbiddenErr.ResourceType())
    }
    return err
}

// DropletForbiddenAsNotFound is a special case due to the CF CLI expecting the error message "Droplet not found" exactly instead of the generic case
// https://github.com/cloudfoundry/korifi/issues/965
func DropletForbiddenAsNotFound(err error) error {
    var forbiddenErr ForbiddenError
    if errors.As(err, &forbiddenErr) {
        return NotFoundError{
            apiError{
                cause:      forbiddenErr.Unwrap(),
                title:      "CF-ResourceNotFound",
                detail:     "Droplet not found",
                code:       10010,
                httpStatus: http.StatusNotFound,
            },
        }
    }
    var notFoundErr NotFoundError
    if errors.As(err, &notFoundErr) {
        return NotFoundError{
            apiError{
                cause:      notFoundErr.Unwrap(),
                title:      "CF-ResourceNotFound",
                detail:     "Droplet not found",
                code:       10010,
                httpStatus: http.StatusNotFound,
            },
        }
    }
    return err
}