From ed498545f8e02ea62cd65276329802fd03405260 Mon Sep 17 00:00:00 2001 From: Tao Yi Date: Thu, 13 Jul 2023 21:15:44 +0800 Subject: [PATCH] implement assigning priorities to Kong routes translated from `HTTPRoute` (#4296) * add traits * split HTTPRoutes and assign priorities to the splitted * add unit tests * add CHANGELOG * fix lint * address comments: splitted->split * address comments again * use traits.encode for expected priorities in tests * Update internal/dataplane/parser/translators/httproute_atc.go Co-authored-by: Mattia Lavacca --------- Co-authored-by: Mattia Lavacca --- CHANGELOG.md | 5 + .../dataplane/parser/translate_httproute.go | 97 ++ .../parser/translate_httproute_test.go | 552 ++++++++++- .../parser/translators/httproute_atc.go | 380 +++++++ .../parser/translators/httproute_atc_test.go | 928 ++++++++++++++++++ test/conformance/gateway_conformance_test.go | 3 - 6 files changed, 1961 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5b9967bf0b..601c6e28ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,10 @@ Adding a new version? You'll need three changes: method is adopted to keep the compatibility with traditional router on maximum effort. [#4240](https://github.com/Kong/kubernetes-ingress-controller/pull/4240) +- Assign priorities to routes translated from HTTPRoutes when parser translates + them to expression based Kong routes. The assigning method follows the + [specification on priorities of matches in `HTTPRoute`][httproute-specification]. + [#4296](https://github.com/Kong/kubernetes-ingress-controller/pull/4296) - When a translated Kong configuration is empty in DB-less mode, the controller will now send the configuration with a single empty `Upstream`. This is to make Gateways using `/status/ready` as their health check ready after receiving the @@ -121,6 +125,7 @@ Adding a new version? You'll need three changes: [#4222](https://github.com/Kong/kubernetes-ingress-controller/pull/4222) [gojson]: https://github.com/goccy/go-json +[httproute-specification]: https://gateway-api.sigs.k8s.io/references/spec/#gateway.networking.k8s.io/v1beta1.HTTPRoute ## [2.10.3] diff --git a/internal/dataplane/parser/translate_httproute.go b/internal/dataplane/parser/translate_httproute.go index 12ccd0cba2..cbdc9e6a59 100644 --- a/internal/dataplane/parser/translate_httproute.go +++ b/internal/dataplane/parser/translate_httproute.go @@ -1,12 +1,15 @@ package parser import ( + "errors" "fmt" "strings" "github.com/blang/semver/v4" + "github.com/bombsimon/logrusr/v2" "github.com/kong/go-kong/kong" "github.com/samber/lo" + k8stypes "k8s.io/apimachinery/pkg/types" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/kongstate" @@ -29,6 +32,11 @@ func (p *Parser) ingressRulesFromHTTPRoutes() ingressRules { return result } + if p.featureFlags.ExpressionRoutes { + p.ingressRulesFromHTTPRoutesUsingExpressionRoutes(httpRouteList, &result) + return result + } + for _, httproute := range httpRouteList { if err := p.ingressRulesFromHTTPRoute(&result, httproute); err != nil { p.registerTranslationFailure(fmt.Sprintf("HTTPRoute can't be routed: %s", err), httproute) @@ -68,6 +76,54 @@ func validateHTTPRoute(httproute *gatewayv1beta1.HTTPRoute) error { return nil } +// ingressRulesFromHTTPRoutesUsingExpressionRoutes translates HTTPRoutes to expression based routes +// when ExpressionRoutes feature flag is enabled. +// Because we need to assign different priorities based on the hostname and match in the specification of HTTPRoutes, +// We need to split the HTTPRoutes into ones with only one hostname and one match, then assign priority to them +// and finally translate the split HTTPRoutes into Kong services and routes with assigned priorities. +func (p *Parser) ingressRulesFromHTTPRoutesUsingExpressionRoutes(httpRoutes []*gatewayv1beta1.HTTPRoute, result *ingressRules) { + // first, split HTTPRoutes by hostnames and matches. + splitHTTPRoutes := []*gatewayv1beta1.HTTPRoute{} + for _, httproute := range httpRoutes { + if err := validateHTTPRoute(httproute); err != nil { + p.registerTranslationFailure(fmt.Sprintf("HTTPRoute can't be routed: %s", err), httproute) + continue + } + splitHTTPRoutes = append(splitHTTPRoutes, translators.SplitHTTPRoute(httproute)...) + } + // assign priorities to split HTTPRoutes. + splitHTTPRoutesWithPriorities := translators.AssignRoutePriorityToSplitHTTPRoutes(logrusr.New(p.logger), splitHTTPRoutes) + httpRouteNameToTranslationFailure := map[k8stypes.NamespacedName][]error{} + + // translate split HTTPRoutes to ingress rules, including services, routes, upstreams. + for _, httpRouteWithPriority := range splitHTTPRoutesWithPriorities { + err := p.ingressRulesFromSplitHTTPRouteWithPriority(result, httpRouteWithPriority) + if err != nil { + nsName := k8stypes.NamespacedName{ + Namespace: httpRouteWithPriority.HTTPRoute.Namespace, + Name: httpRouteWithPriority.HTTPRoute.Name, + } + httpRouteNameToTranslationFailure[nsName] = append(httpRouteNameToTranslationFailure[nsName], err) + } + } + // Register successful parsed objects and translation failures. + // Because one HTTPRoute may be split into multiple HTTPRoutes, we need to de-duplicate by namespace and name. + for _, httproute := range httpRoutes { + nsName := k8stypes.NamespacedName{ + Namespace: httproute.Namespace, + Name: httproute.Name, + } + if translationFailures, ok := httpRouteNameToTranslationFailure[nsName]; ok { + p.registerTranslationFailure( + fmt.Sprintf("HTTPRoute can't be routed: %v", errors.Join(translationFailures...)), + httproute, + ) + continue + } + p.registerSuccessfullyParsedObject(httproute) + } +} + // ingressRulesFromHTTPRouteWithCombinedServiceRoutes generates a set of proto-Kong routes (ingress rules) from an HTTPRoute. // If multiple rules in the HTTPRoute use the same Service, it combines them into a single Kong route. func (p *Parser) ingressRulesFromHTTPRouteWithCombinedServiceRoutes(httproute *gatewayv1beta1.HTTPRoute, result *ingressRules) error { @@ -470,3 +526,44 @@ func httpBackendRefsToBackendRefs(httpBackendRef []gatewayv1beta1.HTTPBackendRef } return backendRefs } + +func (p *Parser) ingressRulesFromSplitHTTPRouteWithPriority( + rules *ingressRules, + httpRouteWithPriority translators.SplitHTTPRouteToKongRoutePriority, +) error { + httpRoute := httpRouteWithPriority.HTTPRoute + if len(httpRoute.Spec.Rules) == 0 { + return translators.ErrRouteValidationNoRules + } + + httpRouteRule := httpRoute.Spec.Rules[0] + if len(httpRoute.Spec.Hostnames) == 0 && len(httpRouteRule.Matches) == 0 { + return translators.ErrRouteValidationNoMatchRulesOrHostnamesSpecified + } + + backendRefs := httpBackendRefsToBackendRefs(httpRouteRule.BackendRefs) + + serviceName := translators.KongServiceNameFromHTTPRouteWithPriority(httpRouteWithPriority) + + kongService, err := generateKongServiceFromBackendRefWithName( + p.logger, + p.storer, + rules, + serviceName, + httpRoute, + "http", + backendRefs..., + ) + if err != nil { + return err + } + + kongService.Routes = append( + kongService.Routes, + translators.KongExpressionRouteFromHTTPRouteWithPriority(httpRouteWithPriority), + ) + // cache the service to avoid duplicates in further loop iterations + rules.ServiceNameToServices[serviceName] = kongService + rules.ServiceNameToParent[serviceName] = httpRoute + return nil +} diff --git a/internal/dataplane/parser/translate_httproute_test.go b/internal/dataplane/parser/translate_httproute_test.go index 153da502de..f7275a4c39 100644 --- a/internal/dataplane/parser/translate_httproute_test.go +++ b/internal/dataplane/parser/translate_httproute_test.go @@ -1,10 +1,12 @@ package parser import ( + "strings" "testing" "github.com/kong/go-kong/kong" "github.com/samber/lo" + "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" corev1 "k8s.io/api/core/v1" @@ -13,6 +15,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + "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/translators" "github.com/kong/kubernetes-ingress-controller/v2/internal/store" @@ -1357,7 +1360,6 @@ func TestIngressRulesFromHTTPRoutes_RegexPrefix(t *testing.T) { fakestore, err := store.NewFakeStore(store.FakeObjects{}) require.NoError(t, err) parser := mustNewParser(t, fakestore) - require.NoError(t, err) parser.featureFlags.RegexPathPrefix = true parserWithCombinedServiceRoutes := mustNewParser(t, fakestore) parserWithCombinedServiceRoutes.featureFlags.RegexPathPrefix = true @@ -1470,6 +1472,554 @@ func TestIngressRulesFromHTTPRoutes_RegexPrefix(t *testing.T) { } } +func TestIngressRulesFromHTTPRoutesUsingExpressionRoutes(t *testing.T) { + fakestore, err := store.NewFakeStore(store.FakeObjects{}) + require.NoError(t, err) + parser := mustNewParser(t, fakestore) + parser.featureFlags.CombinedServiceRoutes = true + parser.featureFlags.ExpressionRoutes = true + httpRouteTypeMeta := metav1.TypeMeta{Kind: "HTTPRoute", APIVersion: gatewayv1beta1.SchemeGroupVersion.String()} + + newResourceFailure := func(reason string, objects ...client.Object) failures.ResourceFailure { + failure, _ := failures.NewResourceFailure(reason, objects...) + return failure + } + + testCases := []struct { + name string + httpRoutes []*gatewayv1beta1.HTTPRoute + expectedKongServices []kongstate.Service + expectedKongRoutes map[string][]kongstate.Route + expectedFailures []failures.ResourceFailure + }{ + { + name: "single HTTPRoute with no hostname and multiple matches", + httpRoutes: []*gatewayv1beta1.HTTPRoute{ + { + TypeMeta: httpRouteTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/v1/foo").Build(), + builder.NewHTTPRouteMatch().WithPathExact("/v1/barr").Build(), + }, + BackendRefs: []gatewayv1beta1.HTTPBackendRef{ + builder.NewHTTPBackendRef("service1").WithPort(80).Build(), + }, + }, + }, + }, + }, + }, + expectedKongServices: []kongstate.Service{ + { + Service: kong.Service{ + Name: kong.String("httproute.default.httproute-1._.0"), + }, + Backends: []kongstate.ServiceBackend{ + { + Name: "service1", + PortDef: kongstate.PortDef{Mode: kongstate.PortModeByNumber, Number: int32(80)}, + }, + }, + }, + }, + expectedKongRoutes: map[string][]kongstate.Route{ + "httproute.default.httproute-1._.0": { + { + Route: kong.Route{ + Name: kong.String("httproute.default.httproute-1._.0.0"), + Expression: kong.String(`((net.protocol == "http") || (net.protocol == "https")) && (http.path == "/v1/foo")`), + PreserveHost: kong.Bool(true), + }, + Plugins: []kong.Plugin{}, + ExpressionRoutes: true, + }, + { + Route: kong.Route{ + Name: kong.String("httproute.default.httproute-1._.0.1"), + Expression: kong.String(`((net.protocol == "http") || (net.protocol == "https")) && (http.path == "/v1/barr")`), + PreserveHost: kong.Bool(true), + }, + Plugins: []kong.Plugin{}, + ExpressionRoutes: true, + }, + }, + }, + }, + { + name: "single HTTPRoute with multiple hostnames and rules", + httpRoutes: []*gatewayv1beta1.HTTPRoute{ + { + TypeMeta: httpRouteTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "foo.com", + "*.bar.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/v1/foo").Build(), + }, + BackendRefs: []gatewayv1beta1.HTTPBackendRef{ + builder.NewHTTPBackendRef("service1").WithPort(80).Build(), + }, + }, + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/v1/barr").Build(), + }, + BackendRefs: []gatewayv1beta1.HTTPBackendRef{ + builder.NewHTTPBackendRef("service2").WithPort(80).Build(), + }, + }, + }, + }, + }, + }, + expectedKongServices: []kongstate.Service{ + { + Service: kong.Service{ + Name: kong.String("httproute.default.httproute-1.foo.com.0"), + }, + Backends: []kongstate.ServiceBackend{ + { + Name: "service1", + PortDef: kongstate.PortDef{Mode: kongstate.PortModeByNumber, Number: int32(80)}, + }, + }, + }, + { + Service: kong.Service{ + Name: kong.String("httproute.default.httproute-1._.bar.com.0"), + }, + Backends: []kongstate.ServiceBackend{ + { + Name: "service1", + PortDef: kongstate.PortDef{Mode: kongstate.PortModeByNumber, Number: int32(80)}, + }, + }, + }, + { + Service: kong.Service{ + Name: kong.String("httproute.default.httproute-1.foo.com.1"), + }, + Backends: []kongstate.ServiceBackend{ + { + Name: "service2", + PortDef: kongstate.PortDef{Mode: kongstate.PortModeByNumber, Number: int32(80)}, + }, + }, + }, + { + Service: kong.Service{ + Name: kong.String("httproute.default.httproute-1._.bar.com.1"), + }, + Backends: []kongstate.ServiceBackend{ + { + Name: "service2", + PortDef: kongstate.PortDef{Mode: kongstate.PortModeByNumber, Number: int32(80)}, + }, + }, + }, + }, + expectedKongRoutes: map[string][]kongstate.Route{ + "httproute.default.httproute-1.foo.com.0": { + { + Route: kong.Route{ + Name: kong.String("httproute.default.httproute-1.foo.com.0.0"), + Expression: kong.String(`(http.host == "foo.com") && ((net.protocol == "http") || (net.protocol == "https")) && (http.path == "/v1/foo")`), + PreserveHost: kong.Bool(true), + }, + Plugins: []kong.Plugin{}, + ExpressionRoutes: true, + }, + }, + "httproute.default.httproute-1._.bar.com.0": { + { + Route: kong.Route{ + Name: kong.String("httproute.default.httproute-1._.bar.com.0.0"), + Expression: kong.String(`(http.host =^ ".bar.com") && ((net.protocol == "http") || (net.protocol == "https")) && (http.path == "/v1/foo")`), + PreserveHost: kong.Bool(true), + }, + Plugins: []kong.Plugin{}, + ExpressionRoutes: true, + }, + }, + "httproute.default.httproute-1.foo.com.1": { + { + Route: kong.Route{ + Name: kong.String("httproute.default.httproute-1.foo.com.1.0"), + Expression: kong.String(`(http.host == "foo.com") && ((net.protocol == "http") || (net.protocol == "https")) && (http.path == "/v1/barr")`), + PreserveHost: kong.Bool(true), + }, + Plugins: []kong.Plugin{}, + ExpressionRoutes: true, + }, + }, + "httproute.default.httproute-1._.bar.com.1": { + { + Route: kong.Route{ + Name: kong.String("httproute.default.httproute-1._.bar.com.1.0"), + Expression: kong.String(`(http.host =^ ".bar.com") && ((net.protocol == "http") || (net.protocol == "https")) && (http.path == "/v1/barr")`), + PreserveHost: kong.Bool(true), + }, + Plugins: []kong.Plugin{}, + ExpressionRoutes: true, + }, + }, + }, + }, + { + name: "multiple HTTPRoutes with translation failures", + httpRoutes: []*gatewayv1beta1.HTTPRoute{ + { + TypeMeta: httpRouteTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-no-host-no-rule", + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"no-rule.example"}, + }, + }, + { + TypeMeta: httpRouteTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "foo.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/v1/foo").Build(), + }, + BackendRefs: []gatewayv1beta1.HTTPBackendRef{ + builder.NewHTTPBackendRef("service1").WithPort(80).Build(), + }, + }, + }, + }, + }, + }, + expectedKongServices: []kongstate.Service{ + { + Service: kong.Service{ + Name: kong.String("httproute.default.httproute-1.foo.com.0"), + }, + Backends: []kongstate.ServiceBackend{ + { + Name: "service1", + PortDef: kongstate.PortDef{Mode: kongstate.PortModeByNumber, Number: int32(80)}, + }, + }, + }, + }, + expectedKongRoutes: map[string][]kongstate.Route{ + "httproute.default.httproute-1.foo.com.0": { + { + Route: kong.Route{ + Name: kong.String("httproute.default.httproute-1.foo.com.0.0"), + Expression: kong.String(`(http.host == "foo.com") && ((net.protocol == "http") || (net.protocol == "https")) && (http.path == "/v1/foo")`), + PreserveHost: kong.Bool(true), + }, + Plugins: []kong.Plugin{}, + ExpressionRoutes: true, + }, + }, + }, + expectedFailures: []failures.ResourceFailure{ + newResourceFailure(translators.ErrRouteValidationNoRules.Error(), &gatewayv1beta1.HTTPRoute{ + TypeMeta: httpRouteTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-no-host-no-rule", + }, + }), + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + failureCollector, err := failures.NewResourceFailuresCollector(logrus.New()) + require.NoError(t, err) + parser.failuresCollector = failureCollector + + result := newIngressRules() + parser.ingressRulesFromHTTPRoutesUsingExpressionRoutes(tc.httpRoutes, &result) + // check services + require.Equal(t, len(tc.expectedKongServices), len(result.ServiceNameToServices), + "should have expected number of services") + for _, expectedKongService := range tc.expectedKongServices { + kongService, ok := result.ServiceNameToServices[*expectedKongService.Name] + require.Truef(t, ok, "should find service %s", expectedKongService.Name) + require.Equal(t, expectedKongService.Backends, kongService.Backends) + // check routes + expectedKongRoutes := tc.expectedKongRoutes[*kongService.Name] + require.Equal(t, len(expectedKongRoutes), len(kongService.Routes)) + + kongRouteNameToRoute := lo.SliceToMap(kongService.Routes, func(r kongstate.Route) (string, kongstate.Route) { + return *r.Name, r + }) + for _, expectedRoute := range expectedKongRoutes { + routeName := expectedRoute.Name + r, ok := kongRouteNameToRoute[*routeName] + require.Truef(t, ok, "should find route %s", *routeName) + require.Equal(t, expectedRoute.Expression, r.Expression) + } + } + // check translation failures + translationFailures := failureCollector.PopResourceFailures() + require.Equal(t, len(tc.expectedFailures), len(translationFailures)) + for _, expectedTranslationFailure := range tc.expectedFailures { + expectedFailureMessage := expectedTranslationFailure.Message() + require.True(t, lo.ContainsBy(translationFailures, func(failure failures.ResourceFailure) bool { + return strings.Contains(failure.Message(), expectedFailureMessage) + })) + } + }) + } +} + +func TestIngressRulesWithPriority(t *testing.T) { + fakestore, err := store.NewFakeStore(store.FakeObjects{}) + require.NoError(t, err) + parser := mustNewParser(t, fakestore) + parser.featureFlags.CombinedServiceRoutes = true + parser.featureFlags.ExpressionRoutes = true + httpRouteTypeMeta := metav1.TypeMeta{Kind: "HTTPRoute", APIVersion: gatewayv1beta1.SchemeGroupVersion.String()} + + testCases := []struct { + name string + httpRouteWithPriority translators.SplitHTTPRouteToKongRoutePriority + expectedKongService kongstate.Service + expectedKongRoute kongstate.Route + expectedError error + }{ + { + name: "no hostname", + httpRouteWithPriority: translators.SplitHTTPRouteToKongRoutePriority{ + HTTPRoute: &gatewayv1beta1.HTTPRoute{ + TypeMeta: httpRouteTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + Annotations: map[string]string{ + translators.InternalRuleIndexAnnotationKey: "0", + translators.InternalMatchIndexAnnotationKey: "0", + }, + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/v1/foo").Build(), + }, + BackendRefs: []gatewayv1beta1.HTTPBackendRef{ + builder.NewHTTPBackendRef("service1").WithPort(80).Build(), + }, + }, + }, + }, + }, + Priority: 1024, + }, + expectedKongService: kongstate.Service{ + Service: kong.Service{ + Name: kong.String("httproute.default.httproute-1._.0"), + }, + Backends: []kongstate.ServiceBackend{ + { + Name: "service1", + PortDef: kongstate.PortDef{Mode: kongstate.PortModeByNumber, Number: int32(80)}, + }, + }, + }, + expectedKongRoute: kongstate.Route{ + Route: kong.Route{ + Name: kong.String("httproute.default.httproute-1._.0.0"), + Expression: kong.String(`((net.protocol == "http") || (net.protocol == "https")) && (http.path == "/v1/foo")`), + PreserveHost: kong.Bool(true), + Priority: kong.Int(1024), + }, + Plugins: []kong.Plugin{}, + ExpressionRoutes: true, + }, + }, + { + name: "precise hostname and filter", + httpRouteWithPriority: translators.SplitHTTPRouteToKongRoutePriority{ + HTTPRoute: &gatewayv1beta1.HTTPRoute{ + TypeMeta: httpRouteTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + Annotations: map[string]string{ + translators.InternalRuleIndexAnnotationKey: "0", + translators.InternalMatchIndexAnnotationKey: "1", + }, + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "foo.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/v1/foo").Build(), + }, + BackendRefs: []gatewayv1beta1.HTTPBackendRef{ + builder.NewHTTPBackendRef("service1").WithPort(80).Build(), + }, + Filters: []gatewayv1beta1.HTTPRouteFilter{ + builder.NewHTTPRouteRequestRedirectFilter(). + WithRequestRedirectStatusCode(301). + WithRequestRedirectHost("bar.com"). + Build(), + }, + }, + }, + }, + }, + Priority: 1024, + }, + expectedKongService: kongstate.Service{ + Service: kong.Service{ + Name: kong.String("httproute.default.httproute-1.foo.com.0"), + }, + Backends: []kongstate.ServiceBackend{ + { + Name: "service1", + PortDef: kongstate.PortDef{Mode: kongstate.PortModeByNumber, Number: int32(80)}, + }, + }, + }, + expectedKongRoute: kongstate.Route{ + Route: kong.Route{ + Name: kong.String("httproute.default.httproute-1.foo.com.0.1"), + Expression: kong.String(`(http.host == "foo.com") && ((net.protocol == "http") || (net.protocol == "https")) && (http.path == "/v1/foo")`), + PreserveHost: kong.Bool(true), + Priority: kong.Int(1024), + }, + Plugins: []kong.Plugin{ + { + Name: kong.String("request-termination"), + Config: kong.Configuration{ + "status_code": kong.Int(301), + }, + }, + { + Name: kong.String("response-transformer"), + Config: kong.Configuration{ + "add": map[string][]string{ + "headers": {"Location: http://bar.com:80/v1/foo"}, + }, + }, + }, + }, + ExpressionRoutes: true, + }, + }, + { + name: "wildcard hostname with multiple backends", + httpRouteWithPriority: translators.SplitHTTPRouteToKongRoutePriority{ + HTTPRoute: &gatewayv1beta1.HTTPRoute{ + TypeMeta: httpRouteTypeMeta, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + Annotations: map[string]string{ + translators.InternalRuleIndexAnnotationKey: "0", + translators.InternalMatchIndexAnnotationKey: "0", + }, + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "*.foo.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/v1/foo").Build(), + }, + BackendRefs: []gatewayv1beta1.HTTPBackendRef{ + builder.NewHTTPBackendRef("service1").WithPort(80).WithWeight(10).Build(), + builder.NewHTTPBackendRef("service2").WithPort(80).WithWeight(20).Build(), + }, + }, + }, + }, + }, + Priority: 1024, + }, + expectedKongService: kongstate.Service{ + Service: kong.Service{ + Name: kong.String("httproute.default.httproute-1._.foo.com.0"), + }, + Backends: []kongstate.ServiceBackend{ + { + Name: "service1", + PortDef: kongstate.PortDef{Mode: kongstate.PortModeByNumber, Number: int32(80)}, + Weight: lo.ToPtr(int32(10)), + }, + { + Name: "service2", + PortDef: kongstate.PortDef{Mode: kongstate.PortModeByNumber, Number: int32(80)}, + Weight: lo.ToPtr(int32(20)), + }, + }, + }, + expectedKongRoute: kongstate.Route{ + Route: kong.Route{ + Name: kong.String("httproute.default.httproute-1._.foo.com.0.0"), + Expression: kong.String(`(http.host =^ ".foo.com") && ((net.protocol == "http") || (net.protocol == "https")) && (http.path == "/v1/foo")`), + PreserveHost: kong.Bool(true), + Priority: kong.Int(1024), + }, + Plugins: []kong.Plugin{}, + ExpressionRoutes: true, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + tc.expectedKongRoute.Tags = util.GenerateTagsForObject(tc.httpRouteWithPriority.HTTPRoute) + tc.expectedKongRoute.Ingress = util.FromK8sObject(tc.httpRouteWithPriority.HTTPRoute) + + res := newIngressRules() + err := parser.ingressRulesFromSplitHTTPRouteWithPriority(&res, tc.httpRouteWithPriority) + if tc.expectedError != nil { + require.ErrorAs(t, err, tc.expectedError) + return + } + require.NoError(t, err) + kongService, ok := res.ServiceNameToServices[*tc.expectedKongService.Name] + require.True(t, ok) + require.Equal(t, tc.expectedKongService.Backends, kongService.Backends) + require.Len(t, kongService.Routes, 1) + require.Equal(t, tc.expectedKongRoute, kongService.Routes[0]) + }) + } +} + func HTTPMethodPointer(method gatewayv1beta1.HTTPMethod) *gatewayv1beta1.HTTPMethod { return &method } diff --git a/internal/dataplane/parser/translators/httproute_atc.go b/internal/dataplane/parser/translators/httproute_atc.go index 6211f9aaea..53a82ff3af 100644 --- a/internal/dataplane/parser/translators/httproute_atc.go +++ b/internal/dataplane/parser/translators/httproute_atc.go @@ -1,9 +1,12 @@ package translators import ( + "fmt" "sort" + "strconv" "strings" + "github.com/go-logr/logr" "github.com/kong/go-kong/kong" "github.com/samber/lo" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" @@ -227,3 +230,380 @@ func matchersFromParentHTTPRoute(hostnames []string, metaAnnotations map[string] } return ret } + +const ( + InternalRuleIndexAnnotationKey = "internal-rule-index" + InternalMatchIndexAnnotationKey = "internal-match-index" +) + +// SplitHTTPRoute splits HTTPRoutes into HTTPRoutes with at most one hostname, and at most one rule +// with exactly one match. It will split one rule with multiple hostnames and multiple matches +// to one hostname and one match per each HTTPRoute. +func SplitHTTPRoute(httproute *gatewayv1beta1.HTTPRoute) []*gatewayv1beta1.HTTPRoute { + // split HTTPRoutes by hostname. + hostnamedRoutes := []*gatewayv1beta1.HTTPRoute{} + if len(httproute.Spec.Hostnames) == 0 { + hostnamedRoutes = append(hostnamedRoutes, httproute.DeepCopy()) + } else { + for _, hostname := range httproute.Spec.Hostnames { + hostNamedRoute := httproute.DeepCopy() + hostNamedRoute.Spec.Hostnames = []gatewayv1beta1.Hostname{hostname} + hostnamedRoutes = append(hostnamedRoutes, hostNamedRoute) + } + } + // split HTTPRoutes (already split once by hostname) by match. + newHTTPRoutes := []*gatewayv1beta1.HTTPRoute{} + for _, route := range hostnamedRoutes { + for i, rule := range route.Spec.Rules { + for j, match := range rule.Matches { + splitRoute := route.DeepCopy() + splitRoute.Spec.Rules = []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{match}, + Filters: rule.Filters, + BackendRefs: rule.BackendRefs, + }, + } + if splitRoute.Annotations == nil { + splitRoute.Annotations = map[string]string{} + } + splitRoute.Annotations[InternalRuleIndexAnnotationKey] = strconv.Itoa(i) + splitRoute.Annotations[InternalMatchIndexAnnotationKey] = strconv.Itoa(j) + newHTTPRoutes = append(newHTTPRoutes, splitRoute) + } + } + } + + return newHTTPRoutes +} + +type SplitHTTPRouteToKongRoutePriority struct { + HTTPRoute *gatewayv1beta1.HTTPRoute + Priority int +} + +type HTTPRoutePriorityTraits struct { + PreciseHostname bool + HostnameLength int + PathType gatewayv1beta1.PathMatchType + PathLength int + HeaderCount int + HasMethodMatch bool + QueryParamCount int +} + +// CalculateSplitHTTPRoutePriorityTraits calculates the parts of priority that can be decided by the +// fields in spec of the already split HTTPRoute. Specification of priority goes as follow: +// (The following comments are extracted from gateway API specification about HTTPRoute) +// +// In the event that multiple HTTPRoutes specify intersecting hostnames, +// precedence must be given to rules from the HTTPRoute with the largest number of: +// +// - Characters in a matching non-wildcard hostname. +// - Characters in a matching hostname. +// +// If ties exist across multiple Routes, the matching precedence rules for HTTPRouteMatches takes over. +// +// Proxy or Load Balancer routing configuration generated from HTTPRoutes MUST prioritize matches based on the following criteria, continuing on ties. +// Across all rules specified on applicable Routes, precedence must be given to the match having: +// +// - "Exact” path match. +// - "Prefix" path match with largest number of characters. +// - Method match. +// - Largest number of header matches. +// - Largest number of query param matches. +func CalculateSplitHTTPRoutePriorityTraits(httpRoute *gatewayv1beta1.HTTPRoute) HTTPRoutePriorityTraits { + traits := HTTPRoutePriorityTraits{} + // the HTTPRoute here have been already split by hostnames and matches, + // so one HTTPRoute have at most one hostname. + if len(httpRoute.Spec.Hostnames) != 0 { + hostname := httpRoute.Spec.Hostnames[0] + traits.HostnameLength = len(hostname) + // if the hostname does not start with *, the split HTTPRoute should have precise hostname. + if !strings.HasPrefix(string(hostname), "*") { + traits.PreciseHostname = true + } + } + + // also, the HTTPRoute have been split so it have at most one match. + if len(httpRoute.Spec.Rules) > 0 && len(httpRoute.Spec.Rules[0].Matches) > 0 { + match := httpRoute.Spec.Rules[0].Matches[0] + if match.Path != nil { + // fill path type. + if match.Path.Type != nil { + traits.PathType = *match.Path.Type + } + // fill path length. + if match.Path.Value != nil { + traits.PathLength = len(*match.Path.Value) + } + } + + // fill number of header matches. + traits.HeaderCount = len(match.Headers) + // fill method match. + if match.Method != nil { + traits.HasMethodMatch = true + } + // fill number of query parameters. + traits.QueryParamCount = len(match.QueryParams) + } + return traits +} + +// EncodeToPriority turns HTTPRoute priority traits into the integer expressed priority. +// +// 4 3 2 1 +// 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0 +// +-+---------------+-+-+-------------------+-+---------+---------+-----------------------------------+ +// |P| host len |E|R| Path length |M|Header No|Query No.| relative order | +// +-+---------------+-+-+-------------------+-+---------+-------- +-----------------------------------+ +// +// Where: +// P: set to 1 if the hostname is non-wildcard. +// host len: host length of hostname. +// E: set to 1 if the path type is `Exact`. +// R: set to 1 if the path type in `RegularExpression`. +// Path length: length of `path.Value`. +// M: set to 1 if Method match is specified. +// Header No.: number of header matches. +// Query No.: number of query parameter matches. +// relative order: relative order of creation timestamp, namespace and name and internal rule/match order between different (split) HTTPRoutes. +func (t HTTPRoutePriorityTraits) EncodeToPriority() int { + const ( + // PreciseHostnameShiftBits assigns bit 49 for marking if the hostname is non-wildcard. + PreciseHostnameShiftBits = 49 + // HostnameLengthShiftBits assigns bits 41-48 for the length of hostname. + HostnameLengthShiftBits = 41 + // ExactPathShiftBits assigns bit 40 to mark if the match is exact path match. + ExactPathShiftBits = 40 + // RegularExpressionPathShiftBits assigns bit 39 to mark if the match is regex path match. + RegularExpressionPathShiftBits = 39 + // PathLengthShiftBits assigns bits 29-38 to path length. (max length = 1024, but must start with /) + PathLengthShiftBits = 29 + // MethodMatchShiftBits assigns bit 28 to mark if method is specified. + MethodMatchShiftBits = 28 + // HeaderNumberShiftBits assign bits 23-27 to number of headers. (max number of headers = 16) + HeaderNumberShiftBits = 23 + // QueryParamNumberShiftBits makes bits 18-22 used for number of query params (max number of query params = 16) + QueryParamNumberShiftBits = 18 + // bits 0-17 are used for relative order of creation timestamp, namespace/name, and internal order of rules and matches. + // the bits are calculated by sorting HTTPRoutes with the same priority calculated from the fields above + // and start from all 1s, then decrease by one for each HTTPRoute. + ) + + var priority int + if t.PreciseHostname { + priority += (1 << PreciseHostnameShiftBits) + } + priority += t.HostnameLength << HostnameLengthShiftBits + + if t.PathType == gatewayv1beta1.PathMatchExact { + priority += (1 << ExactPathShiftBits) + } + if t.PathType == gatewayv1beta1.PathMatchRegularExpression { + priority += (1 << RegularExpressionPathShiftBits) + } + + // max length of path is 1024, but path must start with /, so we use PathLength-1 to fill the bits. + if t.PathLength > 0 { + priority += ((t.PathLength - 1) << PathLengthShiftBits) + } + + priority += (t.HeaderCount << HeaderNumberShiftBits) + if t.HasMethodMatch { + priority += (1 << MethodMatchShiftBits) + } + priority += (t.QueryParamCount << QueryParamNumberShiftBits) + priority += (ResourceKindBitsHTTPRoute << FromResourceKindPriorityShiftBits) + + return priority +} + +// AssignRoutePriorityToSplitHTTPRoutes assigns priority to ALL split HTTPRoutes +// that are split by hostnames and matches from HTTPRoutes listed from the cache. +// Firstly assign "fixed" bits by the following fields of the HTTPRoute: +// hostname, path type, path length, method match, number of header matches, number of query param matches. +// If ties exists in the first step, where multiple HTTPRoute has the same priority +// calculated from the fields, we run a sort for the HTTPRoutes in the tie +// and assign the bits for "relative order" according to the sorting result of these HTTPRoutes. +func AssignRoutePriorityToSplitHTTPRoutes( + logger logr.Logger, + splitHTTPRoutes []*gatewayv1beta1.HTTPRoute, +) []SplitHTTPRouteToKongRoutePriority { + priorityToSplitHTTPRoutes := map[int][]*gatewayv1beta1.HTTPRoute{} + + for _, httpRoute := range splitHTTPRoutes { + anns := httpRoute.Annotations + // skip if HTTPRoute does not contain the annotation, because this means the HTTPRoute is not a split one. + if anns == nil || anns[InternalRuleIndexAnnotationKey] == "" || anns[InternalMatchIndexAnnotationKey] == "" { + continue + } + + priority := CalculateSplitHTTPRoutePriorityTraits(httpRoute).EncodeToPriority() + priorityToSplitHTTPRoutes[priority] = append(priorityToSplitHTTPRoutes[priority], httpRoute) + } + + httpRoutesToPriorities := make([]SplitHTTPRouteToKongRoutePriority, 0, len(splitHTTPRoutes)) + + // Bits 0-17 (18 bits) are assigned for relative order of HTTPRoutes. + // If multiple HTTPRoutes are assigned to the same priority in the previous step, + // sort them then starts with 2^18 -1 and decrease by one for each HTTPRoute; + // If only one HTTPRoute occupies the priority, fill the relative order bits with all 1s. + const RelativeOrderAssignedBits = 18 + const defaultRelativeOrderPriorityBits = (1 << RelativeOrderAssignedBits) - 1 + for priority, routes := range priorityToSplitHTTPRoutes { + if len(routes) == 1 { + httpRoutesToPriorities = append(httpRoutesToPriorities, SplitHTTPRouteToKongRoutePriority{ + HTTPRoute: routes[0], + Priority: priority + defaultRelativeOrderPriorityBits, + }) + continue + } + + sort.Slice(routes, func(i, j int) bool { + return compareSplitHTTPRoutesRelativePriority(routes[i], routes[j]) + }) + + for i, route := range routes { + relativeOrderBits := defaultRelativeOrderPriorityBits - i + // Although it is very unlikely that there are 2^18 = 262144 HTTPRoutes + // should be given priority by their relative order, here we limit the + // relativeOrderBits to be at least 0. + if relativeOrderBits <= 0 { + relativeOrderBits = 0 + } + httpRoutesToPriorities = append(httpRoutesToPriorities, SplitHTTPRouteToKongRoutePriority{ + HTTPRoute: route, + Priority: priority + relativeOrderBits, + }) + } + // Just in case, log a very unlikely scenario where we have more than 2^18 routes with the same base + // priority and we have no bit space for them to be deterministically ordered. + if len(routes) > (1 << 18) { + logger.V(util.WarnLevel).Info("Too many HTTPRoutes to be deterministically ordered", "httproute_number", len(routes)) + } + } + + return httpRoutesToPriorities +} + +func compareSplitHTTPRoutesRelativePriority(route1, route2 *gatewayv1beta1.HTTPRoute) bool { + // compare by creation timestamp. + if !route1.CreationTimestamp.Equal(&route2.CreationTimestamp) { + return route1.CreationTimestamp.Before(&route2.CreationTimestamp) + } + // compare by namespace. + if route1.Namespace != route2.Namespace { + return route1.Namespace < route2.Namespace + } + // compare by name. + if route1.Name != route2.Name { + return route1.Name < route2.Name + } + // if ties still exist, compare by internal rule order and match order. + ruleIndex1, _ := strconv.Atoi(route1.Annotations[InternalRuleIndexAnnotationKey]) + ruleIndex2, _ := strconv.Atoi(route2.Annotations[InternalRuleIndexAnnotationKey]) + if ruleIndex1 != ruleIndex2 { + return ruleIndex1 < ruleIndex2 + } + + matchIndex1, _ := strconv.Atoi(route1.Annotations[InternalMatchIndexAnnotationKey]) + matchIndex2, _ := strconv.Atoi(route2.Annotations[InternalMatchIndexAnnotationKey]) + if matchIndex1 != matchIndex2 { + return matchIndex1 < matchIndex2 + } + + // should be unreachable. + return true +} + +// getHTTPRouteHostnamesAsSliceOfStrings translates the hostnames defined in an +// HTTPRoute specification into a string slice, which is the type required by translating to matchers +// in expression based routes. +func getHTTPRouteHostnamesAsSliceOfStrings(httproute *gatewayv1beta1.HTTPRoute) []string { + return lo.Map(httproute.Spec.Hostnames, func(h gatewayv1beta1.Hostname, _ int) string { + return string(h) + }) +} + +// KongExpressionRouteFromHTTPRouteWithPriority translates split HTTPRoute into expression +// based kong route with assigned priority. +// the HTTPRoute should have at most one hostname, and at most one rule having exactly one match. +func KongExpressionRouteFromHTTPRouteWithPriority( + httpRouteWithPriority SplitHTTPRouteToKongRoutePriority, +) kongstate.Route { + httproute := httpRouteWithPriority.HTTPRoute + tags := util.GenerateTagsForObject(httproute) + // since we split HTTPRoutes by hostname, rule and match, we generate the route name in + // httproute..... format. + hostname := "_" + if len(httproute.Spec.Hostnames) > 0 { + hostname = string(httproute.Spec.Hostnames[0]) + hostname = strings.ReplaceAll(hostname, "*", "_") + } + routeName := fmt.Sprintf("httproute.%s.%s.%s.%s.%s", + httproute.Namespace, + httproute.Name, + hostname, + httproute.Annotations[InternalRuleIndexAnnotationKey], + httproute.Annotations[InternalMatchIndexAnnotationKey], + ) + + r := kongstate.Route{ + Route: kong.Route{ + Name: kong.String(routeName), + PreserveHost: kong.Bool(true), + Tags: tags, + }, + Ingress: util.FromK8sObject(httproute), + ExpressionRoutes: true, + } + + hostnames := getHTTPRouteHostnamesAsSliceOfStrings(httproute) + matchers := matchersFromParentHTTPRoute(hostnames, httproute.Annotations) + + if len(httproute.Spec.Rules) > 0 && len(httproute.Spec.Rules[0].Matches) > 0 { + matchers = append(matchers, generateMatcherFromHTTPRouteMatch(httproute.Spec.Rules[0].Matches[0])) + } + atc.ApplyExpression(&r.Route, atc.And(matchers...), httpRouteWithPriority.Priority) + + // translate filters in the rule. + if len(httproute.Spec.Rules) > 0 { + rule := httproute.Spec.Rules[0] + path := "" + // since we have at most one match per rule, we do not need to generate request redirect for each match. + if len(rule.Matches) > 0 { + match := rule.Matches[0] + if match.Path != nil && match.Path.Value != nil { + path = *match.Path.Value + } + } + + plugins := GeneratePluginsFromHTTPRouteFilters(rule.Filters, path, tags) + r.Plugins = plugins + } + + return r +} + +// KongServiceNameFromHTTPRouteWithPriority generates service name from split HTTPRoutes. +// since one HTTPRoute may be split by hostname and rule, the service name will be generated +// in the format httproute..... +// For example: `httproute.default.example.foo.com.0`. +func KongServiceNameFromHTTPRouteWithPriority( + httpRouteWithPriority SplitHTTPRouteToKongRoutePriority, +) string { + httproute := httpRouteWithPriority.HTTPRoute + hostname := "_" + if len(httproute.Spec.Hostnames) > 0 { + hostname = string(httproute.Spec.Hostnames[0]) + hostname = strings.ReplaceAll(hostname, "*", "_") + } + return fmt.Sprintf("httproute.%s.%s.%s.%s", + httproute.Namespace, + httproute.Name, + hostname, + httproute.Annotations[InternalRuleIndexAnnotationKey], + ) +} diff --git a/internal/dataplane/parser/translators/httproute_atc_test.go b/internal/dataplane/parser/translators/httproute_atc_test.go index c8d29d14b8..ae5999ba2c 100644 --- a/internal/dataplane/parser/translators/httproute_atc_test.go +++ b/internal/dataplane/parser/translators/httproute_atc_test.go @@ -1,10 +1,16 @@ package translators import ( + "reflect" + "strconv" "testing" + "time" + "github.com/go-logr/logr" "github.com/kong/go-kong/kong" + "github.com/samber/lo" "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" "github.com/kong/kubernetes-ingress-controller/v2/internal/dataplane/kongstate" @@ -306,3 +312,925 @@ func TestGenerateMatcherFromHTTPRouteMatch(t *testing.T) { }) } } + +func TestCalculateHTTPRoutePriorityTraits(t *testing.T) { + testCases := []struct { + name string + httpRoute *gatewayv1beta1.HTTPRoute + expectedTraits HTTPRoutePriorityTraits + }{ + { + name: "precise hostname and exact path", + httpRoute: &gatewayv1beta1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "precise-hostname-exact-path", + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"foo.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/foo").Build(), + }, + }, + }, + }, + }, + expectedTraits: HTTPRoutePriorityTraits{ + PreciseHostname: true, + HostnameLength: len("foo.com"), + PathType: gatewayv1beta1.PathMatchExact, + PathLength: len("/foo"), + }, + }, + { + name: "wildcard hostname and prefix path", + httpRoute: &gatewayv1beta1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "wildcard-hostname-prefix-path", + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"*.foo.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathPrefix("/foo/").Build(), + }, + }, + }, + }, + }, + expectedTraits: HTTPRoutePriorityTraits{ + PreciseHostname: false, + HostnameLength: len("*.foo.com"), + PathType: gatewayv1beta1.PathMatchPathPrefix, + PathLength: len("/foo/"), + }, + }, + { + name: "no hostname and regex path, with header matches", + httpRoute: &gatewayv1beta1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "no-hostname-regex-path", + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathRegex("/[a-z0-9]+"). + WithHeader("foo", "bar").Build(), + }, + }, + }, + }, + }, + expectedTraits: HTTPRoutePriorityTraits{ + PathType: gatewayv1beta1.PathMatchRegularExpression, + PathLength: len("/[a-z0-9]+"), + HeaderCount: 1, + }, + }, + { + name: "precise hostname and method, query param match", + httpRoute: &gatewayv1beta1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "precise-hostname-method-query", + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "foo.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithMethod("GET"). + WithQueryParam("foo", "bar").Build(), + }, + }, + }, + }, + }, + expectedTraits: HTTPRoutePriorityTraits{ + PreciseHostname: true, + HostnameLength: len("foo.com"), + HasMethodMatch: true, + QueryParamCount: 1, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + traits := CalculateSplitHTTPRoutePriorityTraits(tc.httpRoute) + require.Equal(t, tc.expectedTraits, traits) + }) + } +} + +func TestEncodeHTTPRoutePriorityFromTraits(t *testing.T) { + testCases := []struct { + name string + traits HTTPRoutePriorityTraits + expectedPriority int + }{ + { + name: "precise hostname and exact path", + traits: HTTPRoutePriorityTraits{ + PreciseHostname: true, + HostnameLength: 7, + PathType: gatewayv1beta1.PathMatchExact, + PathLength: 4, + }, + expectedPriority: (2 << 50) | (1 << 49) | (7 << 41) | (1 << 40) | (3 << 29), + }, + { + name: "wildcard hostname and prefix path", + traits: HTTPRoutePriorityTraits{ + PreciseHostname: false, + HostnameLength: 7, + PathType: gatewayv1beta1.PathMatchPathPrefix, + PathLength: 5, + }, + expectedPriority: (2 << 50) | (7 << 41) | (4 << 29), + }, + { + name: "no hostname and regex path, with header matches", + traits: HTTPRoutePriorityTraits{ + PathType: gatewayv1beta1.PathMatchRegularExpression, + PathLength: 5, + HeaderCount: 2, + }, + expectedPriority: (2 << 50) | (1 << 39) | (4 << 29) | (2 << 23), + }, + { + name: "no hostname and exact path, with method match and query parameter matches", + traits: HTTPRoutePriorityTraits{ + PathType: gatewayv1beta1.PathMatchExact, + PathLength: 5, + HasMethodMatch: true, + QueryParamCount: 1, + }, + expectedPriority: (2 << 50) | (1 << 40) | (4 << 29) | (1 << 28) | (1 << 18), + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.expectedPriority, tc.traits.EncodeToPriority()) + }) + } +} + +func TestSplitHTTPRoutes(t *testing.T) { + namesToBackendRefs := func(names []string) []gatewayv1beta1.HTTPBackendRef { + backendRefs := []gatewayv1beta1.HTTPBackendRef{} + for _, name := range names { + backendRefs = append(backendRefs, + gatewayv1beta1.HTTPBackendRef{ + BackendRef: gatewayv1beta1.BackendRef{ + BackendObjectReference: gatewayv1beta1.BackendObjectReference{ + Name: gatewayv1beta1.ObjectName(name), + }, + }, + }, + ) + } + return backendRefs + } + + testCases := []struct { + name string + httpRoute *gatewayv1beta1.HTTPRoute + splitHTTPRoutes []*gatewayv1beta1.HTTPRoute + }{ + { + name: "no hostname and only one match", + httpRoute: &gatewayv1beta1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "httproute-1", + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/"), + }, + }, + }, + BackendRefs: namesToBackendRefs([]string{"svc1"}), + }, + }, + }, + }, + splitHTTPRoutes: []*gatewayv1beta1.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "httproute-1", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "0", + }, + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/"), + }, + }, + }, + BackendRefs: namesToBackendRefs([]string{"svc1"}), + }, + }, + }, + }, + }, + }, + { + name: "multiple hostnames with one match", + httpRoute: &gatewayv1beta1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "httproute-2", + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "a.foo.com", + "b.foo.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/"), + }, + }, + }, + BackendRefs: namesToBackendRefs([]string{"svc1", "svc2"}), + }, + }, + }, + }, + splitHTTPRoutes: []*gatewayv1beta1.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "httproute-2", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "0", + }, + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "a.foo.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/"), + }, + }, + }, + BackendRefs: namesToBackendRefs([]string{"svc1", "svc2"}), + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "httproute-2", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "0", + }, + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "b.foo.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/"), + }, + }, + }, + BackendRefs: namesToBackendRefs([]string{"svc1", "svc2"}), + }, + }, + }, + }, + }, + }, + { + name: "single hostname with multiple rules and matches", + httpRoute: &gatewayv1beta1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "httproute-3", + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "a.foo.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/foo"), + }, + }, + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/bar"), + }, + }, + }, + BackendRefs: namesToBackendRefs([]string{"svc1"}), + }, + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/v2/foo"), + }, + }, + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/v2/bar"), + }, + }, + }, + BackendRefs: namesToBackendRefs([]string{"svc2"}), + }, + }, + }, + }, + splitHTTPRoutes: []*gatewayv1beta1.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "httproute-3", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "0", + }, + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "a.foo.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/foo"), + }, + }, + }, + BackendRefs: namesToBackendRefs([]string{"svc1"}), + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "httproute-3", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "1", + }, + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "a.foo.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/bar"), + }, + }, + }, + BackendRefs: namesToBackendRefs([]string{"svc1"}), + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "httproute-3", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "1", + InternalMatchIndexAnnotationKey: "0", + }, + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "a.foo.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/v2/foo"), + }, + }, + }, + BackendRefs: namesToBackendRefs([]string{"svc2"}), + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "ns1", + Name: "httproute-3", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "1", + InternalMatchIndexAnnotationKey: "1", + }, + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{ + "a.foo.com", + }, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + { + Path: &gatewayv1beta1.HTTPPathMatch{ + Type: lo.ToPtr(gatewayv1beta1.PathMatchExact), + Value: lo.ToPtr("/v2/bar"), + }, + }, + }, + BackendRefs: namesToBackendRefs([]string{"svc2"}), + }, + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + tc := tc + splitHTTPRoutes := SplitHTTPRoute(tc.httpRoute) + require.Len(t, splitHTTPRoutes, len(splitHTTPRoutes), "should have same number of split HTTPRoutes with expected") + for i, splitHTTPRoute := range tc.splitHTTPRoutes { + require.True(t, reflect.DeepEqual(splitHTTPRoute, splitHTTPRoutes[i])) + } + } +} + +func TestAssignRoutePriorityToSplitHTTPRoutes(t *testing.T) { + type splitHTTPRouteIndex struct { + namespace string + name string + hostname string + ruleIndex int + matchIndex int + } + now := time.Now() + const maxRelativeOrderPriorityBits = (1 << 18) - 1 + + testCases := []struct { + name string + splitHTTPRoutes []*gatewayv1beta1.HTTPRoute + // HTTPRoute index -> priority + priorities map[splitHTTPRouteIndex]int + }{ + { + name: "no dupelicated fixed priority", + splitHTTPRoutes: []*gatewayv1beta1.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "0", + }, + CreationTimestamp: metav1.NewTime(now.Add(-5 * time.Second)), + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"foo.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/foo").Build(), + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-2", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "0", + }, + CreationTimestamp: metav1.NewTime(now.Add(-10 * time.Second)), + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"*.bar.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/bar").Build(), + }, + }, + }, + }, + }, + }, + priorities: map[splitHTTPRouteIndex]int{ + { + namespace: "default", + name: "httproute-1", + hostname: "foo.com", + ruleIndex: 0, + matchIndex: 0, + }: HTTPRoutePriorityTraits{ + PreciseHostname: true, + HostnameLength: len("foo.com"), + PathType: gatewayv1beta1.PathMatchExact, + PathLength: len("/foo"), + }.EncodeToPriority() + maxRelativeOrderPriorityBits, + { + namespace: "default", + name: "httproute-2", + hostname: "*.bar.com", + ruleIndex: 0, + matchIndex: 0, + }: HTTPRoutePriorityTraits{ + PreciseHostname: false, + HostnameLength: len("*.bar.com"), + PathType: gatewayv1beta1.PathMatchExact, + PathLength: len("/bar"), + }.EncodeToPriority() + maxRelativeOrderPriorityBits, + }, + }, + { + name: "break tie by creation timestamp", + splitHTTPRoutes: []*gatewayv1beta1.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "0", + }, + CreationTimestamp: metav1.NewTime(now.Add(-5 * time.Second)), + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"foo.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/foo").Build(), + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-2", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "0", + }, + CreationTimestamp: metav1.NewTime(now.Add(-1 * time.Second)), + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"bar.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/foo").Build(), + }, + }, + }, + }, + }, + }, + priorities: map[splitHTTPRouteIndex]int{ + { + namespace: "default", + name: "httproute-1", + hostname: "foo.com", + ruleIndex: 0, + matchIndex: 0, + }: HTTPRoutePriorityTraits{ + PreciseHostname: true, + HostnameLength: len("foo.com"), + PathType: gatewayv1beta1.PathMatchExact, + PathLength: len("/foo"), + }.EncodeToPriority() + maxRelativeOrderPriorityBits, + { + namespace: "default", + name: "httproute-2", + hostname: "bar.com", + ruleIndex: 0, + matchIndex: 0, + }: HTTPRoutePriorityTraits{ + PreciseHostname: true, + HostnameLength: len("bar.com"), + PathType: gatewayv1beta1.PathMatchExact, + PathLength: len("/foo"), + }.EncodeToPriority() + maxRelativeOrderPriorityBits - 1, + }, + }, + { + name: "break tie namespace and name", + splitHTTPRoutes: []*gatewayv1beta1.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "0", + }, + CreationTimestamp: metav1.NewTime(now.Add(-5 * time.Second)), + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"foo.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/foo").Build(), + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-2", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "0", + }, + CreationTimestamp: metav1.NewTime(now.Add(-5 * time.Second)), + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"bar.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/foo").Build(), + }, + }, + }, + }, + }, + }, + priorities: map[splitHTTPRouteIndex]int{ + { + namespace: "default", + name: "httproute-1", + hostname: "foo.com", + ruleIndex: 0, + matchIndex: 0, + }: HTTPRoutePriorityTraits{ + PreciseHostname: true, + HostnameLength: len("foo.com"), + PathType: gatewayv1beta1.PathMatchExact, + PathLength: len("/foo"), + }.EncodeToPriority() + maxRelativeOrderPriorityBits, + { + namespace: "default", + name: "httproute-2", + hostname: "bar.com", + ruleIndex: 0, + matchIndex: 0, + }: HTTPRoutePriorityTraits{ + PreciseHostname: true, + HostnameLength: len("bar.com"), + PathType: gatewayv1beta1.PathMatchExact, + PathLength: len("/foo"), + }.EncodeToPriority() + maxRelativeOrderPriorityBits - 1, + }, + }, + { + name: "break tie by internal match index", + splitHTTPRoutes: []*gatewayv1beta1.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "0", + }, + CreationTimestamp: metav1.NewTime(now.Add(-5 * time.Second)), + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"foo.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/foo").Build(), + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "1", + }, + CreationTimestamp: metav1.NewTime(now.Add(-5 * time.Second)), + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"foo.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/bar").Build(), + }, + }, + }, + }, + }, + }, + priorities: map[splitHTTPRouteIndex]int{ + { + namespace: "default", + name: "httproute-1", + hostname: "foo.com", + ruleIndex: 0, + matchIndex: 0, + }: HTTPRoutePriorityTraits{ + PreciseHostname: true, + HostnameLength: len("foo.com"), + PathType: gatewayv1beta1.PathMatchExact, + PathLength: len("/foo"), + }.EncodeToPriority() + maxRelativeOrderPriorityBits, + { + namespace: "default", + name: "httproute-1", + hostname: "foo.com", + ruleIndex: 0, + matchIndex: 1, + }: HTTPRoutePriorityTraits{ + PreciseHostname: true, + HostnameLength: len("bar.com"), + PathType: gatewayv1beta1.PathMatchExact, + PathLength: len("/foo"), + }.EncodeToPriority() + maxRelativeOrderPriorityBits - 1, + }, + }, + { + name: "httproutes without rule index and internal match index annotations are omitted", + splitHTTPRoutes: []*gatewayv1beta1.HTTPRoute{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + InternalMatchIndexAnnotationKey: "0", + }, + CreationTimestamp: metav1.NewTime(now.Add(-5 * time.Second)), + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"foo.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/foo").Build(), + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-2", + Annotations: map[string]string{ + InternalRuleIndexAnnotationKey: "0", + }, + CreationTimestamp: metav1.NewTime(now.Add(-10 * time.Second)), + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"*.bar.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/bar").Build(), + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-3", + CreationTimestamp: metav1.NewTime(now.Add(-10 * time.Second)), + }, + Spec: gatewayv1beta1.HTTPRouteSpec{ + Hostnames: []gatewayv1beta1.Hostname{"a.bar.com"}, + Rules: []gatewayv1beta1.HTTPRouteRule{ + { + Matches: []gatewayv1beta1.HTTPRouteMatch{ + builder.NewHTTPRouteMatch().WithPathExact("/bar").Build(), + }, + }, + }, + }, + }, + }, + priorities: map[splitHTTPRouteIndex]int{ + { + namespace: "default", + name: "httproute-1", + hostname: "foo.com", + ruleIndex: 0, + matchIndex: 0, + }: HTTPRoutePriorityTraits{ + PreciseHostname: true, + HostnameLength: len("foo.com"), + PathType: gatewayv1beta1.PathMatchExact, + PathLength: len("/foo"), + }.EncodeToPriority() + maxRelativeOrderPriorityBits, + }, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + splitHTTPRoutesWithPriorities := AssignRoutePriorityToSplitHTTPRoutes(logr.Discard(), tc.splitHTTPRoutes) + require.Equal(t, len(tc.priorities), len(splitHTTPRoutesWithPriorities), "should have required number of results") + for _, r := range splitHTTPRoutesWithPriorities { + httpRoute := r.HTTPRoute + ruleIndex, err := strconv.Atoi(httpRoute.Annotations[InternalRuleIndexAnnotationKey]) + require.NoError(t, err) + matchIndex, err := strconv.Atoi(httpRoute.Annotations[InternalMatchIndexAnnotationKey]) + require.NoError(t, err) + + require.Equalf(t, tc.priorities[splitHTTPRouteIndex{ + namespace: httpRoute.Namespace, + name: httpRoute.Name, + hostname: string(httpRoute.Spec.Hostnames[0]), + ruleIndex: ruleIndex, + matchIndex: matchIndex, + }], r.Priority, "httproute %s/%s: hostname %s, rule %d match %d", + httpRoute.Namespace, httpRoute.Name, httpRoute.Spec.Hostnames[0], ruleIndex, matchIndex) + } + }) + } +} diff --git a/test/conformance/gateway_conformance_test.go b/test/conformance/gateway_conformance_test.go index e6412f871c..7b07a8a32b 100644 --- a/test/conformance/gateway_conformance_test.go +++ b/test/conformance/gateway_conformance_test.go @@ -83,9 +83,6 @@ func TestGatewayConformance(t *testing.T) { ExemptFeatures: exemptFeatures, BaseManifests: conformanceTestsBaseManifests, SkipTests: []string{ - // standard conformance - tests.HTTPRouteHeaderMatching.ShortName, - // https://github.com/Kong/kubernetes-ingress-controller/issues/4166 // requires an 8080 listener, which our manually-built test gateway does not have tests.HTTPRouteRedirectPortAndScheme.ShortName,