diff --git a/go.mod b/go.mod index 203417adca..78ba539efd 100644 --- a/go.mod +++ b/go.mod @@ -42,6 +42,7 @@ require ( k8s.io/klog/v2 v2.130.1 k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 sigs.k8s.io/controller-runtime v0.19.3 + sigs.k8s.io/gateway-api v1.2.0 sigs.k8s.io/yaml v1.4.0 ) @@ -128,7 +129,6 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.9 // indirect - github.com/miekg/dns v1.1.62 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect diff --git a/go.sum b/go.sum index 0778094257..c4fdfffe77 100644 --- a/go.sum +++ b/go.sum @@ -623,6 +623,8 @@ oras.land/oras-go v1.2.5 h1:XpYuAwAb0DfQsunIyMfeET92emK8km3W4yEzZvUbsTo= oras.land/oras-go v1.2.5/go.mod h1:PuAwRShRZCsZb7g8Ar3jKKQR/2A/qN+pkYxIOd/FAoo= sigs.k8s.io/controller-runtime v0.19.3 h1:XO2GvC9OPftRst6xWCpTgBZO04S2cbp0Qqkj8bX1sPw= sigs.k8s.io/controller-runtime v0.19.3/go.mod h1:j4j87DqtsThvwTv5/Tc5NFRyyF/RF0ip4+62tbTSIUM= +sigs.k8s.io/gateway-api v1.2.0 h1:LrToiFwtqKTKZcZtoQPTuo3FxhrrhTgzQG0Te+YGSo8= +sigs.k8s.io/gateway-api v1.2.0/go.mod h1:EpNfEXNjiYfUJypf0eZ0P5iXA9ekSGWaS1WgPaM42X0= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8= sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo= sigs.k8s.io/kustomize/api v0.18.0 h1:hTzp67k+3NEVInwz5BHyzc9rGxIauoXferXyjv5lWPo= diff --git a/pkg/gateway/routeutils/backend.go b/pkg/gateway/routeutils/backend.go new file mode 100644 index 0000000000..3db827191e --- /dev/null +++ b/pkg/gateway/routeutils/backend.go @@ -0,0 +1,98 @@ +package routeutils + +import ( + "context" + "fmt" + "github.com/pkg/errors" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// Backend an abstraction on the Gateway Backend, meant to hide the underlying backend type from consumers (unless they really want to see it :)) +type Backend struct { + Service *corev1.Service + ServicePort *corev1.ServicePort + TypeSpecificBackend interface{} + Weight int + // Add TG config here // +} + +// TODOs: +// 1/ Add reference grant checking +// 2/ Add target group configuration resolution + +// NOTE: Currently routeKind is not used, however, we will need it to load TG specific configuration. +// commonBackendLoader this function will load the services and target group configurations associated with this gateway backend. +func commonBackendLoader(ctx context.Context, k8sClient client.Client, typeSpecificBackend interface{}, backendRef gwv1.BackendRef, routeIdentifier types.NamespacedName, routeKind string) (*Backend, error) { + + // We only support references of type service. + if backendRef.Kind != nil && *backendRef.Kind != "Service" { + return nil, nil + } + + if backendRef.Weight != nil && *backendRef.Weight == 0 { + return nil, nil + } + + if backendRef.Port == nil { + return nil, errors.Errorf("Missing port in backend reference") + } + + var namespace string + if backendRef.Namespace == nil { + namespace = routeIdentifier.Namespace + } else { + namespace = string(*backendRef.Namespace) + } + + // TODO - Need to implement reference grant check here + + svcName := types.NamespacedName{ + Namespace: namespace, + Name: string(backendRef.Name), + } + svc := &corev1.Service{} + err := k8sClient.Get(ctx, svcName, svc) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("Unable to fetch svc object %+v", svcName)) + } + + var servicePort *corev1.ServicePort + + for _, svcPort := range svc.Spec.Ports { + if svcPort.Port == int32(*backendRef.Port) { + servicePort = &svcPort + break + } + } + + if servicePort == nil { + return nil, errors.Errorf("Unable to find service port for port %d", *backendRef.Port) + } + + // TODO - Need to TG CRD look up here + + // Weight specifies the proportion of requests forwarded to the referenced + // backend. This is computed as weight/(sum of all weights in this + // BackendRefs list). For non-zero values, there may be some epsilon from + // the exact proportion defined here depending on the precision an + // implementation supports. Weight is not a percentage and the sum of + // weights does not need to equal 100. + // + // If only one backend is specified, and it has a weight greater than 0, 100% + // of the traffic is forwarded to that backend. If weight is set to 0, no + // traffic should be forwarded for this entry. If unspecified, weight + // defaults to 1. + weight := 1 + if backendRef.Weight != nil { + weight = int(*backendRef.Weight) + } + return &Backend{ + Service: svc, + ServicePort: servicePort, + Weight: weight, + TypeSpecificBackend: typeSpecificBackend, + }, nil +} diff --git a/pkg/gateway/routeutils/backend_test.go b/pkg/gateway/routeutils/backend_test.go new file mode 100644 index 0000000000..46473d1e7c --- /dev/null +++ b/pkg/gateway/routeutils/backend_test.go @@ -0,0 +1,199 @@ +package routeutils + +import ( + "context" + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/stretchr/testify/assert" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + "testing" +) + +func TestCommonBackendLoader(t *testing.T) { + + kind := HTTPRouteKind + + namespaceToUse := "current-namespace" + svcNameToUse := "current-svc" + routeNameToUse := "my-route" + + portConverter := func(port int) *gwv1.PortNumber { + pn := gwv1.PortNumber(port) + return &pn + } + + testCases := []struct { + name string + storedService *corev1.Service + backendRef gwv1.BackendRef + routeIdentifier types.NamespacedName + weight int + servicePort int32 + expectErr bool + expectNoResult bool + }{ + { + name: "backend ref without namespace", + routeIdentifier: types.NamespacedName{ + Namespace: "backend-ref-ns", + Name: routeNameToUse, + }, + backendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{ + Name: gwv1.ObjectName(svcNameToUse), + Port: portConverter(80), + }, + }, + storedService: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "backend-ref-ns", + Name: svcNameToUse, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "port-80", + Port: 80, + }, + }, + }, + }, + weight: 1, + servicePort: 80, + }, + { + name: "backend ref, fill in weight", + routeIdentifier: types.NamespacedName{ + Namespace: "backend-ref-ns", + Name: routeNameToUse, + }, + backendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{ + Name: gwv1.ObjectName(svcNameToUse), + Port: portConverter(80), + }, + Weight: awssdk.Int32(100), + }, + storedService: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "backend-ref-ns", + Name: svcNameToUse, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "port-80", + Port: 80, + }, + }, + }, + }, + weight: 100, + servicePort: 80, + }, + { + name: "backend ref with namespace", + routeIdentifier: types.NamespacedName{ + Name: routeNameToUse, + }, + backendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{ + Name: gwv1.ObjectName(svcNameToUse), + Namespace: (*gwv1.Namespace)(&namespaceToUse), + Port: portConverter(80), + }, + }, + storedService: &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: namespaceToUse, + Name: svcNameToUse, + }, + Spec: corev1.ServiceSpec{ + Ports: []corev1.ServicePort{ + { + Name: "port-80", + Port: 80, + }, + }, + }, + }, + weight: 1, + servicePort: 80, + }, + { + name: "0 weight backend should return nil", + routeIdentifier: types.NamespacedName{ + Name: routeNameToUse, + }, + backendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{ + Name: gwv1.ObjectName(svcNameToUse), + Namespace: (*gwv1.Namespace)(&namespaceToUse), + Port: portConverter(80), + }, + Weight: awssdk.Int32(0), + }, + expectNoResult: true, + }, + { + name: "non-service based backend should return nil", + routeIdentifier: types.NamespacedName{ + Name: routeNameToUse, + }, + backendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{ + Name: gwv1.ObjectName(svcNameToUse), + Namespace: (*gwv1.Namespace)(&namespaceToUse), + Kind: (*gwv1.Kind)(awssdk.String("cat")), + Port: portConverter(80), + }, + }, + expectNoResult: true, + }, + { + name: "missing port in backend ref should result in an error", + routeIdentifier: types.NamespacedName{ + Name: routeNameToUse, + }, + backendRef: gwv1.BackendRef{ + BackendObjectReference: gwv1.BackendObjectReference{ + Name: gwv1.ObjectName(svcNameToUse), + Namespace: (*gwv1.Namespace)(&namespaceToUse), + }, + }, + expectErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + k8sClient := generateTestClient() + + if tc.storedService != nil { + k8sClient.Create(context.Background(), tc.storedService) + } + + result, err := commonBackendLoader(context.Background(), k8sClient, tc.backendRef, tc.backendRef, tc.routeIdentifier, kind) + + if tc.expectErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + + if tc.expectNoResult { + assert.Nil(t, result) + return + } + + assert.Equal(t, tc.storedService, result.Service) + assert.Equal(t, tc.weight, result.Weight) + assert.Equal(t, tc.servicePort, result.ServicePort.Port) + assert.Equal(t, tc.backendRef, result.TypeSpecificBackend) + }) + } + +} diff --git a/pkg/gateway/routeutils/constants.go b/pkg/gateway/routeutils/constants.go new file mode 100644 index 0000000000..5e6478fa18 --- /dev/null +++ b/pkg/gateway/routeutils/constants.go @@ -0,0 +1,34 @@ +package routeutils + +import ( + "context" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// Route Kinds +const ( + TCPRouteKind = "TCPRoute" + UDPRouteKind = "UDPRoute" + TLSRouteKind = "TLSRoute" + HTTPRouteKind = "HTTPRoute" + GRPCRouteKind = "GRPCRoute" +) + +// RouteKind to Route Loader. These functions will pull data directly from the kube api or local cache. +var allRoutes = map[string]func(context context.Context, client client.Client) ([]preLoadRouteDescriptor, error){ + TCPRouteKind: ListTCPRoutes, + UDPRouteKind: ListUDPRoutes, + TLSRouteKind: ListTLSRoutes, + HTTPRouteKind: ListHTTPRoutes, + GRPCRouteKind: ListGRPCRoutes, +} + +// Default protocol map used to infer accepted route kinds when a listener doesn't specify the `allowedRoutes` field. +var defaultProtocolToRouteKindMap = map[gwv1.ProtocolType]string{ + gwv1.TCPProtocolType: TCPRouteKind, + gwv1.UDPProtocolType: UDPRouteKind, + gwv1.TLSProtocolType: TLSRouteKind, + gwv1.HTTPProtocolType: HTTPRouteKind, + gwv1.HTTPSProtocolType: HTTPRouteKind, +} diff --git a/pkg/gateway/routeutils/descriptor.go b/pkg/gateway/routeutils/descriptor.go new file mode 100644 index 0000000000..36c8743eb5 --- /dev/null +++ b/pkg/gateway/routeutils/descriptor.go @@ -0,0 +1,35 @@ +package routeutils + +import ( + "context" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// routeMetadataDescriptor a common set of functions that will describe a route. +// These are intentionally meant to be type agnostic; +// however, consumers can use `GetRawRoute()` to inspect the actual route fields if needed. +type routeMetadataDescriptor interface { + GetRouteNamespacedName() types.NamespacedName + GetRouteKind() string + GetHostnames() []gwv1.Hostname + GetParentRefs() []gwv1.ParentReference + GetRawRoute() interface{} +} + +// preLoadRouteDescriptor this object is used to represent a route description that has not loaded its child data (services, tg config) +// generally use this interface to represent broad data, filter that data down to the absolutely required data, and the call +// loadAttachedRules() to generate a full route description. +type preLoadRouteDescriptor interface { + routeMetadataDescriptor + loadAttachedRules(context context.Context, k8sClient client.Client) (RouteDescriptor, error) +} + +// RouteDescriptor is a type agnostic representation of a Gateway Route. +// This interface holds all data necessary to construct +// an ELBv2 object out of Kubernetes objects. +type RouteDescriptor interface { + routeMetadataDescriptor + GetAttachedRules() []RouteRule +} diff --git a/pkg/gateway/routeutils/grpc.go b/pkg/gateway/routeutils/grpc.go new file mode 100644 index 0000000000..1cd65bd98a --- /dev/null +++ b/pkg/gateway/routeutils/grpc.go @@ -0,0 +1,120 @@ +package routeutils + +import ( + "context" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/aws-load-balancer-controller/pkg/k8s" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +/* +This class holds the representation of a GRPC route. +Generally, outside consumers will use GetRawRouteRule to inspect the +GRPC specific features of the route. +*/ + +/* Route Rule */ + +var _ RouteRule = &convertedGRPCRouteRule{} + +type convertedGRPCRouteRule struct { + rule *gwv1.GRPCRouteRule + backends []Backend +} + +func (t *convertedGRPCRouteRule) GetRawRouteRule() interface{} { + return t.rule +} + +func convertGRPCRouteRule(rule *gwv1.GRPCRouteRule, backends []Backend) RouteRule { + return &convertedGRPCRouteRule{ + rule: rule, + backends: backends, + } +} + +func (t *convertedGRPCRouteRule) GetSectionName() *gwv1.SectionName { + return t.rule.Name +} + +func (t *convertedGRPCRouteRule) GetBackends() []Backend { + return t.backends +} + +/* Route Description */ + +type grpcRouteDescription struct { + route *gwv1.GRPCRoute + rules []RouteRule + backendLoader func(ctx context.Context, k8sClient client.Client, typeSpecificBackend interface{}, backendRef gwv1.BackendRef, routeIdentifier types.NamespacedName, routeKind string) (*Backend, error) +} + +func (grpcRoute *grpcRouteDescription) loadAttachedRules(ctx context.Context, k8sClient client.Client) (RouteDescriptor, error) { + convertedRules := make([]RouteRule, 0) + for _, rule := range grpcRoute.route.Spec.Rules { + convertedBackends := make([]Backend, 0) + for _, backend := range rule.BackendRefs { + convertedBackend, err := grpcRoute.backendLoader(ctx, k8sClient, backend, backend.BackendRef, grpcRoute.GetRouteNamespacedName(), grpcRoute.GetRouteKind()) + if err != nil { + return nil, err + } + if convertedBackend != nil { + convertedBackends = append(convertedBackends, *convertedBackend) + } + } + + convertedRules = append(convertedRules, convertGRPCRouteRule(&rule, convertedBackends)) + } + + grpcRoute.rules = convertedRules + return grpcRoute, nil +} + +func (grpcRoute *grpcRouteDescription) GetHostnames() []gwv1.Hostname { + return grpcRoute.route.Spec.Hostnames +} + +func (grpcRoute *grpcRouteDescription) GetAttachedRules() []RouteRule { + return grpcRoute.rules +} + +func (grpcRoute *grpcRouteDescription) GetParentRefs() []gwv1.ParentReference { + return grpcRoute.route.Spec.ParentRefs +} + +func (grpcRoute *grpcRouteDescription) GetRouteKind() string { + return GRPCRouteKind +} + +func (grpcRoute *grpcRouteDescription) GetRouteNamespacedName() types.NamespacedName { + return k8s.NamespacedName(grpcRoute.route) +} + +func convertGRPCRoute(r gwv1.GRPCRoute) *grpcRouteDescription { + return &grpcRouteDescription{route: &r, backendLoader: commonBackendLoader} +} + +func (grpcRoute *grpcRouteDescription) GetRawRoute() interface{} { + return grpcRoute.route +} + +var _ RouteDescriptor = &grpcRouteDescription{} + +// Can we use an indexer here to query more efficiently? + +func ListGRPCRoutes(context context.Context, k8sClient client.Client) ([]preLoadRouteDescriptor, error) { + routeList := &gwv1.GRPCRouteList{} + err := k8sClient.List(context, routeList) + if err != nil { + return nil, err + } + + result := make([]preLoadRouteDescriptor, 0) + + for _, item := range routeList.Items { + result = append(result, convertGRPCRoute(item)) + } + + return result, err +} diff --git a/pkg/gateway/routeutils/grpc_test.go b/pkg/gateway/routeutils/grpc_test.go new file mode 100644 index 0000000000..2ba3f48de4 --- /dev/null +++ b/pkg/gateway/routeutils/grpc_test.go @@ -0,0 +1,147 @@ +package routeutils + +import ( + "context" + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + "testing" +) + +func Test_ConvertGRPCRuleToRouteRule(t *testing.T) { + + rule := &gwv1.GRPCRouteRule{ + Name: (*gwv1.SectionName)(awssdk.String("my-name")), + Matches: []gwv1.GRPCRouteMatch{}, + Filters: []gwv1.GRPCRouteFilter{}, + BackendRefs: []gwv1.GRPCBackendRef{}, + SessionPersistence: &gwv1.SessionPersistence{}, + } + + backends := []Backend{ + {}, {}, + } + + result := convertGRPCRouteRule(rule, backends) + + assert.Equal(t, backends, result.GetBackends()) + assert.Equal(t, rule, result.GetRawRouteRule().(*gwv1.GRPCRouteRule)) +} + +func Test_ListGRPCRoutes(t *testing.T) { + k8sClient := generateTestClient() + + k8sClient.Create(context.Background(), &gwv1.GRPCRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo1", + Namespace: "bar1", + }, + Spec: gwv1.GRPCRouteSpec{ + Hostnames: []gwv1.Hostname{ + "host1", + }, + Rules: nil, + }, + }) + + k8sClient.Create(context.Background(), &gwv1.GRPCRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo2", + Namespace: "bar2", + }, + Spec: gwv1.GRPCRouteSpec{ + Hostnames: []gwv1.Hostname{ + "host2", + }, + Rules: nil, + }, + }) + + k8sClient.Create(context.Background(), &gwv1.GRPCRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo3", + Namespace: "bar3", + }, + }) + + result, err := ListGRPCRoutes(context.Background(), k8sClient) + + assert.NoError(t, err) + + itemMap := make(map[string]string) + for _, v := range result { + routeNsn := v.GetRouteNamespacedName() + itemMap[routeNsn.Namespace] = routeNsn.Name + assert.Equal(t, GRPCRouteKind, v.GetRouteKind()) + assert.NotNil(t, v.GetRawRoute()) + + if routeNsn.Name == "foo1" { + assert.Equal(t, []gwv1.Hostname{ + "host1", + }, v.GetHostnames()) + } + + if routeNsn.Name == "foo2" { + assert.Equal(t, []gwv1.Hostname{ + "host2", + }, v.GetHostnames()) + } + + if routeNsn.Name == "foo3" { + assert.Equal(t, 0, len(v.GetHostnames())) + } + + } + + assert.Equal(t, "foo1", itemMap["bar1"]) + assert.Equal(t, "foo2", itemMap["bar2"]) + assert.Equal(t, "foo3", itemMap["bar3"]) +} + +func Test_GRPC_LoadAttachedRules(t *testing.T) { + weight := 0 + mockLoader := func(ctx context.Context, k8sClient client.Client, typeSpecificBackend interface{}, backendRef gwv1.BackendRef, routeIdentifier types.NamespacedName, routeKind string) (*Backend, error) { + weight++ + return &Backend{ + Weight: weight, + }, nil + } + + routeDescription := grpcRouteDescription{ + route: &gwv1.GRPCRoute{ + Spec: gwv1.GRPCRouteSpec{Rules: []gwv1.GRPCRouteRule{ + { + BackendRefs: []gwv1.GRPCBackendRef{ + {}, + {}, + }, + }, + { + BackendRefs: []gwv1.GRPCBackendRef{ + {}, + {}, + {}, + {}, + }, + }, + { + BackendRefs: []gwv1.GRPCBackendRef{}, + }, + }}, + }, + rules: nil, + backendLoader: mockLoader, + } + + result, err := routeDescription.loadAttachedRules(context.Background(), nil) + assert.NoError(t, err) + convertedRules := result.GetAttachedRules() + assert.Equal(t, 3, len(convertedRules)) + + assert.Equal(t, 2, len(convertedRules[0].GetBackends())) + assert.Equal(t, 4, len(convertedRules[1].GetBackends())) + assert.Equal(t, 0, len(convertedRules[2].GetBackends())) +} diff --git a/pkg/gateway/routeutils/http.go b/pkg/gateway/routeutils/http.go new file mode 100644 index 0000000000..1288ed56c3 --- /dev/null +++ b/pkg/gateway/routeutils/http.go @@ -0,0 +1,121 @@ +package routeutils + +import ( + "context" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/aws-load-balancer-controller/pkg/k8s" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +/* +This class holds the representation of an HTTP route. +Generally, outside consumers will use GetRawRouteRule to inspect the +HTTP specific features of the route. +*/ + +/* Route Rule */ + +var _ RouteRule = &convertedHTTPRouteRule{} + +type convertedHTTPRouteRule struct { + rule *gwv1.HTTPRouteRule + backends []Backend +} + +func convertHTTPRouteRule(rule *gwv1.HTTPRouteRule, backends []Backend) RouteRule { + return &convertedHTTPRouteRule{ + rule: rule, + backends: backends, + } +} + +func (t *convertedHTTPRouteRule) GetRawRouteRule() interface{} { + return t.rule +} + +func (t *convertedHTTPRouteRule) GetSectionName() *gwv1.SectionName { + return t.rule.Name +} + +func (t *convertedHTTPRouteRule) GetBackends() []Backend { + return t.backends +} + +/* Route Description */ + +type httpRouteDescription struct { + route *gwv1.HTTPRoute + rules []RouteRule + backendLoader func(ctx context.Context, k8sClient client.Client, typeSpecificBackend interface{}, backendRef gwv1.BackendRef, routeIdentifier types.NamespacedName, routeKind string) (*Backend, error) +} + +func (httpRoute *httpRouteDescription) GetAttachedRules() []RouteRule { + return httpRoute.rules +} + +func (httpRoute *httpRouteDescription) loadAttachedRules(ctx context.Context, k8sClient client.Client) (RouteDescriptor, error) { + convertedRules := make([]RouteRule, 0) + for _, rule := range httpRoute.route.Spec.Rules { + convertedBackends := make([]Backend, 0) + for _, backend := range rule.BackendRefs { + convertedBackend, err := httpRoute.backendLoader(ctx, k8sClient, backend, backend.BackendRef, httpRoute.GetRouteNamespacedName(), httpRoute.GetRouteKind()) + if err != nil { + return nil, err + } + + if convertedBackend != nil { + convertedBackends = append(convertedBackends, *convertedBackend) + } + } + + convertedRules = append(convertedRules, convertHTTPRouteRule(&rule, convertedBackends)) + } + + httpRoute.rules = convertedRules + return httpRoute, nil +} + +func (httpRoute *httpRouteDescription) GetHostnames() []gwv1.Hostname { + return httpRoute.route.Spec.Hostnames +} + +func (httpRoute *httpRouteDescription) GetParentRefs() []gwv1.ParentReference { + return httpRoute.route.Spec.ParentRefs +} + +func (httpRoute *httpRouteDescription) GetRouteKind() string { + return HTTPRouteKind +} + +func (httpRoute *httpRouteDescription) GetRouteNamespacedName() types.NamespacedName { + return k8s.NamespacedName(httpRoute.route) +} + +func convertHTTPRoute(r gwv1.HTTPRoute) *httpRouteDescription { + return &httpRouteDescription{route: &r, backendLoader: commonBackendLoader} +} + +func (httpRoute *httpRouteDescription) GetRawRoute() interface{} { + return httpRoute.route +} + +var _ RouteDescriptor = &httpRouteDescription{} + +// Can we use an indexer here to query more efficiently? + +func ListHTTPRoutes(context context.Context, k8sClient client.Client) ([]preLoadRouteDescriptor, error) { + routeList := &gwv1.HTTPRouteList{} + err := k8sClient.List(context, routeList) + if err != nil { + return nil, err + } + + result := make([]preLoadRouteDescriptor, 0) + + for _, item := range routeList.Items { + result = append(result, convertHTTPRoute(item)) + } + + return result, err +} diff --git a/pkg/gateway/routeutils/http_test.go b/pkg/gateway/routeutils/http_test.go new file mode 100644 index 0000000000..27f95dfe79 --- /dev/null +++ b/pkg/gateway/routeutils/http_test.go @@ -0,0 +1,147 @@ +package routeutils + +import ( + "context" + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + "testing" +) + +func Test_ConvertHTTPRuleToRouteRule(t *testing.T) { + + rule := &gwv1.HTTPRouteRule{ + Name: (*gwv1.SectionName)(awssdk.String("my-name")), + Matches: []gwv1.HTTPRouteMatch{}, + Filters: []gwv1.HTTPRouteFilter{}, + BackendRefs: []gwv1.HTTPBackendRef{}, + SessionPersistence: &gwv1.SessionPersistence{}, + } + + backends := []Backend{ + {}, {}, + } + + result := convertHTTPRouteRule(rule, backends) + + assert.Equal(t, backends, result.GetBackends()) + assert.Equal(t, rule, result.GetRawRouteRule().(*gwv1.HTTPRouteRule)) +} + +func Test_ListHTTPRoutes(t *testing.T) { + k8sClient := generateTestClient() + + k8sClient.Create(context.Background(), &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo1", + Namespace: "bar1", + }, + Spec: gwv1.HTTPRouteSpec{ + Hostnames: []gwv1.Hostname{ + "host1", + }, + Rules: nil, + }, + }) + + k8sClient.Create(context.Background(), &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo2", + Namespace: "bar2", + }, + Spec: gwv1.HTTPRouteSpec{ + Hostnames: []gwv1.Hostname{ + "host2", + }, + Rules: nil, + }, + }) + + k8sClient.Create(context.Background(), &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo3", + Namespace: "bar3", + }, + }) + + result, err := ListHTTPRoutes(context.Background(), k8sClient) + + assert.NoError(t, err) + + itemMap := make(map[string]string) + for _, v := range result { + routeNsn := v.GetRouteNamespacedName() + itemMap[routeNsn.Namespace] = routeNsn.Name + assert.Equal(t, HTTPRouteKind, v.GetRouteKind()) + assert.NotNil(t, v.GetRawRoute()) + + if routeNsn.Name == "foo1" { + assert.Equal(t, []gwv1.Hostname{ + "host1", + }, v.GetHostnames()) + } + + if routeNsn.Name == "foo2" { + assert.Equal(t, []gwv1.Hostname{ + "host2", + }, v.GetHostnames()) + } + + if routeNsn.Name == "foo3" { + assert.Equal(t, 0, len(v.GetHostnames())) + } + + } + + assert.Equal(t, "foo1", itemMap["bar1"]) + assert.Equal(t, "foo2", itemMap["bar2"]) + assert.Equal(t, "foo3", itemMap["bar3"]) +} + +func Test_HTTP_LoadAttachedRules(t *testing.T) { + weight := 0 + mockLoader := func(ctx context.Context, k8sClient client.Client, typeSpecificBackend interface{}, backendRef gwv1.BackendRef, routeIdentifier types.NamespacedName, routeKind string) (*Backend, error) { + weight++ + return &Backend{ + Weight: weight, + }, nil + } + + routeDescription := httpRouteDescription{ + route: &gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{Rules: []gwv1.HTTPRouteRule{ + { + BackendRefs: []gwv1.HTTPBackendRef{ + {}, + {}, + }, + }, + { + BackendRefs: []gwv1.HTTPBackendRef{ + {}, + {}, + {}, + {}, + }, + }, + { + BackendRefs: []gwv1.HTTPBackendRef{}, + }, + }}, + }, + rules: nil, + backendLoader: mockLoader, + } + + result, err := routeDescription.loadAttachedRules(context.Background(), nil) + assert.NoError(t, err) + convertedRules := result.GetAttachedRules() + assert.Equal(t, 3, len(convertedRules)) + + assert.Equal(t, 2, len(convertedRules[0].GetBackends())) + assert.Equal(t, 4, len(convertedRules[1].GetBackends())) + assert.Equal(t, 0, len(convertedRules[2].GetBackends())) +} diff --git a/pkg/gateway/routeutils/listener_attachment_helper.go b/pkg/gateway/routeutils/listener_attachment_helper.go new file mode 100644 index 0000000000..8d1baea813 --- /dev/null +++ b/pkg/gateway/routeutils/listener_attachment_helper.go @@ -0,0 +1,101 @@ +package routeutils + +import ( + "context" + "k8s.io/apimachinery/pkg/util/sets" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// listenerAttachmentHelper is an internal utility interface that can be used to determine if a listener will allow +// a route to attach to it. +type listenerAttachmentHelper interface { + listenerAllowsAttachment(ctx context.Context, gw gwv1.Gateway, listener gwv1.Listener, route preLoadRouteDescriptor) (bool, error) +} + +var _ listenerAttachmentHelper = &listenerAttachmentHelperImpl{} + +// listenerAttachmentHelperImpl implements the listenerAttachmentHelper interface. +type listenerAttachmentHelperImpl struct { + namespaceSelector namespaceSelector +} + +// listenerAllowsAttachment utility method to determine if a listener will allow a route to connect using +// Gateway API rules to determine compatibility between lister and route. +func (attachmentHelper *listenerAttachmentHelperImpl) listenerAllowsAttachment(ctx context.Context, gw gwv1.Gateway, listener gwv1.Listener, route preLoadRouteDescriptor) (bool, error) { + namespaceOK, err := attachmentHelper.namespaceCheck(ctx, gw, listener, route) + if err != nil { + return false, err + } + + if !namespaceOK { + return false, nil + } + + if !attachmentHelper.kindCheck(listener, route) { + return false, nil + } + return true, nil +} + +// namespaceCheck namespace check implements the Gateway API spec for namespace matching between listener +// and route to determine compatibility. +func (attachmentHelper *listenerAttachmentHelperImpl) namespaceCheck(ctx context.Context, gw gwv1.Gateway, listener gwv1.Listener, route preLoadRouteDescriptor) (bool, error) { + var allowedNamespaces gwv1.FromNamespaces + + if listener.AllowedRoutes == nil || listener.AllowedRoutes.Namespaces == nil || listener.AllowedRoutes.Namespaces.From == nil { + allowedNamespaces = gwv1.NamespacesFromSame + } else { + allowedNamespaces = *listener.AllowedRoutes.Namespaces.From + } + + namespacedName := route.GetRouteNamespacedName() + + switch allowedNamespaces { + case gwv1.NamespacesFromSame: + return gw.Namespace == namespacedName.Namespace, nil + case gwv1.NamespacesFromAll: + return true, nil + case gwv1.NamespacesFromSelector: + if listener.AllowedRoutes.Namespaces.Selector == nil { + return false, nil + } + // This should be executed off the client-go cache, hence we do not need to perform local caching. + namespaces, err := attachmentHelper.namespaceSelector.getNamespacesFromSelector(ctx, listener.AllowedRoutes.Namespaces.Selector) + if err != nil { + return false, err + } + + if !namespaces.Has(namespacedName.Namespace) { + return false, nil + } + return true, nil + default: + // Unclear what to do in this case, we'll just filter out this route. + return false, nil + } +} + +// kindCheck kind check implements the Gateway API spec for kindCheck matching between listener +// and route to determine compatibility. +func (attachmentHelper *listenerAttachmentHelperImpl) kindCheck(listener gwv1.Listener, route preLoadRouteDescriptor) bool { + + var allowedRoutes sets.Set[string] + + /* + ... + When unspecified or empty, the kinds of Routes + selected are determined using the Listener protocol. + ... + */ + if listener.AllowedRoutes == nil || listener.AllowedRoutes.Kinds == nil || len(listener.AllowedRoutes.Kinds) == 0 { + allowedRoutes = sets.New[string](defaultProtocolToRouteKindMap[listener.Protocol]) + } else { + // TODO - Not sure how to handle versioning (correctly) here. + // So going to ignore the group checks for now :x + allowedRoutes = sets.New[string]() + for _, v := range listener.AllowedRoutes.Kinds { + allowedRoutes.Insert(string(v.Kind)) + } + } + return allowedRoutes.Has(route.GetRouteKind()) +} diff --git a/pkg/gateway/routeutils/listener_attachment_helper_test.go b/pkg/gateway/routeutils/listener_attachment_helper_test.go new file mode 100644 index 0000000000..3e3af83720 --- /dev/null +++ b/pkg/gateway/routeutils/listener_attachment_helper_test.go @@ -0,0 +1,353 @@ +package routeutils + +import ( + "context" + "github.com/pkg/errors" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + "testing" +) + +type mockNamespaceSelector struct { + nss sets.Set[string] + err error +} + +func (mnss *mockNamespaceSelector) getNamespacesFromSelector(_ context.Context, _ *metav1.LabelSelector) (sets.Set[string], error) { + return mnss.nss, mnss.err +} + +func Test_listenerAllowsAttachment(t *testing.T) { + testCases := []struct { + name string + gwNamespace string + routeNamespace string + listenerProtocol gwv1.ProtocolType + expected bool + }{ + { + name: "namespace and kind are ok", + gwNamespace: "ns1", + routeNamespace: "ns1", + listenerProtocol: gwv1.HTTPProtocolType, + expected: true, + }, + { + name: "namespace is not ok", + gwNamespace: "ns1", + routeNamespace: "ns2", + listenerProtocol: gwv1.HTTPProtocolType, + }, + { + name: "kind is not ok", + gwNamespace: "ns1", + routeNamespace: "ns1", + listenerProtocol: gwv1.TLSProtocolType, + }, + } + + // Just using default ns behavior (route ns has to equal gw ns) + // Using an HTTP route always + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + gw := gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw1", + Namespace: tc.gwNamespace, + }, + } + + route := &httpRouteDescription{route: &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route1", + Namespace: tc.routeNamespace, + }, + }} + attachmentHelper := listenerAttachmentHelperImpl{} + result, err := attachmentHelper.listenerAllowsAttachment(context.Background(), gw, gwv1.Listener{ + Protocol: tc.listenerProtocol, + }, route) + assert.NoError(t, err) + assert.Equal(t, tc.expected, result) + }) + } +} + +func Test_namespaceCheck(t *testing.T) { + + type namespaceScenario struct { + scenarioName string + gwNamespace string + routeNamespace string + expected bool + } + + nsSame := gwv1.NamespacesFromSame + nsAll := gwv1.NamespacesFromAll + nsSelector := gwv1.NamespacesFromSelector + testCases := []struct { + namespaceSelectorResult sets.Set[string] + namespaceSelectorError error + listener gwv1.Listener + name string + + scenarios []namespaceScenario + expectErr bool + }{ + { + name: "no listener.allowedroutes defaults to same namespace", + namespaceSelectorError: errors.New("this shouldnt get called"), + scenarios: []namespaceScenario{ + { + scenarioName: "same ns", + gwNamespace: "ns1", + routeNamespace: "ns1", + expected: true, + }, + { + scenarioName: "different ns", + gwNamespace: "ns1", + routeNamespace: "ns2", + }, + }, + }, + { + name: "no listener.allowedroutes.namespaces defaults to same namespace", + namespaceSelectorError: errors.New("this shouldnt get called"), + scenarios: []namespaceScenario{ + { + scenarioName: "same ns", + gwNamespace: "ns1", + routeNamespace: "ns1", + expected: true, + }, + { + scenarioName: "different ns", + gwNamespace: "ns1", + routeNamespace: "ns2", + }, + }, + }, + { + name: "no listener.allowedroutes.namespaces.from defaults to same namespace", + namespaceSelectorError: errors.New("this shouldnt get called"), + scenarios: []namespaceScenario{ + { + scenarioName: "same ns", + gwNamespace: "ns1", + routeNamespace: "ns1", + expected: true, + }, + { + scenarioName: "different ns", + gwNamespace: "ns1", + routeNamespace: "ns2", + }, + }, + }, + { + name: "listener.allowedroutes.namespaces.from set to same", + namespaceSelectorError: errors.New("this shouldnt get called"), + scenarios: []namespaceScenario{ + { + scenarioName: "same ns", + gwNamespace: "ns1", + routeNamespace: "ns1", + expected: true, + }, + { + scenarioName: "different ns", + gwNamespace: "ns1", + routeNamespace: "ns2", + }, + }, + listener: gwv1.Listener{ + AllowedRoutes: &gwv1.AllowedRoutes{ + Namespaces: &gwv1.RouteNamespaces{ + From: &nsSame, + }, + }, + }, + }, + { + name: "listener.allowedroutes.namespaces.from set to all", + namespaceSelectorError: errors.New("this shouldnt get called"), + scenarios: []namespaceScenario{ + { + scenarioName: "same ns", + gwNamespace: "ns1", + routeNamespace: "ns1", + expected: true, + }, + { + scenarioName: "different ns", + gwNamespace: "ns1", + routeNamespace: "ns2", + expected: true, + }, + }, + listener: gwv1.Listener{ + AllowedRoutes: &gwv1.AllowedRoutes{ + Namespaces: &gwv1.RouteNamespaces{ + From: &nsAll, + }, + }, + }, + }, + { + name: "listener.allowedroutes.namespaces.from set to selector with no selector specified", + scenarios: []namespaceScenario{ + { + scenarioName: "same ns", + gwNamespace: "ns1", + routeNamespace: "ns1", + expected: false, + }, + { + scenarioName: "different ns", + gwNamespace: "ns1", + routeNamespace: "ns2", + expected: false, + }, + }, + listener: gwv1.Listener{ + AllowedRoutes: &gwv1.AllowedRoutes{ + Namespaces: &gwv1.RouteNamespaces{ + From: &nsSelector, + }, + }, + }, + }, + { + name: "listener.allowedroutes.namespaces.from set to selector", + scenarios: []namespaceScenario{ + { + scenarioName: "same ns but not in selector", + gwNamespace: "ns1", + routeNamespace: "ns1", + expected: false, + }, + { + scenarioName: "different ns", + gwNamespace: "ns1", + routeNamespace: "ns2", + expected: false, + }, + { + scenarioName: "different ns but in selector results", + gwNamespace: "ns1", + routeNamespace: "ns3", + expected: true, + }, + }, + listener: gwv1.Listener{ + AllowedRoutes: &gwv1.AllowedRoutes{ + Namespaces: &gwv1.RouteNamespaces{ + From: &nsSelector, + Selector: &metav1.LabelSelector{}, + }, + }, + }, + namespaceSelectorResult: sets.New("ns3", "ns5"), + }, + } + + for _, tc := range testCases { + for _, scenario := range tc.scenarios { + t.Run(tc.name+"-"+scenario.scenarioName, func(t *testing.T) { + attachmentHelper := listenerAttachmentHelperImpl{ + namespaceSelector: &mockNamespaceSelector{ + err: tc.namespaceSelectorError, + nss: tc.namespaceSelectorResult, + }, + } + + gw := gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw1", + Namespace: scenario.gwNamespace, + }, + } + + route := &httpRouteDescription{route: &gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route1", + Namespace: scenario.routeNamespace, + }, + }} + + result, err := attachmentHelper.namespaceCheck(context.Background(), gw, tc.listener, route) + + if tc.expectErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, scenario.expected, result) + }) + } + } +} + +func Test_kindCheck(t *testing.T) { + testCases := []struct { + name string + route preLoadRouteDescriptor + listener gwv1.Listener + expectedResult bool + }{ + { + name: "use fallback - https protocol, http route", + route: &httpRouteDescription{}, + listener: gwv1.Listener{ + Protocol: gwv1.HTTPSProtocolType, + }, + expectedResult: true, + }, + { + name: "use fallback - http protocol, http route", + route: &httpRouteDescription{}, + listener: gwv1.Listener{ + Protocol: gwv1.HTTPSProtocolType, + }, + expectedResult: true, + }, + { + name: "use fallback - udp protocol, http route", + route: &httpRouteDescription{}, + listener: gwv1.Listener{ + Protocol: gwv1.UDPProtocolType, + }, + expectedResult: false, + }, + { + name: "use allowed kinds list - no route kinds specified", + route: &httpRouteDescription{}, + listener: gwv1.Listener{ + Protocol: gwv1.HTTPProtocolType, + AllowedRoutes: &gwv1.AllowedRoutes{Kinds: []gwv1.RouteGroupKind{}}, + }, + expectedResult: true, + }, + { + name: "use allowed kinds list - override protocol specific allowed kinds", + route: &httpRouteDescription{}, + listener: gwv1.Listener{ + Protocol: gwv1.UDPProtocolType, + AllowedRoutes: &gwv1.AllowedRoutes{Kinds: []gwv1.RouteGroupKind{ + {Kind: HTTPRouteKind}, + }}, + }, + expectedResult: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + attachmentHelper := listenerAttachmentHelperImpl{} + assert.Equal(t, tc.expectedResult, attachmentHelper.kindCheck(tc.listener, tc.route)) + }) + } +} diff --git a/pkg/gateway/routeutils/loader.go b/pkg/gateway/routeutils/loader.go new file mode 100644 index 0000000000..41ffbf1f9f --- /dev/null +++ b/pkg/gateway/routeutils/loader.go @@ -0,0 +1,115 @@ +package routeutils + +import ( + "context" + "fmt" + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// LoadRouteFilter is an interface that consumers can use to tell the loader which routes to load. +type LoadRouteFilter interface { + IsApplicable(kind string) bool +} + +// routeFilterImpl implements LoadRouteFilter +type routeFilterImpl struct { + acceptedKinds sets.Set[string] +} + +func (r *routeFilterImpl) IsApplicable(kind string) bool { + return r.acceptedKinds.Has(kind) +} + +/* + +TLS mappings -- Should we enforce that here? + +Listener Protocol | TLS Mode | Route Type Supported +TLS | Passthrough | TLSRoute +TLS | Terminate | TCPRoute +HTTPS | Terminate | HTTPRoute +GRPC | Terminate | GRPCRoute +*/ + +// L4RouteFilter use this to load routes only pertaining to the L4 Gateway Implementation (AWS NLB) +var L4RouteFilter LoadRouteFilter = &routeFilterImpl{ + acceptedKinds: sets.New(UDPRouteKind, TCPRouteKind, TLSRouteKind), +} + +// L7RouteFilter use this to load routes only pertaining to the L7 Gateway Implementation (AWS ALB) +var L7RouteFilter LoadRouteFilter = &routeFilterImpl{ + acceptedKinds: sets.New(HTTPRouteKind, GRPCRouteKind), +} + +// Loader will load all data Kubernetes that are pertinent to a gateway (Routes, Services, Target Group Configurations). +// It will output the data using a map which maps listener port to the various routing rules for that port. +type Loader interface { + LoadRoutesForGateway(ctx context.Context, gw gwv1.Gateway, filter LoadRouteFilter) (map[int][]RouteDescriptor, error) +} + +var _ Loader = &loaderImpl{} + +type loaderImpl struct { + mapper listenerToRouteMapper + k8sClient client.Client + allRouteLoaders map[string]func(context context.Context, client client.Client) ([]preLoadRouteDescriptor, error) +} + +// LoadRoutesForGateway loads all relevant data for a single Gateway. +func (l *loaderImpl) LoadRoutesForGateway(ctx context.Context, gw gwv1.Gateway, filter LoadRouteFilter) (map[int][]RouteDescriptor, error) { + // 1. Load all relevant routes according to the filter + loadedRoutes := make([]preLoadRouteDescriptor, 0) + for route, loader := range l.allRouteLoaders { + if filter.IsApplicable(route) { + data, err := loader(ctx, l.k8sClient) + if err != nil { + return nil, err + } + loadedRoutes = append(loadedRoutes, data...) + } + } + + // 2. Remove routes that aren't granted attachment by the listener. + // Map any routes that are granted attachment to the listener port that allows the attachment. + mappedRoutes, err := l.mapper.mapGatewayAndRoutes(ctx, gw, loadedRoutes) + if err != nil { + return nil, err + } + + // 3. Load the underlying resource(s) for each route that is configured. + return l.loadChildResources(ctx, mappedRoutes) +} + +// loadChildResources responsible for loading all resources that a route descriptor references. +func (l *loaderImpl) loadChildResources(ctx context.Context, preloadedRoutes map[int][]preLoadRouteDescriptor) (map[int][]RouteDescriptor, error) { + // Cache to reduce duplicate route look ups. + // Kind -> [NamespacedName:Previously Loaded Descriptor] + resourceCache := make(map[string]RouteDescriptor) + + loadedRouteData := make(map[int][]RouteDescriptor) + + for port, preloadedRouteList := range preloadedRoutes { + for _, preloadedRoute := range preloadedRouteList { + namespacedNameRoute := preloadedRoute.GetRouteNamespacedName() + routeKind := preloadedRoute.GetRouteKind() + cacheKey := fmt.Sprintf("%s-%s-%s", routeKind, namespacedNameRoute.Name, namespacedNameRoute.Namespace) + + cachedRoute, ok := resourceCache[cacheKey] + if ok { + loadedRouteData[port] = append(loadedRouteData[port], cachedRoute) + continue + } + + generatedRoute, err := preloadedRoute.loadAttachedRules(ctx, l.k8sClient) + if err != nil { + return nil, err + } + loadedRouteData[port] = append(loadedRouteData[port], generatedRoute) + resourceCache[cacheKey] = generatedRoute + } + } + + return loadedRouteData, nil +} diff --git a/pkg/gateway/routeutils/loader_test.go b/pkg/gateway/routeutils/loader_test.go new file mode 100644 index 0000000000..0f58c29a69 --- /dev/null +++ b/pkg/gateway/routeutils/loader_test.go @@ -0,0 +1,218 @@ +package routeutils + +import ( + "context" + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + "testing" +) + +type mockMapper struct { + t *testing.T + expectedRoutes []preLoadRouteDescriptor + mapToReturn map[int][]preLoadRouteDescriptor +} + +func (m *mockMapper) mapGatewayAndRoutes(context context.Context, gw gwv1.Gateway, routes []preLoadRouteDescriptor) (map[int][]preLoadRouteDescriptor, error) { + assert.ElementsMatch(m.t, m.expectedRoutes, routes) + return m.mapToReturn, nil +} + +var _ RouteDescriptor = &mockRoute{} + +type mockRoute struct { + namespacedName types.NamespacedName + routeKind string +} + +func (m *mockRoute) loadAttachedRules(context context.Context, k8sClient client.Client) (RouteDescriptor, error) { + return m, nil +} + +func (m *mockRoute) GetRouteNamespacedName() types.NamespacedName { + return m.namespacedName +} + +func (m *mockRoute) GetRouteKind() string { + return m.routeKind +} + +func (m *mockRoute) GetHostnames() []gwv1.Hostname { + //TODO implement me + panic("implement me") +} + +func (m *mockRoute) GetParentRefs() []gwv1.ParentReference { + //TODO implement me + panic("implement me") +} + +func (m *mockRoute) GetRawRoute() interface{} { + //TODO implement me + panic("implement me") +} + +func (m *mockRoute) GetAttachedRules() []RouteRule { + //TODO implement me + panic("implement me") +} + +func TestLoadRoutesForGateway(t *testing.T) { + preLoadHTTPRoutes := []preLoadRouteDescriptor{ + &mockRoute{ + namespacedName: types.NamespacedName{ + Namespace: "http1-ns", + Name: "http1", + }, + routeKind: HTTPRouteKind, + }, + &mockRoute{ + namespacedName: types.NamespacedName{ + Namespace: "http2-ns", + Name: "http2", + }, + routeKind: HTTPRouteKind, + }, + &mockRoute{ + namespacedName: types.NamespacedName{ + Namespace: "http3-ns", + Name: "http3", + }, + routeKind: HTTPRouteKind, + }, + } + + loadedHTTPRoutes := make([]RouteDescriptor, 0) + for _, preload := range preLoadHTTPRoutes { + r, _ := preload.loadAttachedRules(nil, nil) + loadedHTTPRoutes = append(loadedHTTPRoutes, r) + } + + preLoadTCPRoutes := []preLoadRouteDescriptor{ + &mockRoute{ + namespacedName: types.NamespacedName{ + Namespace: "tcp1-ns", + Name: "tcp1", + }, + routeKind: TCPRouteKind, + }, + &mockRoute{ + namespacedName: types.NamespacedName{ + Namespace: "tcp2-ns", + Name: "tcp2", + }, + routeKind: TCPRouteKind, + }, + &mockRoute{ + namespacedName: types.NamespacedName{ + Namespace: "tcp3-ns", + Name: "tcp3", + }, + routeKind: TCPRouteKind, + }, + } + + loadedTCPRoutes := make([]RouteDescriptor, 0) + for _, preload := range preLoadTCPRoutes { + r, _ := preload.loadAttachedRules(nil, nil) + loadedTCPRoutes = append(loadedTCPRoutes, r) + } + + allRouteLoaders := map[string]func(ctx context.Context, k8sClient client.Client) ([]preLoadRouteDescriptor, error){ + HTTPRouteKind: func(ctx context.Context, k8sClient client.Client) ([]preLoadRouteDescriptor, error) { + return preLoadHTTPRoutes, nil + }, + TCPRouteKind: func(ctx context.Context, k8sClient client.Client) ([]preLoadRouteDescriptor, error) { + return preLoadTCPRoutes, nil + }, + } + + testCases := []struct { + name string + acceptedKinds sets.Set[string] + expectedMap map[int][]RouteDescriptor + expectedPreloadMap map[int][]preLoadRouteDescriptor + expectedPreMappedRoutes []preLoadRouteDescriptor + expectError bool + }{ + { + name: "filter allows no routes", + acceptedKinds: make(sets.Set[string]), + expectedPreMappedRoutes: make([]preLoadRouteDescriptor, 0), + expectedMap: make(map[int][]RouteDescriptor), + }, + { + name: "filter only allows http route", + acceptedKinds: sets.New[string](HTTPRouteKind), + expectedPreMappedRoutes: preLoadHTTPRoutes, + expectedPreloadMap: map[int][]preLoadRouteDescriptor{ + 80: preLoadHTTPRoutes, + }, + expectedMap: map[int][]RouteDescriptor{ + 80: loadedHTTPRoutes, + }, + }, + { + name: "filter only allows http route, multiple ports", + acceptedKinds: sets.New[string](HTTPRouteKind), + expectedPreMappedRoutes: preLoadHTTPRoutes, + expectedPreloadMap: map[int][]preLoadRouteDescriptor{ + 80: preLoadHTTPRoutes, + 443: preLoadHTTPRoutes, + }, + expectedMap: map[int][]RouteDescriptor{ + 80: loadedHTTPRoutes, + 443: loadedHTTPRoutes, + }, + }, + { + name: "filter only allows tcp route", + acceptedKinds: sets.New[string](TCPRouteKind), + expectedPreMappedRoutes: preLoadTCPRoutes, + expectedPreloadMap: map[int][]preLoadRouteDescriptor{ + 80: preLoadTCPRoutes, + }, + expectedMap: map[int][]RouteDescriptor{ + 80: loadedTCPRoutes, + }, + }, + { + name: "filter allows both route kinds", + acceptedKinds: sets.New[string](TCPRouteKind, HTTPRouteKind), + expectedPreMappedRoutes: append(preLoadHTTPRoutes, preLoadTCPRoutes...), + expectedPreloadMap: map[int][]preLoadRouteDescriptor{ + 80: preLoadTCPRoutes, + 443: preLoadHTTPRoutes, + }, + expectedMap: map[int][]RouteDescriptor{ + 80: loadedTCPRoutes, + 443: loadedHTTPRoutes, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + loader := loaderImpl{ + mapper: &mockMapper{ + t: t, + expectedRoutes: tc.expectedPreMappedRoutes, + mapToReturn: tc.expectedPreloadMap, + }, + allRouteLoaders: allRouteLoaders, + } + + filter := &routeFilterImpl{acceptedKinds: tc.acceptedKinds} + result, err := loader.LoadRoutesForGateway(context.Background(), gwv1.Gateway{}, filter) + if tc.expectError { + assert.Error(t, err) + return + } + assert.NoError(t, err) + assert.Equal(t, tc.expectedMap, result) + }) + } +} diff --git a/pkg/gateway/routeutils/namespace_selector.go b/pkg/gateway/routeutils/namespace_selector.go new file mode 100644 index 0000000000..0933d02a73 --- /dev/null +++ b/pkg/gateway/routeutils/namespace_selector.go @@ -0,0 +1,45 @@ +package routeutils + +import ( + "context" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// namespaceSelector is an internal utility +// that is responsible for transforming a label selector into the all relevant namespaces +// that match the selector criteria. +type namespaceSelector interface { + getNamespacesFromSelector(context context.Context, selector *metav1.LabelSelector) (sets.Set[string], error) +} + +var _ namespaceSelector = &namespaceSelectorImpl{} + +type namespaceSelectorImpl struct { + k8sClient client.Client +} + +// getNamespacesFromSelector queries the Kubernetes API for all namespaces that match a selector. +func (n *namespaceSelectorImpl) getNamespacesFromSelector(context context.Context, selector *metav1.LabelSelector) (sets.Set[string], error) { + namespaceList := v1.NamespaceList{} + + convertedSelector, err := metav1.LabelSelectorAsSelector(selector) + if err != nil { + return nil, err + } + + err = n.k8sClient.List(context, &namespaceList, client.MatchingLabelsSelector{Selector: convertedSelector}) + if err != nil { + return nil, err + } + + namespaces := sets.New[string]() + + for _, ns := range namespaceList.Items { + namespaces.Insert(ns.Name) + } + + return namespaces, nil +} diff --git a/pkg/gateway/routeutils/namespace_selector_test.go b/pkg/gateway/routeutils/namespace_selector_test.go new file mode 100644 index 0000000000..421c6997ae --- /dev/null +++ b/pkg/gateway/routeutils/namespace_selector_test.go @@ -0,0 +1,115 @@ +package routeutils + +import ( + "context" + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/sets" + "testing" +) + +func Test_getNamespacesFromSelector(t *testing.T) { + + testSelector := &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: "foo", + Operator: metav1.LabelSelectorOpDoesNotExist, + }, + }, + } + + testCases := []struct { + name string + namespacesToAdd []*v1.Namespace + expectedNamespaces sets.Set[string] + expectErr bool + }{ + { + name: "no namespaces", + expectedNamespaces: make(sets.Set[string]), + }, + { + name: "one namespace", + namespacesToAdd: []*v1.Namespace{ + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ns-1", + Labels: map[string]string{}, + }, + }, + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ns-2", + Labels: map[string]string{ + "foo": "bar", + }, + }, + }, + }, + expectedNamespaces: sets.Set[string]{"my-ns-1": sets.Empty{}}, + }, + { + name: "multiple namespaces", + namespacesToAdd: []*v1.Namespace{ + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ns-1", + Labels: map[string]string{}, + }, + }, + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ns-2", + Labels: map[string]string{ + "foo": "bar", + }, + }, + }, + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ns-3", + Labels: map[string]string{}, + }, + }, + { + TypeMeta: metav1.TypeMeta{}, + ObjectMeta: metav1.ObjectMeta{ + Name: "my-ns-4", + Labels: map[string]string{}, + }, + }, + }, + expectedNamespaces: sets.Set[string]{"my-ns-1": sets.Empty{}, "my-ns-3": sets.Empty{}, "my-ns-4": sets.Empty{}}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + k8sClient := generateTestClient() + nsSelector := namespaceSelectorImpl{ + k8sClient: k8sClient, + } + + for _, ns := range tc.namespacesToAdd { + err := k8sClient.Create(context.Background(), ns) + assert.NoError(t, err) + } + + result, err := nsSelector.getNamespacesFromSelector(context.Background(), testSelector) + if tc.expectErr { + assert.Error(t, err) + return + } + assert.Equal(t, tc.expectedNamespaces, result) + assert.NoError(t, err) + }) + } + +} diff --git a/pkg/gateway/routeutils/route_attachment_helper.go b/pkg/gateway/routeutils/route_attachment_helper.go new file mode 100644 index 0000000000..f1a18f8ba3 --- /dev/null +++ b/pkg/gateway/routeutils/route_attachment_helper.go @@ -0,0 +1,65 @@ +package routeutils + +import ( + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// routeAttachmentHelper is an internal utility that is responsible for providing functionality related to route filtering. +type routeAttachmentHelper interface { + doesRouteAttachToGateway(gw gwv1.Gateway, route preLoadRouteDescriptor) bool + routeAllowsAttachmentToListener(listener gwv1.Listener, route preLoadRouteDescriptor) bool +} + +var _ routeAttachmentHelper = &routeAttachmentHelperImpl{} + +type routeAttachmentHelperImpl struct { +} + +// doesRouteAttachToGateway is responsible for determining if a route and gateway should be connected. +// This function implements the Gateway API spec for determining Gateway -> Route attachment. +func (rah *routeAttachmentHelperImpl) doesRouteAttachToGateway(gw gwv1.Gateway, route preLoadRouteDescriptor) bool { + for _, parentRef := range route.GetParentRefs() { + + // Default for kind is Gateway. + if parentRef.Kind != nil && *parentRef.Kind != "Gateway" { + continue + } + + var namespaceToCompare string + + if parentRef.Namespace != nil { + namespaceToCompare = string(*parentRef.Namespace) + } else { + namespaceToCompare = gw.Namespace + } + + if string(parentRef.Name) == gw.Name && gw.Namespace == namespaceToCompare { + return true + } + } + + return false +} + +// routeAllowsAttachmentToListener is responsible for determining if a route and listener should be connected. This function is slightly different than +// listenerAttachmentHelper as it handles listener -> route relationships. This utility handles route -> listener relationships. +// In order for a relationship to be established, both listener and route must agree to the connection. +// This function implements the Gateway API spec for route -> listener attachment. +// This function assumes that the caller has already validated that the gateway that owns the listener allows for route +// attachment. +func (rah *routeAttachmentHelperImpl) routeAllowsAttachmentToListener(listener gwv1.Listener, route preLoadRouteDescriptor) bool { + for _, parentRef := range route.GetParentRefs() { + + if parentRef.SectionName != nil && string(*parentRef.SectionName) != string(listener.Name) { + continue + } + + if parentRef.Port != nil && *parentRef.Port != listener.Port { + continue + } + + return true + } + + return false +} diff --git a/pkg/gateway/routeutils/route_attachment_helper_test.go b/pkg/gateway/routeutils/route_attachment_helper_test.go new file mode 100644 index 0000000000..8439d34587 --- /dev/null +++ b/pkg/gateway/routeutils/route_attachment_helper_test.go @@ -0,0 +1,366 @@ +package routeutils + +import ( + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + "testing" +) + +func Test_doesRouteAttachToGateway(t *testing.T) { + testCases := []struct { + name string + gw gwv1.Gateway + route preLoadRouteDescriptor + result bool + }{ + { + name: "parent ref has nil kind and matching name / namespace", + gw: gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw", + Namespace: "ns1", + }, + }, + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + Name: "gw", + }, + }, + }, + }, + }), + result: true, + }, + { + name: "parent ref has gateway kind and matching name / namespace default to gw namespace when ref doesnt have one", + gw: gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw", + Namespace: "ns1", + }, + }, + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + Name: "gw", + Kind: (*gwv1.Kind)(awssdk.String("Gateway")), + }, + }, + }, + }, + }), + result: true, + }, + { + name: "parent ref has gateway kind and matching name / namespace default to gw namespace", + gw: gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw", + Namespace: "ns1", + }, + }, + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + Name: "gw", + Namespace: (*gwv1.Namespace)(awssdk.String("ns1")), + Kind: (*gwv1.Kind)(awssdk.String("Gateway")), + }, + }, + }, + }, + }), + result: true, + }, + { + name: "multiple parent refs should return true when one matches", + gw: gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw", + Namespace: "ns1", + }, + }, + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + Name: "gw2", + Namespace: (*gwv1.Namespace)(awssdk.String("ns1")), + Kind: (*gwv1.Kind)(awssdk.String("Other")), + }, + { + Name: "gw2", + Namespace: (*gwv1.Namespace)(awssdk.String("ns1")), + Kind: (*gwv1.Kind)(awssdk.String("Gateway")), + }, + { + Name: "gw3", + Namespace: (*gwv1.Namespace)(awssdk.String("ns1")), + Kind: (*gwv1.Kind)(awssdk.String("Gateway")), + }, + { + Name: "gw", + Namespace: (*gwv1.Namespace)(awssdk.String("ns1")), + Kind: (*gwv1.Kind)(awssdk.String("Gateway")), + }, + }, + }, + }, + }), + result: true, + }, + { + name: "multiple parent refs should return false if none matches", + gw: gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw", + Namespace: "ns1", + }, + }, + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + Name: "gw2", + Namespace: (*gwv1.Namespace)(awssdk.String("ns1")), + Kind: (*gwv1.Kind)(awssdk.String("Other")), + }, + { + Name: "gw2", + Namespace: (*gwv1.Namespace)(awssdk.String("ns1")), + Kind: (*gwv1.Kind)(awssdk.String("Gateway")), + }, + { + Name: "gw3", + Namespace: (*gwv1.Namespace)(awssdk.String("ns1")), + Kind: (*gwv1.Kind)(awssdk.String("Gateway")), + }, + { + Name: "gw4", + Namespace: (*gwv1.Namespace)(awssdk.String("ns1")), + Kind: (*gwv1.Kind)(awssdk.String("Gateway")), + }, + }, + }, + }, + }), + }, + { + name: "parent ref has gateway kind and matching name but namespace different", + gw: gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw", + Namespace: "ns1", + }, + }, + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + Name: "gw", + Namespace: (*gwv1.Namespace)(awssdk.String("ns2")), + Kind: (*gwv1.Kind)(awssdk.String("Gateway")), + }, + }, + }, + }, + }), + }, + { + name: "parent ref has gateway kind and matching name but name different", + gw: gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw", + Namespace: "ns1", + }, + }, + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + Name: "gw-other", + Kind: (*gwv1.Kind)(awssdk.String("Gateway")), + }, + }, + }, + }, + }), + }, + { + name: "no parent refs", + route: convertHTTPRoute(gwv1.HTTPRoute{}), + }, + { + name: "parent ref has non gateway kind", + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + Kind: (*gwv1.Kind)(awssdk.String("other kind")), + }, + }, + }, + }, + }), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + helper := &routeAttachmentHelperImpl{} + assert.Equal(t, tc.result, helper.doesRouteAttachToGateway(tc.gw, tc.route)) + }) + } +} + +func Test_routeAllowsAttachmentToListener(t *testing.T) { + testCases := []struct { + name string + listener gwv1.Listener + route preLoadRouteDescriptor + result bool + }{ + { + name: "allows attachment section and port correct", + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + SectionName: (*gwv1.SectionName)(awssdk.String("sectionname")), + Port: (*gwv1.PortNumber)(awssdk.Int32(80)), + }, + }, + }, + }, + }), + listener: gwv1.Listener{ + Name: "sectionname", + Port: 80, + }, + result: true, + }, + { + name: "allows attachment section specified", + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + SectionName: (*gwv1.SectionName)(awssdk.String("sectionname")), + }, + }, + }, + }, + }), + listener: gwv1.Listener{ + Name: "sectionname", + Port: 80, + }, + result: true, + }, + { + name: "allows attachment port specified", + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + Port: (*gwv1.PortNumber)(awssdk.Int32(80)), + }, + }, + }, + }, + }), + listener: gwv1.Listener{ + Name: "sectionname", + Port: 80, + }, + result: true, + }, + { + name: "multiple parent refs one ref allows attachment", + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + SectionName: (*gwv1.SectionName)(awssdk.String("sectionname1")), + Port: (*gwv1.PortNumber)(awssdk.Int32(80)), + }, + { + SectionName: (*gwv1.SectionName)(awssdk.String("sectionname2")), + Port: (*gwv1.PortNumber)(awssdk.Int32(80)), + }, + { + SectionName: (*gwv1.SectionName)(awssdk.String("sectionname3")), + Port: (*gwv1.PortNumber)(awssdk.Int32(80)), + }, + { + SectionName: (*gwv1.SectionName)(awssdk.String("sectionname")), + Port: (*gwv1.PortNumber)(awssdk.Int32(80)), + }, + }, + }, + }, + }), + listener: gwv1.Listener{ + Name: "sectionname", + Port: 80, + }, + result: true, + }, + { + name: "multiple parent refs one ref none attachment", + route: convertHTTPRoute(gwv1.HTTPRoute{ + Spec: gwv1.HTTPRouteSpec{ + CommonRouteSpec: gwv1.CommonRouteSpec{ + ParentRefs: []gwv1.ParentReference{ + { + SectionName: (*gwv1.SectionName)(awssdk.String("sectionname1")), + Port: (*gwv1.PortNumber)(awssdk.Int32(80)), + }, + { + SectionName: (*gwv1.SectionName)(awssdk.String("sectionname2")), + Port: (*gwv1.PortNumber)(awssdk.Int32(80)), + }, + { + SectionName: (*gwv1.SectionName)(awssdk.String("sectionname3")), + Port: (*gwv1.PortNumber)(awssdk.Int32(80)), + }, + { + SectionName: (*gwv1.SectionName)(awssdk.String("sectionname4")), + Port: (*gwv1.PortNumber)(awssdk.Int32(80)), + }, + }, + }, + }, + }), + listener: gwv1.Listener{ + Name: "sectionname", + Port: 80, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + helper := &routeAttachmentHelperImpl{} + assert.Equal(t, tc.result, helper.routeAllowsAttachmentToListener(tc.listener, tc.route)) + }) + } +} diff --git a/pkg/gateway/routeutils/route_listener_mapper.go b/pkg/gateway/routeutils/route_listener_mapper.go new file mode 100644 index 0000000000..51afc1eb67 --- /dev/null +++ b/pkg/gateway/routeutils/route_listener_mapper.go @@ -0,0 +1,54 @@ +package routeutils + +import ( + "context" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// listenerToRouteMapper is an internal utility that will map a list of routes to the listeners of a gateway +// if the gateway and/or route are incompatible, then route is discarded. +type listenerToRouteMapper interface { + mapGatewayAndRoutes(context context.Context, gw gwv1.Gateway, routes []preLoadRouteDescriptor) (map[int][]preLoadRouteDescriptor, error) +} + +var _ listenerToRouteMapper = &listenerToRouteMapperImpl{} + +type listenerToRouteMapperImpl struct { + listenerAttachmentHelper listenerAttachmentHelper + routeAttachmentHelper routeAttachmentHelper +} + +// mapGatewayAndRoutes will map route to the corresponding listener ports using the Gateway API spec rules. +func (ltr *listenerToRouteMapperImpl) mapGatewayAndRoutes(ctx context.Context, gw gwv1.Gateway, routes []preLoadRouteDescriptor) (map[int][]preLoadRouteDescriptor, error) { + result := make(map[int][]preLoadRouteDescriptor) + + // First filter out any routes that are not intended for this Gateway. + routesForGateway := make([]preLoadRouteDescriptor, 0) + for _, route := range routes { + if ltr.routeAttachmentHelper.doesRouteAttachToGateway(gw, route) { + routesForGateway = append(routesForGateway, route) + } + } + + // Next, greedily looking for the route to attach to. + for _, listener := range gw.Spec.Listeners { + for _, route := range routesForGateway { + + // We need to check both paths (route -> listener) and (listener -> route) + // for connection viability. + if !ltr.routeAttachmentHelper.routeAllowsAttachmentToListener(listener, route) { + continue + } + + allowedAttachment, err := ltr.listenerAttachmentHelper.listenerAllowsAttachment(ctx, gw, listener, route) + if err != nil { + return nil, err + } + + if allowedAttachment { + result[int(listener.Port)] = append(result[int(listener.Port)], route) + } + } + } + return result, nil +} diff --git a/pkg/gateway/routeutils/route_listener_mapper_test.go b/pkg/gateway/routeutils/route_listener_mapper_test.go new file mode 100644 index 0000000000..3f44c3ecb5 --- /dev/null +++ b/pkg/gateway/routeutils/route_listener_mapper_test.go @@ -0,0 +1,329 @@ +package routeutils + +import ( + "context" + "fmt" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + "testing" +) + +type mockListenerAttachmentHelper struct { + attachmentMap map[string]bool +} + +func makeListenerAttachmentMapKey(listener gwv1.Listener, route preLoadRouteDescriptor) string { + nsn := route.GetRouteNamespacedName() + return fmt.Sprintf("%s-%d-%s-%s", listener.Name, listener.Port, nsn.Name, nsn.Namespace) +} + +func (m *mockListenerAttachmentHelper) listenerAllowsAttachment(ctx context.Context, gw gwv1.Gateway, listener gwv1.Listener, route preLoadRouteDescriptor) (bool, error) { + k := makeListenerAttachmentMapKey(listener, route) + return m.attachmentMap[k], nil +} + +type mockRouteAttachmentHelper struct { + routeGatewayMap map[string]bool + routeListenerMap map[string]bool +} + +func makeRouteGatewayMapKey(gw gwv1.Gateway, route preLoadRouteDescriptor) string { + nsn := route.GetRouteNamespacedName() + return fmt.Sprintf("%s-%s-%s-%s", gw.Name, gw.Namespace, nsn.Name, nsn.Namespace) +} + +func (m *mockRouteAttachmentHelper) doesRouteAttachToGateway(gw gwv1.Gateway, route preLoadRouteDescriptor) bool { + k := makeRouteGatewayMapKey(gw, route) + return m.routeGatewayMap[k] +} + +func (m *mockRouteAttachmentHelper) routeAllowsAttachmentToListener(listener gwv1.Listener, route preLoadRouteDescriptor) bool { + k := makeListenerAttachmentMapKey(listener, route) + return m.routeListenerMap[k] +} + +func Test_mapGatewayAndRoutes(t *testing.T) { + + route1 := convertHTTPRoute(gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route1", + Namespace: "ns1", + }, + }) + + route2 := convertHTTPRoute(gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route2", + Namespace: "ns2", + }, + }) + + route3 := convertHTTPRoute(gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route3", + Namespace: "ns3", + }, + }) + + route4 := convertHTTPRoute(gwv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route4", + Namespace: "ns4", + }, + }) + + gateway := gwv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: "gw1", + Namespace: "ns-gw", + }, + Spec: gwv1.GatewaySpec{ + Listeners: []gwv1.Listener{ + { + Name: "section80", + Port: gwv1.PortNumber(80), + }, + { + Name: "section81", + Port: gwv1.PortNumber(81), + }, + { + Name: "section82", + Port: gwv1.PortNumber(82), + }, + }, + }, + } + + testCases := []struct { + name string + gw gwv1.Gateway + routes []preLoadRouteDescriptor + listenerAttachmentMap map[string]bool + routeGatewayMap map[string]bool + routeListenerMap map[string]bool + expected map[int][]preLoadRouteDescriptor + expectErr bool + }{ + { + name: "routes get mapped to each listener", + gw: gateway, + routes: []preLoadRouteDescriptor{route1, route2, route3, route4}, + listenerAttachmentMap: map[string]bool{ + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route3): true, + }, + routeListenerMap: map[string]bool{ + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route3): true, + }, + routeGatewayMap: map[string]bool{ + makeRouteGatewayMapKey(gateway, route1): true, + makeRouteGatewayMapKey(gateway, route2): true, + makeRouteGatewayMapKey(gateway, route3): true, + makeRouteGatewayMapKey(gateway, route4): true, + }, + expected: map[int][]preLoadRouteDescriptor{ + 80: { + route1, + }, + 81: { + route2, + }, + 82: { + route3, + }, + }, + }, + { + name: "all routes to all listeners", + gw: gateway, + routes: []preLoadRouteDescriptor{route1, route2, route3, route4}, + listenerAttachmentMap: map[string]bool{ + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route3): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route3): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route3): true, + }, + routeListenerMap: map[string]bool{ + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route3): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route3): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route3): true, + }, + routeGatewayMap: map[string]bool{ + makeRouteGatewayMapKey(gateway, route1): true, + makeRouteGatewayMapKey(gateway, route2): true, + makeRouteGatewayMapKey(gateway, route3): true, + makeRouteGatewayMapKey(gateway, route4): true, + }, + expected: map[int][]preLoadRouteDescriptor{ + 80: { + route1, + route2, + route3, + }, + 81: { + route1, + route2, + route3, + }, + 82: { + route1, + route2, + route3, + }, + }, + }, + { + name: "gateway doesnt allow attachment, no result", + gw: gateway, + routes: []preLoadRouteDescriptor{route1, route2, route3, route4}, + listenerAttachmentMap: map[string]bool{ + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route3): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route3): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route3): true, + }, + routeListenerMap: map[string]bool{ + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route3): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route3): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route3): true, + }, + routeGatewayMap: map[string]bool{}, + expected: map[int][]preLoadRouteDescriptor{}, + }, + { + name: "route allows all attachment, but listener only allows subset", + gw: gateway, + routes: []preLoadRouteDescriptor{route1, route2, route3, route4}, + listenerAttachmentMap: map[string]bool{ + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route3): true, + }, + routeListenerMap: map[string]bool{ + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route3): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route3): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route3): true, + }, + routeGatewayMap: map[string]bool{ + makeRouteGatewayMapKey(gateway, route1): true, + makeRouteGatewayMapKey(gateway, route2): true, + makeRouteGatewayMapKey(gateway, route3): true, + makeRouteGatewayMapKey(gateway, route4): true, + }, + expected: map[int][]preLoadRouteDescriptor{ + 80: { + route1, + }, + 81: { + route2, + }, + 82: { + route3, + }, + }, + }, + { + name: "listener allows all attachment, but route only allows subset", + gw: gateway, + routes: []preLoadRouteDescriptor{route1, route2, route3, route4}, + listenerAttachmentMap: map[string]bool{ + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route3): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route3): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route3): true, + }, + routeListenerMap: map[string]bool{ + makeListenerAttachmentMapKey(gateway.Spec.Listeners[0], route1): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[1], route2): true, + makeListenerAttachmentMapKey(gateway.Spec.Listeners[2], route3): true, + }, + routeGatewayMap: map[string]bool{ + makeRouteGatewayMapKey(gateway, route1): true, + makeRouteGatewayMapKey(gateway, route2): true, + makeRouteGatewayMapKey(gateway, route3): true, + makeRouteGatewayMapKey(gateway, route4): true, + }, + expected: map[int][]preLoadRouteDescriptor{ + 80: { + route1, + }, + 81: { + route2, + }, + 82: { + route3, + }, + }, + }, + { + name: "no output", + expected: make(map[int][]preLoadRouteDescriptor), + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + mapper := listenerToRouteMapperImpl{ + listenerAttachmentHelper: &mockListenerAttachmentHelper{ + attachmentMap: tc.listenerAttachmentMap, + }, + routeAttachmentHelper: &mockRouteAttachmentHelper{ + routeListenerMap: tc.routeListenerMap, + routeGatewayMap: tc.routeGatewayMap, + }, + } + + result, err := mapper.mapGatewayAndRoutes(context.Background(), tc.gw, tc.routes) + + if tc.expectErr { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + assert.Equal(t, len(tc.expected), len(result)) + + for k, v := range tc.expected { + assert.ElementsMatch(t, v, result[k]) + } + }) + } +} diff --git a/pkg/gateway/routeutils/route_rule.go b/pkg/gateway/routeutils/route_rule.go new file mode 100644 index 0000000000..016a840079 --- /dev/null +++ b/pkg/gateway/routeutils/route_rule.go @@ -0,0 +1,12 @@ +package routeutils + +import ( + gwv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// RouteRule is a type agnostic representation of Routing Rules. +type RouteRule interface { + GetRawRouteRule() interface{} + GetSectionName() *gwv1.SectionName + GetBackends() []Backend +} diff --git a/pkg/gateway/routeutils/tcp.go b/pkg/gateway/routeutils/tcp.go new file mode 100644 index 0000000000..677abc3ff5 --- /dev/null +++ b/pkg/gateway/routeutils/tcp.go @@ -0,0 +1,123 @@ +package routeutils + +import ( + "context" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/aws-load-balancer-controller/pkg/k8s" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwalpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +/* +This class holds the representation of an TCP route. +Generally, outside consumers will use GetRawRouteRule to inspect the +TCP specific features of the route. +*/ + +/* Route Rule */ + +var _ RouteRule = &convertedTCPRouteRule{} + +type convertedTCPRouteRule struct { + rule *gwalpha2.TCPRouteRule + backends []Backend +} + +func convertTCPRouteRule(rule *gwalpha2.TCPRouteRule, backends []Backend) RouteRule { + return &convertedTCPRouteRule{ + rule: rule, + backends: backends, + } +} + +func (t *convertedTCPRouteRule) GetRawRouteRule() interface{} { + return t.rule +} + +func (t *convertedTCPRouteRule) GetSectionName() *gwv1.SectionName { + return t.rule.Name +} + +func (t *convertedTCPRouteRule) GetBackends() []Backend { + return t.backends +} + +/* Route Description */ + +type tcpRouteDescription struct { + route *gwalpha2.TCPRoute + rules []RouteRule + backendLoader func(ctx context.Context, k8sClient client.Client, typeSpecificBackend interface{}, backendRef gwv1.BackendRef, routeIdentifier types.NamespacedName, routeKind string) (*Backend, error) +} + +func (tcpRoute *tcpRouteDescription) GetAttachedRules() []RouteRule { + return tcpRoute.rules +} + +func (tcpRoute *tcpRouteDescription) loadAttachedRules(ctx context.Context, k8sClient client.Client) (RouteDescriptor, error) { + + convertedRules := make([]RouteRule, 0) + for _, rule := range tcpRoute.route.Spec.Rules { + convertedBackends := make([]Backend, 0) + + for _, backend := range rule.BackendRefs { + convertedBackend, err := tcpRoute.backendLoader(ctx, k8sClient, backend, backend, tcpRoute.GetRouteNamespacedName(), tcpRoute.GetRouteKind()) + if err != nil { + return nil, err + } + if convertedBackend != nil { + convertedBackends = append(convertedBackends, *convertedBackend) + } + } + + convertedRules = append(convertedRules, convertTCPRouteRule(&rule, convertedBackends)) + } + + tcpRoute.rules = convertedRules + return tcpRoute, nil +} + +func (tcpRoute *tcpRouteDescription) GetHostnames() []gwv1.Hostname { + return []gwv1.Hostname{} +} + +func (tcpRoute *tcpRouteDescription) GetRouteKind() string { + return TCPRouteKind +} + +func (tcpRoute *tcpRouteDescription) GetRouteNamespacedName() types.NamespacedName { + return k8s.NamespacedName(tcpRoute.route) +} + +func convertTCPRoute(r gwalpha2.TCPRoute) *tcpRouteDescription { + return &tcpRouteDescription{route: &r, backendLoader: commonBackendLoader} +} + +func (tcpRoute *tcpRouteDescription) GetRawRoute() interface{} { + return tcpRoute.route +} + +func (tcpRoute *tcpRouteDescription) GetParentRefs() []gwv1.ParentReference { + return tcpRoute.route.Spec.ParentRefs +} + +var _ RouteDescriptor = &tcpRouteDescription{} + +// Can we use an indexer here to query more efficiently? + +func ListTCPRoutes(context context.Context, k8sClient client.Client) ([]preLoadRouteDescriptor, error) { + routeList := &gwalpha2.TCPRouteList{} + err := k8sClient.List(context, routeList) + if err != nil { + return nil, err + } + + result := make([]preLoadRouteDescriptor, 0) + + for _, item := range routeList.Items { + result = append(result, convertTCPRoute(item)) + } + + return result, err +} diff --git a/pkg/gateway/routeutils/tcp_test.go b/pkg/gateway/routeutils/tcp_test.go new file mode 100644 index 0000000000..d9eda7640a --- /dev/null +++ b/pkg/gateway/routeutils/tcp_test.go @@ -0,0 +1,123 @@ +package routeutils + +import ( + "context" + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwalpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + "testing" +) + +func Test_ConvertTCPRuleToRouteRule(t *testing.T) { + + rule := &gwalpha2.TCPRouteRule{ + Name: (*gwv1.SectionName)(awssdk.String("my-name")), + BackendRefs: []gwalpha2.BackendRef{}, + } + + backends := []Backend{ + {}, {}, + } + + result := convertTCPRouteRule(rule, backends) + + assert.Equal(t, backends, result.GetBackends()) + assert.Equal(t, rule, result.GetRawRouteRule().(*gwalpha2.TCPRouteRule)) +} + +func Test_ListTCPRoutes(t *testing.T) { + k8sClient := generateTestClient() + + k8sClient.Create(context.Background(), &gwalpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo1", + Namespace: "bar1", + }, + Spec: gwalpha2.TCPRouteSpec{ + Rules: nil, + }, + }) + + k8sClient.Create(context.Background(), &gwalpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo2", + Namespace: "bar2", + }, + Spec: gwalpha2.TCPRouteSpec{ + Rules: nil, + }, + }) + + k8sClient.Create(context.Background(), &gwalpha2.TCPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo3", + Namespace: "bar3", + }, + }) + + result, err := ListTCPRoutes(context.Background(), k8sClient) + + assert.NoError(t, err) + + itemMap := make(map[string]string) + for _, v := range result { + routeNsn := v.GetRouteNamespacedName() + itemMap[routeNsn.Namespace] = routeNsn.Name + assert.Equal(t, TCPRouteKind, v.GetRouteKind()) + assert.NotNil(t, v.GetRawRoute()) + assert.Equal(t, 0, len(v.GetHostnames())) + } + + assert.Equal(t, "foo1", itemMap["bar1"]) + assert.Equal(t, "foo2", itemMap["bar2"]) + assert.Equal(t, "foo3", itemMap["bar3"]) +} + +func Test_TCP_LoadAttachedRules(t *testing.T) { + weight := 0 + mockLoader := func(ctx context.Context, k8sClient client.Client, typeSpecificBackend interface{}, backendRef gwv1.BackendRef, routeIdentifier types.NamespacedName, routeKind string) (*Backend, error) { + weight++ + return &Backend{ + Weight: weight, + }, nil + } + + routeDescription := tcpRouteDescription{ + route: &gwalpha2.TCPRoute{ + Spec: gwalpha2.TCPRouteSpec{Rules: []gwalpha2.TCPRouteRule{ + { + BackendRefs: []gwalpha2.BackendRef{ + {}, + {}, + }, + }, + { + BackendRefs: []gwalpha2.BackendRef{ + {}, + {}, + {}, + {}, + }, + }, + { + BackendRefs: []gwalpha2.BackendRef{}, + }, + }}, + }, + rules: nil, + backendLoader: mockLoader, + } + + result, err := routeDescription.loadAttachedRules(context.Background(), nil) + assert.NoError(t, err) + convertedRules := result.GetAttachedRules() + assert.Equal(t, 3, len(convertedRules)) + + assert.Equal(t, 2, len(convertedRules[0].GetBackends())) + assert.Equal(t, 4, len(convertedRules[1].GetBackends())) + assert.Equal(t, 0, len(convertedRules[2].GetBackends())) +} diff --git a/pkg/gateway/routeutils/test_utils.go b/pkg/gateway/routeutils/test_utils.go new file mode 100644 index 0000000000..98c1fccd5f --- /dev/null +++ b/pkg/gateway/routeutils/test_utils.go @@ -0,0 +1,20 @@ +package routeutils + +import ( + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + elbv2api "sigs.k8s.io/aws-load-balancer-controller/apis/elbv2/v1beta1" + "sigs.k8s.io/controller-runtime/pkg/client" + testclient "sigs.k8s.io/controller-runtime/pkg/client/fake" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwalpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +func generateTestClient() client.Client { + k8sSchema := runtime.NewScheme() + clientgoscheme.AddToScheme(k8sSchema) + elbv2api.AddToScheme(k8sSchema) + gwv1.AddToScheme(k8sSchema) + gwalpha2.AddToScheme(k8sSchema) + return testclient.NewClientBuilder().WithScheme(k8sSchema).Build() +} diff --git a/pkg/gateway/routeutils/tls.go b/pkg/gateway/routeutils/tls.go new file mode 100644 index 0000000000..e3d0596c3d --- /dev/null +++ b/pkg/gateway/routeutils/tls.go @@ -0,0 +1,121 @@ +package routeutils + +import ( + "context" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/aws-load-balancer-controller/pkg/k8s" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwalpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +/* +This class holds the representation of an TLS route. +Generally, outside consumers will use GetRawRouteRule to inspect the +TLS specific features of the route. +*/ + +/* Route Rule */ + +var _ RouteRule = &convertedTLSRouteRule{} + +type convertedTLSRouteRule struct { + rule *gwalpha2.TLSRouteRule + backends []Backend +} + +func convertTLSRouteRule(rule *gwalpha2.TLSRouteRule, backends []Backend) RouteRule { + return &convertedTLSRouteRule{ + rule: rule, + backends: backends, + } +} + +func (t *convertedTLSRouteRule) GetRawRouteRule() interface{} { + return t.rule +} + +func (t *convertedTLSRouteRule) GetSectionName() *gwv1.SectionName { + return t.rule.Name +} + +func (t *convertedTLSRouteRule) GetBackends() []Backend { + return t.backends +} + +/* Route Description */ + +type tlsRouteDescription struct { + route *gwalpha2.TLSRoute + rules []RouteRule + backendLoader func(ctx context.Context, k8sClient client.Client, typeSpecificBackend interface{}, backendRef gwv1.BackendRef, routeIdentifier types.NamespacedName, routeKind string) (*Backend, error) +} + +func (tlsRoute *tlsRouteDescription) GetAttachedRules() []RouteRule { + return tlsRoute.rules +} + +func (tlsRoute *tlsRouteDescription) loadAttachedRules(ctx context.Context, k8sClient client.Client) (RouteDescriptor, error) { + convertedRules := make([]RouteRule, 0) + for _, rule := range tlsRoute.route.Spec.Rules { + convertedBackends := make([]Backend, 0) + + for _, backend := range rule.BackendRefs { + convertedBackend, err := tlsRoute.backendLoader(ctx, k8sClient, backend, backend, tlsRoute.GetRouteNamespacedName(), tlsRoute.GetRouteKind()) + if err != nil { + return nil, err + } + + if convertedBackend != nil { + convertedBackends = append(convertedBackends, *convertedBackend) + } + } + + convertedRules = append(convertedRules, convertTLSRouteRule(&rule, convertedBackends)) + } + + tlsRoute.rules = convertedRules + return tlsRoute, nil +} + +func (tlsRoute *tlsRouteDescription) GetHostnames() []gwv1.Hostname { + return tlsRoute.route.Spec.Hostnames +} + +func (tlsRoute *tlsRouteDescription) GetParentRefs() []gwv1.ParentReference { + return tlsRoute.route.Spec.ParentRefs +} + +func (tlsRoute *tlsRouteDescription) GetRouteKind() string { + return TLSRouteKind +} + +func convertTLSRoute(r gwalpha2.TLSRoute) *tlsRouteDescription { + return &tlsRouteDescription{route: &r, backendLoader: commonBackendLoader} +} + +func (tlsRoute *tlsRouteDescription) GetRouteNamespacedName() types.NamespacedName { + return k8s.NamespacedName(tlsRoute.route) +} + +func (tlsRoute *tlsRouteDescription) GetRawRoute() interface{} { + return tlsRoute.route +} + +var _ RouteDescriptor = &tlsRouteDescription{} + +func ListTLSRoutes(context context.Context, k8sClient client.Client) ([]preLoadRouteDescriptor, error) { + routeList := &gwalpha2.TLSRouteList{} + err := k8sClient.List(context, routeList) + if err != nil { + return nil, err + } + + result := make([]preLoadRouteDescriptor, 0) + + for _, item := range routeList.Items { + result = append(result, convertTLSRoute(item)) + } + + return result, err +} diff --git a/pkg/gateway/routeutils/tls_test.go b/pkg/gateway/routeutils/tls_test.go new file mode 100644 index 0000000000..375feeb014 --- /dev/null +++ b/pkg/gateway/routeutils/tls_test.go @@ -0,0 +1,145 @@ +package routeutils + +import ( + "context" + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwalpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + "testing" +) + +func Test_ConvertTLSRuleToRouteRule(t *testing.T) { + + rule := &gwalpha2.TLSRouteRule{ + Name: (*gwv1.SectionName)(awssdk.String("my-name")), + BackendRefs: []gwalpha2.BackendRef{}, + } + + backends := []Backend{ + {}, {}, + } + + result := convertTLSRouteRule(rule, backends) + + assert.Equal(t, backends, result.GetBackends()) + assert.Equal(t, rule, result.GetRawRouteRule().(*gwalpha2.TLSRouteRule)) +} + +func Test_ListTLSRoutes(t *testing.T) { + k8sClient := generateTestClient() + + k8sClient.Create(context.Background(), &gwalpha2.TLSRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo1", + Namespace: "bar1", + }, + Spec: gwalpha2.TLSRouteSpec{ + Hostnames: []gwv1.Hostname{ + "host1", + }, + Rules: nil, + }, + }) + + k8sClient.Create(context.Background(), &gwalpha2.TLSRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo2", + Namespace: "bar2", + }, + Spec: gwalpha2.TLSRouteSpec{ + Hostnames: []gwv1.Hostname{ + "host2", + }, + Rules: nil, + }, + }) + + k8sClient.Create(context.Background(), &gwalpha2.TLSRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo3", + Namespace: "bar3", + }, + }) + + result, err := ListTLSRoutes(context.Background(), k8sClient) + + assert.NoError(t, err) + + itemMap := make(map[string]string) + for _, v := range result { + routeNsn := v.GetRouteNamespacedName() + itemMap[routeNsn.Namespace] = routeNsn.Name + assert.Equal(t, TLSRouteKind, v.GetRouteKind()) + assert.NotNil(t, v.GetRawRoute()) + + if routeNsn.Name == "foo1" { + assert.Equal(t, []gwv1.Hostname{ + "host1", + }, v.GetHostnames()) + } + + if routeNsn.Name == "foo2" { + assert.Equal(t, []gwv1.Hostname{ + "host2", + }, v.GetHostnames()) + } + + if routeNsn.Name == "foo3" { + assert.Equal(t, 0, len(v.GetHostnames())) + } + + } + + assert.Equal(t, "foo1", itemMap["bar1"]) + assert.Equal(t, "foo2", itemMap["bar2"]) + assert.Equal(t, "foo3", itemMap["bar3"]) +} + +func Test_TLS_LoadAttachedRules(t *testing.T) { + weight := 0 + mockLoader := func(ctx context.Context, k8sClient client.Client, typeSpecificBackend interface{}, backendRef gwv1.BackendRef, routeIdentifier types.NamespacedName, routeKind string) (*Backend, error) { + weight++ + return &Backend{ + Weight: weight, + }, nil + } + + routeDescription := tlsRouteDescription{ + route: &gwalpha2.TLSRoute{ + Spec: gwalpha2.TLSRouteSpec{Rules: []gwalpha2.TLSRouteRule{ + { + BackendRefs: []gwalpha2.BackendRef{ + {}, + {}, + }, + }, + { + BackendRefs: []gwalpha2.BackendRef{ + {}, + {}, + {}, + {}, + }, + }, + { + BackendRefs: []gwalpha2.BackendRef{}, + }, + }}, + }, + rules: nil, + backendLoader: mockLoader, + } + + result, err := routeDescription.loadAttachedRules(context.Background(), nil) + assert.NoError(t, err) + convertedRules := result.GetAttachedRules() + assert.Equal(t, 3, len(convertedRules)) + + assert.Equal(t, 2, len(convertedRules[0].GetBackends())) + assert.Equal(t, 4, len(convertedRules[1].GetBackends())) + assert.Equal(t, 0, len(convertedRules[2].GetBackends())) +} diff --git a/pkg/gateway/routeutils/udp.go b/pkg/gateway/routeutils/udp.go new file mode 100644 index 0000000000..fa7b122f75 --- /dev/null +++ b/pkg/gateway/routeutils/udp.go @@ -0,0 +1,120 @@ +package routeutils + +import ( + "context" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/aws-load-balancer-controller/pkg/k8s" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwalpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +/* +This class holds the representation of an UDP route. +Generally, outside consumers will use GetRawRouteRule to inspect the +UDP specific features of the route. +*/ + +/* Route Rule */ + +var _ RouteRule = &convertedUDPRouteRule{} + +type convertedUDPRouteRule struct { + rule *gwalpha2.UDPRouteRule + backends []Backend +} + +func convertUDPRouteRule(rule *gwalpha2.UDPRouteRule, backends []Backend) RouteRule { + return &convertedUDPRouteRule{ + rule: rule, + backends: backends, + } +} + +func (t *convertedUDPRouteRule) GetRawRouteRule() interface{} { + return t.rule +} + +func (t *convertedUDPRouteRule) GetSectionName() *gwv1.SectionName { + return t.rule.Name +} + +func (t *convertedUDPRouteRule) GetBackends() []Backend { + return t.backends +} + +/* Route Description */ + +type udpRouteDescription struct { + route *gwalpha2.UDPRoute + rules []RouteRule + backendLoader func(ctx context.Context, k8sClient client.Client, typeSpecificBackend interface{}, backendRef gwv1.BackendRef, routeIdentifier types.NamespacedName, routeKind string) (*Backend, error) +} + +func (udpRoute *udpRouteDescription) GetAttachedRules() []RouteRule { + return udpRoute.rules +} + +func (udpRoute *udpRouteDescription) loadAttachedRules(ctx context.Context, k8sClient client.Client) (RouteDescriptor, error) { + convertedRules := make([]RouteRule, 0) + for _, rule := range udpRoute.route.Spec.Rules { + convertedBackends := make([]Backend, 0) + + for _, backend := range rule.BackendRefs { + convertedBackend, err := udpRoute.backendLoader(ctx, k8sClient, backend, backend, udpRoute.GetRouteNamespacedName(), udpRoute.GetRouteKind()) + if err != nil { + return nil, err + } + if convertedBackend != nil { + convertedBackends = append(convertedBackends, *convertedBackend) + } + } + + convertedRules = append(convertedRules, convertUDPRouteRule(&rule, convertedBackends)) + } + + udpRoute.rules = convertedRules + return udpRoute, nil +} + +func (udpRoute *udpRouteDescription) GetHostnames() []gwv1.Hostname { + return []gwv1.Hostname{} +} + +func (udpRoute *udpRouteDescription) GetParentRefs() []gwv1.ParentReference { + return udpRoute.route.Spec.ParentRefs +} + +func (udpRoute *udpRouteDescription) GetRouteKind() string { + return UDPRouteKind +} + +func convertUDPRoute(r gwalpha2.UDPRoute) *udpRouteDescription { + return &udpRouteDescription{route: &r, backendLoader: commonBackendLoader} +} + +func (udpRoute *udpRouteDescription) GetRouteNamespacedName() types.NamespacedName { + return k8s.NamespacedName(udpRoute.route) +} + +func (udpRoute *udpRouteDescription) GetRawRoute() interface{} { + return udpRoute.route +} + +var _ RouteDescriptor = &udpRouteDescription{} + +func ListUDPRoutes(context context.Context, k8sClient client.Client) ([]preLoadRouteDescriptor, error) { + routeList := &gwalpha2.UDPRouteList{} + err := k8sClient.List(context, routeList) + if err != nil { + return nil, err + } + + result := make([]preLoadRouteDescriptor, 0) + + for _, item := range routeList.Items { + result = append(result, convertUDPRoute(item)) + } + + return result, err +} diff --git a/pkg/gateway/routeutils/udp_test.go b/pkg/gateway/routeutils/udp_test.go new file mode 100644 index 0000000000..69b82d1cd9 --- /dev/null +++ b/pkg/gateway/routeutils/udp_test.go @@ -0,0 +1,123 @@ +package routeutils + +import ( + "context" + awssdk "github.com/aws/aws-sdk-go-v2/aws" + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + gwv1 "sigs.k8s.io/gateway-api/apis/v1" + gwalpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + "testing" +) + +func Test_ConvertUDPRuleToRouteRule(t *testing.T) { + + rule := &gwalpha2.UDPRouteRule{ + Name: (*gwv1.SectionName)(awssdk.String("my-name")), + BackendRefs: []gwalpha2.BackendRef{}, + } + + backends := []Backend{ + {}, {}, + } + + result := convertUDPRouteRule(rule, backends) + + assert.Equal(t, backends, result.GetBackends()) + assert.Equal(t, rule, result.GetRawRouteRule().(*gwalpha2.UDPRouteRule)) +} + +func Test_ListUDPRoutes(t *testing.T) { + k8sClient := generateTestClient() + + k8sClient.Create(context.Background(), &gwalpha2.UDPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo1", + Namespace: "bar1", + }, + Spec: gwalpha2.UDPRouteSpec{ + Rules: nil, + }, + }) + + k8sClient.Create(context.Background(), &gwalpha2.UDPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo2", + Namespace: "bar2", + }, + Spec: gwalpha2.UDPRouteSpec{ + Rules: nil, + }, + }) + + k8sClient.Create(context.Background(), &gwalpha2.UDPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "foo3", + Namespace: "bar3", + }, + }) + + result, err := ListUDPRoutes(context.Background(), k8sClient) + + assert.NoError(t, err) + + itemMap := make(map[string]string) + for _, v := range result { + routeNsn := v.GetRouteNamespacedName() + itemMap[routeNsn.Namespace] = routeNsn.Name + assert.Equal(t, UDPRouteKind, v.GetRouteKind()) + assert.NotNil(t, v.GetRawRoute()) + assert.Equal(t, 0, len(v.GetHostnames())) + } + + assert.Equal(t, "foo1", itemMap["bar1"]) + assert.Equal(t, "foo2", itemMap["bar2"]) + assert.Equal(t, "foo3", itemMap["bar3"]) +} + +func Test_UDP_LoadAttachedRules(t *testing.T) { + weight := 0 + mockLoader := func(ctx context.Context, k8sClient client.Client, typeSpecificBackend interface{}, backendRef gwv1.BackendRef, routeIdentifier types.NamespacedName, routeKind string) (*Backend, error) { + weight++ + return &Backend{ + Weight: weight, + }, nil + } + + routeDescription := udpRouteDescription{ + route: &gwalpha2.UDPRoute{ + Spec: gwalpha2.UDPRouteSpec{Rules: []gwalpha2.UDPRouteRule{ + { + BackendRefs: []gwalpha2.BackendRef{ + {}, + {}, + }, + }, + { + BackendRefs: []gwalpha2.BackendRef{ + {}, + {}, + {}, + {}, + }, + }, + { + BackendRefs: []gwalpha2.BackendRef{}, + }, + }}, + }, + rules: nil, + backendLoader: mockLoader, + } + + result, err := routeDescription.loadAttachedRules(context.Background(), nil) + assert.NoError(t, err) + convertedRules := result.GetAttachedRules() + assert.Equal(t, 3, len(convertedRules)) + + assert.Equal(t, 2, len(convertedRules[0].GetBackends())) + assert.Equal(t, 4, len(convertedRules[1].GetBackends())) + assert.Equal(t, 0, len(convertedRules[2].GetBackends())) +} diff --git a/pkg/service/model_build_target_group.go b/pkg/service/model_build_target_group.go index 0b14f45452..d566ad3b1d 100644 --- a/pkg/service/model_build_target_group.go +++ b/pkg/service/model_build_target_group.go @@ -205,65 +205,65 @@ func (t *defaultModelBuildTask) buildTargetGroupName(_ context.Context, svcPort } func (t *defaultModelBuildTask) buildTargetGroupAttributes(_ context.Context, port corev1.ServicePort) ([]elbv2model.TargetGroupAttribute, error) { - var rawAttributes map[string]string - if _, err := t.annotationParser.ParseStringMapAnnotation(annotations.SvcLBSuffixTargetGroupAttributes, &rawAttributes, t.service.Annotations); err != nil { - return nil, err - } - if rawAttributes == nil { - rawAttributes = make(map[string]string) - } - if _, ok := rawAttributes[tgAttrsProxyProtocolV2Enabled]; !ok { - rawAttributes[tgAttrsProxyProtocolV2Enabled] = strconv.FormatBool(t.defaultProxyProtocolV2Enabled) - } - - var proxyProtocolPerTG string - if t.annotationParser.ParseStringAnnotation(annotations.SvcLBSuffixProxyProtocolPerTargetGroup, &proxyProtocolPerTG, t.service.Annotations) { - ports := strings.Split(proxyProtocolPerTG, ",") - enabledPorts := make(map[string]struct{}) - for _, p := range ports { - trimmedPort := strings.TrimSpace(p) - if trimmedPort != "" { - if _, err := strconv.Atoi(trimmedPort); err != nil { - return nil, errors.Errorf("invalid port number in proxy-protocol-per-target-group: %v", trimmedPort) - } - enabledPorts[trimmedPort] = struct{}{} - } - } - - currentPortStr := strconv.FormatInt(int64(port.Port), 10) - if _, enabled := enabledPorts[currentPortStr]; enabled { - rawAttributes[tgAttrsProxyProtocolV2Enabled] = "true" - } else { - rawAttributes[tgAttrsProxyProtocolV2Enabled] = "false" - } - } - - proxyV2Annotation := "" - if exists := t.annotationParser.ParseStringAnnotation(annotations.SvcLBSuffixProxyProtocol, &proxyV2Annotation, t.service.Annotations); exists { - if proxyV2Annotation != "*" { - return []elbv2model.TargetGroupAttribute{}, errors.Errorf("invalid value %v for Load Balancer proxy protocol v2 annotation, only value currently supported is *", proxyV2Annotation) - } - rawAttributes[tgAttrsProxyProtocolV2Enabled] = "true" - } - - if rawPreserveIPEnabled, ok := rawAttributes[tgAttrsPreserveClientIPEnabled]; ok { - _, err := strconv.ParseBool(rawPreserveIPEnabled) - if err != nil { - return nil, errors.Wrapf(err, "failed to parse attribute %v=%v", tgAttrsPreserveClientIPEnabled, rawPreserveIPEnabled) - } - } - - attributes := make([]elbv2model.TargetGroupAttribute, 0, len(rawAttributes)) - for attrKey, attrValue := range rawAttributes { - attributes = append(attributes, elbv2model.TargetGroupAttribute{ - Key: attrKey, - Value: attrValue, - }) - } - sort.Slice(attributes, func(i, j int) bool { - return attributes[i].Key < attributes[j].Key - }) - return attributes, nil + var rawAttributes map[string]string + if _, err := t.annotationParser.ParseStringMapAnnotation(annotations.SvcLBSuffixTargetGroupAttributes, &rawAttributes, t.service.Annotations); err != nil { + return nil, err + } + if rawAttributes == nil { + rawAttributes = make(map[string]string) + } + if _, ok := rawAttributes[tgAttrsProxyProtocolV2Enabled]; !ok { + rawAttributes[tgAttrsProxyProtocolV2Enabled] = strconv.FormatBool(t.defaultProxyProtocolV2Enabled) + } + + var proxyProtocolPerTG string + if t.annotationParser.ParseStringAnnotation(annotations.SvcLBSuffixProxyProtocolPerTargetGroup, &proxyProtocolPerTG, t.service.Annotations) { + ports := strings.Split(proxyProtocolPerTG, ",") + enabledPorts := make(map[string]struct{}) + for _, p := range ports { + trimmedPort := strings.TrimSpace(p) + if trimmedPort != "" { + if _, err := strconv.Atoi(trimmedPort); err != nil { + return nil, errors.Errorf("invalid port number in proxy-protocol-per-target-group: %v", trimmedPort) + } + enabledPorts[trimmedPort] = struct{}{} + } + } + + currentPortStr := strconv.FormatInt(int64(port.Port), 10) + if _, enabled := enabledPorts[currentPortStr]; enabled { + rawAttributes[tgAttrsProxyProtocolV2Enabled] = "true" + } else { + rawAttributes[tgAttrsProxyProtocolV2Enabled] = "false" + } + } + + proxyV2Annotation := "" + if exists := t.annotationParser.ParseStringAnnotation(annotations.SvcLBSuffixProxyProtocol, &proxyV2Annotation, t.service.Annotations); exists { + if proxyV2Annotation != "*" { + return []elbv2model.TargetGroupAttribute{}, errors.Errorf("invalid value %v for Load Balancer proxy protocol v2 annotation, only value currently supported is *", proxyV2Annotation) + } + rawAttributes[tgAttrsProxyProtocolV2Enabled] = "true" + } + + if rawPreserveIPEnabled, ok := rawAttributes[tgAttrsPreserveClientIPEnabled]; ok { + _, err := strconv.ParseBool(rawPreserveIPEnabled) + if err != nil { + return nil, errors.Wrapf(err, "failed to parse attribute %v=%v", tgAttrsPreserveClientIPEnabled, rawPreserveIPEnabled) + } + } + + attributes := make([]elbv2model.TargetGroupAttribute, 0, len(rawAttributes)) + for attrKey, attrValue := range rawAttributes { + attributes = append(attributes, elbv2model.TargetGroupAttribute{ + Key: attrKey, + Value: attrValue, + }) + } + sort.Slice(attributes, func(i, j int) bool { + return attributes[i].Key < attributes[j].Key + }) + return attributes, nil } func (t *defaultModelBuildTask) buildPreserveClientIPFlag(_ context.Context, targetType elbv2model.TargetType, tgAttrs []elbv2model.TargetGroupAttribute) (bool, error) { @@ -736,4 +736,4 @@ func (t *defaultModelBuildTask) buildTargetGroupBindingMultiClusterFlag(svc *cor return rawEnabled, nil } return false, nil -} \ No newline at end of file +} diff --git a/pkg/service/model_build_target_group_test.go b/pkg/service/model_build_target_group_test.go index 3a0422effd..9af589fad0 100644 --- a/pkg/service/model_build_target_group_test.go +++ b/pkg/service/model_build_target_group_test.go @@ -146,7 +146,7 @@ func Test_defaultModelBuilderTask_targetGroupAttrs(t *testing.T) { svc: &corev1.Service{ ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ - "service.beta.kubernetes.io/aws-load-balancer-proxy-protocol-per-target-group": "80", + "service.beta.kubernetes.io/aws-load-balancer-proxy-protocol-per-target-group": "80", }, }, }, @@ -154,7 +154,7 @@ func Test_defaultModelBuilderTask_targetGroupAttrs(t *testing.T) { wantError: false, wantValue: []elbv2.TargetGroupAttribute{ { - Key: tgAttrsProxyProtocolV2Enabled, + Key: tgAttrsProxyProtocolV2Enabled, Value: "true", }, }, @@ -165,8 +165,7 @@ func Test_defaultModelBuilderTask_targetGroupAttrs(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Annotations: map[string]string{ "service.beta.kubernetes.io/aws-load-balancer-proxy-protocol-per-target-group": "443, 22", - "service.beta.kubernetes.io/aws-load-balancer-proxy-protocol": "*", - + "service.beta.kubernetes.io/aws-load-balancer-proxy-protocol": "*", }, }, }, @@ -174,7 +173,7 @@ func Test_defaultModelBuilderTask_targetGroupAttrs(t *testing.T) { wantError: false, wantValue: []elbv2.TargetGroupAttribute{ { - Key: tgAttrsProxyProtocolV2Enabled, + Key: tgAttrsProxyProtocolV2Enabled, Value: "true", }, },