diff --git a/pkg/gateway/model/model_build_listener.go b/pkg/gateway/model/model_build_listener.go index 0eb45e599..c60bd21fe 100644 --- a/pkg/gateway/model/model_build_listener.go +++ b/pkg/gateway/model/model_build_listener.go @@ -187,7 +187,7 @@ func (l listenerBuilderImpl) buildL4ListenerSpec(ctx context.Context, stack core func (l listenerBuilderImpl) buildListenerRules(ctx context.Context, stack core.Stack, ls *elbv2model.Listener, ipAddressType elbv2model.IPAddressType, gw *gwv1.Gateway, port int32, lbCfg elbv2gw.LoadBalancerConfiguration, routes map[int32][]routeutils.RouteDescriptor) ([]types.NamespacedName, error) { // sort all rules based on precedence - rulesWithPrecedenceOrder := routeutils.SortAllRulesByPrecedence(routes[port]) + rulesWithPrecedenceOrder := routeutils.SortAllRulesByPrecedence(routes[port], port) secrets := make([]types.NamespacedName, 0) var albRules []elbv2model.Rule for _, ruleWithPrecedence := range rulesWithPrecedenceOrder { @@ -509,8 +509,17 @@ func mapGatewayListenerConfigsByPort(gw *gwv1.Gateway, routes map[int32][]routeu if listenerRoutes != nil { for _, route := range listenerRoutes { - for _, routeHostname := range route.GetHostnames() { - gwListenerConfigs[port].hostnames.Insert(string(routeHostname)) + // Use compatible hostnames (intersection) instead of raw route hostnames + compatibleHostnamesByPort := route.GetCompatibleHostnamesByPort()[port] + if len(compatibleHostnamesByPort) > 0 { + for _, hostname := range compatibleHostnamesByPort { + gwListenerConfigs[port].hostnames.Insert(string(hostname)) + } + } else { + // Fallback to route hostnames if no compatible hostnames + for _, routeHostname := range route.GetHostnames() { + gwListenerConfigs[port].hostnames.Insert(string(routeHostname)) + } } } } diff --git a/pkg/gateway/model/model_build_listener_test.go b/pkg/gateway/model/model_build_listener_test.go index a5f28d251..6422f90ab 100644 --- a/pkg/gateway/model/model_build_listener_test.go +++ b/pkg/gateway/model/model_build_listener_test.go @@ -431,7 +431,7 @@ func Test_buildListenerTags(t *testing.T) { }, externalManagedTags: []string{"ExternalTag", "ManagedByTeam"}, expectedTags: nil, - expectedErr: errors.New("external managed tag key ExternalTag cannot be specified"), + expectedErr: errors.New("external managed tag key"), }, } @@ -448,6 +448,7 @@ func Test_buildListenerTags(t *testing.T) { if tt.expectedErr != nil { assert.Error(t, err) assert.Contains(t, err.Error(), tt.expectedErr.Error()) + assert.True(t, strings.Contains(err.Error(), "ExternalTag") || strings.Contains(err.Error(), "ManagedByTeam")) assert.Nil(t, got) } else { assert.NoError(t, err) diff --git a/pkg/gateway/routeutils/descriptor.go b/pkg/gateway/routeutils/descriptor.go index b9451389f..c3a1a8a90 100644 --- a/pkg/gateway/routeutils/descriptor.go +++ b/pkg/gateway/routeutils/descriptor.go @@ -2,10 +2,11 @@ package routeutils import ( "context" + "time" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" gwv1 "sigs.k8s.io/gateway-api/apis/v1" - "time" ) // routeMetadataDescriptor a common set of functions that will describe a route. @@ -21,6 +22,15 @@ type routeMetadataDescriptor interface { GetRouteListenerRuleConfigRefs() []gwv1.LocalObjectReference GetRouteGeneration() int64 GetRouteCreateTimestamp() time.Time + // GetCompatibleHostnamesByPort returns the compatible hostnames for each listener port. + // Compatible hostnames are computed during route attachment by intersecting listener hostnames + // with route hostnames (considering wildcards). The map key is the listener port number. + // When a route attaches to multiple listeners on the same port, hostnames are aggregated. + // When a route attaches to listeners on different ports, each port has its own hostname list. + GetCompatibleHostnamesByPort() map[int32][]gwv1.Hostname + // setCompatibleHostnamesByPort is a package-private method to set compatible hostnames. + // This is called by the loader after route attachment validation. + setCompatibleHostnamesByPort(map[int32][]gwv1.Hostname) } type routeLoadError struct { diff --git a/pkg/gateway/routeutils/grpc.go b/pkg/gateway/routeutils/grpc.go index 0b31666c6..a4c8cc82d 100644 --- a/pkg/gateway/routeutils/grpc.go +++ b/pkg/gateway/routeutils/grpc.go @@ -51,9 +51,10 @@ func (t *convertedGRPCRouteRule) GetListenerRuleConfig() *elbv2gw.ListenerRuleCo /* Route Description */ type grpcRouteDescription struct { - route *gwv1.GRPCRoute - rules []RouteRule - ruleAccumulator attachedRuleAccumulator[gwv1.GRPCRouteRule] + route *gwv1.GRPCRoute + rules []RouteRule + ruleAccumulator attachedRuleAccumulator[gwv1.GRPCRouteRule] + compatibleHostnamesByPort map[int32][]gwv1.Hostname } func (grpcRoute *grpcRouteDescription) loadAttachedRules(ctx context.Context, k8sClient client.Client) (RouteDescriptor, []routeLoadError) { @@ -143,6 +144,14 @@ func (grpcRoute *grpcRouteDescription) GetRouteCreateTimestamp() time.Time { return grpcRoute.route.CreationTimestamp.Time } +func (grpcRoute *grpcRouteDescription) GetCompatibleHostnamesByPort() map[int32][]gwv1.Hostname { + return grpcRoute.compatibleHostnamesByPort +} + +func (grpcRoute *grpcRouteDescription) setCompatibleHostnamesByPort(hostnamesByPort map[int32][]gwv1.Hostname) { + grpcRoute.compatibleHostnamesByPort = hostnamesByPort +} + var _ RouteDescriptor = &grpcRouteDescription{} // Can we use an indexer here to query more efficiently? diff --git a/pkg/gateway/routeutils/http.go b/pkg/gateway/routeutils/http.go index 5f748207d..306000226 100644 --- a/pkg/gateway/routeutils/http.go +++ b/pkg/gateway/routeutils/http.go @@ -51,9 +51,10 @@ func (t *convertedHTTPRouteRule) GetListenerRuleConfig() *elbv2gw.ListenerRuleCo /* Route Description */ type httpRouteDescription struct { - route *gwv1.HTTPRoute - rules []RouteRule - ruleAccumulator attachedRuleAccumulator[gwv1.HTTPRouteRule] + route *gwv1.HTTPRoute + rules []RouteRule + ruleAccumulator attachedRuleAccumulator[gwv1.HTTPRouteRule] + compatibleHostnamesByPort map[int32][]gwv1.Hostname } func (httpRoute *httpRouteDescription) GetAttachedRules() []RouteRule { @@ -136,6 +137,14 @@ func (httpRoute *httpRouteDescription) GetRouteCreateTimestamp() time.Time { return httpRoute.route.CreationTimestamp.Time } +func (httpRoute *httpRouteDescription) GetCompatibleHostnamesByPort() map[int32][]gwv1.Hostname { + return httpRoute.compatibleHostnamesByPort +} + +func (httpRoute *httpRouteDescription) setCompatibleHostnamesByPort(hostnamesByPort map[int32][]gwv1.Hostname) { + httpRoute.compatibleHostnamesByPort = hostnamesByPort +} + func convertHTTPRoute(r gwv1.HTTPRoute) *httpRouteDescription { return &httpRouteDescription{route: &r, ruleAccumulator: defaultHTTPRuleAccumulator} } diff --git a/pkg/gateway/routeutils/listener_attachment_helper.go b/pkg/gateway/routeutils/listener_attachment_helper.go index 3d6a9b9be..a9fa21ef4 100644 --- a/pkg/gateway/routeutils/listener_attachment_helper.go +++ b/pkg/gateway/routeutils/listener_attachment_helper.go @@ -15,7 +15,7 @@ import ( // 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, hostnamesFromHttpRoutes map[types.NamespacedName][]gwv1.Hostname, hostnamesFromGrpcRoutes map[types.NamespacedName][]gwv1.Hostname) (bool, *RouteData, error) + listenerAllowsAttachment(ctx context.Context, gw gwv1.Gateway, listener gwv1.Listener, route preLoadRouteDescriptor, hostnamesFromHttpRoutes map[types.NamespacedName][]gwv1.Hostname, hostnamesFromGrpcRoutes map[types.NamespacedName][]gwv1.Hostname) ([]gwv1.Hostname, bool, *RouteData, error) } var _ listenerAttachmentHelper = &listenerAttachmentHelperImpl{} @@ -35,33 +35,36 @@ func newListenerAttachmentHelper(k8sClient client.Client, logger logr.Logger) li // 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, hostnamesFromHttpRoutes map[types.NamespacedName][]gwv1.Hostname, hostnamesFromGrpcRoutes map[types.NamespacedName][]gwv1.Hostname) (bool, *RouteData, error) { +// Returns: (compatibleHostnames, allowed, failedRouteData, error) +func (attachmentHelper *listenerAttachmentHelperImpl) listenerAllowsAttachment(ctx context.Context, gw gwv1.Gateway, listener gwv1.Listener, route preLoadRouteDescriptor, hostnamesFromHttpRoutes map[types.NamespacedName][]gwv1.Hostname, hostnamesFromGrpcRoutes map[types.NamespacedName][]gwv1.Hostname) ([]gwv1.Hostname, bool, *RouteData, error) { // check namespace namespaceOK, err := attachmentHelper.namespaceCheck(ctx, gw, listener, route) if err != nil { - return false, nil, err + return nil, false, nil, err } if !namespaceOK { rd := GenerateRouteData(false, true, string(gwv1.RouteReasonNotAllowedByListeners), RouteStatusInfoRejectedMessageNamespaceNotMatch, route.GetRouteNamespacedName(), route.GetRouteKind(), route.GetRouteGeneration(), gw) - return false, &rd, nil + return nil, false, &rd, nil } // check kind kindOK := attachmentHelper.kindCheck(listener, route) if !kindOK { rd := GenerateRouteData(false, true, string(gwv1.RouteReasonNotAllowedByListeners), RouteStatusInfoRejectedMessageKindNotMatch, route.GetRouteNamespacedName(), route.GetRouteKind(), route.GetRouteGeneration(), gw) - return false, &rd, nil + return nil, false, &rd, nil } - // check hostname - if (route.GetRouteKind() == HTTPRouteKind || route.GetRouteKind() == GRPCRouteKind || route.GetRouteKind() == TLSRouteKind) && route.GetHostnames() != nil { - hostnameOK, err := attachmentHelper.hostnameCheck(listener, route) + // check hostname and get compatible hostnames + var compatibleHostnames []gwv1.Hostname + if route.GetRouteKind() == HTTPRouteKind || route.GetRouteKind() == GRPCRouteKind || route.GetRouteKind() == TLSRouteKind { + var hostnameOK bool + compatibleHostnames, hostnameOK, err = attachmentHelper.hostnameCheck(listener, route) if err != nil { - return false, nil, err + return nil, false, nil, err } if !hostnameOK { rd := GenerateRouteData(false, true, string(gwv1.RouteReasonNoMatchingListenerHostname), RouteStatusInfoRejectedMessageNoMatchingHostname, route.GetRouteNamespacedName(), route.GetRouteKind(), route.GetRouteGeneration(), gw) - return false, &rd, nil + return nil, false, &rd, nil } } @@ -71,11 +74,11 @@ func (attachmentHelper *listenerAttachmentHelperImpl) listenerAllowsAttachment(c if !hostnameUniquenessOK { message := fmt.Sprintf("HTTPRoute and GRPCRoute have overlap hostname, attachment is rejected. Conflict route: %s", conflictRoute) rd := GenerateRouteData(false, true, string(gwv1.RouteReasonNotAllowedByListeners), message, route.GetRouteNamespacedName(), route.GetRouteKind(), route.GetRouteGeneration(), gw) - return false, &rd, nil + return nil, false, &rd, nil } } - return true, nil, nil + return compatibleHostnames, true, nil, nil } // namespaceCheck namespace check implements the Gateway API spec for namespace matching between listener @@ -170,10 +173,18 @@ func (attachmentHelper *listenerAttachmentHelperImpl) kindCheck(listener gwv1.Li return true } -func (attachmentHelper *listenerAttachmentHelperImpl) hostnameCheck(listener gwv1.Listener, route preLoadRouteDescriptor) (bool, error) { - // A route can attach to listener if it does not have hostname or listener does not have hostname - if listener.Hostname == nil || len(route.GetHostnames()) == 0 { - return true, nil +func (attachmentHelper *listenerAttachmentHelperImpl) hostnameCheck(listener gwv1.Listener, route preLoadRouteDescriptor) ([]gwv1.Hostname, bool, error) { + // If route has no hostnames but listener does, use listener hostname + if route.GetHostnames() == nil || len(route.GetHostnames()) == 0 { + if listener.Hostname != nil { + return []gwv1.Hostname{*listener.Hostname}, true, nil + } + return nil, true, nil + } + + // If listener has no hostname, route can attach + if listener.Hostname == nil { + return nil, true, nil } // validate listener hostname, return if listener hostname is not valid @@ -181,13 +192,14 @@ func (attachmentHelper *listenerAttachmentHelperImpl) hostnameCheck(listener gwv if err != nil { attachmentHelper.logger.Error(err, "listener hostname is not valid", "listener", listener.Name, "hostname", *listener.Hostname) initialErrorMessage := fmt.Sprintf("listener hostname %s is not valid (listener name %s)", listener.Name, *listener.Hostname) - return false, wrapError(errors.Errorf("%s", initialErrorMessage), gwv1.GatewayReasonListenersNotValid, gwv1.RouteReasonUnsupportedValue, nil, nil) + return nil, false, wrapError(errors.Errorf("%s", initialErrorMessage), gwv1.GatewayReasonListenersNotValid, gwv1.RouteReasonUnsupportedValue, nil, nil) } if !isListenerHostnameValid { - return false, nil + return nil, false, nil } + compatibleHostnames := []gwv1.Hostname{} for _, hostname := range route.GetHostnames() { // validate route hostname, skip invalid hostname isHostnameValid, err := IsHostNameInValidFormat(string(hostname)) @@ -196,12 +208,18 @@ func (attachmentHelper *listenerAttachmentHelperImpl) hostnameCheck(listener gwv continue } - // check if two hostnames have overlap (compatible) - if isHostnameCompatible(string(hostname), string(*listener.Hostname)) { - return true, nil + // check if two hostnames have overlap (compatible) and get the more specific one + if compatible, ok := getCompatibleHostname(string(hostname), string(*listener.Hostname)); ok { + compatibleHostnames = append(compatibleHostnames, gwv1.Hostname(compatible)) } } - return false, nil + + if len(compatibleHostnames) == 0 { + return nil, false, nil + } + + // Return computed compatible hostnames without storing in route + return compatibleHostnames, true, nil } func (attachmentHelper *listenerAttachmentHelperImpl) crossServingHostnameUniquenessCheck(route preLoadRouteDescriptor, hostnamesFromHttpRoutes map[types.NamespacedName][]gwv1.Hostname, hostnamesFromGrpcRoutes map[types.NamespacedName][]gwv1.Hostname) (bool, string) { diff --git a/pkg/gateway/routeutils/listener_attachment_helper_test.go b/pkg/gateway/routeutils/listener_attachment_helper_test.go index 8f22cd31c..de9d0b960 100644 --- a/pkg/gateway/routeutils/listener_attachment_helper_test.go +++ b/pkg/gateway/routeutils/listener_attachment_helper_test.go @@ -2,6 +2,8 @@ package routeutils import ( "context" + "testing" + "github.com/go-logr/logr" "github.com/pkg/errors" "github.com/stretchr/testify/assert" @@ -9,7 +11,6 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/sets" gwv1 "sigs.k8s.io/gateway-api/apis/v1" - "testing" ) type mockNamespaceSelector struct { @@ -87,7 +88,7 @@ func Test_listenerAllowsAttachment(t *testing.T) { } hostnameFromHttpRoute := map[types.NamespacedName][]gwv1.Hostname{} hostnameFromGrpcRoute := map[types.NamespacedName][]gwv1.Hostname{} - result, statusUpdate, err := attachmentHelper.listenerAllowsAttachment(context.Background(), gw, gwv1.Listener{ + _, result, statusUpdate, err := attachmentHelper.listenerAllowsAttachment(context.Background(), gw, gwv1.Listener{ Protocol: tc.listenerProtocol, }, route, hostnameFromHttpRoute, hostnameFromGrpcRoute) assert.NoError(t, err) @@ -535,7 +536,7 @@ func Test_hostnameCheck(t *testing.T) { logger: logr.Discard(), } - result, err := helper.hostnameCheck(tt.listener, tt.route) + _, result, err := helper.hostnameCheck(tt.listener, tt.route) assert.Equal(t, tt.expectedResult, result) if tt.expectedError { @@ -547,6 +548,119 @@ func Test_hostnameCheck(t *testing.T) { } } +func Test_hostnameIntersection(t *testing.T) { + tests := []struct { + name string + listenerHostname *gwv1.Hostname + routeHostnames []gwv1.Hostname + expectedAttachment bool + expectedCompatibleHostnames []gwv1.Hostname + expectEmpty bool + }{ + { + name: "Route has nil hostnames - inherits listener hostname", + listenerHostname: ptr(gwv1.Hostname("bar.com")), + routeHostnames: nil, + expectedAttachment: true, + expectedCompatibleHostnames: []gwv1.Hostname{"bar.com"}, + }, + { + name: "Route has NO hostnames - inherits listener hostname", + listenerHostname: ptr(gwv1.Hostname("bar.com")), + routeHostnames: []gwv1.Hostname{}, + expectedAttachment: true, + expectedCompatibleHostnames: []gwv1.Hostname{"bar.com"}, + }, + { + name: "Listener has NO hostname", + listenerHostname: nil, + routeHostnames: []gwv1.Hostname{"foo.com"}, + expectedAttachment: true, + expectEmpty: true, + }, + { + name: "Both have NO hostnames", + listenerHostname: nil, + routeHostnames: []gwv1.Hostname{}, + expectedAttachment: true, + expectEmpty: true, + }, + { + name: "Exact match", + listenerHostname: ptr(gwv1.Hostname("bar.com")), + routeHostnames: []gwv1.Hostname{"bar.com"}, + expectedAttachment: true, + expectedCompatibleHostnames: []gwv1.Hostname{"bar.com"}, + }, + { + name: "Listener wildcard matches route", + listenerHostname: ptr(gwv1.Hostname("*.bar.com")), + routeHostnames: []gwv1.Hostname{"foo.bar.com"}, + expectedAttachment: true, + expectedCompatibleHostnames: []gwv1.Hostname{"foo.bar.com"}, + }, + { + name: "Route wildcard matches listener", + listenerHostname: ptr(gwv1.Hostname("foo.bar.com")), + routeHostnames: []gwv1.Hostname{"*.bar.com"}, + expectedAttachment: true, + expectedCompatibleHostnames: []gwv1.Hostname{"foo.bar.com"}, + }, + { + name: "Both wildcards, compatible", + listenerHostname: ptr(gwv1.Hostname("*.bar.com")), + routeHostnames: []gwv1.Hostname{"*.bar.com"}, + expectedAttachment: true, + expectedCompatibleHostnames: []gwv1.Hostname{"*.bar.com"}, + }, + { + name: "No overlap - rejected", + listenerHostname: ptr(gwv1.Hostname("bar.com")), + routeHostnames: []gwv1.Hostname{"foo.com"}, + expectedAttachment: false, + expectEmpty: true, + }, + { + name: "Multiple route hostnames, partial match", + listenerHostname: ptr(gwv1.Hostname("*.bar.com")), + routeHostnames: []gwv1.Hostname{"foo.bar.com", "baz.bar.com", "unrelated.com"}, + expectedAttachment: true, + expectedCompatibleHostnames: []gwv1.Hostname{"foo.bar.com", "baz.bar.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + helper := &listenerAttachmentHelperImpl{ + logger: logr.Discard(), + } + + listener := gwv1.Listener{ + Hostname: tt.listenerHostname, + } + + route := &mockRoute{ + hostnames: tt.routeHostnames, + } + + compatibleHostnames, result, err := helper.hostnameCheck(listener, route) + + assert.NoError(t, err) + assert.Equal(t, tt.expectedAttachment, result) + + if tt.expectEmpty { + assert.Empty(t, compatibleHostnames) + } else { + assert.Equal(t, tt.expectedCompatibleHostnames, compatibleHostnames) + } + }) + } +} + +func ptr[T any](v T) *T { + return &v +} + func Test_crossServingHostnameUniquenessCheck(t *testing.T) { hostnames := []gwv1.Hostname{"example.com"} namespace := "test-namespace" diff --git a/pkg/gateway/routeutils/loader.go b/pkg/gateway/routeutils/loader.go index 8072e4cf4..561e87c3e 100644 --- a/pkg/gateway/routeutils/loader.go +++ b/pkg/gateway/routeutils/loader.go @@ -6,6 +6,7 @@ import ( "github.com/go-logr/logr" "github.com/pkg/errors" + "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" @@ -117,7 +118,7 @@ func (l *loaderImpl) LoadRoutesForGateway(ctx context.Context, gw gwv1.Gateway, // 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, statusUpdates, err := l.mapper.mapGatewayAndRoutes(ctx, gw, loadedRoutes) + mappedRoutes, compatibleHostnamesByPort, statusUpdates, err := l.mapper.mapGatewayAndRoutes(ctx, gw, loadedRoutes) routeStatusUpdates = append(routeStatusUpdates, statusUpdates...) @@ -129,7 +130,7 @@ func (l *loaderImpl) LoadRoutesForGateway(ctx context.Context, gw gwv1.Gateway, attachedRouteMap := buildAttachedRouteMap(gw, mappedRoutes) // 3. Load the underlying resource(s) for each route that is configured. - loadedRoute, childRouteLoadUpdates, err := l.loadChildResources(ctx, mappedRoutes, gw) + loadedRoute, childRouteLoadUpdates, err := l.loadChildResources(ctx, mappedRoutes, compatibleHostnamesByPort, gw) routeStatusUpdates = append(routeStatusUpdates, childRouteLoadUpdates...) if err != nil { return nil, err @@ -149,7 +150,7 @@ func (l *loaderImpl) LoadRoutesForGateway(ctx context.Context, gw gwv1.Gateway, } // loadChildResources responsible for loading all resources that a route descriptor references. -func (l *loaderImpl) loadChildResources(ctx context.Context, preloadedRoutes map[int][]preLoadRouteDescriptor, gw gwv1.Gateway) (map[int32][]RouteDescriptor, []RouteData, error) { +func (l *loaderImpl) loadChildResources(ctx context.Context, preloadedRoutes map[int][]preLoadRouteDescriptor, compatibleHostnamesByPort map[int32]map[types.NamespacedName][]gwv1.Hostname, gw gwv1.Gateway) (map[int32][]RouteDescriptor, []RouteData, error) { // Cache to reduce duplicate route lookups. // Kind -> [NamespacedName:Previously Loaded Descriptor] resourceCache := make(map[string]RouteDescriptor) @@ -180,11 +181,26 @@ func (l *loaderImpl) loadChildResources(ctx context.Context, preloadedRoutes map } } } + loadedRouteData[int32(port)] = append(loadedRouteData[int32(port)], generatedRoute) resourceCache[cacheKey] = generatedRoute } } + // Set compatible hostnames by port for all routes + for _, route := range resourceCache { + hostnamesByPort := make(map[int32][]gwv1.Hostname) + routeKey := route.GetRouteNamespacedName() + for port, compatibleHostnames := range compatibleHostnamesByPort { + if hostnames, exists := compatibleHostnames[routeKey]; exists { + hostnamesByPort[port] = hostnames + } + } + if len(hostnamesByPort) > 0 { + route.setCompatibleHostnamesByPort(hostnamesByPort) + } + } + return loadedRouteData, failedRoutes, nil } diff --git a/pkg/gateway/routeutils/loader_test.go b/pkg/gateway/routeutils/loader_test.go index 6c391b83b..c2be5f4f5 100644 --- a/pkg/gateway/routeutils/loader_test.go +++ b/pkg/gateway/routeutils/loader_test.go @@ -22,18 +22,27 @@ type mockMapper struct { routeStatusUpdates []RouteData } -func (m *mockMapper) mapGatewayAndRoutes(context context.Context, gw gwv1.Gateway, routes []preLoadRouteDescriptor) (map[int][]preLoadRouteDescriptor, []RouteData, error) { +func (m *mockMapper) mapGatewayAndRoutes(context context.Context, gw gwv1.Gateway, routes []preLoadRouteDescriptor) (map[int][]preLoadRouteDescriptor, map[int32]map[types.NamespacedName][]gwv1.Hostname, []RouteData, error) { assert.ElementsMatch(m.t, m.expectedRoutes, routes) - return m.mapToReturn, m.routeStatusUpdates, nil + return m.mapToReturn, make(map[int32]map[types.NamespacedName][]gwv1.Hostname), m.routeStatusUpdates, nil } var _ RouteDescriptor = &mockRoute{} type mockRoute struct { - namespacedName types.NamespacedName - routeKind RouteKind - generation int64 - hostnames []gwv1.Hostname + namespacedName types.NamespacedName + routeKind RouteKind + generation int64 + hostnames []gwv1.Hostname + CompatibleHostnamesByPort map[int32][]gwv1.Hostname +} + +func (m *mockRoute) GetCompatibleHostnamesByPort() map[int32][]gwv1.Hostname { + return m.CompatibleHostnamesByPort +} + +func (m *mockRoute) setCompatibleHostnamesByPort(hostnamesByPort map[int32][]gwv1.Hostname) { + m.CompatibleHostnamesByPort = hostnamesByPort } func (m *mockRoute) loadAttachedRules(context context.Context, k8sClient client.Client) (RouteDescriptor, []routeLoadError) { diff --git a/pkg/gateway/routeutils/mock_route.go b/pkg/gateway/routeutils/mock_route.go index 3fa26a2d3..6908d0b80 100644 --- a/pkg/gateway/routeutils/mock_route.go +++ b/pkg/gateway/routeutils/mock_route.go @@ -28,12 +28,13 @@ func (m *MockRule) GetListenerRuleConfig() *elbv2gw.ListenerRuleConfiguration { var _ RouteRule = &MockRule{} type MockRoute struct { - Kind RouteKind - Name string - Namespace string - Hostnames []string - CreationTime time.Time - Rules []RouteRule + Kind RouteKind + Name string + Namespace string + Hostnames []string + CreationTime time.Time + Rules []RouteRule + CompatibleHostnamesByPort map[int32][]gwv1.Hostname } func (m *MockRoute) GetBackendRefs() []gwv1.BackendRef { @@ -88,4 +89,12 @@ func (m *MockRoute) GetRouteCreateTimestamp() time.Time { return m.CreationTime } +func (m *MockRoute) GetCompatibleHostnamesByPort() map[int32][]gwv1.Hostname { + return m.CompatibleHostnamesByPort +} + +func (m *MockRoute) setCompatibleHostnamesByPort(hostnamesByPort map[int32][]gwv1.Hostname) { + m.CompatibleHostnamesByPort = hostnamesByPort +} + var _ RouteDescriptor = &MockRoute{} diff --git a/pkg/gateway/routeutils/route_listener_mapper.go b/pkg/gateway/routeutils/route_listener_mapper.go index 0e1fce5ff..40262d15c 100644 --- a/pkg/gateway/routeutils/route_listener_mapper.go +++ b/pkg/gateway/routeutils/route_listener_mapper.go @@ -2,6 +2,7 @@ package routeutils import ( "context" + "github.com/go-logr/logr" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -11,7 +12,7 @@ import ( // 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 the route is discarded. type listenerToRouteMapper interface { - mapGatewayAndRoutes(context context.Context, gw gwv1.Gateway, routes []preLoadRouteDescriptor) (map[int][]preLoadRouteDescriptor, []RouteData, error) + mapGatewayAndRoutes(context context.Context, gw gwv1.Gateway, routes []preLoadRouteDescriptor) (map[int][]preLoadRouteDescriptor, map[int32]map[types.NamespacedName][]gwv1.Hostname, []RouteData, error) } var _ listenerToRouteMapper = &listenerToRouteMapperImpl{} @@ -31,8 +32,10 @@ func newListenerToRouteMapper(k8sClient client.Client, logger logr.Logger) liste } // 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, []RouteData, error) { +// Returns: (routesByPort, compatibleHostnamesByPort, failedRoutes, error) +func (ltr *listenerToRouteMapperImpl) mapGatewayAndRoutes(ctx context.Context, gw gwv1.Gateway, routes []preLoadRouteDescriptor) (map[int][]preLoadRouteDescriptor, map[int32]map[types.NamespacedName][]gwv1.Hostname, []RouteData, error) { result := make(map[int][]preLoadRouteDescriptor) + compatibleHostnamesByPort := make(map[int32]map[types.NamespacedName][]gwv1.Hostname) failedRoutes := make([]RouteData, 0) // First filter out any routes that are not intended for this Gateway. @@ -58,9 +61,9 @@ func (ltr *listenerToRouteMapperImpl) mapGatewayAndRoutes(ctx context.Context, g continue } - allowedAttachment, failedRouteData, err := ltr.listenerAttachmentHelper.listenerAllowsAttachment(ctx, gw, listener, route, hostnamesFromHttpRoutes, hostnamesFromGrpcRoutes) + compatibleHostnames, allowedAttachment, failedRouteData, err := ltr.listenerAttachmentHelper.listenerAllowsAttachment(ctx, gw, listener, route, hostnamesFromHttpRoutes, hostnamesFromGrpcRoutes) if err != nil { - return nil, failedRoutes, err + return nil, nil, failedRoutes, err } if failedRouteData != nil { @@ -70,9 +73,18 @@ func (ltr *listenerToRouteMapperImpl) mapGatewayAndRoutes(ctx context.Context, g ltr.logger.V(1).Info("listener allows attachment", "route", route.GetRouteNamespacedName(), "allowedAttachment", allowedAttachment) if allowedAttachment { - result[int(listener.Port)] = append(result[int(listener.Port)], route) + port := int32(listener.Port) + result[int(port)] = append(result[int(port)], route) + + // Store compatible hostnames per port per route + if compatibleHostnamesByPort[port] == nil { + compatibleHostnamesByPort[port] = make(map[types.NamespacedName][]gwv1.Hostname) + } + // Append hostnames for routes that attach to multiple listeners on the same port + routeKey := route.GetRouteNamespacedName() + compatibleHostnamesByPort[port][routeKey] = append(compatibleHostnamesByPort[port][routeKey], compatibleHostnames...) } } } - return result, failedRoutes, nil + return result, compatibleHostnamesByPort, failedRoutes, nil } diff --git a/pkg/gateway/routeutils/route_listener_mapper_test.go b/pkg/gateway/routeutils/route_listener_mapper_test.go index 0f7bf62d1..f14f31178 100644 --- a/pkg/gateway/routeutils/route_listener_mapper_test.go +++ b/pkg/gateway/routeutils/route_listener_mapper_test.go @@ -20,9 +20,9 @@ func makeListenerAttachmentMapKey(listener gwv1.Listener, route preLoadRouteDesc 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, hostnamesFromHttpRoutes map[types.NamespacedName][]gwv1.Hostname, hostnamesFromGrpcRoutes map[types.NamespacedName][]gwv1.Hostname) (bool, *RouteData, error) { +func (m *mockListenerAttachmentHelper) listenerAllowsAttachment(ctx context.Context, gw gwv1.Gateway, listener gwv1.Listener, route preLoadRouteDescriptor, hostnamesFromHttpRoutes map[types.NamespacedName][]gwv1.Hostname, hostnamesFromGrpcRoutes map[types.NamespacedName][]gwv1.Hostname) ([]gwv1.Hostname, bool, *RouteData, error) { k := makeListenerAttachmentMapKey(listener, route) - return m.attachmentMap[k], nil, nil + return nil, m.attachmentMap[k], nil, nil } type mockRouteAttachmentHelper struct { @@ -313,7 +313,7 @@ func Test_mapGatewayAndRoutes(t *testing.T) { }, logger: logr.Discard(), } - result, statusUpdates, err := mapper.mapGatewayAndRoutes(context.Background(), tc.gw, tc.routes) + result, _, statusUpdates, err := mapper.mapGatewayAndRoutes(context.Background(), tc.gw, tc.routes) if tc.expectErr { assert.Error(t, err) diff --git a/pkg/gateway/routeutils/route_rule_precedence.go b/pkg/gateway/routeutils/route_rule_precedence.go index 1d08b943e..48dd6ad0d 100644 --- a/pkg/gateway/routeutils/route_rule_precedence.go +++ b/pkg/gateway/routeutils/route_rule_precedence.go @@ -2,10 +2,11 @@ package routeutils import ( "math" - v1 "sigs.k8s.io/gateway-api/apis/v1" "sort" "strings" "time" + + v1 "sigs.k8s.io/gateway-api/apis/v1" ) type RulePrecedence struct { @@ -45,13 +46,13 @@ type CommonRulePrecedence struct { RouteCreateTimestamp time.Time } -func SortAllRulesByPrecedence(routes []RouteDescriptor) []RulePrecedence { +func SortAllRulesByPrecedence(routes []RouteDescriptor, port int32) []RulePrecedence { var allRoutes []RulePrecedence var httpRoutes []RulePrecedence var grpcRoutes []RulePrecedence for _, route := range routes { - routeInfo := getCommonRouteInfo(route) + routeInfo := getCommonRouteInfo(route, port) for ruleIndex, rule := range route.GetAttachedRules() { rawRule := rule.GetRawRouteRule() switch r := rawRule.(type) { @@ -257,13 +258,20 @@ func compareCommonTieBreakers(ruleOne RulePrecedence, ruleTwo RulePrecedence) bo return ruleOne.CommonRulePrecedence.MatchIndexInRule < ruleTwo.CommonRulePrecedence.MatchIndexInRule } -func getCommonRouteInfo(route RouteDescriptor) CommonRulePrecedence { +func getCommonRouteInfo(route RouteDescriptor, port int32) CommonRulePrecedence { routeNamespacedName := route.GetRouteNamespacedName().String() routeCreateTimestamp := route.GetRouteCreateTimestamp() - // get hostname in string array format - hostnames := make([]string, len(route.GetHostnames())) - for i, hostname := range route.GetHostnames() { - hostnames[i] = string(hostname) + // Use compatible hostnames computed during route attachment + compatibleHostnamesByPort := route.GetCompatibleHostnamesByPort()[port] + hostnames := make([]string, 0) + for _, h := range compatibleHostnamesByPort { + hostnames = append(hostnames, string(h)) + } + // If no compatible hostnames, use route hostnames + if len(hostnames) == 0 { + for _, h := range route.GetHostnames() { + hostnames = append(hostnames, string(h)) + } } return CommonRulePrecedence{ RouteDescriptor: route, diff --git a/pkg/gateway/routeutils/route_rule_precedence_test.go b/pkg/gateway/routeutils/route_rule_precedence_test.go index 2cc62cd9b..9f137e023 100644 --- a/pkg/gateway/routeutils/route_rule_precedence_test.go +++ b/pkg/gateway/routeutils/route_rule_precedence_test.go @@ -1,13 +1,14 @@ package routeutils import ( + "math" + "testing" + "time" + awssdk "github.com/aws/aws-sdk-go-v2/aws" "github.com/stretchr/testify/assert" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "math" gwv1 "sigs.k8s.io/gateway-api/apis/v1" - "testing" - "time" ) var ( @@ -154,6 +155,7 @@ func Test_SortAllRulesByPrecedence(t *testing.T) { name string input []RouteDescriptor output []RulePrecedence + port int32 }{ { name: "no routes", @@ -396,7 +398,7 @@ func Test_SortAllRulesByPrecedence(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - result := SortAllRulesByPrecedence(tc.input) + result := SortAllRulesByPrecedence(tc.input, tc.port) assert.Equal(t, tc.output, result) }) } diff --git a/pkg/gateway/routeutils/tcp.go b/pkg/gateway/routeutils/tcp.go index b054fc7e3..cfe7743a2 100644 --- a/pkg/gateway/routeutils/tcp.go +++ b/pkg/gateway/routeutils/tcp.go @@ -52,9 +52,10 @@ func (t *convertedTCPRouteRule) GetListenerRuleConfig() *elbv2gw.ListenerRuleCon /* Route Description */ type tcpRouteDescription struct { - route *gwalpha2.TCPRoute - rules []RouteRule - ruleAccumulator attachedRuleAccumulator[gwalpha2.TCPRouteRule] + route *gwalpha2.TCPRoute + rules []RouteRule + ruleAccumulator attachedRuleAccumulator[gwalpha2.TCPRouteRule] + compatibleHostnamesByPort map[int32][]gwv1.Hostname } func (tcpRoute *tcpRouteDescription) GetAttachedRules() []RouteRule { @@ -119,6 +120,14 @@ func (tcpRoute *tcpRouteDescription) GetRouteCreateTimestamp() time.Time { return tcpRoute.route.CreationTimestamp.Time } +func (tcpRoute *tcpRouteDescription) GetCompatibleHostnamesByPort() map[int32][]gwv1.Hostname { + return tcpRoute.compatibleHostnamesByPort +} + +func (tcpRoute *tcpRouteDescription) setCompatibleHostnamesByPort(hostnamesByPort map[int32][]gwv1.Hostname) { + tcpRoute.compatibleHostnamesByPort = hostnamesByPort +} + var _ RouteDescriptor = &tcpRouteDescription{} // Can we use an indexer here to query more efficiently? diff --git a/pkg/gateway/routeutils/tls.go b/pkg/gateway/routeutils/tls.go index 670bddca3..5e6e7ec03 100644 --- a/pkg/gateway/routeutils/tls.go +++ b/pkg/gateway/routeutils/tls.go @@ -52,9 +52,10 @@ func (t *convertedTLSRouteRule) GetListenerRuleConfig() *elbv2gw.ListenerRuleCon /* Route Description */ type tlsRouteDescription struct { - route *gwalpha2.TLSRoute - rules []RouteRule - ruleAccumulator attachedRuleAccumulator[gwalpha2.TLSRouteRule] + route *gwalpha2.TLSRoute + rules []RouteRule + ruleAccumulator attachedRuleAccumulator[gwalpha2.TLSRouteRule] + compatibleHostnamesByPort map[int32][]gwv1.Hostname } func (tlsRoute *tlsRouteDescription) GetAttachedRules() []RouteRule { @@ -119,6 +120,14 @@ func (tlsRoute *tlsRouteDescription) GetRouteCreateTimestamp() time.Time { return tlsRoute.route.CreationTimestamp.Time } +func (tlsRoute *tlsRouteDescription) GetCompatibleHostnamesByPort() map[int32][]gwv1.Hostname { + return tlsRoute.compatibleHostnamesByPort +} + +func (tlsRoute *tlsRouteDescription) setCompatibleHostnamesByPort(hostnamesByPort map[int32][]gwv1.Hostname) { + tlsRoute.compatibleHostnamesByPort = hostnamesByPort +} + var _ RouteDescriptor = &tlsRouteDescription{} func ListTLSRoutes(context context.Context, k8sClient client.Client, opts ...client.ListOption) ([]preLoadRouteDescriptor, error) { diff --git a/pkg/gateway/routeutils/udp.go b/pkg/gateway/routeutils/udp.go index cd07f38c7..7dc39715c 100644 --- a/pkg/gateway/routeutils/udp.go +++ b/pkg/gateway/routeutils/udp.go @@ -52,9 +52,10 @@ func (t *convertedUDPRouteRule) GetListenerRuleConfig() *elbv2gw.ListenerRuleCon /* Route Description */ type udpRouteDescription struct { - route *gwalpha2.UDPRoute - rules []RouteRule - ruleAccumulator attachedRuleAccumulator[gwalpha2.UDPRouteRule] + route *gwalpha2.UDPRoute + rules []RouteRule + ruleAccumulator attachedRuleAccumulator[gwalpha2.UDPRouteRule] + compatibleHostnamesByPort map[int32][]gwv1.Hostname } func (udpRoute *udpRouteDescription) GetAttachedRules() []RouteRule { @@ -118,6 +119,14 @@ func (udpRoute *udpRouteDescription) GetRouteCreateTimestamp() time.Time { return udpRoute.route.CreationTimestamp.Time } +func (udpRoute *udpRouteDescription) GetCompatibleHostnamesByPort() map[int32][]gwv1.Hostname { + return udpRoute.compatibleHostnamesByPort +} + +func (udpRoute *udpRouteDescription) setCompatibleHostnamesByPort(hostnamesByPort map[int32][]gwv1.Hostname) { + udpRoute.compatibleHostnamesByPort = hostnamesByPort +} + var _ RouteDescriptor = &udpRouteDescription{} func ListUDPRoutes(context context.Context, k8sClient client.Client, opts ...client.ListOption) ([]preLoadRouteDescriptor, error) { diff --git a/pkg/gateway/routeutils/utils.go b/pkg/gateway/routeutils/utils.go index bf174aaa1..7a148d558 100644 --- a/pkg/gateway/routeutils/utils.go +++ b/pkg/gateway/routeutils/utils.go @@ -3,11 +3,12 @@ package routeutils import ( "context" "fmt" + "net" + "strings" + corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" - "net" "sigs.k8s.io/controller-runtime/pkg/client" - "strings" ) // ListL4Routes retrieves all Layer 4 routes (TCP, UDP, TLS) from the cluster. @@ -122,20 +123,35 @@ func IsHostNameInValidFormat(hostName string) (bool, error) { // isHostnameCompatible checks if given two hostnames are compatible with each other // this function is used to check if listener hostname and Route hostname match func isHostnameCompatible(hostnameOne, hostnameTwo string) bool { + _, isCompatible := getCompatibleHostname(hostnameOne, hostnameTwo) + return isCompatible +} + +// getCompatibleHostname returns the more specific hostname if two hostnames are compatible. +// Two hostnames are compatible if: +// 1. They are exactly the same (e.g., "example.com" and "example.com") +// 2. One is a wildcard that matches the other (e.g., "*.example.com" matches "api.example.com") +// +// When compatible, returns the more specific hostname (non-wildcard) and true. +// When incompatible (e.g., "api.example.com" vs "web.example.com"), returns empty string and false. +// This is used to match Gateway listener hostnames with Route hostnames for traffic routing. +func getCompatibleHostname(hostnameOne, hostnameTwo string) (string, bool) { // exact match if hostnameOne == hostnameTwo { - return true + return hostnameOne, true } - // suffix match - hostnameOne is a wildcard + // hostnameOne is wildcard, hostnameTwo matches - return hostnameTwo (more specific) if strings.HasPrefix(hostnameOne, "*.") && strings.HasSuffix(hostnameTwo, hostnameOne[1:]) { - return true + return hostnameTwo, true } - // suffix match - hostnameTwo is a wildcard + + // hostnameTwo is wildcard, hostnameOne matches - return hostnameOne (more specific) if strings.HasPrefix(hostnameTwo, "*.") && strings.HasSuffix(hostnameOne, hostnameTwo[1:]) { - return true + return hostnameOne, true } - return false + + return "", false } func generateInvalidMessageWithRouteDetails(initialMessage string, routeKind RouteKind, routeIdentifier types.NamespacedName) string { diff --git a/pkg/gateway/routeutils/utils_test.go b/pkg/gateway/routeutils/utils_test.go index c875dbcae..e63187f24 100644 --- a/pkg/gateway/routeutils/utils_test.go +++ b/pkg/gateway/routeutils/utils_test.go @@ -3,13 +3,14 @@ package routeutils import ( "context" "fmt" - corev1 "k8s.io/api/core/v1" - "sigs.k8s.io/aws-load-balancer-controller/pkg/testutils" - gwalpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" "strings" "testing" "time" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/aws-load-balancer-controller/pkg/testutils" + gwalpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + "github.com/golang/mock/gomock" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -26,6 +27,7 @@ type mockPreLoadRouteDescriptor struct { backendRefs []gwv1.BackendRef listenerRuleConfigurations []gwv1.LocalObjectReference namespacedName types.NamespacedName + compatibleHostnames []gwv1.Hostname } func (m mockPreLoadRouteDescriptor) GetAttachedRules() []RouteRule { @@ -75,6 +77,16 @@ func (m mockPreLoadRouteDescriptor) GetRouteCreateTimestamp() time.Time { panic("implement me") } +func (m mockPreLoadRouteDescriptor) GetCompatibleHostnamesByPort() map[int32][]gwv1.Hostname { + return map[int32][]gwv1.Hostname{80: m.compatibleHostnames} +} + +func (m mockPreLoadRouteDescriptor) setCompatibleHostnamesByPort(hostnamesByPort map[int32][]gwv1.Hostname) { + if hostnamesByPort[80] != nil { + m.compatibleHostnames = hostnamesByPort[80] + } +} + func (m mockPreLoadRouteDescriptor) loadAttachedRules(context context.Context, k8sClient client.Client) (RouteDescriptor, []routeLoadError) { //TODO implement me panic("implement me") @@ -557,18 +569,6 @@ func Test_isHostnameCompatible(t *testing.T) { hostnameTwo: "test.sub.example.com", want: true, }, - { - name: "empty hostnames", - hostnameOne: "", - hostnameTwo: "", - want: true, - }, - { - name: "one empty hostname", - hostnameOne: "example.com", - hostnameTwo: "", - want: false, - }, { name: "wildcard with root domain", hostnameOne: "*.example.com", @@ -610,3 +610,81 @@ func Test_isHostnameCompatible(t *testing.T) { }) } } + +func Test_getCompatibleHostname(t *testing.T) { + tests := []struct { + name string + hostnameOne string + hostnameTwo string + wantHostname string + wantOk bool + }{ + { + name: "exact match", + hostnameOne: "example.com", + hostnameTwo: "example.com", + wantHostname: "example.com", + wantOk: true, + }, + { + name: "wildcard in first, specific in second", + hostnameOne: "*.example.com", + hostnameTwo: "api.example.com", + wantHostname: "api.example.com", + wantOk: true, + }, + { + name: "wildcard in second, specific in first", + hostnameOne: "api.example.com", + hostnameTwo: "*.example.com", + wantHostname: "api.example.com", + wantOk: true, + }, + { + name: "incompatible hostnames", + hostnameOne: "example.com", + hostnameTwo: "different.com", + wantHostname: "", + wantOk: false, + }, + { + name: "wildcard does not match", + hostnameOne: "*.example.com", + hostnameTwo: "api.different.com", + wantHostname: "", + wantOk: false, + }, + { + name: "both wildcards same domain", + hostnameOne: "*.example.com", + hostnameTwo: "*.example.com", + wantHostname: "*.example.com", + wantOk: true, + }, + { + name: "both wildcards different domains", + hostnameOne: "*.example.com", + hostnameTwo: "*.different.com", + wantHostname: "", + wantOk: false, + }, + { + name: "nested subdomain with wildcard", + hostnameOne: "*.example.com", + hostnameTwo: "sub.api.example.com", + wantHostname: "sub.api.example.com", + wantOk: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotHostname, gotOk := getCompatibleHostname(tt.hostnameOne, tt.hostnameTwo) + if gotHostname != tt.wantHostname { + t.Errorf("getCompatibleHostname() hostname = %v, want %v", gotHostname, tt.wantHostname) + } + if gotOk != tt.wantOk { + t.Errorf("getCompatibleHostname() ok = %v, want %v", gotOk, tt.wantOk) + } + }) + } +}