cloudfoundry/korifi

View on GitHub
controllers/webhooks/networking/routes/validator.go

Summary

Maintainability
A
35 mins
Test Coverage
A
93%
package routes
 
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
 
korifiv1alpha1 "code.cloudfoundry.org/korifi/controllers/api/v1alpha1"
"code.cloudfoundry.org/korifi/controllers/webhooks"
validationwebhook "code.cloudfoundry.org/korifi/controllers/webhooks/validation"
"github.com/hashicorp/go-multierror"
 
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/validation"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)
 
const (
RouteEntityType = "route"
 
RouteDestinationNotInSpaceErrorType = "RouteDestinationNotInSpaceError"
RouteDestinationNotInSpaceErrorMessage = "Route destination app not found in space"
RouteHostNameValidationErrorType = "RouteHostNameValidationError"
RoutePathValidationErrorType = "RoutePathValidationError"
RouteSubdomainValidationErrorType = "RouteSubdomainValidationError"
RouteSubdomainValidationErrorMessage = "Subdomains must each be at most 63 characters"
 
HostEmptyError = "host cannot be empty"
HostLengthError = "host is too long (maximum is 63 characters)"
HostFormatError = "host must be either \"*\" or contain only alphanumeric characters, \"_\", or \"-\""
 
InvalidURIError = "Invalid Route URI"
PathIsSlashError = "Path cannot be a single slash"
PathHasQuestionMarkError = "Path cannot contain a question mark"
PathLengthExceededError = "Path cannot exceed 128 characters"
)
 
var logger = logf.Log.WithName("route-validation")
 
//+kubebuilder:webhook:path=/validate-korifi-cloudfoundry-org-v1alpha1-cfroute,mutating=false,failurePolicy=fail,sideEffects=NoneOnDryRun,groups=korifi.cloudfoundry.org,resources=cfroutes,verbs=create;update;delete,versions=v1alpha1,name=vcfroute.korifi.cloudfoundry.org,admissionReviewVersions={v1,v1beta1}
 
type Validator struct {
duplicateValidator webhooks.NameValidator
rootNamespace string
client client.Client
}
 
var _ webhook.CustomValidator = &Validator{}
 
func NewValidator(
nameValidator webhooks.NameValidator,
rootNamespace string,
client client.Client,
) *Validator {
return &Validator{
duplicateValidator: nameValidator,
rootNamespace: rootNamespace,
client: client,
}
}
 
func (v *Validator) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).
For(&korifiv1alpha1.CFRoute{}).
WithValidator(v).
Complete()
}
 
func (v *Validator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
route, ok := obj.(*korifiv1alpha1.CFRoute)
if !ok {
return nil, apierrors.NewBadRequest(fmt.Sprintf("expected nil, a CFRoute but got a %T", obj))
}
 
cfDomain, err := v.validateRoute(ctx, route)
if err != nil {
return nil, err
}
 
route.Status.FQDN = cfDomain.Spec.Name
 
return nil, v.duplicateValidator.ValidateCreate(ctx, logger, v.rootNamespace, route)
}
 
Method `Validator.ValidateUpdate` has 9 return statements (exceeds 8 allowed).
func (v *Validator) ValidateUpdate(ctx context.Context, oldObj, obj runtime.Object) (admission.Warnings, error) {
route, ok := obj.(*korifiv1alpha1.CFRoute)
if !ok {
return nil, apierrors.NewBadRequest(fmt.Sprintf("expected a CFRoute but got a %T", obj))
}
 
if !route.GetDeletionTimestamp().IsZero() {
return nil, nil
}
 
oldRoute, ok := oldObj.(*korifiv1alpha1.CFRoute)
if !ok {
return nil, apierrors.NewBadRequest(fmt.Sprintf("expected a CFRoute but got a %T", obj))
}
 
immutableError := validationwebhook.ValidationError{
Type: validationwebhook.ImmutableFieldErrorType,
}
 
if route.Spec.Host != oldRoute.Spec.Host {
immutableError.Message = fmt.Sprintf(validationwebhook.ImmutableFieldErrorMessageTemplate, "CFRoute.Spec.Host")
return nil, immutableError.ExportJSONError()
}
 
if route.Spec.Path != oldRoute.Spec.Path {
immutableError.Message = fmt.Sprintf(validationwebhook.ImmutableFieldErrorMessageTemplate, "CFRoute.Spec.Path")
return nil, immutableError.ExportJSONError()
}
 
if route.Spec.Protocol != oldRoute.Spec.Protocol {
immutableError.Message = fmt.Sprintf(validationwebhook.ImmutableFieldErrorMessageTemplate, "CFRoute.Spec.Protocol")
return nil, immutableError.ExportJSONError()
}
 
if route.Spec.DomainRef.Name != oldRoute.Spec.DomainRef.Name {
immutableError.Message = fmt.Sprintf(validationwebhook.ImmutableFieldErrorMessageTemplate, "CFRoute.Spec.DomainRef.Name")
return nil, immutableError.ExportJSONError()
}
 
err := v.validateDestinations(ctx, route)
if err != nil {
return nil, err
}
 
return nil, v.duplicateValidator.ValidateUpdate(ctx, logger, v.rootNamespace, oldRoute, route)
}
 
