diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f088eb375..a9633993e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -81,6 +81,8 @@ Adding a new version? You'll need three changes: - Allow regex expressions in `HTTPRoute` configuration and provide validation in admission webhook. Before this change admission webhook used to reject entirely such configurations incorrectly as not supported yet. [#4608](https://github.com/Kong/kubernetes-ingress-controller/pull/4608) +- Provide validation in admission webhook for `Ingress` paths (validate regex expressions). + [#4647](https://github.com/Kong/kubernetes-ingress-controller/pull/4647) - Add new feature gate `RewriteURIs` to enable/disable the `konghq.com/rewrite` annotation (default disabled). [#4360](https://github.com/Kong/kubernetes-ingress-controller/pull/4360) diff --git a/internal/admission/handler.go b/internal/admission/handler.go index ce19f83326..1d90da6dba 100644 --- a/internal/admission/handler.go +++ b/internal/admission/handler.go @@ -9,6 +9,7 @@ import ( "github.com/sirupsen/logrus" admissionv1 "k8s.io/api/admission/v1" corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" @@ -98,6 +99,11 @@ var ( Version: gatewayv1beta1.SchemeGroupVersion.Version, Resource: "httproutes", } + ingressGVResource = metav1.GroupVersionResource{ + Group: netv1.SchemeGroupVersion.Group, + Version: netv1.SchemeGroupVersion.Version, + Resource: "ingresses", + } ) func (h RequestHandler) handleValidation(ctx context.Context, request admissionv1.AdmissionRequest) ( @@ -122,6 +128,8 @@ func (h RequestHandler) handleValidation(ctx context.Context, request admissionv return h.handleHTTPRoute(ctx, request, responseBuilder) case kongIngressGVResource: return h.handleKongIngress(ctx, request, responseBuilder) + case ingressGVResource: + return h.handleIngress(ctx, request, responseBuilder) default: return nil, fmt.Errorf("unknown resource type to validate: %s/%s %s", request.Resource.Group, request.Resource.Version, @@ -313,3 +321,17 @@ func (h RequestHandler) handleKongIngress(_ context.Context, request admissionv1 return responseBuilder.Build(), nil } + +func (h RequestHandler) handleIngress(ctx context.Context, request admissionv1.AdmissionRequest, responseBuilder *ResponseBuilder) (*admissionv1.AdmissionResponse, error) { + ingress := netv1.Ingress{} + _, _, err := codecs.UniversalDeserializer().Decode(request.Object.Raw, nil, &ingress) + if err != nil { + return nil, err + } + ok, message, err := h.Validator.ValidateIngress(ctx, ingress) + if err != nil { + return nil, err + } + + return responseBuilder.Allowed(ok).WithMessage(message).Build(), nil +} diff --git a/internal/admission/server_test.go b/internal/admission/server_test.go index fd874ce4cb..1760527be5 100644 --- a/internal/admission/server_test.go +++ b/internal/admission/server_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" admissionv1 "k8s.io/api/admission/v1" corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" @@ -69,6 +70,10 @@ func (v KongFakeValidator) ValidateHTTPRoute(_ context.Context, _ gatewayv1beta1 return v.Result, v.Message, v.Error } +func (v KongFakeValidator) ValidateIngress(_ context.Context, _ netv1.Ingress) (bool, string, error) { + return v.Result, v.Message, v.Error +} + func TestServeHTTPBasic(t *testing.T) { assert := assert.New(t) res := httptest.NewRecorder() diff --git a/internal/admission/validator.go b/internal/admission/validator.go index 8ad00ab671..3e8e62c2a8 100644 --- a/internal/admission/validator.go +++ b/internal/admission/validator.go @@ -9,6 +9,7 @@ import ( "github.com/kong/go-kong/kong" "github.com/sirupsen/logrus" corev1 "k8s.io/api/core/v1" + netv1 "k8s.io/api/networking/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" @@ -19,6 +20,7 @@ import ( "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/parser" credsvalidation "github.com/kong/kubernetes-ingress-controller/v2/internal/validation/consumers/credentials" gatewayvalidators "github.com/kong/kubernetes-ingress-controller/v2/internal/validation/gateway" + ingressvalidator "github.com/kong/kubernetes-ingress-controller/v2/internal/validation/ingress" "github.com/kong/kubernetes-ingress-controller/v2/internal/versions" kongv1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1" kongv1beta1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1beta1" @@ -33,6 +35,7 @@ type KongValidator interface { ValidateCredential(ctx context.Context, secret corev1.Secret) (bool, string, error) ValidateGateway(ctx context.Context, gateway gatewaycontroller.Gateway) (bool, string, error) ValidateHTTPRoute(ctx context.Context, httproute gatewaycontroller.HTTPRoute) (bool, string, error) + ValidateIngress(ctx context.Context, ingress netv1.Ingress) (bool, string, error) } // AdminAPIServicesProvider provides KongHTTPValidator with Kong Admin API services that are needed to perform @@ -55,7 +58,8 @@ type KongHTTPValidator struct { ParserFeatures parser.FeatureFlags KongVersion semver.Version - ingressClassMatcher func(*metav1.ObjectMeta, string, annotations.ClassMatching) bool + ingressClassMatcher func(*metav1.ObjectMeta, string, annotations.ClassMatching) bool + ingressV1ClassMatcher func(*netv1.Ingress, annotations.ClassMatching) bool } // NewKongHTTPValidator provides a new KongHTTPValidator object provided a @@ -70,7 +74,6 @@ func NewKongHTTPValidator( parserFeatures parser.FeatureFlags, kongVersion semver.Version, ) KongHTTPValidator { - matcher := annotations.IngressClassValidatorFuncFromObjectMeta(ingressClass) return KongHTTPValidator{ Logger: logger, SecretGetter: &managerClientSecretGetter{managerClient: managerClient}, @@ -79,7 +82,8 @@ func NewKongHTTPValidator( ParserFeatures: parserFeatures, KongVersion: kongVersion, - ingressClassMatcher: matcher, + ingressClassMatcher: annotations.IngressClassValidatorFuncFromObjectMeta(ingressClass), + ingressV1ClassMatcher: annotations.IngressClassValidatorFuncFromV1Ingress(ingressClass), } } @@ -423,7 +427,7 @@ func (validator KongHTTPValidator) ValidateHTTPRoute( // Now that we know whether or not the HTTPRoute is linked to a managed // Gateway we can run it through full validation. - var routeValidator gatewayvalidators.RouteValidator = noOpRoutesValidator{} + var routeValidator routeValidator = noOpRoutesValidator{} if routesSvc, ok := validator.AdminAPIServicesProvider.GetRoutesService(); ok { routeValidator = routesSvc } @@ -432,6 +436,26 @@ func (validator KongHTTPValidator) ValidateHTTPRoute( ) } +func (validator KongHTTPValidator) ValidateIngress( + ctx context.Context, ingress netv1.Ingress, +) (bool, string, error) { + // Ignore Ingresses that are being managed by another controller. + if !validator.ingressClassMatcher(&ingress.ObjectMeta, annotations.IngressClassKey, annotations.ExactClassMatch) && + !validator.ingressV1ClassMatcher(&ingress, annotations.ExactClassMatch) { + return true, "", nil + } + + var routeValidator routeValidator = noOpRoutesValidator{} + if routesSvc, ok := validator.AdminAPIServicesProvider.GetRoutesService(); ok { + routeValidator = routesSvc + } + return ingressvalidator.ValidateIngress(ctx, routeValidator, validator.ParserFeatures, validator.KongVersion, &ingress) +} + +type routeValidator interface { + Validate(context.Context, *kong.Route) (bool, string, error) +} + type noOpRoutesValidator struct{} func (noOpRoutesValidator) Validate(_ context.Context, _ *kong.Route) (bool, string, error) { diff --git a/internal/dataplane/parser/translate_ingress.go b/internal/dataplane/parser/translate_ingress.go index 9c2c7c72c1..8cc2ab07c9 100644 --- a/internal/dataplane/parser/translate_ingress.go +++ b/internal/dataplane/parser/translate_ingress.go @@ -8,6 +8,7 @@ import ( "github.com/kong/go-kong/kong" netv1 "k8s.io/api/networking/v1" + "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/failures" "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/kongstate" "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/parser/atc" "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/parser/translators" @@ -57,7 +58,13 @@ func (p *Parser) ingressRulesFromIngressV1() ingressRules { } // Translate Ingress objects into Kong Services. - servicesCache := p.ingressesV1ToKongServices(ingressList, icp) + servicesCache := IngressesV1ToKongServices( + p.featureFlags, + ingressList, + icp, + p.parsedObjectsCollector, + p.failuresCollector, + ) for i := range servicesCache { service := servicesCache[i] if err := translators.MaybeRewriteURI(&service, p.featureFlags.RewriteURIs); err != nil { @@ -79,40 +86,48 @@ func (p *Parser) ingressRulesFromIngressV1() ingressRules { return result } -// ingressV1ToKongServicesCache is a cache of Kong Services indexed by their name. -type kongServicesCache map[string]kongstate.Service +// KongServicesCache is a cache of Kong Services indexed by their name. +type KongServicesCache map[string]kongstate.Service -// ingressesV1ToKongServices translates IngressV1 object into Kong Service. It inserts the Kong Service into the passed servicesCache. -// Returns true if the passed servicesCache was updated. -func (p *Parser) ingressesV1ToKongServices( +// IngressesV1ToKongServices translates IngressV1 object into Kong Service, returns them indexed by name. +// Argument parsedObjectsCollector is used to register all successfully parsed objects. In case of a failure, +// the object is registered in failuresCollector. +func IngressesV1ToKongServices( + featureFlags FeatureFlags, ingresses []*netv1.Ingress, icp kongv1alpha1.IngressClassParametersSpec, -) kongServicesCache { - if p.featureFlags.CombinedServiceRoutes { - return p.ingressV1ToKongServiceCombinedRoutes(ingresses, icp) + parsedObjectsCollector *ObjectsCollector, + failuresCollector *failures.ResourceFailuresCollector, +) KongServicesCache { + if featureFlags.CombinedServiceRoutes { + return ingressV1ToKongServiceCombinedRoutes(featureFlags, ingresses, icp, parsedObjectsCollector) } - return p.ingressV1ToKongServiceLegacy(ingresses, icp) + return ingressV1ToKongServiceLegacy(featureFlags, ingresses, icp, parsedObjectsCollector, failuresCollector) } // ingressV1ToKongServiceLegacy translates a slice of IngressV1 object into Kong Services. -func (p *Parser) ingressV1ToKongServiceCombinedRoutes( +func ingressV1ToKongServiceCombinedRoutes( + featureFlags FeatureFlags, ingresses []*netv1.Ingress, icp kongv1alpha1.IngressClassParametersSpec, -) kongServicesCache { + parsedObjectsCollector *ObjectsCollector, +) KongServicesCache { return translators.TranslateIngresses(ingresses, icp, translators.TranslateIngressFeatureFlags{ - RegexPathPrefix: p.featureFlags.RegexPathPrefix, - ExpressionRoutes: p.featureFlags.ExpressionRoutes, - CombinedServices: p.featureFlags.CombinedServices, - }, p.parsedObjectsCollector) + RegexPathPrefix: featureFlags.RegexPathPrefix, + ExpressionRoutes: featureFlags.ExpressionRoutes, + CombinedServices: featureFlags.CombinedServices, + }, parsedObjectsCollector) } // ingressV1ToKongServiceLegacy translates a slice IngressV1 object into Kong Services. -func (p *Parser) ingressV1ToKongServiceLegacy(ingresses []*netv1.Ingress, icp kongv1alpha1.IngressClassParametersSpec) kongServicesCache { - servicesCache := make(kongServicesCache) +func ingressV1ToKongServiceLegacy( + featureFlags FeatureFlags, ingresses []*netv1.Ingress, icp kongv1alpha1.IngressClassParametersSpec, parsedObjectsCollector *ObjectsCollector, failuresCollector *failures.ResourceFailuresCollector, +) KongServicesCache { + servicesCache := make(KongServicesCache) for _, ingress := range ingresses { ingressSpec := ingress.Spec - maybePrependRegexPrefixFn := translators.MaybePrependRegexPrefixForIngressV1Fn(ingress, icp.EnableLegacyRegexDetection && p.featureFlags.RegexPathPrefix) + maybePrependRegexPrefixFn := translators.MaybePrependRegexPrefixForIngressV1Fn(ingress, icp.EnableLegacyRegexDetection && featureFlags.RegexPathPrefix) for i, rule := range ingressSpec.Rules { if rule.HTTP == nil { continue @@ -124,12 +139,10 @@ func (p *Parser) ingressV1ToKongServiceLegacy(ingresses []*netv1.Ingress, icp ko rulePath.PathType = &pathTypeImplementationSpecific } - paths := translators.PathsFromIngressPaths(rulePath, p.featureFlags.RegexPathPrefix) + paths := translators.PathsFromIngressPaths(rulePath, featureFlags.RegexPathPrefix) if paths == nil { - // registering a failure, but technically it should never happen thanks to Kubernetes API validations - p.registerTranslationFailure( - fmt.Sprintf("could not translate Ingress Path %s to Kong paths", rulePath.Path), ingress, - ) + // Registering a failure, but technically it should never happen thanks to Kubernetes API validations. + failuresCollector.PushResourceFailure(fmt.Sprintf("could not translate Ingress Path %s to Kong paths", rulePath.Path), ingress) continue } @@ -192,7 +205,7 @@ func (p *Parser) ingressV1ToKongServiceLegacy(ingresses []*netv1.Ingress, icp ko service.Routes = append(service.Routes, r) servicesCache[serviceName] = service - p.registerSuccessfullyParsedObject(ingress) + parsedObjectsCollector.Add(ingress) // Register successfully parsed object. } } } diff --git a/internal/validation/gateway/httproute.go b/internal/validation/gateway/httproute.go index 1f8d394ba0..b109f7dab2 100644 --- a/internal/validation/gateway/httproute.go +++ b/internal/validation/gateway/httproute.go @@ -13,7 +13,7 @@ import ( "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/parser/translators" ) -type RouteValidator interface { +type routeValidator interface { Validate(context.Context, *kong.Route) (bool, string, error) } @@ -28,7 +28,7 @@ type RouteValidator interface { // validation endpoint. func ValidateHTTPRoute( ctx context.Context, - routesValidator RouteValidator, + routesValidator routeValidator, parserFeatures parser.FeatureFlags, kongVersion semver.Version, httproute *gatewayv1beta1.HTTPRoute, @@ -188,7 +188,7 @@ func getListenersForHTTPRouteValidation(sectionName *gatewayv1beta1.SectionName, } func validateWithKongGateway( - ctx context.Context, routesValidator RouteValidator, parserFeatures parser.FeatureFlags, kongVersion semver.Version, httproute *gatewayv1beta1.HTTPRoute, + ctx context.Context, routesValidator routeValidator, parserFeatures parser.FeatureFlags, kongVersion semver.Version, httproute *gatewayv1beta1.HTTPRoute, ) (bool, string, error) { // Translate HTTPRoute to Kong Route object(s) that can be sent directly to the Admin API for validation. // Use KIC parser that works both for traditional and expressions based routes. diff --git a/internal/validation/ingress/ingress.go b/internal/validation/ingress/ingress.go new file mode 100644 index 0000000000..16fdcc13c3 --- /dev/null +++ b/internal/validation/ingress/ingress.go @@ -0,0 +1,81 @@ +package ingress + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/blang/semver/v4" + "github.com/kong/go-kong/kong" + "github.com/sirupsen/logrus" + netv1 "k8s.io/api/networking/v1" + + "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/failures" + "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/parser" + "github.com/kong/kubernetes-ingress-controller/v2/internal/versions" + kongv1alpha1 "github.com/kong/kubernetes-ingress-controller/v2/pkg/apis/configuration/v1alpha1" +) + +type routeValidator interface { + Validate(context.Context, *kong.Route) (bool, string, error) +} + +func ValidateIngress( + ctx context.Context, + routesValidator routeValidator, + parserFeatures parser.FeatureFlags, + kongVersion semver.Version, + ingress *netv1.Ingress, +) (bool, string, error) { + discardLogger := logrus.New() + discardLogger.Out = io.Discard + failuresCollector, err := failures.NewResourceFailuresCollector(discardLogger) + if err != nil { + return false, "", err + } + var icp kongv1alpha1.IngressClassParametersSpec + if kongVersion.LT(versions.ExplicitRegexPathVersionCutoff) { + icp.EnableLegacyRegexDetection = true + } + result := parser.IngressesV1ToKongServices( + parserFeatures, + []*netv1.Ingress{ingress}, + icp, + &parser.ObjectsCollector{}, // It's irrelevant for validation. + failuresCollector, + ) + + var errMsgs []string + for _, f := range failuresCollector.PopResourceFailures() { + errMsgs = append(errMsgs, f.Message()) + } + if len(errMsgs) > 0 { + return false, validationMsg(errMsgs), nil + } + var kongRoutes []kong.Route + for _, r := range result { + for _, route := range r.Routes { + kongRoutes = append(kongRoutes, route.Route) + } + } + // Validate by using feature of Kong Gateway. + for _, kg := range kongRoutes { + kg := kg + ok, msg, err := routesValidator.Validate(ctx, &kg) + if err != nil { + return false, fmt.Sprintf("unable to validate Ingress schema: %s", err.Error()), nil + } + if !ok { + errMsgs = append(errMsgs, msg) + } + } + if len(errMsgs) > 0 { + return false, validationMsg(errMsgs), nil + } + return true, "", nil +} + +func validationMsg(errMsgs []string) string { + return fmt.Sprintf("Ingress failed schema validation: %s", strings.Join(errMsgs, ", ")) +} diff --git a/internal/versions/versions.go b/internal/versions/versions.go index 36e1ab4467..3ffc2d5e71 100644 --- a/internal/versions/versions.go +++ b/internal/versions/versions.go @@ -5,11 +5,10 @@ import ( ) var ( - // RegexHeaderVersionCutoff is the Kong version prior to the addition of support for regular expression heade - // matches. + // RegexHeaderVersionCutoff is the Kong version prior to the addition of support for regular expression for matching headers. RegexHeaderVersionCutoff = semver.Version{Major: 2, Minor: 8} - // ExplicitRegexPathVersionCutoff is the lowest Kong version adding the explicit "~" prefixes in regular expression paths. + // ExplicitRegexPathVersionCutoff is the lowest Kong version requiring the explicit "~" prefixes in regular expression paths. ExplicitRegexPathVersionCutoff = semver.Version{Major: 3, Minor: 0} // PluginOrderingVersionCutoff is the Kong version prior to the addition of plugin ordering. diff --git a/test/integration/httproute_webhook_test.go b/test/integration/httproute_webhook_test.go index 664ce2cb4e..ca8a57cbb3 100644 --- a/test/integration/httproute_webhook_test.go +++ b/test/integration/httproute_webhook_test.go @@ -219,12 +219,12 @@ func setUpEnvForTestingHTTPRouteValidationWebhook(ctx context.Context, t *testin ) { ns, cleaner := helpers.Setup(ctx, t, env) namespace = ns.Name - t.Logf("created namespace: %q", namespace) + const webhookName = "kong-validations-gateway" ensureAdmissionRegistration( ctx, t, namespace, - "kong-validations-gateway", + webhookName, []admregv1.RuleWithOperations{ { Rule: admregv1.Rule{ @@ -266,7 +266,7 @@ func setUpEnvForTestingHTTPRouteValidationWebhook(ctx context.Context, t *testin t.Logf("created unmanaged gateway: %q", unmanagedGateway.Name) t.Log("waiting for webhook service to be connective") - ensureWebhookServiceIsConnective(ctx, t, "kong-validations-gateway") + ensureWebhookServiceIsConnective(ctx, t, webhookName) return namespace, gatewayClient, managedGateway, unmanagedGateway } diff --git a/test/integration/ingress_webhook_test.go b/test/integration/ingress_webhook_test.go new file mode 100644 index 0000000000..be57121f8e --- /dev/null +++ b/test/integration/ingress_webhook_test.go @@ -0,0 +1,362 @@ +//go:build integration_tests + +package integration + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/samber/lo" + "github.com/stretchr/testify/require" + admregv1 "k8s.io/api/admissionregistration/v1" + netv1 "k8s.io/api/networking/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/kong/kubernetes-ingress-controller/v2/internal/annotations" + "github.com/kong/kubernetes-ingress-controller/v2/test/consts" + "github.com/kong/kubernetes-ingress-controller/v2/test/internal/helpers" +) + +type testCaseIngressValidation struct { + Name string + Ingress *netv1.Ingress + WantCreateErr bool + WantCreateErrSubstring string +} + +// commonIngressValidationTestCases returns a list of test cases for validating Ingress that are common +// to both traditional and expressions routers (in case of an expected error the same message is returned). +func commonIngressValidationTestCases() []testCaseIngressValidation { + return []testCaseIngressValidation{ + { + Name: "a valid ingress passes validation", + Ingress: &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + }, + Spec: netv1.IngressSpec{ + IngressClassName: lo.ToPtr(consts.IngressClass), + Rules: []netv1.IngressRule{ + { + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + constructIngressPathImplSpecific("/foo"), + }, + }, + }, + }, + }, + }, + }, + WantCreateErr: false, + }, + { + Name: "an invalid ingress passes validation when Ingress class is not set to KIC's (it's not ours)", + Ingress: &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + }, + Spec: netv1.IngressSpec{ + IngressClassName: lo.ToPtr("third-party-ingress-class"), + Rules: []netv1.IngressRule{ + { + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + constructIngressPathImplSpecific("/foo"), + constructIngressPathImplSpecific("/~/foo[[["), + }, + }, + }, + }, + }, + }, + }, + WantCreateErr: false, + }, + { + Name: "valid Ingress with multiple hosts, paths (with valid regex expressions) passes validation", + Ingress: &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + Annotations: map[string]string{ + annotations.IngressClassKey: consts.IngressClass, + }, + }, + Spec: netv1.IngressSpec{ + Rules: []netv1.IngressRule{ + { + Host: "foo.com", + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + constructIngressPathImplSpecific("/foo"), + constructIngressPathImplSpecific("/bar[1-9]"), + }, + }, + }, + }, + { + Host: "bar.com", + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + constructIngressPathImplSpecific("/baz"), + }, + }, + }, + }, + { + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + constructIngressPathImplSpecific("/test"), + constructIngressPathImplSpecific("/~/foo[1-9]"), + }, + }, + }, + }, + }, + }, + }, + WantCreateErr: false, + }, + { + Name: "fail when path in Ingress does not start with '/' (K8s builtin Ingress validation)", + Ingress: &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + }, + Spec: netv1.IngressSpec{ + IngressClassName: lo.ToPtr(consts.IngressClass), + Rules: []netv1.IngressRule{ + { + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + constructIngressPathImplSpecific("~/foo[1-9]"), + constructIngressPathImplSpecific("/bar"), + }, + }, + }, + }, + }, + }, + }, + WantCreateErr: true, + WantCreateErrSubstring: "Invalid value: \"~/foo[1-9]\": must be an absolute path", + }, + } +} + +// invalidRegexInIngressPathTestCase returns a test case for a Ingress with an invalid regex in the path, +// in the format that is common for both traditional and expressions routers. Error message is different +// for router flavors, thus it has passed by caller. +func invalidRegexInIngressPathTestCase(wantCreateErrSubstring string) testCaseIngressValidation { + return testCaseIngressValidation{ + Name: "valid path format with invalid regex expression fails validation", + Ingress: &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + }, + Spec: netv1.IngressSpec{ + IngressClassName: lo.ToPtr(consts.IngressClass), + Rules: []netv1.IngressRule{ + { + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + constructIngressPathImplSpecific("/bar"), + constructIngressPathImplSpecific("/~/baz[1-9]"), + }, + }, + }, + }, + { + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + constructIngressPathImplSpecific(`/~/foo[[[`), + }, + }, + }, + }, + }, + }, + }, + WantCreateErr: true, + WantCreateErrSubstring: wantCreateErrSubstring, + } +} + +func TestIngressValidationWebhookTraditionalRouter(t *testing.T) { + skipTestForNonKindCluster(t) + skipTestForRouterFlavors(t, expressions) + + ctx := context.Background() + namespace := setUpEnvForTestingIngressValidationWebhook(ctx, t) + testCases := append( + commonIngressValidationTestCases(), + invalidRegexInIngressPathTestCase(`invalid regex: '/foo[[['`), + testCaseIngressValidation{ + Name: "path should start with '/' or '~/' (regex path) (Kong Gateway requirement for non-expressions router)", + Ingress: &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + Annotations: map[string]string{ + annotations.IngressClassKey: consts.IngressClass, + }, + }, + Spec: netv1.IngressSpec{ + Rules: []netv1.IngressRule{ + { + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + constructIngressPathImplSpecific("/bar"), + constructIngressPathImplSpecific("/~foo[1-9]"), + }, + }, + }, + }, + }, + }, + }, + WantCreateErr: true, + WantCreateErrSubstring: `should start with: / (fixed path) or ~/ (regex path)`, + }, + ) + testIngressValidationWebhook(ctx, t, namespace, testCases) +} + +func TestIngressValidationWebhookExpressionsRouter(t *testing.T) { + skipTestForNonKindCluster(t) + skipTestForRouterFlavors(t, traditional, traditionalCompatible) + + ctx := context.Background() + namespace := setUpEnvForTestingIngressValidationWebhook(ctx, t) + testCases := append( + commonIngressValidationTestCases(), + invalidRegexInIngressPathTestCase("regex parse error:\n ^/foo[[[\n ^\nerror: unclosed character class"), + testCaseIngressValidation{ + Name: "valid regex path passes validation", + Ingress: &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + }, + Spec: netv1.IngressSpec{ + IngressClassName: lo.ToPtr(consts.IngressClass), + Rules: []netv1.IngressRule{ + { + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + constructIngressPathImplSpecific("/bar"), + constructIngressPathImplSpecific("/~baz[1-9]"), + }, + }, + }, + }, + }, + }, + }, + WantCreateErr: false, + }, + testCaseIngressValidation{ + Name: "invalid regex path fails validation", + Ingress: &netv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: uuid.NewString(), + }, + Spec: netv1.IngressSpec{ + IngressClassName: lo.ToPtr(consts.IngressClass), + Rules: []netv1.IngressRule{ + { + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + constructIngressPathImplSpecific("/bar"), + constructIngressPathImplSpecific("/~baz[1-9]"), + }, + }, + }, + }, + { + IngressRuleValue: netv1.IngressRuleValue{ + HTTP: &netv1.HTTPIngressRuleValue{ + Paths: []netv1.HTTPIngressPath{ + constructIngressPathImplSpecific("/~foo[[["), + }, + }, + }, + }, + }, + }, + }, + WantCreateErr: true, + WantCreateErrSubstring: "regex parse error:\n ^foo[[[\n ^\nerror: unclosed character class", + }, + ) + testIngressValidationWebhook(ctx, t, namespace, testCases) +} + +// setUpEnvForTestingIngressValidationWebhook sets up the environment for testing Ingress validation webhook, +// it sets it only for objects applied to namespace specified as argument. +func setUpEnvForTestingIngressValidationWebhook(ctx context.Context, t *testing.T) (namespace string) { + ns, _ := helpers.Setup(ctx, t, env) + namespace = ns.Name + const webhookName = "kong-validations-ingress" + ensureAdmissionRegistration( + ctx, + t, + namespace, + webhookName, + []admregv1.RuleWithOperations{ + { + Rule: admregv1.Rule{ + APIGroups: []string{"networking.k8s.io"}, + APIVersions: []string{"v1"}, + Resources: []string{"ingresses"}, + }, + Operations: []admregv1.OperationType{admregv1.Create, admregv1.Update}, + }, + }, + ) + ensureWebhookServiceIsConnective(ctx, t, webhookName) + return namespace +} + +// testIngressValidationWebhook tries to create the given Ingress (passed in testCaseIngressValidation) and asserts expected results. +func testIngressValidationWebhook( + ctx context.Context, t *testing.T, namespace string, testCases []testCaseIngressValidation, +) { + for _, tC := range testCases { + t.Run(tC.Name, func(t *testing.T) { + _, err := env.Cluster().Client().NetworkingV1().Ingresses(namespace).Create(ctx, tC.Ingress, metav1.CreateOptions{}) + if tC.WantCreateErr { + require.NotEmpty(t, tC.WantCreateErrSubstring) + require.Error(t, err) + require.Contains(t, err.Error(), tC.WantCreateErrSubstring) + } else { + require.NoError(t, err) + } + }) + } +} + +func constructIngressPathImplSpecific(path string) netv1.HTTPIngressPath { + return netv1.HTTPIngressPath{ + Path: path, + PathType: lo.ToPtr(netv1.PathTypeImplementationSpecific), + Backend: netv1.IngressBackend{ + Service: &netv1.IngressServiceBackend{ + Name: "foo", + Port: netv1.ServiceBackendPort{ + Number: 80, + }, + }, + }, + } +}