func (v *Validator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
route, ok := obj.(*korifiv1alpha1.CFRoute)
if !ok {
return nil, apierrors.NewBadRequest(fmt.Sprintf("expected a CFRoute but got a %T", obj))
}
 
return nil, v.duplicateValidator.ValidateDelete(ctx, logger, v.rootNamespace, route)
}
 
func (v *Validator) validateRoute(ctx context.Context, route *korifiv1alpha1.CFRoute) (*korifiv1alpha1.CFDomain, error) {
domain, err := v.fetchDomain(ctx, route)
if err != nil {
return domain, err
}
 
err = v.validateDestinations(ctx, route)
if err != nil {
return domain, err
}
 
if err = validateFQDN(route.Spec.Host, domain.Spec.Name); err != nil {
return nil, err
}
 
if err = validatePath(route.Spec.Path); err != nil {
return nil, err
}
 
return domain, nil
}
 
func (v *Validator) fetchDomain(ctx context.Context, route *korifiv1alpha1.CFRoute) (*korifiv1alpha1.CFDomain, error) {
domain := &korifiv1alpha1.CFDomain{}
err := v.client.Get(ctx, types.NamespacedName{Name: route.Spec.DomainRef.Name, Namespace: route.Spec.DomainRef.Namespace}, domain)
if err != nil {
errMessage := "Error while retrieving CFDomain object"
logger.Info(errMessage, "reason", err)
return nil, validationwebhook.ValidationError{
Type: validationwebhook.UnknownErrorType,
Message: errMessage,
}.ExportJSONError()
}
return domain, err
}
 
func (v *Validator) validateDestinations(ctx context.Context, route *korifiv1alpha1.CFRoute) error {
err := v.checkDestinationsExistInNamespace(ctx, *route)
if err != nil {
validationErr := validationwebhook.ValidationError{}
 
if apierrors.IsNotFound(err) {
validationErr.Type = RouteDestinationNotInSpaceErrorType
validationErr.Message = RouteDestinationNotInSpaceErrorMessage
} else {
validationErr.Type = validationwebhook.UnknownErrorType
validationErr.Message = validationwebhook.UnknownErrorMessage
}
 
logger.Info(validationErr.Message, "reason", err)
return validationErr.ExportJSONError()
}
 
return nil
}
 
func validateFQDN(host, domain string) error {
// we only need to validate that "<host>.<domain>" is not too long and that
// <host> is either "*" or a valid dns label. The domain webhook already
// guarantees that the domain is well formed
if len(host+"."+domain) > validation.DNS1123SubdomainMaxLength {
return validationwebhook.ValidationError{
Type: RouteSubdomainValidationErrorType,
Message: fmt.Sprintf("A valid DNS-1123 subdomain must not exceed %d characters.", validation.DNS1123SubdomainMaxLength),
}.ExportJSONError()
}
 
host = strings.ToLower(host)
err := validateHost(host)
if err != nil {
return validationwebhook.ValidationError{
Type: RouteHostNameValidationErrorType,
Message: fmt.Sprintf("Host %q is not valid: %s", host, err.Error()),
}.ExportJSONError()
}
 
return nil
}
 
func validateHost(host string) error {
if host == "*" {
return nil
}
 
var multiErr *multierror.Error
for _, err := range validation.IsDNS1123Label(host) {
multiErr = multierror.Append(multiErr, errors.New(err))
}
 
if multiErr == nil {
return nil
}
 
return multiErr.ErrorOrNil()
}
 
func validatePath(path string) error {
var errStrings []string
 
if path == "" {
return nil
}
 
_, err := url.ParseRequestURI(path)
if err != nil {
errStrings = append(errStrings, InvalidURIError)
}
 
if path == "/" {
errStrings = append(errStrings, PathIsSlashError)
}
 
if strings.Contains(path, "?") {
errStrings = append(errStrings, PathHasQuestionMarkError)
}
 
if len(path) > 128 {
errStrings = append(errStrings, PathLengthExceededError)
}
 
if len(errStrings) == 0 {
return nil
}
 
if len(errStrings) > 0 {
return validationwebhook.ValidationError{
Type: RoutePathValidationErrorType,
Message: strings.Join(errStrings, ", "),
}.ExportJSONError()
}
 
return nil
}
 
func (v *Validator) checkDestinationsExistInNamespace(ctx context.Context, route korifiv1alpha1.CFRoute) error {
for _, destination := range route.Spec.Destinations {
err := v.client.Get(ctx, client.ObjectKey{Namespace: route.Namespace, Name: destination.AppRef.Name}, &korifiv1alpha1.CFApp{})
if err != nil {
return err
}
}
 
return nil
}