diff --git a/integration/k8s_conformance_test.go b/integration/k8s_conformance_test.go index 6c6d99ee1..3ba562b2c 100644 --- a/integration/k8s_conformance_test.go +++ b/integration/k8s_conformance_test.go @@ -199,19 +199,10 @@ func (s *K8sConformanceSuite) TestK8sGatewayAPIConformance() { RunTest: *k8sConformanceRunTest, // Until the feature are all supported, following tests are skipped. SkipTests: []string{ - tests.GatewayClassObservedGenerationBump.ShortName, - tests.GatewayWithAttachedRoutes.ShortName, - tests.GatewayModifyListeners.ShortName, - tests.GatewayInvalidTLSConfiguration.ShortName, - tests.HTTPRouteHostnameIntersection.ShortName, tests.HTTPRouteListenerHostnameMatching.ShortName, - tests.HTTPRouteInvalidReferenceGrant.ShortName, tests.HTTPRouteInvalidCrossNamespaceParentRef.ShortName, - tests.HTTPRouteInvalidParentRefNotMatchingSectionName.ShortName, - tests.HTTPRouteInvalidCrossNamespaceBackendRef.ShortName, tests.HTTPRouteMatchingAcrossRoutes.ShortName, tests.HTTPRoutePartiallyInvalidViaInvalidReferenceGrant.ShortName, - tests.HTTPRouteRedirectHostAndStatus.ShortName, tests.HTTPRoutePathMatchOrder.ShortName, tests.HTTPRouteHeaderMatching.ShortName, tests.HTTPRouteReferenceGrant.ShortName, diff --git a/pkg/provider/kubernetes/gateway/client.go b/pkg/provider/kubernetes/gateway/client.go index 06809147c..7c2756886 100644 --- a/pkg/provider/kubernetes/gateway/client.go +++ b/pkg/provider/kubernetes/gateway/client.go @@ -32,11 +32,11 @@ type resourceEventHandler struct { ev chan<- interface{} } -func (reh *resourceEventHandler) OnAdd(obj interface{}, isInInitialList bool) { +func (reh *resourceEventHandler) OnAdd(obj interface{}, _ bool) { eventHandlerFunc(reh.ev, obj) } -func (reh *resourceEventHandler) OnUpdate(oldObj, newObj interface{}) { +func (reh *resourceEventHandler) OnUpdate(_, newObj interface{}) { eventHandlerFunc(reh.ev, newObj) } @@ -49,19 +49,21 @@ func (reh *resourceEventHandler) OnDelete(obj interface{}) { // The stores can then be accessed via the Get* functions. type Client interface { WatchAll(namespaces []string, stopCh <-chan struct{}) (<-chan interface{}, error) - GetGatewayClasses() ([]*gatev1.GatewayClass, error) UpdateGatewayStatus(gateway *gatev1.Gateway, gatewayStatus gatev1.GatewayStatus) error UpdateGatewayClassStatus(gatewayClass *gatev1.GatewayClass, condition metav1.Condition) error - UpdateHTTPRouteStatus(ctx context.Context, gateway *gatev1.Gateway, nsName ktypes.NamespacedName, status gatev1.HTTPRouteStatus) error - GetGateways() []*gatev1.Gateway - GetHTTPRoutes(namespaces []string) ([]*gatev1.HTTPRoute, error) - GetTCPRoutes(namespaces []string) ([]*gatev1alpha2.TCPRoute, error) - GetTLSRoutes(namespaces []string) ([]*gatev1alpha2.TLSRoute, error) - GetReferenceGrants(namespace string) ([]*gatev1beta1.ReferenceGrant, error) + UpdateHTTPRouteStatus(ctx context.Context, route ktypes.NamespacedName, status gatev1.HTTPRouteStatus) error + UpdateTCPRouteStatus(ctx context.Context, route ktypes.NamespacedName, status gatev1alpha2.TCPRouteStatus) error + UpdateTLSRouteStatus(ctx context.Context, route ktypes.NamespacedName, status gatev1alpha2.TLSRouteStatus) error + ListGatewayClasses() ([]*gatev1.GatewayClass, error) + ListGateways() []*gatev1.Gateway + ListHTTPRoutes() ([]*gatev1.HTTPRoute, error) + ListTCPRoutes() ([]*gatev1alpha2.TCPRoute, error) + ListTLSRoutes() ([]*gatev1alpha2.TLSRoute, error) + ListNamespaces(selector labels.Selector) ([]string, error) + ListReferenceGrants(namespace string) ([]*gatev1beta1.ReferenceGrant, error) GetService(namespace, name string) (*corev1.Service, bool, error) GetSecret(namespace, name string) (*corev1.Secret, bool, error) GetEndpoints(namespace, name string) (*corev1.Endpoints, bool, error) - GetNamespaces(selector labels.Selector) ([]string, error) } type clientWrapper struct { @@ -280,7 +282,7 @@ func (c *clientWrapper) WatchAll(namespaces []string, stopCh <-chan struct{}) (< return eventCh, nil } -func (c *clientWrapper) GetNamespaces(selector labels.Selector) ([]string, error) { +func (c *clientWrapper) ListNamespaces(selector labels.Selector) ([]string, error) { ns, err := c.factoryNamespace.Core().V1().Namespaces().Lister().List(selector) if err != nil { return nil, err @@ -297,22 +299,12 @@ func (c *clientWrapper) GetNamespaces(selector labels.Selector) ([]string, error return namespaces, nil } -func (c *clientWrapper) GetHTTPRoutes(namespaces []string) ([]*gatev1.HTTPRoute, error) { +func (c *clientWrapper) ListHTTPRoutes() ([]*gatev1.HTTPRoute, error) { var httpRoutes []*gatev1.HTTPRoute - for _, namespace := range namespaces { - if !c.isWatchedNamespace(namespace) { - log.Warn().Msgf("Failed to get HTTPRoutes: %q is not within watched namespaces", namespace) - continue - } - + for _, namespace := range c.watchedNamespaces { routes, err := c.factoriesGateway[c.lookupNamespace(namespace)].Gateway().V1().HTTPRoutes().Lister().HTTPRoutes(namespace).List(labels.Everything()) if err != nil { - return nil, err - } - - if len(routes) == 0 { - log.Debug().Msgf("No HTTPRoutes found in namespace %q", namespace) - continue + return nil, fmt.Errorf("listing HTTP routes in namespace %s", namespace) } httpRoutes = append(httpRoutes, routes...) @@ -321,53 +313,35 @@ func (c *clientWrapper) GetHTTPRoutes(namespaces []string) ([]*gatev1.HTTPRoute, return httpRoutes, nil } -func (c *clientWrapper) GetTCPRoutes(namespaces []string) ([]*gatev1alpha2.TCPRoute, error) { +func (c *clientWrapper) ListTCPRoutes() ([]*gatev1alpha2.TCPRoute, error) { var tcpRoutes []*gatev1alpha2.TCPRoute - for _, namespace := range namespaces { - if !c.isWatchedNamespace(namespace) { - log.Warn().Msgf("Failed to get TCPRoutes: %q is not within watched namespaces", namespace) - continue - } - + for _, namespace := range c.watchedNamespaces { routes, err := c.factoriesGateway[c.lookupNamespace(namespace)].Gateway().V1alpha2().TCPRoutes().Lister().TCPRoutes(namespace).List(labels.Everything()) if err != nil { - return nil, err - } - - if len(routes) == 0 { - log.Debug().Msgf("No TCPRoutes found in namespace %q", namespace) - continue + return nil, fmt.Errorf("listing TCP routes in namespace %s", namespace) } tcpRoutes = append(tcpRoutes, routes...) } + return tcpRoutes, nil } -func (c *clientWrapper) GetTLSRoutes(namespaces []string) ([]*gatev1alpha2.TLSRoute, error) { +func (c *clientWrapper) ListTLSRoutes() ([]*gatev1alpha2.TLSRoute, error) { var tlsRoutes []*gatev1alpha2.TLSRoute - for _, namespace := range namespaces { - if !c.isWatchedNamespace(namespace) { - log.Warn().Msgf("Failed to get TLSRoutes: %q is not within watched namespaces", namespace) - continue - } - + for _, namespace := range c.watchedNamespaces { routes, err := c.factoriesGateway[c.lookupNamespace(namespace)].Gateway().V1alpha2().TLSRoutes().Lister().TLSRoutes(namespace).List(labels.Everything()) if err != nil { - return nil, err - } - - if len(routes) == 0 { - log.Debug().Msgf("No TLSRoutes found in namespace %q", namespace) - continue + return nil, fmt.Errorf("listing TLS routes in namespace %s", namespace) } tlsRoutes = append(tlsRoutes, routes...) } + return tlsRoutes, nil } -func (c *clientWrapper) GetReferenceGrants(namespace string) ([]*gatev1beta1.ReferenceGrant, error) { +func (c *clientWrapper) ListReferenceGrants(namespace string) ([]*gatev1beta1.ReferenceGrant, error) { if !c.isWatchedNamespace(namespace) { log.Warn().Msgf("Failed to get ReferenceGrants: %q is not within watched namespaces", namespace) @@ -382,7 +356,7 @@ func (c *clientWrapper) GetReferenceGrants(namespace string) ([]*gatev1beta1.Ref return referenceGrants, nil } -func (c *clientWrapper) GetGateways() []*gatev1.Gateway { +func (c *clientWrapper) ListGateways() []*gatev1.Gateway { var result []*gatev1.Gateway for ns, factory := range c.factoriesGateway { @@ -397,7 +371,7 @@ func (c *clientWrapper) GetGateways() []*gatev1.Gateway { return result } -func (c *clientWrapper) GetGatewayClasses() ([]*gatev1.GatewayClass, error) { +func (c *clientWrapper) ListGatewayClasses() ([]*gatev1.GatewayClass, error) { return c.factoryGatewayClass.Gateway().V1().GatewayClasses().Lister().List(labels.Everything()) } @@ -437,7 +411,7 @@ func (c *clientWrapper) UpdateGatewayStatus(gateway *gatev1.Gateway, gatewayStat return fmt.Errorf("cannot update Gateway status %s/%s: namespace is not within watched namespaces", gateway.Namespace, gateway.Name) } - if statusEquals(gateway.Status, gatewayStatus) { + if gatewayStatusEquals(gateway.Status, gatewayStatus) { return nil } @@ -455,89 +429,106 @@ func (c *clientWrapper) UpdateGatewayStatus(gateway *gatev1.Gateway, gatewayStat return nil } -func (c *clientWrapper) UpdateHTTPRouteStatus(ctx context.Context, gateway *gatev1.Gateway, nsName ktypes.NamespacedName, status gatev1.HTTPRouteStatus) error { - if !c.isWatchedNamespace(nsName.Namespace) { - return fmt.Errorf("updating HTTPRoute status %s/%s: namespace is not within watched namespaces", nsName.Namespace, nsName.Name) +func (c *clientWrapper) UpdateHTTPRouteStatus(ctx context.Context, route ktypes.NamespacedName, status gatev1.HTTPRouteStatus) error { + if !c.isWatchedNamespace(route.Namespace) { + return fmt.Errorf("updating HTTPRoute status %s/%s: namespace is not within watched namespaces", route.Namespace, route.Name) } - route, err := c.factoriesGateway[c.lookupNamespace(nsName.Namespace)].Gateway().V1().HTTPRoutes().Lister().HTTPRoutes(nsName.Namespace).Get(nsName.Name) + currentRoute, err := c.factoriesGateway[c.lookupNamespace(route.Namespace)].Gateway().V1().HTTPRoutes().Lister().HTTPRoutes(route.Namespace).Get(route.Name) if err != nil { - return fmt.Errorf("getting HTTPRoute %s/%s: %w", nsName.Namespace, nsName.Name, err) + return fmt.Errorf("getting HTTPRoute %s/%s: %w", route.Namespace, route.Name, err) } - var statuses []gatev1.RouteParentStatus - for _, status := range route.Status.Parents { - if status.ControllerName != controllerName { - statuses = append(statuses, status) - continue - } - if status.ParentRef.Namespace != nil && string(*status.ParentRef.Namespace) != gateway.Namespace { - statuses = append(statuses, status) - continue - } - if string(status.ParentRef.Name) != gateway.Name { - statuses = append(statuses, status) + // TODO: keep statuses for gateways managed by other Traefik instances. + var parentStatuses []gatev1.RouteParentStatus + for _, currentParentStatus := range currentRoute.Status.Parents { + if currentParentStatus.ControllerName != controllerName { + parentStatuses = append(parentStatuses, currentParentStatus) continue } } - statuses = append(statuses, status.Parents...) - route = route.DeepCopy() - route.Status = gatev1.HTTPRouteStatus{ + parentStatuses = append(parentStatuses, status.Parents...) + + currentRoute = currentRoute.DeepCopy() + currentRoute.Status = gatev1.HTTPRouteStatus{ RouteStatus: gatev1.RouteStatus{ - Parents: statuses, + Parents: parentStatuses, }, } - if _, err := c.csGateway.GatewayV1().HTTPRoutes(nsName.Namespace).UpdateStatus(ctx, route, metav1.UpdateOptions{}); err != nil { - return fmt.Errorf("updating HTTPRoute %s/%s status: %w", nsName.Namespace, nsName.Name, err) + if _, err := c.csGateway.GatewayV1().HTTPRoutes(route.Namespace).UpdateStatus(ctx, currentRoute, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("updating HTTPRoute %s/%s status: %w", route.Namespace, route.Name, err) } return nil } -func statusEquals(oldStatus, newStatus gatev1.GatewayStatus) bool { - if len(oldStatus.Listeners) != len(newStatus.Listeners) { - return false +func (c *clientWrapper) UpdateTCPRouteStatus(ctx context.Context, route ktypes.NamespacedName, status gatev1alpha2.TCPRouteStatus) error { + if !c.isWatchedNamespace(route.Namespace) { + return fmt.Errorf("updating TCPRoute status %s/%s: namespace is not within watched namespaces", route.Namespace, route.Name) } - if !conditionsEquals(oldStatus.Conditions, newStatus.Conditions) { - return false + currentRoute, err := c.factoriesGateway[c.lookupNamespace(route.Namespace)].Gateway().V1alpha2().TCPRoutes().Lister().TCPRoutes(route.Namespace).Get(route.Name) + if err != nil { + return fmt.Errorf("getting TCPRoute %s/%s: %w", route.Namespace, route.Name, err) } - listenerMatches := 0 - for _, newListener := range newStatus.Listeners { - for _, oldListener := range oldStatus.Listeners { - if newListener.Name == oldListener.Name { - if !conditionsEquals(newListener.Conditions, oldListener.Conditions) { - return false - } - - listenerMatches++ - } + // TODO: keep statuses for gateways managed by other Traefik instances. + var parentStatuses []gatev1alpha2.RouteParentStatus + for _, currentParentStatus := range currentRoute.Status.Parents { + if currentParentStatus.ControllerName != controllerName { + parentStatuses = append(parentStatuses, currentParentStatus) + continue } } - return listenerMatches == len(oldStatus.Listeners) + parentStatuses = append(parentStatuses, status.Parents...) + + currentRoute = currentRoute.DeepCopy() + currentRoute.Status = gatev1alpha2.TCPRouteStatus{ + RouteStatus: gatev1.RouteStatus{ + Parents: parentStatuses, + }, + } + + if _, err := c.csGateway.GatewayV1alpha2().TCPRoutes(route.Namespace).UpdateStatus(ctx, currentRoute, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("updating TCPRoute %s/%s status: %w", route.Namespace, route.Name, err) + } + return nil } -func conditionsEquals(conditionsA, conditionsB []metav1.Condition) bool { - if len(conditionsA) != len(conditionsB) { - return false +func (c *clientWrapper) UpdateTLSRouteStatus(ctx context.Context, route ktypes.NamespacedName, status gatev1alpha2.TLSRouteStatus) error { + if !c.isWatchedNamespace(route.Namespace) { + return fmt.Errorf("updating TLSRoute status %s/%s: namespace is not within watched namespaces", route.Namespace, route.Name) } - conditionMatches := 0 - for _, conditionA := range conditionsA { - for _, conditionB := range conditionsB { - if conditionA.Type == conditionB.Type { - if conditionA.Reason != conditionB.Reason || conditionA.Status != conditionB.Status || conditionA.Message != conditionB.Message || conditionA.ObservedGeneration != conditionB.ObservedGeneration { - return false - } - conditionMatches++ - } + currentRoute, err := c.factoriesGateway[c.lookupNamespace(route.Namespace)].Gateway().V1alpha2().TLSRoutes().Lister().TLSRoutes(route.Namespace).Get(route.Name) + if err != nil { + return fmt.Errorf("getting TLSRoute %s/%s: %w", route.Namespace, route.Name, err) + } + + // TODO: keep statuses for gateways managed by other Traefik instances. + var parentStatuses []gatev1alpha2.RouteParentStatus + for _, currentParentStatus := range currentRoute.Status.Parents { + if currentParentStatus.ControllerName != controllerName { + parentStatuses = append(parentStatuses, currentParentStatus) + continue } } - return conditionMatches == len(conditionsA) + parentStatuses = append(parentStatuses, status.Parents...) + + currentRoute = currentRoute.DeepCopy() + currentRoute.Status = gatev1alpha2.TLSRouteStatus{ + RouteStatus: gatev1.RouteStatus{ + Parents: parentStatuses, + }, + } + + if _, err := c.csGateway.GatewayV1alpha2().TLSRoutes(route.Namespace).UpdateStatus(ctx, currentRoute, metav1.UpdateOptions{}); err != nil { + return fmt.Errorf("updating TLSRoute %s/%s status: %w", route.Namespace, route.Name, err) + } + return nil } // GetService returns the named service from the given namespace. @@ -582,11 +573,21 @@ func (c *clientWrapper) GetSecret(namespace, name string) (*corev1.Secret, bool, // The distinction is necessary because we index all informers on the special // identifier iff all-namespaces are requested but receive specific namespace // identifiers from the Kubernetes API, so we have to bridge this gap. -func (c *clientWrapper) lookupNamespace(ns string) string { +func (c *clientWrapper) lookupNamespace(namespace string) string { if c.isNamespaceAll { return metav1.NamespaceAll } - return ns + return namespace +} + +// isWatchedNamespace checks to ensure that the namespace is being watched before we request +// it to ensure we don't panic by requesting an out-of-watch object. +func (c *clientWrapper) isWatchedNamespace(namespace string) bool { + if c.isNamespaceAll { + return true + } + + return slices.Contains(c.watchedNamespaces, namespace) } // eventHandlerFunc will pass the obj on to the events channel or drop it. @@ -608,12 +609,51 @@ func translateNotFoundError(err error) (bool, error) { return err == nil, err } -// isWatchedNamespace checks to ensure that the namespace is being watched before we request -// it to ensure we don't panic by requesting an out-of-watch object. -func (c *clientWrapper) isWatchedNamespace(ns string) bool { - if c.isNamespaceAll { - return true +func gatewayStatusEquals(statusA, statusB gatev1.GatewayStatus) bool { + if len(statusA.Listeners) != len(statusB.Listeners) { + return false } - return slices.Contains(c.watchedNamespaces, ns) + if !conditionsEquals(statusA.Conditions, statusB.Conditions) { + return false + } + + listenerMatches := 0 + for _, newListener := range statusB.Listeners { + for _, oldListener := range statusA.Listeners { + if newListener.Name == oldListener.Name { + if !conditionsEquals(newListener.Conditions, oldListener.Conditions) { + return false + } + + if newListener.AttachedRoutes != oldListener.AttachedRoutes { + return false + } + + listenerMatches++ + } + } + } + + return listenerMatches == len(statusA.Listeners) +} + +func conditionsEquals(conditionsA, conditionsB []metav1.Condition) bool { + if len(conditionsA) != len(conditionsB) { + return false + } + + conditionMatches := 0 + for _, conditionA := range conditionsA { + for _, conditionB := range conditionsB { + if conditionA.Type == conditionB.Type { + if conditionA.Reason != conditionB.Reason || conditionA.Status != conditionB.Status || conditionA.Message != conditionB.Message || conditionA.ObservedGeneration != conditionB.ObservedGeneration { + return false + } + conditionMatches++ + } + } + } + + return conditionMatches == len(conditionsA) } diff --git a/pkg/provider/kubernetes/gateway/client_test.go b/pkg/provider/kubernetes/gateway/client_test.go index d4bd4582b..6b371b6fc 100644 --- a/pkg/provider/kubernetes/gateway/client_test.go +++ b/pkg/provider/kubernetes/gateway/client_test.go @@ -8,7 +8,7 @@ import ( gatev1 "sigs.k8s.io/gateway-api/apis/v1" ) -func TestStatusEquals(t *testing.T) { +func Test_gatewayStatusEquals(t *testing.T) { testCases := []struct { desc string statusA gatev1.GatewayStatus @@ -230,13 +230,45 @@ func TestStatusEquals(t *testing.T) { }, expected: false, }, + { + desc: "Gateway listeners with same conditions but different number of attached routes", + statusA: gatev1.GatewayStatus{ + Listeners: []gatev1.ListenerStatus{ + { + Name: "foo", + AttachedRoutes: 1, + Conditions: []metav1.Condition{ + { + Type: "foobar", + Reason: "foobar", + }, + }, + }, + }, + }, + statusB: gatev1.GatewayStatus{ + Listeners: []gatev1.ListenerStatus{ + { + Name: "foo", + AttachedRoutes: 2, + Conditions: []metav1.Condition{ + { + Type: "foobar", + Reason: "foobar", + }, + }, + }, + }, + }, + expected: false, + }, } for _, test := range testCases { t.Run(test.desc, func(t *testing.T) { t.Parallel() - result := statusEquals(test.statusA, test.statusB) + result := gatewayStatusEquals(test.statusA, test.statusB) assert.Equal(t, test.expected, result) }) diff --git a/pkg/provider/kubernetes/gateway/fixtures/httproute/simple_with_bad_rule.yml b/pkg/provider/kubernetes/gateway/fixtures/httproute/simple_with_bad_rule.yml deleted file mode 100644 index d1e0842a2..000000000 --- a/pkg/provider/kubernetes/gateway/fixtures/httproute/simple_with_bad_rule.yml +++ /dev/null @@ -1,46 +0,0 @@ ---- -kind: GatewayClass -apiVersion: gateway.networking.k8s.io/v1 -metadata: - name: my-gateway-class -spec: - controllerName: traefik.io/gateway-controller - ---- -kind: Gateway -apiVersion: gateway.networking.k8s.io/v1 -metadata: - name: my-gateway - namespace: default -spec: - gatewayClassName: my-gateway-class - listeners: # Use GatewayClass defaults for listener definition. - - name: http - protocol: HTTP - port: 80 - allowedRoutes: - namespaces: - from: Same - ---- -kind: HTTPRoute -apiVersion: gateway.networking.k8s.io/v1 -metadata: - name: http-app-1 - namespace: default -spec: - parentRefs: - - name: my-gateway - kind: Gateway - group: gateway.networking.k8s.io - hostnames: - - "foo.com" - rules: - - matches: - - path: - type: Unsupported - value: /bar - backendRefs: - - name: whoami - port: 80 - weight: 1 diff --git a/pkg/provider/kubernetes/gateway/fixtures/tlsroute/with_invalid_SNI_matching.yml b/pkg/provider/kubernetes/gateway/fixtures/tlsroute/with_invalid_SNI_matching.yml deleted file mode 100644 index f1485032d..000000000 --- a/pkg/provider/kubernetes/gateway/fixtures/tlsroute/with_invalid_SNI_matching.yml +++ /dev/null @@ -1,49 +0,0 @@ ---- -kind: GatewayClass -apiVersion: gateway.networking.k8s.io/v1 -metadata: - name: my-gateway-class -spec: - controllerName: traefik.io/gateway-controller - ---- -kind: Gateway -apiVersion: gateway.networking.k8s.io/v1 -metadata: - name: my-gateway - namespace: default -spec: - gatewayClassName: my-gateway-class - listeners: # Use GatewayClass defaults for listener definition. - - name: tls - protocol: TLS - port: 9001 - tls: - mode: Passthrough - allowedRoutes: - kinds: - - kind: TLSRoute - group: gateway.networking.k8s.io - namespaces: - from: Same - ---- -kind: TLSRoute -apiVersion: gateway.networking.k8s.io/v1alpha2 -metadata: - name: tls-app-1 - namespace: default -spec: - parentRefs: - - name: my-gateway - kind: Gateway - group: gateway.networking.k8s.io - hostnames: - - "*.foo.*.bar" - rules: - - backendRefs: - - name: whoamitcp - port: 9000 - weight: 1 - kind: Service - group: "" diff --git a/pkg/provider/kubernetes/gateway/httproute.go b/pkg/provider/kubernetes/gateway/httproute.go new file mode 100644 index 000000000..3410764d9 --- /dev/null +++ b/pkg/provider/kubernetes/gateway/httproute.go @@ -0,0 +1,575 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/rs/zerolog/log" + "github.com/traefik/traefik/v3/pkg/config/dynamic" + "github.com/traefik/traefik/v3/pkg/provider" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ktypes "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + gatev1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func (p *Provider) loadHTTPRoutes(ctx context.Context, client Client, gatewayListeners []gatewayListener, conf *dynamic.Configuration) { + routes, err := client.ListHTTPRoutes() + if err != nil { + log.Ctx(ctx).Error().Err(err).Msg("Unable to list HTTPRoutes") + return + } + + for _, route := range routes { + logger := log.Ctx(ctx).With(). + Str("http_route", route.Name). + Str("namespace", route.Namespace). + Logger() + + var parentStatuses []gatev1.RouteParentStatus + for _, parentRef := range route.Spec.ParentRefs { + parentStatus := &gatev1.RouteParentStatus{ + ParentRef: parentRef, + ControllerName: controllerName, + Conditions: []metav1.Condition{ + { + Type: string(gatev1.RouteConditionAccepted), + Status: metav1.ConditionTrue, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonAccepted), + }, + }, + } + + var attachedListeners bool + notAcceptedReason := gatev1.RouteReasonNoMatchingParent + for _, listener := range gatewayListeners { + if !matchListener(listener, route.Namespace, parentRef) { + continue + } + + if !allowRoute(listener, route.Namespace, kindHTTPRoute) { + notAcceptedReason = gatev1.RouteReasonNotAllowedByListeners + continue + } + + hostnames, ok := findMatchingHostnames(listener.Hostname, route.Spec.Hostnames) + if !ok { + notAcceptedReason = gatev1.RouteReasonNoMatchingListenerHostname + continue + } + + listener.Status.AttachedRoutes++ + + // TODO should we build the conf if the listener is not attached + // only consider the route attached if the listener is in an "attached" state. + if listener.Attached { + attachedListeners = true + } + resolveConditions := p.loadHTTPRoute(logger.WithContext(ctx), client, listener, route, hostnames, conf) + + // TODO: handle more accurately route conditions (in case of multiple listener matching). + for _, condition := range resolveConditions { + parentStatus.Conditions = appendCondition(parentStatus.Conditions, condition) + } + } + + if !attachedListeners { + parentStatus.Conditions = []metav1.Condition{ + { + Type: string(gatev1.RouteConditionAccepted), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(notAcceptedReason), + }, + { + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonRefNotPermitted), + }, + } + } + + parentStatuses = append(parentStatuses, *parentStatus) + } + + status := gatev1.HTTPRouteStatus{ + RouteStatus: gatev1.RouteStatus{ + Parents: parentStatuses, + }, + } + if err := client.UpdateHTTPRouteStatus(ctx, ktypes.NamespacedName{Namespace: route.Namespace, Name: route.Name}, status); err != nil { + logger.Error(). + Err(err). + Msg("Unable to update HTTPRoute status") + } + } +} + +func (p *Provider) loadHTTPRoute(ctx context.Context, client Client, listener gatewayListener, route *gatev1.HTTPRoute, hostnames []gatev1.Hostname, conf *dynamic.Configuration) []metav1.Condition { + routeConditions := []metav1.Condition{ + { + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionTrue, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteConditionResolvedRefs), + }, + } + + hostRule := hostRule(hostnames) + + for _, routeRule := range route.Spec.Rules { + router := dynamic.Router{ + RuleSyntax: "v3", + Rule: routerRule(routeRule, hostRule), + EntryPoints: []string{listener.EPName}, + } + if listener.Protocol == gatev1.HTTPSProtocolType { + router.TLS = &dynamic.RouterTLSConfig{} + } + + // Adding the gateway desc and the entryPoint desc prevents overlapping of routers build from the same routes. + routerName := route.Name + "-" + listener.GWName + "-" + listener.EPName + routerKey := makeRouterKey(router.Rule, makeID(route.Namespace, routerName)) + + var wrr dynamic.WeightedRoundRobin + wrrName := provider.Normalize(routerKey + "-wrr") + + middlewares, err := p.loadMiddlewares(listener.Protocol, route.Namespace, routerKey, routeRule.Filters) + if err != nil { + log.Ctx(ctx).Error(). + Err(err). + Msg("Unable to load HTTPRoute filters") + + wrr.Services = append(wrr.Services, dynamic.WRRService{ + Name: "invalid-httproute-filter", + Status: ptr.To(500), + Weight: ptr.To(1), + }) + + conf.HTTP.Services[wrrName] = &dynamic.Service{Weighted: &wrr} + router.Service = wrrName + } else { + for name, middleware := range middlewares { + // If the middleware config is nil in the return of the loadMiddlewares function, + // it means that we just need a reference to that middleware. + if middleware != nil { + conf.HTTP.Middlewares[name] = middleware + } + + router.Middlewares = append(router.Middlewares, name) + } + + // Traefik internal service can be used only if there is only one BackendRef service reference. + if len(routeRule.BackendRefs) == 1 && isInternalService(routeRule.BackendRefs[0].BackendRef) { + router.Service = string(routeRule.BackendRefs[0].Name) + } else { + for _, backendRef := range routeRule.BackendRefs { + name, svc, errCondition := p.loadHTTPService(client, route, backendRef) + weight := ptr.To(int(ptr.Deref(backendRef.Weight, 1))) + if errCondition != nil { + routeConditions = appendCondition(routeConditions, *errCondition) + wrr.Services = append(wrr.Services, dynamic.WRRService{ + Name: name, + Status: ptr.To(500), + Weight: weight, + }) + continue + } + + if svc != nil { + conf.HTTP.Services[name] = svc + } + + wrr.Services = append(wrr.Services, dynamic.WRRService{ + Name: name, + Weight: weight, + }) + } + + conf.HTTP.Services[wrrName] = &dynamic.Service{Weighted: &wrr} + router.Service = wrrName + } + } + + rt := &router + p.applyRouterTransform(ctx, rt, route) + + routerKey = provider.Normalize(routerKey) + conf.HTTP.Routers[routerKey] = rt + } + + return routeConditions +} + +// loadHTTPService returns a dynamic.Service config corresponding to the given gatev1.HTTPBackendRef. +// Note that the returned dynamic.Service config can be nil (for cross-provider, internal services, and backendFunc). +func (p *Provider) loadHTTPService(client Client, route *gatev1.HTTPRoute, backendRef gatev1.HTTPBackendRef) (string, *dynamic.Service, *metav1.Condition) { + group := groupCore + if backendRef.Group != nil && *backendRef.Group != "" { + group = string(*backendRef.Group) + } + + kind := ptr.Deref(backendRef.Kind, "Service") + namespace := ptr.Deref(backendRef.Namespace, gatev1.Namespace(route.Namespace)) + namespaceStr := string(namespace) + serviceName := provider.Normalize(makeID(namespaceStr, string(backendRef.Name))) + + // TODO support cross namespace through ReferenceGrant. + if namespaceStr != route.Namespace { + return serviceName, nil, &metav1.Condition{ + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonRefNotPermitted), + Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s/%s/%s namespace not allowed", group, kind, namespace, backendRef.Name), + } + } + + if group != groupCore || kind != "Service" { + name, service, err := p.loadHTTPBackendRef(namespaceStr, backendRef) + if err != nil { + return serviceName, nil, &metav1.Condition{ + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonInvalidKind), + Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s/%s/%s: %s", group, kind, namespace, backendRef.Name, err), + } + } + + return name, service, nil + } + + port := ptr.Deref(backendRef.Port, gatev1.PortNumber(0)) + if port == 0 { + return serviceName, nil, &metav1.Condition{ + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonUnsupportedProtocol), + Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s/%s/%s port is required", group, kind, namespace, backendRef.Name), + } + } + + portStr := strconv.FormatInt(int64(port), 10) + serviceName = provider.Normalize(serviceName + "-" + portStr) + + lb, err := loadHTTPServers(client, namespaceStr, backendRef) + if err != nil { + return serviceName, nil, &metav1.Condition{ + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonBackendNotFound), + Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s/%s/%s: %s", group, kind, namespace, backendRef.Name, err), + } + } + + return serviceName, &dynamic.Service{LoadBalancer: lb}, nil +} + +func (p *Provider) loadHTTPBackendRef(namespace string, backendRef gatev1.HTTPBackendRef) (string, *dynamic.Service, error) { + // Support for cross-provider references (e.g: api@internal). + // This provides the same behavior as for IngressRoutes. + if *backendRef.Kind == "TraefikService" && strings.Contains(string(backendRef.Name), "@") { + return string(backendRef.Name), nil, nil + } + + backendFunc, ok := p.groupKindBackendFuncs[string(*backendRef.Group)][string(*backendRef.Kind)] + if !ok { + return "", nil, fmt.Errorf("unsupported HTTPBackendRef %s/%s/%s", *backendRef.Group, *backendRef.Kind, backendRef.Name) + } + if backendFunc == nil { + return "", nil, fmt.Errorf("undefined backendFunc for HTTPBackendRef %s/%s/%s", *backendRef.Group, *backendRef.Kind, backendRef.Name) + } + + return backendFunc(string(backendRef.Name), namespace) +} + +func (p *Provider) loadMiddlewares(listenerProtocol gatev1.ProtocolType, namespace, prefix string, filters []gatev1.HTTPRouteFilter) (map[string]*dynamic.Middleware, error) { + middlewares := make(map[string]*dynamic.Middleware) + + for i, filter := range filters { + switch filter.Type { + case gatev1.HTTPRouteFilterRequestRedirect: + middlewareName := provider.Normalize(fmt.Sprintf("%s-%s-%d", prefix, strings.ToLower(string(filter.Type)), i)) + middlewares[middlewareName] = createRedirectRegexMiddleware(listenerProtocol, filter.RequestRedirect) + + case gatev1.HTTPRouteFilterRequestHeaderModifier: + middlewareName := provider.Normalize(fmt.Sprintf("%s-%s-%d", prefix, strings.ToLower(string(filter.Type)), i)) + middlewares[middlewareName] = createRequestHeaderModifier(filter.RequestHeaderModifier) + + case gatev1.HTTPRouteFilterExtensionRef: + name, middleware, err := p.loadHTTPRouteFilterExtensionRef(namespace, filter.ExtensionRef) + if err != nil { + return nil, fmt.Errorf("loading ExtensionRef filter %s: %w", filter.Type, err) + } + + middlewares[name] = middleware + + default: + // As per the spec: https://gateway-api.sigs.k8s.io/api-types/httproute/#filters-optional + // In all cases where incompatible or unsupported filters are + // specified, implementations MUST add a warning condition to + // status. + return nil, fmt.Errorf("unsupported filter %s", filter.Type) + } + } + + return middlewares, nil +} + +func (p *Provider) loadHTTPRouteFilterExtensionRef(namespace string, extensionRef *gatev1.LocalObjectReference) (string, *dynamic.Middleware, error) { + if extensionRef == nil { + return "", nil, errors.New("filter extension ref undefined") + } + + filterFunc, ok := p.groupKindFilterFuncs[string(extensionRef.Group)][string(extensionRef.Kind)] + if !ok { + return "", nil, fmt.Errorf("unsupported filter extension ref %s/%s/%s", extensionRef.Group, extensionRef.Kind, extensionRef.Name) + } + if filterFunc == nil { + return "", nil, fmt.Errorf("undefined filterFunc for filter extension ref %s/%s/%s", extensionRef.Group, extensionRef.Kind, extensionRef.Name) + } + + return filterFunc(string(extensionRef.Name), namespace) +} + +// TODO support cross namespace through ReferencePolicy. +func loadHTTPServers(client Client, namespace string, backendRef gatev1.HTTPBackendRef) (*dynamic.ServersLoadBalancer, error) { + service, exists, err := client.GetService(namespace, string(backendRef.Name)) + if err != nil { + return nil, fmt.Errorf("getting service: %w", err) + } + if !exists { + return nil, errors.New("service not found") + } + + var portSpec corev1.ServicePort + var match bool + + for _, p := range service.Spec.Ports { + if backendRef.Port == nil || p.Port == int32(*backendRef.Port) { + portSpec = p + match = true + break + } + } + if !match { + return nil, errors.New("service port not found") + } + + endpoints, endpointsExists, err := client.GetEndpoints(namespace, string(backendRef.Name)) + if err != nil { + return nil, fmt.Errorf("getting endpoints: %w", err) + } + if !endpointsExists { + return nil, errors.New("endpoints not found") + } + + if len(endpoints.Subsets) == 0 { + return nil, errors.New("subset not found") + } + + lb := &dynamic.ServersLoadBalancer{} + lb.SetDefaults() + + var port int32 + var portStr string + for _, subset := range endpoints.Subsets { + for _, p := range subset.Ports { + if portSpec.Name == p.Name { + port = p.Port + break + } + } + + if port == 0 { + return nil, errors.New("cannot define a port") + } + + protocol := getProtocol(portSpec) + + portStr = strconv.FormatInt(int64(port), 10) + for _, addr := range subset.Addresses { + lb.Servers = append(lb.Servers, dynamic.Server{ + URL: fmt.Sprintf("%s://%s", protocol, net.JoinHostPort(addr.IP, portStr)), + }) + } + } + + return lb, nil +} + +func hostRule(hostnames []gatev1.Hostname) string { + var rules []string + + for _, hostname := range hostnames { + host := string(hostname) + + wildcard := strings.Count(host, "*") + if wildcard == 0 { + rules = append(rules, fmt.Sprintf("Host(`%s`)", host)) + continue + } + + host = strings.Replace(regexp.QuoteMeta(host), `\*\.`, `[a-z0-9-\.]+\.`, 1) + rules = append(rules, fmt.Sprintf("HostRegexp(`^%s$`)", host)) + } + + switch len(rules) { + case 0: + return "" + case 1: + return rules[0] + default: + return fmt.Sprintf("(%s)", strings.Join(rules, " || ")) + } +} + +func routerRule(routeRule gatev1.HTTPRouteRule, hostRule string) string { + var rule string + var matchesRules []string + + for _, match := range routeRule.Matches { + path := ptr.Deref(match.Path, gatev1.HTTPPathMatch{ + Type: ptr.To(gatev1.PathMatchPathPrefix), + Value: ptr.To("/"), + }) + pathType := ptr.Deref(path.Type, gatev1.PathMatchPathPrefix) + pathValue := ptr.Deref(path.Value, "/") + + var matchRules []string + switch pathType { + case gatev1.PathMatchExact: + matchRules = append(matchRules, fmt.Sprintf("Path(`%s`)", pathValue)) + case gatev1.PathMatchPathPrefix: + matchRules = append(matchRules, buildPathMatchPathPrefixRule(pathValue)) + case gatev1.PathMatchRegularExpression: + matchRules = append(matchRules, fmt.Sprintf("PathRegexp(`%s`)", pathValue)) + } + + matchRules = append(matchRules, headerRules(match.Headers)...) + matchesRules = append(matchesRules, strings.Join(matchRules, " && ")) + } + + // If no matches are specified, the default is a prefix + // path match on "/", which has the effect of matching every + // HTTP request. + if len(routeRule.Matches) == 0 { + matchesRules = append(matchesRules, "PathPrefix(`/`)") + } + + if hostRule != "" { + if len(matchesRules) == 0 { + return hostRule + } + rule += hostRule + " && " + } + + if len(matchesRules) == 1 { + return rule + matchesRules[0] + } + + if len(rule) == 0 { + return strings.Join(matchesRules, " || ") + } + + return rule + "(" + strings.Join(matchesRules, " || ") + ")" +} + +func headerRules(headers []gatev1.HTTPHeaderMatch) []string { + var headerRules []string + for _, header := range headers { + typ := ptr.Deref(header.Type, gatev1.HeaderMatchExact) + switch typ { + case gatev1.HeaderMatchExact: + headerRules = append(headerRules, fmt.Sprintf("Header(`%s`,`%s`)", header.Name, header.Value)) + case gatev1.HeaderMatchRegularExpression: + headerRules = append(headerRules, fmt.Sprintf("HeaderRegexp(`%s`,`%s`)", header.Name, header.Value)) + } + } + return headerRules +} + +func buildPathMatchPathPrefixRule(path string) string { + if path == "/" { + return "PathPrefix(`/`)" + } + + path = strings.TrimSuffix(path, "/") + return fmt.Sprintf("(Path(`%[1]s`) || PathPrefix(`%[1]s/`))", path) +} + +// createRequestHeaderModifier does not enforce/check the configuration, +// as the spec indicates that either the webhook or CEL (since v1.0 GA Release) should enforce that. +func createRequestHeaderModifier(filter *gatev1.HTTPHeaderFilter) *dynamic.Middleware { + sets := map[string]string{} + for _, header := range filter.Set { + sets[string(header.Name)] = header.Value + } + + adds := map[string]string{} + for _, header := range filter.Add { + adds[string(header.Name)] = header.Value + } + + return &dynamic.Middleware{ + RequestHeaderModifier: &dynamic.RequestHeaderModifier{ + Set: sets, + Add: adds, + Remove: filter.Remove, + }, + } +} + +func createRedirectRegexMiddleware(listenerProtocol gatev1.ProtocolType, filter *gatev1.HTTPRequestRedirectFilter) *dynamic.Middleware { + // The spec allows for an empty string in which case we should use the + // scheme of the request which in this case is the listener scheme. + filterScheme := ptr.Deref(filter.Scheme, strings.ToLower(string(listenerProtocol))) + statusCode := ptr.Deref(filter.StatusCode, http.StatusFound) + + port := "${port}" + if filter.Port != nil { + port = fmt.Sprintf(":%d", *filter.Port) + } + + hostname := "${hostname}" + if filter.Hostname != nil && *filter.Hostname != "" { + hostname = string(*filter.Hostname) + } + + return &dynamic.Middleware{ + RedirectRegex: &dynamic.RedirectRegex{ + Regex: `^[a-z]+:\/\/(?P.+@)?(?P\[[\w:\.]+\]|[\w\._-]+)(?P:\d+)?\/(?P.*)`, + Replacement: fmt.Sprintf("%s://${userinfo}%s%s/${path}", filterScheme, hostname, port), + Permanent: statusCode == http.StatusMovedPermanently, + }, + } +} + +func getProtocol(portSpec corev1.ServicePort) string { + protocol := "http" + if portSpec.Port == 443 || strings.HasPrefix(portSpec.Name, "https") { + protocol = "https" + } + + return protocol +} diff --git a/pkg/provider/kubernetes/gateway/httproute_test.go b/pkg/provider/kubernetes/gateway/httproute_test.go new file mode 100644 index 000000000..2698acfdb --- /dev/null +++ b/pkg/provider/kubernetes/gateway/httproute_test.go @@ -0,0 +1,281 @@ +package gateway + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/utils/ptr" + gatev1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func Test_hostRule(t *testing.T) { + testCases := []struct { + desc string + hostnames []gatev1.Hostname + expectedRule string + expectErr bool + }{ + { + desc: "Empty rule and matches", + expectedRule: "", + }, + { + desc: "One Host", + hostnames: []gatev1.Hostname{ + "Foo", + }, + expectedRule: "Host(`Foo`)", + }, + { + desc: "Multiple Hosts", + hostnames: []gatev1.Hostname{ + "Foo", + "Bar", + "Bir", + }, + expectedRule: "(Host(`Foo`) || Host(`Bar`) || Host(`Bir`))", + }, + { + desc: "Several Host and wildcard", + hostnames: []gatev1.Hostname{ + "*.bar.foo", + "bar.foo", + "foo.foo", + }, + expectedRule: "(HostRegexp(`^[a-z0-9-\\.]+\\.bar\\.foo$`) || Host(`bar.foo`) || Host(`foo.foo`))", + }, + { + desc: "Host with wildcard", + hostnames: []gatev1.Hostname{ + "*.bar.foo", + }, + expectedRule: "HostRegexp(`^[a-z0-9-\\.]+\\.bar\\.foo$`)", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + rule := hostRule(test.hostnames) + assert.Equal(t, test.expectedRule, rule) + }) + } +} + +func Test_routerRule(t *testing.T) { + testCases := []struct { + desc string + routeRule gatev1.HTTPRouteRule + hostRule string + expectedRule string + expectedError bool + }{ + { + desc: "Empty rule and matches", + expectedRule: "PathPrefix(`/`)", + }, + { + desc: "One Host rule without matches", + hostRule: "Host(`foo.com`)", + expectedRule: "Host(`foo.com`) && PathPrefix(`/`)", + }, + { + desc: "One HTTPRouteMatch with nil HTTPHeaderMatch", + routeRule: gatev1.HTTPRouteRule{ + Matches: []gatev1.HTTPRouteMatch{ + { + Path: ptr.To(gatev1.HTTPPathMatch{ + Type: ptr.To(gatev1.PathMatchPathPrefix), + Value: ptr.To("/"), + }), + Headers: nil, + }, + }, + }, + expectedRule: "PathPrefix(`/`)", + }, + { + desc: "One HTTPRouteMatch with nil HTTPHeaderMatch Type", + routeRule: gatev1.HTTPRouteRule{ + Matches: []gatev1.HTTPRouteMatch{ + { + Path: ptr.To(gatev1.HTTPPathMatch{ + Type: ptr.To(gatev1.PathMatchPathPrefix), + Value: ptr.To("/"), + }), + Headers: []gatev1.HTTPHeaderMatch{ + {Name: "foo", Value: "bar"}, + }, + }, + }, + }, + expectedRule: "PathPrefix(`/`) && Header(`foo`,`bar`)", + }, + { + desc: "One HTTPRouteMatch with nil HTTPPathMatch", + routeRule: gatev1.HTTPRouteRule{ + Matches: []gatev1.HTTPRouteMatch{ + {Path: nil}, + }, + }, + expectedRule: "PathPrefix(`/`)", + }, + { + desc: "One HTTPRouteMatch with nil HTTPPathMatch Type", + routeRule: gatev1.HTTPRouteRule{ + Matches: []gatev1.HTTPRouteMatch{ + { + Path: &gatev1.HTTPPathMatch{ + Type: nil, + Value: ptr.To("/foo/"), + }, + }, + }, + }, + expectedRule: "(Path(`/foo`) || PathPrefix(`/foo/`))", + }, + { + desc: "One HTTPRouteMatch with nil HTTPPathMatch Values", + routeRule: gatev1.HTTPRouteRule{ + Matches: []gatev1.HTTPRouteMatch{ + { + Path: &gatev1.HTTPPathMatch{ + Type: ptr.To(gatev1.PathMatchExact), + Value: nil, + }, + }, + }, + }, + expectedRule: "Path(`/`)", + }, + { + desc: "One Path in matches", + routeRule: gatev1.HTTPRouteRule{ + Matches: []gatev1.HTTPRouteMatch{ + { + Path: &gatev1.HTTPPathMatch{ + Type: ptr.To(gatev1.PathMatchExact), + Value: ptr.To("/foo/"), + }, + }, + }, + }, + expectedRule: "Path(`/foo/`)", + }, + { + desc: "One Path in matches and another empty", + routeRule: gatev1.HTTPRouteRule{ + Matches: []gatev1.HTTPRouteMatch{ + { + Path: &gatev1.HTTPPathMatch{ + Type: ptr.To(gatev1.PathMatchExact), + Value: ptr.To("/foo/"), + }, + }, + {}, + }, + }, + expectedRule: "Path(`/foo/`) || PathPrefix(`/`)", + }, + { + desc: "Path OR Header rules", + routeRule: gatev1.HTTPRouteRule{ + Matches: []gatev1.HTTPRouteMatch{ + { + Path: &gatev1.HTTPPathMatch{ + Type: ptr.To(gatev1.PathMatchExact), + Value: ptr.To("/foo/"), + }, + }, + { + Headers: []gatev1.HTTPHeaderMatch{ + { + Type: ptr.To(gatev1.HeaderMatchExact), + Name: "my-header", + Value: "foo", + }, + }, + }, + }, + }, + expectedRule: "Path(`/foo/`) || PathPrefix(`/`) && Header(`my-header`,`foo`)", + }, + { + desc: "Path && Header rules", + routeRule: gatev1.HTTPRouteRule{ + Matches: []gatev1.HTTPRouteMatch{ + { + Path: &gatev1.HTTPPathMatch{ + Type: ptr.To(gatev1.PathMatchExact), + Value: ptr.To("/foo/"), + }, + Headers: []gatev1.HTTPHeaderMatch{ + { + Type: ptr.To(gatev1.HeaderMatchExact), + Name: "my-header", + Value: "foo", + }, + }, + }, + }, + }, + expectedRule: "Path(`/foo/`) && Header(`my-header`,`foo`)", + }, + { + desc: "Host && Path && Header rules", + hostRule: "Host(`foo.com`)", + routeRule: gatev1.HTTPRouteRule{ + Matches: []gatev1.HTTPRouteMatch{ + { + Path: &gatev1.HTTPPathMatch{ + Type: ptr.To(gatev1.PathMatchExact), + Value: ptr.To("/foo/"), + }, + Headers: []gatev1.HTTPHeaderMatch{ + { + Type: ptr.To(gatev1.HeaderMatchExact), + Name: "my-header", + Value: "foo", + }, + }, + }, + }, + }, + expectedRule: "Host(`foo.com`) && Path(`/foo/`) && Header(`my-header`,`foo`)", + }, + { + desc: "Host && (Path || Header) rules", + hostRule: "Host(`foo.com`)", + routeRule: gatev1.HTTPRouteRule{ + Matches: []gatev1.HTTPRouteMatch{ + { + Path: &gatev1.HTTPPathMatch{ + Type: ptr.To(gatev1.PathMatchExact), + Value: ptr.To("/foo/"), + }, + }, + { + Headers: []gatev1.HTTPHeaderMatch{ + { + Type: ptr.To(gatev1.HeaderMatchExact), + Name: "my-header", + Value: "foo", + }, + }, + }, + }, + }, + expectedRule: "Host(`foo.com`) && (Path(`/foo/`) || PathPrefix(`/`) && Header(`my-header`,`foo`))", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + rule := routerRule(test.routeRule, test.hostRule) + assert.Equal(t, test.expectedRule, rule) + }) + } +} diff --git a/pkg/provider/kubernetes/gateway/kubernetes.go b/pkg/provider/kubernetes/gateway/kubernetes.go index 2aa5c8df8..e6cea12a1 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes.go +++ b/pkg/provider/kubernetes/gateway/kubernetes.go @@ -5,10 +5,7 @@ import ( "crypto/sha256" "errors" "fmt" - "net" - "net/http" "os" - "regexp" "slices" "sort" "strconv" @@ -23,7 +20,6 @@ import ( "github.com/traefik/traefik/v3/pkg/config/dynamic" "github.com/traefik/traefik/v3/pkg/job" "github.com/traefik/traefik/v3/pkg/logs" - "github.com/traefik/traefik/v3/pkg/provider" traefikv1alpha1 "github.com/traefik/traefik/v3/pkg/provider/kubernetes/crd/traefikio/v1alpha1" "github.com/traefik/traefik/v3/pkg/provider/kubernetes/k8s" "github.com/traefik/traefik/v3/pkg/safe" @@ -32,7 +28,6 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" - ktypes "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" gatev1 "sigs.k8s.io/gateway-api/apis/v1" gatev1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" @@ -104,6 +99,24 @@ type ExtensionBuilderRegistry interface { RegisterBackendFuncs(group, kind string, builderFunc BuildBackendFunc) } +type gatewayListener struct { + Name string + + Protocol gatev1.ProtocolType + TLS *gatev1.GatewayTLSConfig + Hostname *gatev1.Hostname + Status *gatev1.ListenerStatus + AllowedNamespaces []string + AllowedRouteKinds []string + + Attached bool + + GWName string + GWNamespace string + GWGeneration int64 + EPName string +} + // RegisterFilterFuncs registers an allowed Group, Kind, and builder for the Filter ExtensionRef objects. func (p *Provider) RegisterFilterFuncs(group, kind string, builderFunc BuildFilterFunc) { if p.groupKindFilterFuncs == nil { @@ -221,7 +234,7 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. // Note that event is the *first* event that came in during this throttling interval -- if we're hitting our throttle, we may have dropped events. // This is fine, because we don't treat different event types differently. // But if we do in the future, we'll need to track more information about the dropped events. - conf := p.loadConfigurationFromGateway(ctxLog, k8sClient) + conf := p.loadConfigurationFromGateways(ctxLog, k8sClient) confHash, err := hashstructure.Hash(conf, nil) switch { @@ -258,110 +271,7 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. } // TODO Handle errors and update resources statuses (gatewayClass, gateway). -func (p *Provider) loadConfigurationFromGateway(ctx context.Context, client Client) *dynamic.Configuration { - logger := log.Ctx(ctx) - - gatewayClassNames := map[string]struct{}{} - - gatewayClasses, err := client.GetGatewayClasses() - if err != nil { - logger.Error().Err(err).Msg("Cannot find GatewayClasses") - return &dynamic.Configuration{ - HTTP: &dynamic.HTTPConfiguration{ - Routers: map[string]*dynamic.Router{}, - Middlewares: map[string]*dynamic.Middleware{}, - Services: map[string]*dynamic.Service{}, - ServersTransports: map[string]*dynamic.ServersTransport{}, - }, - TCP: &dynamic.TCPConfiguration{ - Routers: map[string]*dynamic.TCPRouter{}, - Middlewares: map[string]*dynamic.TCPMiddleware{}, - Services: map[string]*dynamic.TCPService{}, - ServersTransports: map[string]*dynamic.TCPServersTransport{}, - }, - UDP: &dynamic.UDPConfiguration{ - Routers: map[string]*dynamic.UDPRouter{}, - Services: map[string]*dynamic.UDPService{}, - }, - TLS: &dynamic.TLSConfiguration{}, - } - } - - for _, gatewayClass := range gatewayClasses { - if gatewayClass.Spec.ControllerName == controllerName { - gatewayClassNames[gatewayClass.Name] = struct{}{} - - err := client.UpdateGatewayClassStatus(gatewayClass, metav1.Condition{ - Type: string(gatev1.GatewayClassConditionStatusAccepted), - Status: metav1.ConditionTrue, - ObservedGeneration: gatewayClass.Generation, - Reason: "Handled", - Message: "Handled by Traefik controller", - LastTransitionTime: metav1.Now(), - }) - if err != nil { - logger.Error().Err(err).Msgf("Failed to update %s condition", gatev1.GatewayClassConditionStatusAccepted) - } - } - } - - cfgs := map[string]*dynamic.Configuration{} - - // TODO check if we can only use the default filtering mechanism - for _, gateway := range client.GetGateways() { - logger := log.Ctx(ctx).With().Str("gateway", gateway.Name).Str("namespace", gateway.Namespace).Logger() - ctxLog := logger.WithContext(ctx) - - if _, ok := gatewayClassNames[string(gateway.Spec.GatewayClassName)]; !ok { - continue - } - - cfg, err := p.createGatewayConf(ctxLog, client, gateway) - if err != nil { - logger.Error().Err(err).Send() - continue - } - - cfgs[gateway.Name+gateway.Namespace] = cfg - } - - conf := provider.Merge(ctx, cfgs) - - conf.TLS = &dynamic.TLSConfiguration{} - - for _, cfg := range cfgs { - if conf.TLS == nil { - conf.TLS = &dynamic.TLSConfiguration{} - } - - conf.TLS.Certificates = append(conf.TLS.Certificates, cfg.TLS.Certificates...) - - for name, options := range cfg.TLS.Options { - if conf.TLS.Options == nil { - conf.TLS.Options = map[string]tls.Options{} - } - - conf.TLS.Options[name] = options - } - - for name, store := range cfg.TLS.Stores { - if conf.TLS.Stores == nil { - conf.TLS.Stores = map[string]tls.Store{} - } - - conf.TLS.Stores[name] = store - } - } - - return conf -} - -func (p *Provider) createGatewayConf(ctx context.Context, client Client, gateway *gatev1.Gateway) (*dynamic.Configuration, error) { - addresses, err := p.gatewayAddresses(client) - if err != nil { - return nil, fmt.Errorf("get Gateway status addresses: %w", err) - } - +func (p *Provider) loadConfigurationFromGateways(ctx context.Context, client Client) *dynamic.Configuration { conf := &dynamic.Configuration{ HTTP: &dynamic.HTTPConfiguration{ Routers: map[string]*dynamic.Router{}, @@ -382,74 +292,168 @@ func (p *Provider) createGatewayConf(ctx context.Context, client Client, gateway TLS: &dynamic.TLSConfiguration{}, } - tlsConfigs := make(map[string]*tls.CertAndStores) - - // GatewayReasonListenersNotValid is used when one or more - // Listeners have an invalid or unsupported configuration - // and cannot be configured on the Gateway. - listenerStatuses, httpRouteParentStatuses := p.fillGatewayConf(ctx, client, gateway, conf, tlsConfigs) - - if len(tlsConfigs) > 0 { - conf.TLS.Certificates = append(conf.TLS.Certificates, getTLSConfig(tlsConfigs)...) + addresses, err := p.gatewayAddresses(client) + if err != nil { + log.Ctx(ctx).Error().Err(err).Msg("Unable to get Gateway status addresses") + return nil } - httpRouteStatuses := makeHTTPRouteStatuses(gateway.Namespace, httpRouteParentStatuses) - for nsName, status := range httpRouteStatuses { - if err := client.UpdateHTTPRouteStatus(ctx, gateway, nsName, status); err != nil { - log.Error(). + gatewayClasses, err := client.ListGatewayClasses() + if err != nil { + log.Ctx(ctx).Error().Err(err).Msg("Unable to list GatewayClasses") + return nil + } + + gatewayClassNames := map[string]struct{}{} + for _, gatewayClass := range gatewayClasses { + if gatewayClass.Spec.ControllerName != controllerName { + continue + } + + gatewayClassNames[gatewayClass.Name] = struct{}{} + + err := client.UpdateGatewayClassStatus(gatewayClass, metav1.Condition{ + Type: string(gatev1.GatewayClassConditionStatusAccepted), + Status: metav1.ConditionTrue, + ObservedGeneration: gatewayClass.Generation, + Reason: "Handled", + Message: "Handled by Traefik controller", + LastTransitionTime: metav1.Now(), + }) + if err != nil { + log.Ctx(ctx). + Error(). Err(err). - Str("namespace", nsName.Namespace). - Str("name", nsName.Name). - Msg("Unable to update HTTPRoute status") + Str("gateway_class", gatewayClass.Name). + Msg("Unable to update GatewayClass status") } } - gatewayStatus, errG := p.makeGatewayStatus(gateway, listenerStatuses, addresses) - if err = client.UpdateGatewayStatus(gateway, gatewayStatus); err != nil { - log.Error(). - Err(err). + gateways := client.ListGateways() + + var gatewayListeners []gatewayListener + for _, gateway := range gateways { + logger := log.Ctx(ctx).With(). + Str("gateway", gateway.Name). Str("namespace", gateway.Namespace). - Str("name", gateway.Name). - Msg("Unable to update Gateway status") - } - if errG != nil { - return nil, fmt.Errorf("creating gateway status: %w", errG) + Logger() + + if _, ok := gatewayClassNames[string(gateway.Spec.GatewayClassName)]; !ok { + continue + } + + gatewayListeners = append(gatewayListeners, p.loadGatewayListeners(logger.WithContext(ctx), client, gateway, conf)...) } - return conf, nil + p.loadHTTPRoutes(ctx, client, gatewayListeners, conf) + + if p.ExperimentalChannel { + p.loadTCPRoutes(ctx, client, gatewayListeners, conf) + p.loadTLSRoutes(ctx, client, gatewayListeners, conf) + } + + for _, gateway := range gateways { + logger := log.Ctx(ctx).With(). + Str("gateway", gateway.Name). + Str("namespace", gateway.Namespace). + Logger() + + var listeners []gatewayListener + for _, listener := range gatewayListeners { + if listener.GWName == gateway.Name && listener.GWNamespace == gateway.Namespace { + listeners = append(listeners, listener) + } + } + + gatewayStatus, errG := p.makeGatewayStatus(gateway, listeners, addresses) + if err = client.UpdateGatewayStatus(gateway, gatewayStatus); err != nil { + logger.Error(). + Err(err). + Msg("Unable to update Gateway status") + } + if errG != nil { + logger.Error(). + Err(errG). + Msg("Unable to create Gateway status") + } + } + + return conf } -func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway *gatev1.Gateway, conf *dynamic.Configuration, tlsConfigs map[string]*tls.CertAndStores) ([]gatev1.ListenerStatus, map[ktypes.NamespacedName][]gatev1.RouteParentStatus) { - logger := log.Ctx(ctx) +func (p *Provider) loadGatewayListeners(ctx context.Context, client Client, gateway *gatev1.Gateway, conf *dynamic.Configuration) []gatewayListener { + tlsConfigs := make(map[string]*tls.CertAndStores) allocatedListeners := make(map[string]struct{}) - listenerStatuses := make([]gatev1.ListenerStatus, len(gateway.Spec.Listeners)) - httpRouteParentStatuses := make(map[ktypes.NamespacedName][]gatev1.RouteParentStatus) + gatewayListeners := make([]gatewayListener, len(gateway.Spec.Listeners)) for i, listener := range gateway.Spec.Listeners { - listenerStatuses[i] = gatev1.ListenerStatus{ - Name: listener.Name, - SupportedKinds: []gatev1.RouteGroupKind{}, - Conditions: []metav1.Condition{}, - // AttachedRoutes: 0 TODO Set to number of Routes associated with a Listener regardless of Gateway or Route status + gatewayListeners[i] = gatewayListener{ + Name: string(listener.Name), + GWName: gateway.Name, + GWNamespace: gateway.Namespace, + GWGeneration: gateway.Generation, + Protocol: listener.Protocol, + TLS: listener.TLS, + Hostname: listener.Hostname, + Status: &gatev1.ListenerStatus{ + Name: listener.Name, + SupportedKinds: []gatev1.RouteGroupKind{}, + Conditions: []metav1.Condition{}, + }, + } + + ep, err := p.entryPointName(listener.Port, listener.Protocol) + if err != nil { + // update "Detached" status with "PortUnavailable" reason + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ + Type: string(gatev1.ListenerConditionAccepted), + Status: metav1.ConditionFalse, + ObservedGeneration: gateway.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.ListenerReasonPortUnavailable), + Message: fmt.Sprintf("Cannot find entryPoint for Gateway: %v", err), + }) + + continue + } + gatewayListeners[i].EPName = ep + + allowedRoutes := ptr.Deref(listener.AllowedRoutes, gatev1.AllowedRoutes{Namespaces: &gatev1.RouteNamespaces{From: ptr.To(gatev1.NamespacesFromSame)}}) + gatewayListeners[i].AllowedNamespaces, err = allowedNamespaces(client, gateway.Namespace, allowedRoutes.Namespaces) + if err != nil { + // update "ResolvedRefs" status true with "InvalidRoutesRef" reason + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ + Type: string(gatev1.ListenerConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: gateway.Generation, + LastTransitionTime: metav1.Now(), + Reason: "InvalidRouteNamespacesSelector", // Should never happen as the selector is validated by kubernetes + Message: fmt.Sprintf("Invalid route namespaces selector: %v", err), + }) + + continue } supportedKinds, conditions := supportedRouteKinds(listener.Protocol, p.ExperimentalChannel) if len(conditions) > 0 { - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, conditions...) + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, conditions...) continue } - routeKinds, conditions := getAllowedRouteKinds(gateway, listener, supportedKinds) - listenerStatuses[i].SupportedKinds = routeKinds + routeKinds, conditions := allowedRouteKinds(gateway, listener, supportedKinds) + for _, kind := range routeKinds { + gatewayListeners[i].AllowedRouteKinds = append(gatewayListeners[i].AllowedRouteKinds, string(kind.Kind)) + } + gatewayListeners[i].Status.SupportedKinds = routeKinds if len(conditions) > 0 { - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, conditions...) + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, conditions...) continue } listenerKey := makeListenerKey(listener) if _, ok := allocatedListeners[listenerKey]; ok { - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{ + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionConflicted), Status: metav1.ConditionTrue, ObservedGeneration: gateway.Generation, @@ -463,23 +467,8 @@ func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway * allocatedListeners[listenerKey] = struct{}{} - ep, err := p.entryPointName(listener.Port, listener.Protocol) - if err != nil { - // update "Detached" status with "PortUnavailable" reason - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{ - Type: string(gatev1.ListenerConditionAccepted), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.ListenerReasonPortUnavailable), - Message: fmt.Sprintf("Cannot find entryPoint for Gateway: %v", err), - }) - - continue - } - if (listener.Protocol == gatev1.HTTPProtocolType || listener.Protocol == gatev1.TCPProtocolType) && listener.TLS != nil { - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{ + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionAccepted), Status: metav1.ConditionFalse, ObservedGeneration: gateway.Generation, @@ -495,7 +484,7 @@ func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway * if listener.Protocol == gatev1.HTTPSProtocolType || listener.Protocol == gatev1.TLSProtocolType { if listener.TLS == nil || (len(listener.TLS.CertificateRefs) == 0 && listener.TLS.Mode != nil && *listener.TLS.Mode != gatev1.TLSModePassthrough) { // update "Detached" status with "UnsupportedProtocol" reason - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{ + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionAccepted), Status: metav1.ConditionFalse, ObservedGeneration: gateway.Generation, @@ -517,7 +506,7 @@ func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway * if isTLSPassthrough && len(listener.TLS.CertificateRefs) > 0 { // https://gateway-api.sigs.k8s.io/v1alpha2/references/spec/#gateway.networking.k8s.io/v1alpha2.GatewayTLSConfig - logger.Warn().Msg("In case of Passthrough TLS mode, no TLS settings take effect as the TLS session from the client is NOT terminated at the Gateway") + log.Ctx(ctx).Warn().Msg("In case of Passthrough TLS mode, no TLS settings take effect as the TLS session from the client is NOT terminated at the Gateway") } // Allowed configurations: @@ -525,7 +514,7 @@ func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway * // Protocol TLS -> Terminate -> TLSRoute/TCPRoute // Protocol HTTPS -> Terminate -> HTTPRoute if listener.Protocol == gatev1.HTTPSProtocolType && isTLSPassthrough { - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{ + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionAccepted), Status: metav1.ConditionFalse, ObservedGeneration: gateway.Generation, @@ -540,7 +529,7 @@ func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway * if !isTLSPassthrough { if len(listener.TLS.CertificateRefs) == 0 { // update "ResolvedRefs" status true with "InvalidCertificateRef" reason - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{ + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionResolvedRefs), Status: metav1.ConditionFalse, ObservedGeneration: gateway.Generation, @@ -558,7 +547,7 @@ func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway * if certificateRef.Kind == nil || *certificateRef.Kind != "Secret" || certificateRef.Group == nil || (*certificateRef.Group != "" && *certificateRef.Group != groupCore) { // update "ResolvedRefs" status true with "InvalidCertificateRef" reason - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{ + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionResolvedRefs), Status: metav1.ConditionFalse, ObservedGeneration: gateway.Generation, @@ -576,9 +565,9 @@ func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway * } if certificateNamespace != gateway.Namespace { - referenceGrants, err := client.GetReferenceGrants(certificateNamespace) + referenceGrants, err := client.ListReferenceGrants(certificateNamespace) if err != nil { - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{ + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionResolvedRefs), Status: metav1.ConditionFalse, ObservedGeneration: gateway.Generation, @@ -586,13 +575,14 @@ func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway * Reason: string(gatev1.ListenerReasonRefNotPermitted), Message: fmt.Sprintf("Cannot find any ReferenceGrant: %v", err), }) + continue } referenceGrants = filterReferenceGrantsFrom(referenceGrants, "gateway.networking.k8s.io", "Gateway", gateway.Namespace) referenceGrants = filterReferenceGrantsTo(referenceGrants, groupCore, "Secret", string(certificateRef.Name)) if len(referenceGrants) == 0 { - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, metav1.Condition{ + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionResolvedRefs), Status: metav1.ConditionFalse, ObservedGeneration: gateway.Generation, @@ -611,7 +601,7 @@ func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway * if err != nil { // update "ResolvedRefs" status false with "InvalidCertificateRef" reason // update "Programmed" status false with "Invalid" reason - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, + gatewayListeners[i].Status.Conditions = append(gatewayListeners[i].Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionResolvedRefs), Status: metav1.ConditionFalse, @@ -637,33 +627,23 @@ func (p *Provider) fillGatewayConf(ctx context.Context, client Client, gateway * } } - for _, routeKind := range routeKinds { - switch routeKind.Kind { - case kindHTTPRoute: - listenerConditions, routeStatuses := p.gatewayHTTPRouteToHTTPConf(ctx, ep, listener, gateway, client, conf) - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, listenerConditions...) - for nsName, status := range routeStatuses { - httpRouteParentStatuses[nsName] = append(httpRouteParentStatuses[nsName], status) - } - - case kindTCPRoute: - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, gatewayTCPRouteToTCPConf(ctx, ep, listener, gateway, client, conf)...) - case kindTLSRoute: - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, gatewayTLSRouteToTCPConf(ctx, ep, listener, gateway, client, conf)...) - } - } + gatewayListeners[i].Attached = true } - return listenerStatuses, httpRouteParentStatuses + if len(tlsConfigs) > 0 { + conf.TLS.Certificates = append(conf.TLS.Certificates, getTLSConfig(tlsConfigs)...) + } + + return gatewayListeners } -func (p *Provider) makeGatewayStatus(gateway *gatev1.Gateway, listenerStatuses []gatev1.ListenerStatus, addresses []gatev1.GatewayStatusAddress) (gatev1.GatewayStatus, error) { +func (p *Provider) makeGatewayStatus(gateway *gatev1.Gateway, listeners []gatewayListener, addresses []gatev1.GatewayStatusAddress) (gatev1.GatewayStatus, error) { gatewayStatus := gatev1.GatewayStatus{Addresses: addresses} var result error - for i, listener := range listenerStatuses { - if len(listener.Conditions) == 0 { - listenerStatuses[i].Conditions = append(listenerStatuses[i].Conditions, + for _, listener := range listeners { + if len(listener.Status.Conditions) == 0 { + listener.Status.Conditions = append(listener.Status.Conditions, metav1.Condition{ Type: string(gatev1.ListenerConditionAccepted), Status: metav1.ConditionTrue, @@ -690,14 +670,17 @@ func (p *Provider) makeGatewayStatus(gateway *gatev1.Gateway, listenerStatuses [ }, ) + // TODO: refactor + gatewayStatus.Listeners = append(gatewayStatus.Listeners, *listener.Status) continue } - for _, condition := range listener.Conditions { + for _, condition := range listener.Status.Conditions { result = multierror.Append(result, errors.New(condition.Message)) } + + gatewayStatus.Listeners = append(gatewayStatus.Listeners, *listener.Status) } - gatewayStatus.Listeners = listenerStatuses if result != nil { // GatewayConditionReady "Ready", GatewayConditionReason "ListenersNotValid" @@ -806,524 +789,6 @@ func (p *Provider) entryPointName(port gatev1.PortNumber, protocol gatev1.Protoc return "", fmt.Errorf("no matching entryPoint for port %d and protocol %q", port, protocol) } -func (p *Provider) gatewayHTTPRouteToHTTPConf(ctx context.Context, ep string, listener gatev1.Listener, gateway *gatev1.Gateway, client Client, conf *dynamic.Configuration) ([]metav1.Condition, map[ktypes.NamespacedName]gatev1.RouteParentStatus) { - // Should not happen due to validation. - if listener.AllowedRoutes == nil { - return nil, nil - } - - namespaces, err := getRouteBindingSelectorNamespace(client, gateway.Namespace, listener.AllowedRoutes.Namespaces) - if err != nil { - // update "ResolvedRefs" status true with "InvalidRoutesRef" reason - return []metav1.Condition{{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: "InvalidRouteNamespacesSelector", // Should never happen as the selector is validated by kubernetes - Message: fmt.Sprintf("Invalid route namespaces selector: %v", err), - }}, nil - } - - routes, err := client.GetHTTPRoutes(namespaces) - if err != nil { - // update "ResolvedRefs" status true with "RefNotPermitted" reason - return []metav1.Condition{{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.ListenerReasonRefNotPermitted), - Message: fmt.Sprintf("Cannot fetch HTTPRoutes: %v", err), - }}, nil - } - - if len(routes) == 0 { - log.Ctx(ctx).Debug().Msg("No HTTPRoutes found") - return nil, nil - } - - routeStatuses := map[ktypes.NamespacedName]gatev1.RouteParentStatus{} - for _, route := range routes { - routeNsName := ktypes.NamespacedName{Namespace: route.Namespace, Name: route.Name} - - parentRef, ok := shouldAttach(gateway, listener, route.Namespace, route.Spec.CommonRouteSpec) - if !ok { - // TODO: to add an invalid HTTPRoute status when no parent is matching, - // we have to start the attachment evaluation from the route not from the listeners. - // This will fix the HTTPRouteInvalidParentRefNotMatchingSectionName test. - continue - } - - routeConditions := []metav1.Condition{ - { - Type: string(gatev1.RouteConditionAccepted), - Status: metav1.ConditionTrue, - ObservedGeneration: route.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.RouteReasonAccepted), - }, - { - Type: string(gatev1.RouteConditionResolvedRefs), - Status: metav1.ConditionTrue, - ObservedGeneration: route.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.RouteConditionResolvedRefs), - }, - } - - hostnames := matchingHostnames(listener, route.Spec.Hostnames) - if len(hostnames) == 0 && listener.Hostname != nil && *listener.Hostname != "" && len(route.Spec.Hostnames) > 0 { - // TODO update the corresponding route parent status. - // https://gateway-api.sigs.k8s.io/v1alpha2/references/spec/#gateway.networking.k8s.io/v1alpha2.TLSRoute - continue - } - - hostRule, err := hostRule(hostnames) - if err != nil { - // TODO update the route status condition. - continue - } - - for _, routeRule := range route.Spec.Rules { - rule, err := extractRule(routeRule, hostRule) - if err != nil { - // TODO update the route status condition. - continue - } - - router := dynamic.Router{ - Rule: rule, - RuleSyntax: "v3", - EntryPoints: []string{ep}, - } - - if listener.Protocol == gatev1.HTTPSProtocolType && listener.TLS != nil { - // TODO support let's encrypt. - router.TLS = &dynamic.RouterTLSConfig{} - } - - // Adding the gateway desc and the entryPoint desc prevents overlapping of routers build from the same routes. - routerName := route.Name + "-" + gateway.Name + "-" + ep - routerKey, err := makeRouterKey(router.Rule, makeID(route.Namespace, routerName)) - if err != nil { - // TODO update the route status condition. - continue - } - - middlewares, err := p.loadMiddlewares(listener, route.Namespace, routerKey, routeRule.Filters) - if err != nil { - // TODO update the route status condition. - continue - } - - for middlewareName, middleware := range middlewares { - // If the middleware is not defined in the return of the loadMiddlewares function, it means we just need a reference to that middleware. - if middleware != nil { - conf.HTTP.Middlewares[middlewareName] = middleware - } - - router.Middlewares = append(router.Middlewares, middlewareName) - } - - if len(routeRule.BackendRefs) == 0 { - continue - } - - // Traefik internal service can be used only if there is only one BackendRef service reference. - if len(routeRule.BackendRefs) == 1 && isInternalService(routeRule.BackendRefs[0].BackendRef) { - router.Service = string(routeRule.BackendRefs[0].Name) - } else { - var wrr dynamic.WeightedRoundRobin - for _, backendRef := range routeRule.BackendRefs { - weight := ptr.To(int(ptr.Deref(backendRef.Weight, 1))) - - name, svc, errCondition := p.loadHTTPService(client, route, backendRef) - if errCondition != nil { - routeConditions = appendCondition(routeConditions, *errCondition) - wrr.Services = append(wrr.Services, dynamic.WRRService{ - Name: name, - Weight: weight, - Status: ptr.To(500), - }) - continue - } - - if svc != nil { - conf.HTTP.Services[name] = svc - } - - wrr.Services = append(wrr.Services, dynamic.WRRService{ - Name: name, - Weight: weight, - }) - } - - wrrName := provider.Normalize(routerKey + "-wrr") - conf.HTTP.Services[wrrName] = &dynamic.Service{Weighted: &wrr} - - router.Service = wrrName - } - - rt := &router - p.applyRouterTransform(ctx, rt, route) - - routerKey = provider.Normalize(routerKey) - conf.HTTP.Routers[routerKey] = rt - } - - routeStatuses[routeNsName] = gatev1.RouteParentStatus{ - ParentRef: parentRef, - ControllerName: controllerName, - Conditions: routeConditions, - } - } - - return nil, routeStatuses -} - -// loadHTTPService returns a dynamic.Service config corresponding to the given gatev1.HTTPBackendRef. -// Note that the returned dynamic.Service config can be nil (for cross-provider, internal services, and backendFunc). -func (p *Provider) loadHTTPService(client Client, route *gatev1.HTTPRoute, backendRef gatev1.HTTPBackendRef) (string, *dynamic.Service, *metav1.Condition) { - group := groupCore - if backendRef.Group != nil && *backendRef.Group != "" { - group = string(*backendRef.Group) - } - - kind := ptr.Deref(backendRef.Kind, "Service") - namespace := ptr.Deref(backendRef.Namespace, gatev1.Namespace(route.Namespace)) - namespaceStr := string(namespace) - serviceName := provider.Normalize(makeID(namespaceStr, string(backendRef.Name))) - - if group != groupCore || kind != "Service" { - // TODO support cross namespace through ReferencePolicy. - if namespaceStr != route.Namespace { - return serviceName, nil, &metav1.Condition{ - Type: string(gatev1.RouteConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: route.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.RouteReasonRefNotPermitted), - Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s/%s/%s namespace not allowed", group, kind, namespace, backendRef.Name), - } - } - - name, service, err := p.loadHTTPBackendRef(namespaceStr, backendRef) - if err != nil { - return serviceName, nil, &metav1.Condition{ - Type: string(gatev1.RouteConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: route.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.RouteReasonInvalidKind), - Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s/%s/%s: %s", group, kind, namespace, backendRef.Name, err), - } - } - - return name, service, nil - } - - port := ptr.Deref(backendRef.Port, gatev1.PortNumber(0)) - if port == 0 { - return serviceName, nil, &metav1.Condition{ - Type: string(gatev1.RouteConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: route.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.RouteReasonUnsupportedProtocol), - Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s/%s/%s port is required", group, kind, namespace, backendRef.Name), - } - } - - portStr := strconv.FormatInt(int64(port), 10) - serviceName = provider.Normalize(serviceName + "-" + portStr) - - lb, err := loadHTTPServers(client, namespaceStr, backendRef) - if err != nil { - return serviceName, nil, &metav1.Condition{ - Type: string(gatev1.RouteConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: route.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.RouteReasonBackendNotFound), - Message: fmt.Sprintf("Cannot load HTTPBackendRef %s/%s/%s/%s: %s", group, kind, namespace, backendRef.Name, err), - } - } - - return serviceName, &dynamic.Service{LoadBalancer: lb}, nil -} - -func (p *Provider) loadHTTPBackendRef(namespace string, backendRef gatev1.HTTPBackendRef) (string, *dynamic.Service, error) { - // Support for cross-provider references (e.g: api@internal). - // This provides the same behavior as for IngressRoutes. - if *backendRef.Kind == "TraefikService" && strings.Contains(string(backendRef.Name), "@") { - return string(backendRef.Name), nil, nil - } - - backendFunc, ok := p.groupKindBackendFuncs[string(*backendRef.Group)][string(*backendRef.Kind)] - if !ok { - return "", nil, fmt.Errorf("unsupported HTTPBackendRef %s/%s/%s", *backendRef.Group, *backendRef.Kind, backendRef.Name) - } - if backendFunc == nil { - return "", nil, fmt.Errorf("undefined backendFunc for HTTPBackendRef %s/%s/%s", *backendRef.Group, *backendRef.Kind, backendRef.Name) - } - - return backendFunc(string(backendRef.Name), namespace) -} - -func (p *Provider) loadMiddlewares(listener gatev1.Listener, namespace string, prefix string, filters []gatev1.HTTPRouteFilter) (map[string]*dynamic.Middleware, error) { - middlewares := make(map[string]*dynamic.Middleware) - - // The spec allows for an empty string in which case we should use the - // scheme of the request which in this case is the listener scheme. - var listenerScheme string - switch listener.Protocol { - case gatev1.HTTPProtocolType: - listenerScheme = "http" - case gatev1.HTTPSProtocolType: - listenerScheme = "https" - default: - return nil, fmt.Errorf("invalid listener protocol %s", listener.Protocol) - } - - for i, filter := range filters { - var middleware *dynamic.Middleware - switch filter.Type { - case gatev1.HTTPRouteFilterRequestRedirect: - var err error - middleware, err = createRedirectRegexMiddleware(listenerScheme, filter.RequestRedirect) - if err != nil { - return nil, fmt.Errorf("creating RedirectRegex middleware: %w", err) - } - - middlewareName := provider.Normalize(fmt.Sprintf("%s-%s-%d", prefix, strings.ToLower(string(filter.Type)), i)) - middlewares[middlewareName] = middleware - case gatev1.HTTPRouteFilterExtensionRef: - name, middleware, err := p.loadHTTPRouteFilterExtensionRef(namespace, filter.ExtensionRef) - if err != nil { - return nil, fmt.Errorf("unsupported filter %s: %w", filter.Type, err) - } - - middlewares[name] = middleware - - case gatev1.HTTPRouteFilterRequestHeaderModifier: - middlewareName := provider.Normalize(fmt.Sprintf("%s-%s-%d", prefix, strings.ToLower(string(filter.Type)), i)) - middlewares[middlewareName] = createRequestHeaderModifier(filter.RequestHeaderModifier) - - default: - // As per the spec: - // https://gateway-api.sigs.k8s.io/api-types/httproute/#filters-optional - // In all cases where incompatible or unsupported filters are - // specified, implementations MUST add a warning condition to - // status. - return nil, fmt.Errorf("unsupported filter %s", filter.Type) - } - } - - return middlewares, nil -} - -func (p *Provider) loadHTTPRouteFilterExtensionRef(namespace string, extensionRef *gatev1.LocalObjectReference) (string, *dynamic.Middleware, error) { - if extensionRef == nil { - return "", nil, errors.New("filter extension ref undefined") - } - - filterFunc, ok := p.groupKindFilterFuncs[string(extensionRef.Group)][string(extensionRef.Kind)] - if !ok { - return "", nil, fmt.Errorf("unsupported filter extension ref %s/%s/%s", extensionRef.Group, extensionRef.Kind, extensionRef.Name) - } - if filterFunc == nil { - return "", nil, fmt.Errorf("undefined filterFunc for filter extension ref %s/%s/%s", extensionRef.Group, extensionRef.Kind, extensionRef.Name) - } - - return filterFunc(string(extensionRef.Name), namespace) -} - -// TODO support cross namespace through ReferencePolicy. -func loadHTTPServers(client Client, namespace string, backendRef gatev1.HTTPBackendRef) (*dynamic.ServersLoadBalancer, error) { - service, exists, err := client.GetService(namespace, string(backendRef.Name)) - if err != nil { - return nil, fmt.Errorf("getting service: %w", err) - } - if !exists { - return nil, errors.New("service not found") - } - - var portSpec corev1.ServicePort - var match bool - - for _, p := range service.Spec.Ports { - if backendRef.Port == nil || p.Port == int32(*backendRef.Port) { - portSpec = p - match = true - break - } - } - if !match { - return nil, errors.New("service port not found") - } - - endpoints, endpointsExists, err := client.GetEndpoints(namespace, string(backendRef.Name)) - if err != nil { - return nil, fmt.Errorf("getting endpoints: %w", err) - } - if !endpointsExists { - return nil, errors.New("endpoints not found") - } - - if len(endpoints.Subsets) == 0 { - return nil, errors.New("subset not found") - } - - lb := &dynamic.ServersLoadBalancer{} - lb.SetDefaults() - - var port int32 - var portStr string - for _, subset := range endpoints.Subsets { - for _, p := range subset.Ports { - if portSpec.Name == p.Name { - port = p.Port - break - } - } - - if port == 0 { - return nil, errors.New("cannot define a port") - } - - protocol := getProtocol(portSpec) - - portStr = strconv.FormatInt(int64(port), 10) - for _, addr := range subset.Addresses { - lb.Servers = append(lb.Servers, dynamic.Server{ - URL: fmt.Sprintf("%s://%s", protocol, net.JoinHostPort(addr.IP, portStr)), - }) - } - } - - return lb, nil -} - -// loadTCPServices is generating a WRR service, even when there is only one target. -func loadTCPServices(client Client, namespace string, backendRefs []gatev1.BackendRef) (*dynamic.TCPService, map[string]*dynamic.TCPService, error) { - services := map[string]*dynamic.TCPService{} - - wrrSvc := &dynamic.TCPService{ - Weighted: &dynamic.TCPWeightedRoundRobin{ - Services: []dynamic.TCPWRRService{}, - }, - } - - for _, backendRef := range backendRefs { - if backendRef.Group == nil || backendRef.Kind == nil { - // Should not happen as this is validated by kubernetes - continue - } - - if isInternalService(backendRef) { - return nil, nil, fmt.Errorf("traefik internal service %s is not allowed in a WRR loadbalancer", backendRef.Name) - } - - weight := int(ptr.Deref(backendRef.Weight, 1)) - - if isTraefikService(backendRef) { - wrrSvc.Weighted.Services = append(wrrSvc.Weighted.Services, dynamic.TCPWRRService{Name: string(backendRef.Name), Weight: &weight}) - continue - } - - if *backendRef.Group != "" && *backendRef.Group != groupCore && *backendRef.Kind != "Service" { - return nil, nil, fmt.Errorf("unsupported BackendRef %s/%s/%s", *backendRef.Group, *backendRef.Kind, backendRef.Name) - } - - svc := dynamic.TCPService{ - LoadBalancer: &dynamic.TCPServersLoadBalancer{}, - } - - service, exists, err := client.GetService(namespace, string(backendRef.Name)) - if err != nil { - return nil, nil, err - } - - if !exists { - return nil, nil, errors.New("service not found") - } - - if len(service.Spec.Ports) > 1 && backendRef.Port == nil { - // If the port is unspecified and the backend is a Service - // object consisting of multiple port definitions, the route - // must be dropped from the Gateway. The controller should - // raise the "ResolvedRefs" condition on the Gateway with the - // "DroppedRoutes" reason. The gateway status for this route - // should be updated with a condition that describes the error - // more specifically. - log.Error().Msg("A multiple ports Kubernetes Service cannot be used if unspecified backendRef.Port") - continue - } - - var portSpec corev1.ServicePort - var match bool - - for _, p := range service.Spec.Ports { - if backendRef.Port == nil || p.Port == int32(*backendRef.Port) { - portSpec = p - match = true - break - } - } - - if !match { - return nil, nil, errors.New("service port not found") - } - - endpoints, endpointsExists, endpointsErr := client.GetEndpoints(namespace, string(backendRef.Name)) - if endpointsErr != nil { - return nil, nil, endpointsErr - } - - if !endpointsExists { - return nil, nil, errors.New("endpoints not found") - } - - if len(endpoints.Subsets) == 0 { - return nil, nil, errors.New("subset not found") - } - - var port int32 - var portStr string - for _, subset := range endpoints.Subsets { - for _, p := range subset.Ports { - if portSpec.Name == p.Name { - port = p.Port - break - } - } - - if port == 0 { - return nil, nil, errors.New("cannot define a port") - } - - portStr = strconv.FormatInt(int64(port), 10) - for _, addr := range subset.Addresses { - svc.LoadBalancer.Servers = append(svc.LoadBalancer.Servers, dynamic.TCPServer{ - Address: net.JoinHostPort(addr.IP, portStr), - }) - } - } - - serviceName := provider.Normalize(makeID(service.Namespace, service.Name) + "-" + portStr) - services[serviceName] = &svc - - wrrSvc.Weighted.Services = append(wrrSvc.Weighted.Services, dynamic.TCPWRRService{Name: serviceName, Weight: &weight}) - } - - if len(wrrSvc.Weighted.Services) == 0 { - return nil, nil, errors.New("no service has been created") - } - - return wrrSvc, services, nil -} - func supportedRouteKinds(protocol gatev1.ProtocolType, experimentalChannel bool) ([]gatev1.RouteGroupKind, []metav1.Condition) { group := gatev1.Group(gatev1.GroupName) @@ -1335,9 +800,9 @@ func supportedRouteKinds(protocol gatev1.ProtocolType, experimentalChannel bool) return nil, []metav1.Condition{{ Type: string(gatev1.ListenerConditionConflicted), - Status: metav1.ConditionFalse, + Status: metav1.ConditionTrue, LastTransitionTime: metav1.Now(), - Reason: string(gatev1.ListenerReasonInvalidRouteKinds), + Reason: string(gatev1.ListenerReasonProtocolConflict), Message: fmt.Sprintf("Protocol %q requires the experimental channel support to be enabled, please use the `experimentalChannel` option", protocol), }} @@ -1354,7 +819,7 @@ func supportedRouteKinds(protocol gatev1.ProtocolType, experimentalChannel bool) return nil, []metav1.Condition{{ Type: string(gatev1.ListenerConditionConflicted), - Status: metav1.ConditionFalse, + Status: metav1.ConditionTrue, LastTransitionTime: metav1.Now(), Reason: string(gatev1.ListenerReasonInvalidRouteKinds), Message: fmt.Sprintf("Protocol %q requires the experimental channel support to be enabled, please use the `experimentalChannel` option", protocol), @@ -1362,24 +827,21 @@ func supportedRouteKinds(protocol gatev1.ProtocolType, experimentalChannel bool) } return nil, []metav1.Condition{{ - Type: string(gatev1.ListenerConditionAccepted), - Status: metav1.ConditionFalse, + Type: string(gatev1.ListenerConditionConflicted), + Status: metav1.ConditionTrue, LastTransitionTime: metav1.Now(), Reason: string(gatev1.ListenerReasonUnsupportedProtocol), Message: fmt.Sprintf("Unsupported listener protocol %q", protocol), }} } -func getAllowedRouteKinds(gateway *gatev1.Gateway, listener gatev1.Listener, supportedKinds []gatev1.RouteGroupKind) ([]gatev1.RouteGroupKind, []metav1.Condition) { +func allowedRouteKinds(gateway *gatev1.Gateway, listener gatev1.Listener, supportedKinds []gatev1.RouteGroupKind) ([]gatev1.RouteGroupKind, []metav1.Condition) { if listener.AllowedRoutes == nil || len(listener.AllowedRoutes.Kinds) == 0 { return supportedKinds, nil } - var ( - routeKinds = []gatev1.RouteGroupKind{} - conditions []metav1.Condition - ) - + var conditions []metav1.Condition + routeKinds := []gatev1.RouteGroupKind{} uniqRouteKinds := map[gatev1.Kind]struct{}{} for _, routeKind := range listener.AllowedRoutes.Kinds { var isSupported bool @@ -1411,375 +873,7 @@ func getAllowedRouteKinds(gateway *gatev1.Gateway, listener gatev1.Listener, sup return routeKinds, conditions } -func gatewayTCPRouteToTCPConf(ctx context.Context, ep string, listener gatev1.Listener, gateway *gatev1.Gateway, client Client, conf *dynamic.Configuration) []metav1.Condition { - if listener.AllowedRoutes == nil { - // Should not happen due to validation. - return nil - } - - namespaces, err := getRouteBindingSelectorNamespace(client, gateway.Namespace, listener.AllowedRoutes.Namespaces) - if err != nil { - // update "ResolvedRefs" status true with "InvalidRoutesRef" reason - return []metav1.Condition{{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: "InvalidRouteNamespacesSelector", // TODO should never happen as the selector is validated by Kubernetes - Message: fmt.Sprintf("Invalid route namespaces selector: %v", err), - }} - } - - routes, err := client.GetTCPRoutes(namespaces) - if err != nil { - // update "ResolvedRefs" status true with "InvalidRoutesRef" reason - return []metav1.Condition{{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.ListenerReasonRefNotPermitted), - Message: fmt.Sprintf("Cannot fetch TCPRoutes: %v", err), - }} - } - - if len(routes) == 0 { - log.Ctx(ctx).Debug().Msg("No TCPRoutes found") - return nil - } - - var conditions []metav1.Condition - for _, route := range routes { - if _, ok := shouldAttach(gateway, listener, route.Namespace, route.Spec.CommonRouteSpec); !ok { - continue - } - - router := dynamic.TCPRouter{ - Rule: "HostSNI(`*`)", - EntryPoints: []string{ep}, - RuleSyntax: "v3", - } - - if listener.Protocol == gatev1.TLSProtocolType && listener.TLS != nil { - // TODO support let's encrypt - router.TLS = &dynamic.RouterTCPTLSConfig{ - Passthrough: listener.TLS.Mode != nil && *listener.TLS.Mode == gatev1.TLSModePassthrough, - } - } - - // Adding the gateway desc and the entryPoint desc prevents overlapping of routers build from the same routes. - routerName := route.Name + "-" + gateway.Name + "-" + ep - routerKey, err := makeRouterKey("", makeID(route.Namespace, routerName)) - if err != nil { - // update "ResolvedRefs" status true with "DroppedRoutes" reason - conditions = append(conditions, metav1.Condition{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: "InvalidRouterKey", // Should never happen - Message: fmt.Sprintf("Skipping TCPRoute %s: cannot make router's key with rule %s: %v", route.Name, router.Rule, err), - }) - - // TODO update the RouteStatus condition / deduplicate conditions on listener - continue - } - - routerKey = provider.Normalize(routerKey) - - var ruleServiceNames []string - for i, rule := range route.Spec.Rules { - if rule.BackendRefs == nil { - // Should not happen due to validation. - // https://github.com/kubernetes-sigs/gateway-api/blob/v0.4.0/apis/v1alpha2/tcproute_types.go#L76 - continue - } - - wrrService, subServices, err := loadTCPServices(client, route.Namespace, rule.BackendRefs) - if err != nil { - // update "ResolvedRefs" status true with "DroppedRoutes" reason - conditions = append(conditions, metav1.Condition{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: "InvalidBackendRefs", // TODO check the spec if a proper reason is introduced at some point - Message: fmt.Sprintf("Cannot load TCPRoute service %s/%s: %v", route.Namespace, route.Name, err), - }) - - // TODO update the RouteStatus condition / deduplicate conditions on listener - continue - } - - for svcName, svc := range subServices { - conf.TCP.Services[svcName] = svc - } - - serviceName := fmt.Sprintf("%s-wrr-%d", routerKey, i) - conf.TCP.Services[serviceName] = wrrService - - ruleServiceNames = append(ruleServiceNames, serviceName) - } - - if len(ruleServiceNames) == 1 { - router.Service = ruleServiceNames[0] - conf.TCP.Routers[routerKey] = &router - continue - } - - routeServiceKey := routerKey + "-wrr" - routeService := &dynamic.TCPService{Weighted: &dynamic.TCPWeightedRoundRobin{}} - - for _, name := range ruleServiceNames { - service := dynamic.TCPWRRService{Name: name} - service.SetDefaults() - - routeService.Weighted.Services = append(routeService.Weighted.Services, service) - } - - conf.TCP.Services[routeServiceKey] = routeService - - router.Service = routeServiceKey - conf.TCP.Routers[routerKey] = &router - } - - return conditions -} - -func gatewayTLSRouteToTCPConf(ctx context.Context, ep string, listener gatev1.Listener, gateway *gatev1.Gateway, client Client, conf *dynamic.Configuration) []metav1.Condition { - if listener.AllowedRoutes == nil { - // Should not happen due to validation. - return nil - } - - namespaces, err := getRouteBindingSelectorNamespace(client, gateway.Namespace, listener.AllowedRoutes.Namespaces) - if err != nil { - // update "ResolvedRefs" status true with "InvalidRoutesRef" reason - return []metav1.Condition{{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: "InvalidRouteNamespacesSelector", // TODO should never happen as the selector is validated by Kubernetes - Message: fmt.Sprintf("Invalid route namespaces selector: %v", err), - }} - } - - routes, err := client.GetTLSRoutes(namespaces) - if err != nil { - // update "ResolvedRefs" status true with "InvalidRoutesRef" reason - return []metav1.Condition{{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: string(gatev1.ListenerReasonRefNotPermitted), - Message: fmt.Sprintf("Cannot fetch TLSRoutes: %v", err), - }} - } - - if len(routes) == 0 { - log.Ctx(ctx).Debug().Msg("No TLSRoutes found") - return nil - } - - var conditions []metav1.Condition - for _, route := range routes { - if _, ok := shouldAttach(gateway, listener, route.Namespace, route.Spec.CommonRouteSpec); !ok { - continue - } - - hostnames := matchingHostnames(listener, route.Spec.Hostnames) - if len(hostnames) == 0 && listener.Hostname != nil && *listener.Hostname != "" && len(route.Spec.Hostnames) > 0 { - for _, parent := range route.Status.Parents { - parent.Conditions = append(parent.Conditions, metav1.Condition{ - Type: string(gatev1.GatewayClassConditionStatusAccepted), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - Reason: string(gatev1.ListenerReasonHostnameConflict), - Message: fmt.Sprintf("No hostname match between listener: %v and route: %v", listener.Hostname, route.Spec.Hostnames), - LastTransitionTime: metav1.Now(), - }) - } - - continue - } - - rule, err := hostSNIRule(hostnames) - if err != nil { - // update "ResolvedRefs" status true with "InvalidHostnames" reason - conditions = append(conditions, metav1.Condition{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: "InvalidHostnames", // TODO check the spec if a proper reason is introduced at some point - Message: fmt.Sprintf("Skipping TLSRoute %s: cannot make route's SNI match: %v", route.Name, err), - }) - // TODO update the RouteStatus condition / deduplicate conditions on listener - continue - } - - router := dynamic.TCPRouter{ - Rule: rule, - RuleSyntax: "v3", - EntryPoints: []string{ep}, - TLS: &dynamic.RouterTCPTLSConfig{ - Passthrough: listener.TLS.Mode != nil && *listener.TLS.Mode == gatev1.TLSModePassthrough, - }, - } - - // Adding the gateway desc and the entryPoint desc prevents overlapping of routers build from the same routes. - routerName := route.Name + "-" + gateway.Name + "-" + ep - routerKey, err := makeRouterKey(rule, makeID(route.Namespace, routerName)) - if err != nil { - // update "ResolvedRefs" status true with "DroppedRoutes" reason - conditions = append(conditions, metav1.Condition{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: "InvalidRouterKey", // Should never happen - Message: fmt.Sprintf("Skipping TLSRoute %s: cannot make router's key with rule %s: %v", route.Name, router.Rule, err), - }) - - // TODO update the RouteStatus condition / deduplicate conditions on listener - continue - } - - routerKey = provider.Normalize(routerKey) - - var ruleServiceNames []string - for i, routeRule := range route.Spec.Rules { - if len(routeRule.BackendRefs) == 0 { - // Should not happen due to validation. - // https://github.com/kubernetes-sigs/gateway-api/blob/v0.4.0/apis/v1alpha2/tlsroute_types.go#L120 - continue - } - - wrrService, subServices, err := loadTCPServices(client, route.Namespace, routeRule.BackendRefs) - if err != nil { - // update "ResolvedRefs" status true with "InvalidBackendRefs" reason - conditions = append(conditions, metav1.Condition{ - Type: string(gatev1.ListenerConditionResolvedRefs), - Status: metav1.ConditionFalse, - ObservedGeneration: gateway.Generation, - LastTransitionTime: metav1.Now(), - Reason: "InvalidBackendRefs", // TODO check the spec if a proper reason is introduced at some point - Message: fmt.Sprintf("Cannot load TLSRoute service %s/%s: %v", route.Namespace, route.Name, err), - }) - - // TODO update the RouteStatus condition / deduplicate conditions on listener - continue - } - - for svcName, svc := range subServices { - conf.TCP.Services[svcName] = svc - } - - serviceName := fmt.Sprintf("%s-wrr-%d", routerKey, i) - conf.TCP.Services[serviceName] = wrrService - - ruleServiceNames = append(ruleServiceNames, serviceName) - } - - if len(ruleServiceNames) == 1 { - router.Service = ruleServiceNames[0] - conf.TCP.Routers[routerKey] = &router - continue - } - - routeServiceKey := routerKey + "-wrr" - routeService := &dynamic.TCPService{Weighted: &dynamic.TCPWeightedRoundRobin{}} - - for _, name := range ruleServiceNames { - service := dynamic.TCPWRRService{Name: name} - service.SetDefaults() - - routeService.Weighted.Services = append(routeService.Weighted.Services, service) - } - - conf.TCP.Services[routeServiceKey] = routeService - - router.Service = routeServiceKey - conf.TCP.Routers[routerKey] = &router - } - - return conditions -} - -// Because of Kubernetes validation we admit that the given Hostnames are valid. -// https://github.com/kubernetes-sigs/gateway-api/blob/ff9883da4cad8554cd300394f725ab3a27502785/apis/v1alpha2/shared_types.go#L252 -func matchingHostnames(listener gatev1.Listener, hostnames []gatev1.Hostname) []gatev1.Hostname { - if listener.Hostname == nil || *listener.Hostname == "" { - return hostnames - } - - if len(hostnames) == 0 { - return []gatev1.Hostname{*listener.Hostname} - } - - listenerLabels := strings.Split(string(*listener.Hostname), ".") - - var matches []gatev1.Hostname - - for _, hostname := range hostnames { - if hostname == *listener.Hostname { - matches = append(matches, hostname) - continue - } - - hostnameLabels := strings.Split(string(hostname), ".") - if len(listenerLabels) != len(hostnameLabels) { - continue - } - - if !slices.Equal(listenerLabels[1:], hostnameLabels[1:]) { - continue - } - - if listenerLabels[0] == "*" { - matches = append(matches, hostname) - continue - } - - if hostnameLabels[0] == "*" { - matches = append(matches, *listener.Hostname) - continue - } - } - - return matches -} - -func shouldAttach(gateway *gatev1.Gateway, listener gatev1.Listener, routeNamespace string, routeSpec gatev1.CommonRouteSpec) (gatev1.ParentReference, bool) { - for _, parentRef := range routeSpec.ParentRefs { - if parentRef.Group == nil || *parentRef.Group != gatev1.GroupName { - continue - } - - if parentRef.Kind == nil || *parentRef.Kind != kindGateway { - continue - } - - if parentRef.SectionName != nil && *parentRef.SectionName != listener.Name { - continue - } - - namespace := routeNamespace - if parentRef.Namespace != nil { - namespace = string(*parentRef.Namespace) - } - - if namespace == gateway.Namespace && string(parentRef.Name) == gateway.Name { - return parentRef, true - } - } - - return gatev1.ParentReference{}, false -} - -func getRouteBindingSelectorNamespace(client Client, gatewayNamespace string, routeNamespaces *gatev1.RouteNamespaces) ([]string, error) { +func allowedNamespaces(client Client, gatewayNamespace string, routeNamespaces *gatev1.RouteNamespaces) ([]string, error) { if routeNamespaces == nil || routeNamespaces.From == nil { return []string{gatewayNamespace}, nil } @@ -1797,183 +891,111 @@ func getRouteBindingSelectorNamespace(client Client, gatewayNamespace string, ro return nil, fmt.Errorf("malformed selector: %w", err) } - return client.GetNamespaces(selector) + return client.ListNamespaces(selector) } return nil, fmt.Errorf("unsupported RouteSelectType: %q", *routeNamespaces.From) } -func hostRule(hostnames []gatev1.Hostname) (string, error) { - var rules []string +func findMatchingHostnames(listenerHostname *gatev1.Hostname, routeHostnames []gatev1.Hostname) ([]gatev1.Hostname, bool) { + if listenerHostname == nil { + return routeHostnames, true + } - for _, hostname := range hostnames { - host := string(hostname) - // When unspecified, "", or *, all hostnames are matched. - // This field can be omitted for protocols that don't require hostname based matching. - // TODO Refactor this when building support for TLS options. - if host == "*" || host == "" { - return "", nil - } + if len(routeHostnames) == 0 { + return []gatev1.Hostname{*listenerHostname}, true + } - wildcard := strings.Count(host, "*") - if wildcard == 0 { - rules = append(rules, fmt.Sprintf("Host(`%s`)", host)) + var matches []gatev1.Hostname + for _, routeHostname := range routeHostnames { + if match := findMatchingHostname(*listenerHostname, routeHostname); match != "" { + matches = append(matches, match) continue } - // https://gateway-api.sigs.k8s.io/v1alpha2/references/spec/#gateway.networking.k8s.io/v1alpha2.Hostname - if !strings.HasPrefix(host, "*.") || wildcard > 1 { - return "", fmt.Errorf("invalid rule: %q", host) + if match := findMatchingHostname(routeHostname, *listenerHostname); match != "" { + matches = append(matches, match) + continue } - - host = strings.Replace(regexp.QuoteMeta(host), `\*\.`, `[a-zA-Z0-9-]+\.`, 1) - rules = append(rules, fmt.Sprintf("HostRegexp(`^%s$`)", host)) } - switch len(rules) { - case 0: - return "", nil - case 1: - return rules[0], nil - default: - return fmt.Sprintf("(%s)", strings.Join(rules, " || ")), nil - } + return matches, len(matches) > 0 } -func hostSNIRule(hostnames []gatev1.Hostname) (string, error) { - rules := make([]string, 0, len(hostnames)) - uniqHostnames := map[gatev1.Hostname]struct{}{} - - for _, hostname := range hostnames { - if len(hostname) == 0 { - continue - } - - if _, exists := uniqHostnames[hostname]; exists { - continue - } - - host := string(hostname) - uniqHostnames[hostname] = struct{}{} - - wildcard := strings.Count(host, "*") - if wildcard == 0 { - rules = append(rules, fmt.Sprintf("HostSNI(`%s`)", host)) - continue - } - - if !strings.HasPrefix(host, "*.") || wildcard > 1 { - return "", fmt.Errorf("invalid rule: %q", host) - } - - host = strings.Replace(regexp.QuoteMeta(host), `\*\.`, `[a-zA-Z0-9-]+\.`, 1) - rules = append(rules, fmt.Sprintf("HostSNIRegexp(`^%s$`)", host)) +func findMatchingHostname(h1, h2 gatev1.Hostname) gatev1.Hostname { + if h1 == h2 { + return h1 } - if len(hostnames) == 0 || len(rules) == 0 { - return "HostSNI(`*`)", nil + if !strings.HasPrefix(string(h1), "*.") { + return "" } - return strings.Join(rules, " || "), nil + trimmedH1 := strings.TrimPrefix(string(h1), "*") + // root domain doesn't match subdomain wildcard. + if trimmedH1 == string(h2) { + return "" + } + + if !strings.HasSuffix(string(h2), trimmedH1) { + return "" + } + + return lessWildcards(h1, h2) } -func extractRule(routeRule gatev1.HTTPRouteRule, hostRule string) (string, error) { - var rule string - var matchesRules []string - - for _, match := range routeRule.Matches { - if (match.Path == nil || match.Path.Type == nil) && match.Headers == nil { - continue - } - - var matchRules []string - - if match.Path != nil && match.Path.Type != nil && match.Path.Value != nil { - switch *match.Path.Type { - case gatev1.PathMatchExact: - matchRules = append(matchRules, fmt.Sprintf("Path(`%s`)", *match.Path.Value)) - case gatev1.PathMatchPathPrefix: - matchRules = append(matchRules, buildPathMatchPathPrefixRule(*match.Path.Value)) - case gatev1.PathMatchRegularExpression: - matchRules = append(matchRules, fmt.Sprintf("PathRegexp(`%s`)", *match.Path.Value)) - default: - return "", fmt.Errorf("unsupported path match type %s", *match.Path.Type) - } - } - - headerRules, err := extractHeaderRules(match.Headers) - if err != nil { - return "", err - } - - matchRules = append(matchRules, headerRules...) - matchesRules = append(matchesRules, strings.Join(matchRules, " && ")) +func lessWildcards(h1, h2 gatev1.Hostname) gatev1.Hostname { + if strings.Count(string(h1), "*") > strings.Count(string(h2), "*") { + return h2 } - // If no matches are specified, the default is a prefix - // path match on "/", which has the effect of matching every - // HTTP request. - if len(routeRule.Matches) == 0 { - matchesRules = append(matchesRules, "PathPrefix(`/`)") - } - - if hostRule != "" { - if len(matchesRules) == 0 { - return hostRule, nil - } - rule += hostRule + " && " - } - - if len(matchesRules) == 1 { - return rule + matchesRules[0], nil - } - - if len(rule) == 0 { - return strings.Join(matchesRules, " || "), nil - } - - return rule + "(" + strings.Join(matchesRules, " || ") + ")", nil + return h1 } -func extractHeaderRules(headers []gatev1.HTTPHeaderMatch) ([]string, error) { - var headerRules []string - - // TODO handle other headers types - for _, header := range headers { - if header.Type == nil { - // Should never happen due to kubernetes validation. - continue - } - - switch *header.Type { - case gatev1.HeaderMatchExact: - headerRules = append(headerRules, fmt.Sprintf("Header(`%s`,`%s`)", header.Name, header.Value)) - default: - return nil, fmt.Errorf("unsupported header match type %s", *header.Type) - } +func allowRoute(listener gatewayListener, routeNamespace, routeKind string) bool { + if !slices.Contains(listener.AllowedRouteKinds, routeKind) { + return false } - return headerRules, nil + return slices.ContainsFunc(listener.AllowedNamespaces, func(allowedNamespace string) bool { + return allowedNamespace == corev1.NamespaceAll || allowedNamespace == routeNamespace + }) } -func buildPathMatchPathPrefixRule(path string) string { - if path == "/" { - return "PathPrefix(`/`)" +func matchListener(listener gatewayListener, routeNamespace string, parentRef gatev1.ParentReference) bool { + if ptr.Deref(parentRef.Group, gatev1.GroupName) != gatev1.GroupName { + return false } - path = strings.TrimSuffix(path, "/") - return fmt.Sprintf("(Path(`%[1]s`) || PathPrefix(`%[1]s/`))", path) + if ptr.Deref(parentRef.Kind, kindGateway) != kindGateway { + return false + } + + parentRefNamespace := string(ptr.Deref(parentRef.Namespace, gatev1.Namespace(routeNamespace))) + if listener.GWNamespace != parentRefNamespace { + return false + } + + if string(parentRef.Name) != listener.GWName { + return false + } + + sectionName := string(ptr.Deref(parentRef.SectionName, "")) + if sectionName != "" && sectionName != listener.Name { + return false + } + + return true } -func makeRouterKey(rule, name string) (string, error) { +func makeRouterKey(rule, name string) string { h := sha256.New() - if _, err := h.Write([]byte(rule)); err != nil { - return "", err - } - key := fmt.Sprintf("%s-%.10x", name, h.Sum(nil)) + // As explained in https://pkg.go.dev/hash#Hash, + // Write never returns an error. + h.Write([]byte(rule)) - return key, nil + return fmt.Sprintf("%s-%.10x", name, h.Sum(nil)) } func makeID(namespace, name string) string { @@ -2057,76 +1079,6 @@ func getCertificateBlocks(secret *corev1.Secret, namespace, secretName string) ( return cert, key, nil } -// createRequestHeaderModifier does not enforce/check the configuration, -// as the spec indicates that either the webhook or CEL (since v1.0 GA Release) should enforce that. -func createRequestHeaderModifier(filter *gatev1.HTTPHeaderFilter) *dynamic.Middleware { - sets := map[string]string{} - for _, header := range filter.Set { - sets[string(header.Name)] = header.Value - } - - adds := map[string]string{} - for _, header := range filter.Add { - adds[string(header.Name)] = header.Value - } - - return &dynamic.Middleware{ - RequestHeaderModifier: &dynamic.RequestHeaderModifier{ - Set: sets, - Add: adds, - Remove: filter.Remove, - }, - } -} - -func createRedirectRegexMiddleware(scheme string, filter *gatev1.HTTPRequestRedirectFilter) (*dynamic.Middleware, error) { - // Use the HTTPRequestRedirectFilter scheme if defined. - filterScheme := scheme - if filter.Scheme != nil { - filterScheme = *filter.Scheme - } - - if filterScheme != "http" && filterScheme != "https" { - return nil, fmt.Errorf("invalid scheme %s", filterScheme) - } - - statusCode := http.StatusFound - if filter.StatusCode != nil { - statusCode = *filter.StatusCode - } - - if statusCode != http.StatusMovedPermanently && statusCode != http.StatusFound { - return nil, fmt.Errorf("invalid status code %d", statusCode) - } - - port := "${port}" - if filter.Port != nil { - port = fmt.Sprintf(":%d", *filter.Port) - } - - hostname := "${hostname}" - if filter.Hostname != nil && *filter.Hostname != "" { - hostname = string(*filter.Hostname) - } - - return &dynamic.Middleware{ - RedirectRegex: &dynamic.RedirectRegex{ - Regex: `^[a-z]+:\/\/(?P.+@)?(?P\[[\w:\.]+\]|[\w\._-]+)(?P:\d+)?\/(?P.*)`, - Replacement: fmt.Sprintf("%s://${userinfo}%s%s/${path}", filterScheme, hostname, port), - Permanent: statusCode == http.StatusMovedPermanently, - }, - }, nil -} - -func getProtocol(portSpec corev1.ServicePort) string { - protocol := "http" - if portSpec.Port == 443 || strings.HasPrefix(portSpec.Name, "https") { - protocol = "https" - } - - return protocol -} - func throttleEvents(ctx context.Context, throttleDuration time.Duration, pool *safe.Pool, eventsChan <-chan interface{}) chan interface{} { if throttleDuration == 0 { return nil @@ -2241,74 +1193,6 @@ func kindToString(p *gatev1.Kind) string { return string(*p) } -func makeHTTPRouteStatuses(gwNs string, routeParentStatuses map[ktypes.NamespacedName][]gatev1.RouteParentStatus) map[ktypes.NamespacedName]gatev1.HTTPRouteStatus { - res := map[ktypes.NamespacedName]gatev1.HTTPRouteStatus{} - - for nsName, parentStatuses := range routeParentStatuses { - var httpRouteStatus gatev1.HTTPRouteStatus - for _, parentStatus := range parentStatuses { - exists := slices.ContainsFunc(httpRouteStatus.Parents, func(status gatev1.RouteParentStatus) bool { - return parentRefEquals(gwNs, parentStatus.ParentRef, status.ParentRef) - }) - if !exists { - httpRouteStatus.Parents = append(httpRouteStatus.Parents, parentStatus) - } - } - - res[nsName] = httpRouteStatus - } - - return res -} - -func parentRefEquals(gwNs string, p1, p2 gatev1.ParentReference) bool { - if !pointerEquals(p1.Group, p2.Group) { - return false - } - - if !pointerEquals(p1.Kind, p2.Kind) { - return false - } - - if !pointerEquals(p1.SectionName, p2.SectionName) { - return false - } - - if p1.Name != p2.Name { - return false - } - - p1Ns := gwNs - if p1.Namespace != nil { - p1Ns = string(*p1.Namespace) - } - - p2Ns := gwNs - if p2.Namespace != nil { - p2Ns = string(*p2.Namespace) - } - - return p1Ns == p2Ns -} - -func pointerEquals[T comparable](p1, p2 *T) bool { - if p1 == nil && p2 == nil { - return true - } - - var val1 T - if p1 != nil { - val1 = *p1 - } - - var val2 T - if p2 != nil { - val2 = *p2 - } - - return val1 == val2 -} - func appendCondition(conditions []metav1.Condition, condition metav1.Condition) []metav1.Condition { res := []metav1.Condition{condition} for _, c := range conditions { diff --git a/pkg/provider/kubernetes/gateway/kubernetes_test.go b/pkg/provider/kubernetes/gateway/kubernetes_test.go index d83ea3e38..033f19420 100644 --- a/pkg/provider/kubernetes/gateway/kubernetes_test.go +++ b/pkg/provider/kubernetes/gateway/kubernetes_test.go @@ -17,6 +17,7 @@ import ( "github.com/traefik/traefik/v3/pkg/provider/kubernetes/k8s" "github.com/traefik/traefik/v3/pkg/tls" "github.com/traefik/traefik/v3/pkg/types" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" kubefake "k8s.io/client-go/kubernetes/fake" @@ -484,32 +485,6 @@ func TestLoadHTTPRoutes(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, - { - desc: "Empty caused unsupported HTTPRoute rule", - entryPoints: map[string]Entrypoint{"web": { - Address: ":80", - }}, - paths: []string{"services.yml", "httproute/simple_with_bad_rule.yml"}, - expected: &dynamic.Configuration{ - UDP: &dynamic.UDPConfiguration{ - Routers: map[string]*dynamic.UDPRouter{}, - Services: map[string]*dynamic.UDPService{}, - }, - TCP: &dynamic.TCPConfiguration{ - Routers: map[string]*dynamic.TCPRouter{}, - Middlewares: map[string]*dynamic.TCPMiddleware{}, - Services: map[string]*dynamic.TCPService{}, - ServersTransports: map[string]*dynamic.TCPServersTransport{}, - }, - HTTP: &dynamic.HTTPConfiguration{ - Routers: map[string]*dynamic.Router{}, - Middlewares: map[string]*dynamic.Middleware{}, - Services: map[string]*dynamic.Service{}, - ServersTransports: map[string]*dynamic.ServersTransport{}, - }, - TLS: &dynamic.TLSConfiguration{}, - }, - }, { desc: "Empty because no tcp route defined tls protocol", paths: []string{"services.yml", "tcproute/without_tcproute_tls_protocol.yml"}, @@ -814,16 +789,16 @@ func TestLoadHTTPRoutes(t *testing.T) { }, HTTP: &dynamic.HTTPConfiguration{ Routers: map[string]*dynamic.Router{ - "default-http-app-1-my-gateway-web-3b78e2feb3295ddd87f0": { + "default-http-app-1-my-gateway-web-baa117c0219e3878749f": { EntryPoints: []string{"web"}, - Service: "default-http-app-1-my-gateway-web-3b78e2feb3295ddd87f0-wrr", - Rule: "(Host(`foo.com`) || HostRegexp(`^[a-zA-Z0-9-]+\\.bar\\.com$`)) && PathPrefix(`/`)", + Service: "default-http-app-1-my-gateway-web-baa117c0219e3878749f-wrr", + Rule: "(Host(`foo.com`) || HostRegexp(`^[a-z0-9-\\.]+\\.bar\\.com$`)) && PathPrefix(`/`)", RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, Services: map[string]*dynamic.Service{ - "default-http-app-1-my-gateway-web-3b78e2feb3295ddd87f0-wrr": { + "default-http-app-1-my-gateway-web-baa117c0219e3878749f-wrr": { Weighted: &dynamic.WeightedRoundRobin{ Services: []dynamic.WRRService{ { @@ -874,16 +849,16 @@ func TestLoadHTTPRoutes(t *testing.T) { }, HTTP: &dynamic.HTTPConfiguration{ Routers: map[string]*dynamic.Router{ - "default-http-app-1-my-gateway-web-b0521a61fb43068694b4": { + "default-http-app-1-my-gateway-web-45eba2eaf40ac792e036": { EntryPoints: []string{"web"}, - Service: "default-http-app-1-my-gateway-web-b0521a61fb43068694b4-wrr", - Rule: "(Host(`foo.com`) || HostRegexp(`^[a-zA-Z0-9-]+\\.foo\\.com$`)) && PathPrefix(`/`)", + Service: "default-http-app-1-my-gateway-web-45eba2eaf40ac792e036-wrr", + Rule: "(Host(`foo.com`) || HostRegexp(`^[a-z0-9-\\.]+\\.foo\\.com$`)) && PathPrefix(`/`)", RuleSyntax: "v3", }, }, Middlewares: map[string]*dynamic.Middleware{}, Services: map[string]*dynamic.Service{ - "default-http-app-1-my-gateway-web-b0521a61fb43068694b4-wrr": { + "default-http-app-1-my-gateway-web-45eba2eaf40ac792e036-wrr": { Weighted: &dynamic.WeightedRoundRobin{ Services: []dynamic.WRRService{ { @@ -1809,7 +1784,7 @@ func TestLoadHTTPRoutes(t *testing.T) { <-eventCh } - conf := p.loadConfigurationFromGateway(context.Background(), client) + conf := p.loadConfigurationFromGateways(context.Background(), client) assert.Equal(t, test.expected, conf) }) } @@ -2125,7 +2100,7 @@ func TestLoadHTTPRoutes_backendExtensionRef(t *testing.T) { p.RegisterBackendFuncs(group, kind, backendFunc) } } - conf := p.loadConfigurationFromGateway(context.Background(), client) + conf := p.loadConfigurationFromGateways(context.Background(), client) assert.Equal(t, test.expected, conf) }) } @@ -2287,9 +2262,28 @@ func TestLoadHTTPRoutes_filterExtensionRef(t *testing.T) { ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, HTTP: &dynamic.HTTPConfiguration{ - Routers: map[string]*dynamic.Router{}, - Middlewares: map[string]*dynamic.Middleware{}, - Services: map[string]*dynamic.Service{}, + Routers: map[string]*dynamic.Router{ + "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06": { + EntryPoints: []string{"web"}, + Service: "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", + Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr": { + Weighted: &dynamic.WeightedRoundRobin{ + Services: []dynamic.WRRService{ + { + Name: "invalid-httproute-filter", + Weight: ptr.To(1), + Status: ptr.To(500), + }, + }, + }, + }, + }, ServersTransports: map[string]*dynamic.ServersTransport{}, }, TLS: &dynamic.TLSConfiguration{}, @@ -2317,9 +2311,28 @@ func TestLoadHTTPRoutes_filterExtensionRef(t *testing.T) { ServersTransports: map[string]*dynamic.TCPServersTransport{}, }, HTTP: &dynamic.HTTPConfiguration{ - Routers: map[string]*dynamic.Router{}, - Middlewares: map[string]*dynamic.Middleware{}, - Services: map[string]*dynamic.Service{}, + Routers: map[string]*dynamic.Router{ + "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06": { + EntryPoints: []string{"web"}, + Service: "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr", + Rule: "Host(`foo.com`) && Path(`/bar`)", + RuleSyntax: "v3", + }, + }, + Middlewares: map[string]*dynamic.Middleware{}, + Services: map[string]*dynamic.Service{ + "default-http-app-1-my-gateway-web-1c0cf64bde37d9d0df06-wrr": { + Weighted: &dynamic.WeightedRoundRobin{ + Services: []dynamic.WRRService{ + { + Name: "invalid-httproute-filter", + Weight: ptr.To(1), + Status: ptr.To(500), + }, + }, + }, + }, + }, ServersTransports: map[string]*dynamic.ServersTransport{}, }, TLS: &dynamic.TLSConfiguration{}, @@ -2357,7 +2370,7 @@ func TestLoadHTTPRoutes_filterExtensionRef(t *testing.T) { p.RegisterFilterFuncs(group, kind, filterFunc) } } - conf := p.loadConfigurationFromGateway(context.Background(), client) + conf := p.loadConfigurationFromGateways(context.Background(), client) assert.Equal(t, test.expected, conf) }) } @@ -3136,7 +3149,7 @@ func TestLoadTCPRoutes(t *testing.T) { <-eventCh } - conf := p.loadConfigurationFromGateway(context.Background(), client) + conf := p.loadConfigurationFromGateways(context.Background(), client) assert.Equal(t, test.expected, conf) }) } @@ -3299,7 +3312,16 @@ func TestLoadTLSRoutes(t *testing.T) { Services: map[string]*dynamic.Service{}, ServersTransports: map[string]*dynamic.ServersTransport{}, }, - TLS: &dynamic.TLSConfiguration{}, + TLS: &dynamic.TLSConfiguration{ + Certificates: []*tls.CertAndStores{ + { + Certificate: tls.Certificate{ + CertFile: types.FileOrContent("-----BEGIN CERTIFICATE-----\n-----END CERTIFICATE-----"), + KeyFile: types.FileOrContent("-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----"), + }, + }, + }, + }, }, }, { @@ -3336,32 +3358,6 @@ func TestLoadTLSRoutes(t *testing.T) { TLS: &dynamic.TLSConfiguration{}, }, }, - { - desc: "Empty caused by simple TLSRoute with invalid SNI matching", - paths: []string{"services.yml", "tlsroute/with_invalid_SNI_matching.yml"}, - entryPoints: map[string]Entrypoint{ - "tls": {Address: ":9001"}, - }, - expected: &dynamic.Configuration{ - UDP: &dynamic.UDPConfiguration{ - Routers: map[string]*dynamic.UDPRouter{}, - Services: map[string]*dynamic.UDPService{}, - }, - TCP: &dynamic.TCPConfiguration{ - Routers: map[string]*dynamic.TCPRouter{}, - Middlewares: map[string]*dynamic.TCPMiddleware{}, - Services: map[string]*dynamic.TCPService{}, - ServersTransports: map[string]*dynamic.TCPServersTransport{}, - }, - HTTP: &dynamic.HTTPConfiguration{ - Routers: map[string]*dynamic.Router{}, - Middlewares: map[string]*dynamic.Middleware{}, - Services: map[string]*dynamic.Service{}, - ServersTransports: map[string]*dynamic.ServersTransport{}, - }, - TLS: &dynamic.TLSConfiguration{}, - }, - }, { desc: "Simple TLS listener to TCPRoute in Terminate mode", paths: []string{"services.yml", "tlsroute/simple_TLS_to_TCPRoute.yml"}, @@ -4284,7 +4280,7 @@ func TestLoadTLSRoutes(t *testing.T) { <-eventCh } - conf := p.loadConfigurationFromGateway(context.Background(), client) + conf := p.loadConfigurationFromGateways(context.Background(), client) assert.Equal(t, test.expected, conf) }) } @@ -5317,7 +5313,7 @@ func TestLoadMixedRoutes(t *testing.T) { <-eventCh } - conf := p.loadConfigurationFromGateway(context.Background(), client) + conf := p.loadConfigurationFromGateways(context.Background(), client) assert.Equal(t, test.expected, conf) }) } @@ -5528,747 +5524,135 @@ func TestLoadRoutesWithReferenceGrants(t *testing.T) { <-eventCh } - conf := p.loadConfigurationFromGateway(context.Background(), client) + conf := p.loadConfigurationFromGateways(context.Background(), client) assert.Equal(t, test.expected, conf) }) } } -func Test_hostRule(t *testing.T) { - testCases := []struct { - desc string - hostnames []gatev1.Hostname - expectedRule string - expectErr bool - }{ - { - desc: "Empty rule and matches", - expectedRule: "", - }, - { - desc: "One Host", - hostnames: []gatev1.Hostname{ - "Foo", - }, - expectedRule: "Host(`Foo`)", - }, - { - desc: "Multiple Hosts", - hostnames: []gatev1.Hostname{ - "Foo", - "Bar", - "Bir", - }, - expectedRule: "(Host(`Foo`) || Host(`Bar`) || Host(`Bir`))", - }, - { - desc: "Multiple Hosts with empty one", - hostnames: []gatev1.Hostname{ - "Foo", - "", - "Bir", - }, - expectedRule: "", - }, - { - desc: "Multiple empty hosts", - hostnames: []gatev1.Hostname{ - "", - "", - "", - }, - expectedRule: "", - }, - { - desc: "Several Host and wildcard", - hostnames: []gatev1.Hostname{ - "*.bar.foo", - "bar.foo", - "foo.foo", - }, - expectedRule: "(HostRegexp(`^[a-zA-Z0-9-]+\\.bar\\.foo$`) || Host(`bar.foo`) || Host(`foo.foo`))", - }, - { - desc: "Host with wildcard", - hostnames: []gatev1.Hostname{ - "*.bar.foo", - }, - expectedRule: "HostRegexp(`^[a-zA-Z0-9-]+\\.bar\\.foo$`)", - }, - { - desc: "Alone wildcard", - hostnames: []gatev1.Hostname{ - "*", - "*.foo.foo", - }, - }, - { - desc: "Multiple alone Wildcard", - hostnames: []gatev1.Hostname{ - "foo.foo", - "*.*", - }, - expectErr: true, - }, - { - desc: "Multiple Wildcard", - hostnames: []gatev1.Hostname{ - "foo.foo", - "*.toto.*.bar.foo", - }, - expectErr: true, - }, - { - desc: "Multiple subdomain with misplaced wildcard", - hostnames: []gatev1.Hostname{ - "foo.foo", - "toto.*.bar.foo", - }, - expectErr: true, - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - rule, err := hostRule(test.hostnames) - - assert.Equal(t, test.expectedRule, rule) - if test.expectErr { - assert.Error(t, err) - } - }) - } -} - -func Test_extractRule(t *testing.T) { - testCases := []struct { - desc string - routeRule gatev1.HTTPRouteRule - hostRule string - expectedRule string - expectedError bool - }{ - { - desc: "Empty rule and matches", - expectedRule: "PathPrefix(`/`)", - }, - { - desc: "One Host rule without matches", - hostRule: "Host(`foo.com`)", - expectedRule: "Host(`foo.com`) && PathPrefix(`/`)", - }, - { - desc: "One HTTPRouteMatch with nil HTTPHeaderMatch", - routeRule: gatev1.HTTPRouteRule{ - Matches: []gatev1.HTTPRouteMatch{ - {Headers: nil}, - }, - }, - expectedRule: "", - }, - { - desc: "One HTTPRouteMatch with nil HTTPHeaderMatch Type", - routeRule: gatev1.HTTPRouteRule{ - Matches: []gatev1.HTTPRouteMatch{ - { - Headers: []gatev1.HTTPHeaderMatch{ - {Type: nil, Name: "foo", Value: "bar"}, - }, - }, - }, - }, - expectedRule: "", - }, - { - desc: "One HTTPRouteMatch with nil HTTPPathMatch", - routeRule: gatev1.HTTPRouteRule{ - Matches: []gatev1.HTTPRouteMatch{ - {Path: nil}, - }, - }, - expectedRule: "", - }, - { - desc: "One HTTPRouteMatch with nil HTTPPathMatch Type", - routeRule: gatev1.HTTPRouteRule{ - Matches: []gatev1.HTTPRouteMatch{ - { - Path: &gatev1.HTTPPathMatch{ - Type: nil, - Value: ptr.To("/foo/"), - }, - }, - }, - }, - expectedRule: "", - }, - { - desc: "One HTTPRouteMatch with nil HTTPPathMatch Values", - routeRule: gatev1.HTTPRouteRule{ - Matches: []gatev1.HTTPRouteMatch{ - { - Path: &gatev1.HTTPPathMatch{ - Type: pathMatchTypePtr(gatev1.PathMatchExact), - Value: nil, - }, - }, - }, - }, - expectedRule: "", - }, - { - desc: "One Path in matches", - routeRule: gatev1.HTTPRouteRule{ - Matches: []gatev1.HTTPRouteMatch{ - { - Path: &gatev1.HTTPPathMatch{ - Type: pathMatchTypePtr(gatev1.PathMatchExact), - Value: ptr.To("/foo/"), - }, - }, - }, - }, - expectedRule: "Path(`/foo/`)", - }, - { - desc: "One Path in matches and another unknown", - routeRule: gatev1.HTTPRouteRule{ - Matches: []gatev1.HTTPRouteMatch{ - { - Path: &gatev1.HTTPPathMatch{ - Type: pathMatchTypePtr(gatev1.PathMatchExact), - Value: ptr.To("/foo/"), - }, - }, - { - Path: &gatev1.HTTPPathMatch{ - Type: pathMatchTypePtr("unknown"), - Value: ptr.To("/foo/"), - }, - }, - }, - }, - expectedError: true, - }, - { - desc: "One Path in matches and another empty", - routeRule: gatev1.HTTPRouteRule{ - Matches: []gatev1.HTTPRouteMatch{ - { - Path: &gatev1.HTTPPathMatch{ - Type: pathMatchTypePtr(gatev1.PathMatchExact), - Value: ptr.To("/foo/"), - }, - }, - {}, - }, - }, - expectedRule: "Path(`/foo/`)", - }, - { - desc: "Path OR Header rules", - routeRule: gatev1.HTTPRouteRule{ - Matches: []gatev1.HTTPRouteMatch{ - { - Path: &gatev1.HTTPPathMatch{ - Type: pathMatchTypePtr(gatev1.PathMatchExact), - Value: ptr.To("/foo/"), - }, - }, - { - Headers: []gatev1.HTTPHeaderMatch{ - { - Type: headerMatchTypePtr(gatev1.HeaderMatchExact), - Name: "my-header", - Value: "foo", - }, - }, - }, - }, - }, - expectedRule: "Path(`/foo/`) || Header(`my-header`,`foo`)", - }, - { - desc: "Path && Header rules", - routeRule: gatev1.HTTPRouteRule{ - Matches: []gatev1.HTTPRouteMatch{ - { - Path: &gatev1.HTTPPathMatch{ - Type: pathMatchTypePtr(gatev1.PathMatchExact), - Value: ptr.To("/foo/"), - }, - Headers: []gatev1.HTTPHeaderMatch{ - { - Type: headerMatchTypePtr(gatev1.HeaderMatchExact), - Name: "my-header", - Value: "foo", - }, - }, - }, - }, - }, - expectedRule: "Path(`/foo/`) && Header(`my-header`,`foo`)", - }, - { - desc: "Host && Path && Header rules", - hostRule: "Host(`foo.com`)", - routeRule: gatev1.HTTPRouteRule{ - Matches: []gatev1.HTTPRouteMatch{ - { - Path: &gatev1.HTTPPathMatch{ - Type: pathMatchTypePtr(gatev1.PathMatchExact), - Value: ptr.To("/foo/"), - }, - Headers: []gatev1.HTTPHeaderMatch{ - { - Type: headerMatchTypePtr(gatev1.HeaderMatchExact), - Name: "my-header", - Value: "foo", - }, - }, - }, - }, - }, - expectedRule: "Host(`foo.com`) && Path(`/foo/`) && Header(`my-header`,`foo`)", - }, - { - desc: "Host && (Path || Header) rules", - hostRule: "Host(`foo.com`)", - routeRule: gatev1.HTTPRouteRule{ - Matches: []gatev1.HTTPRouteMatch{ - { - Path: &gatev1.HTTPPathMatch{ - Type: pathMatchTypePtr(gatev1.PathMatchExact), - Value: ptr.To("/foo/"), - }, - }, - { - Headers: []gatev1.HTTPHeaderMatch{ - { - Type: headerMatchTypePtr(gatev1.HeaderMatchExact), - Name: "my-header", - Value: "foo", - }, - }, - }, - }, - }, - expectedRule: "Host(`foo.com`) && (Path(`/foo/`) || Header(`my-header`,`foo`))", - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - rule, err := extractRule(test.routeRule, test.hostRule) - if test.expectedError { - assert.Error(t, err) - return - } - - require.NoError(t, err) - assert.Equal(t, test.expectedRule, rule) - }) - } -} - -func Test_hostSNIRule(t *testing.T) { - testCases := []struct { - desc string - hostnames []gatev1.Hostname - expectedRule string - expectError bool - }{ - { - desc: "Empty", - expectedRule: "HostSNI(`*`)", - }, - { - desc: "Empty hostname", - hostnames: []gatev1.Hostname{""}, - expectedRule: "HostSNI(`*`)", - }, - { - desc: "Unsupported wildcard", - hostnames: []gatev1.Hostname{"*"}, - expectError: true, - }, - { - desc: "Supported wildcard", - hostnames: []gatev1.Hostname{"*.foo"}, - expectedRule: "HostSNIRegexp(`^[a-zA-Z0-9-]+\\.foo$`)", - }, - { - desc: "Multiple malformed wildcard", - hostnames: []gatev1.Hostname{"*.foo.*"}, - expectError: true, - }, - { - desc: "Some empty hostnames", - hostnames: []gatev1.Hostname{"foo", "", "bar"}, - expectedRule: "HostSNI(`foo`) || HostSNI(`bar`)", - }, - { - desc: "Valid hostname", - hostnames: []gatev1.Hostname{"foo"}, - expectedRule: "HostSNI(`foo`)", - }, - { - desc: "Multiple valid hostnames", - hostnames: []gatev1.Hostname{"foo", "bar"}, - expectedRule: "HostSNI(`foo`) || HostSNI(`bar`)", - }, - { - desc: "Multiple valid hostnames with wildcard", - hostnames: []gatev1.Hostname{"bar.foo", "foo.foo", "*.foo"}, - expectedRule: "HostSNI(`bar.foo`) || HostSNI(`foo.foo`) || HostSNIRegexp(`^[a-zA-Z0-9-]+\\.foo$`)", - }, - { - desc: "Multiple overlapping hostnames", - hostnames: []gatev1.Hostname{"foo", "bar", "foo", "baz"}, - expectedRule: "HostSNI(`foo`) || HostSNI(`bar`) || HostSNI(`baz`)", - }, - } - - for _, test := range testCases { - t.Run(test.desc, func(t *testing.T) { - t.Parallel() - - rule, err := hostSNIRule(test.hostnames) - if test.expectError { - assert.Error(t, err) - return - } - - require.NoError(t, err) - assert.Equal(t, test.expectedRule, rule) - }) - } -} - -func Test_shouldAttach(t *testing.T) { +func Test_matchListener(t *testing.T) { testCases := []struct { desc string - gateway *gatev1.Gateway - listener gatev1.Listener + gwListener gatewayListener + parentRef gatev1.ParentReference routeNamespace string - routeSpec gatev1.CommonRouteSpec - wantAttach bool - wantParentRef gatev1.ParentReference + wantMatch bool }{ { - desc: "No ParentRefs", - gateway: &gatev1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gateway", - Namespace: "default", - }, + desc: "Unsupported group", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", }, - listener: gatev1.Listener{ - Name: "foo", + parentRef: gatev1.ParentReference{ + Group: ptr.To(gatev1.Group("foo")), }, - routeNamespace: "default", - routeSpec: gatev1.CommonRouteSpec{ - ParentRefs: nil, - }, - wantAttach: false, + wantMatch: false, }, { - desc: "Unsupported Kind", - gateway: &gatev1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gateway", - Namespace: "default", - }, + desc: "Unsupported kind", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", }, - listener: gatev1.Listener{ - Name: "foo", + parentRef: gatev1.ParentReference{ + Group: ptr.To(gatev1.Group(gatev1.GroupName)), + Kind: ptr.To(gatev1.Kind("foo")), }, - routeNamespace: "default", - routeSpec: gatev1.CommonRouteSpec{ - ParentRefs: []gatev1.ParentReference{ - { - SectionName: sectionNamePtr("bar"), - Name: "gateway", - Namespace: namespacePtr("default"), - Kind: kindPtr("Foo"), - Group: groupPtr(gatev1.GroupName), - }, - }, - }, - wantAttach: false, + wantMatch: false, }, { - desc: "Unsupported Group", - gateway: &gatev1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gateway", - Namespace: "default", - }, + desc: "Namespace does not match the listener", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", }, - listener: gatev1.Listener{ - Name: "foo", + parentRef: gatev1.ParentReference{ + Namespace: ptr.To(gatev1.Namespace("foo")), + Group: ptr.To(gatev1.Group(gatev1.GroupName)), + Kind: ptr.To(gatev1.Kind("Gateway")), }, - routeNamespace: "default", - routeSpec: gatev1.CommonRouteSpec{ - ParentRefs: []gatev1.ParentReference{ - { - SectionName: sectionNamePtr("bar"), - Name: "gateway", - Namespace: namespacePtr("default"), - Kind: kindPtr("Gateway"), - Group: groupPtr("foo.com"), - }, - }, - }, - wantAttach: false, + wantMatch: false, }, { - desc: "Kind is nil", - gateway: &gatev1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gateway", - Namespace: "default", - }, + desc: "Route namespace defaulting does not match the listener", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", }, - listener: gatev1.Listener{ - Name: "foo", + routeNamespace: "foo", + parentRef: gatev1.ParentReference{ + Group: ptr.To(gatev1.Group(gatev1.GroupName)), + Kind: ptr.To(gatev1.Kind("Gateway")), }, - routeNamespace: "default", - routeSpec: gatev1.CommonRouteSpec{ - ParentRefs: []gatev1.ParentReference{ - { - SectionName: sectionNamePtr("bar"), - Name: "gateway", - Namespace: namespacePtr("default"), - Group: groupPtr(gatev1.GroupName), - }, - }, - }, - wantAttach: false, + wantMatch: false, }, { - desc: "Group is nil", - gateway: &gatev1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gateway", - Namespace: "default", - }, + desc: "Name does not match the listener", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", }, - listener: gatev1.Listener{ - Name: "foo", + parentRef: gatev1.ParentReference{ + Namespace: ptr.To(gatev1.Namespace("default")), + Name: "foo", + Group: ptr.To(gatev1.Group(gatev1.GroupName)), + Kind: ptr.To(gatev1.Kind("Gateway")), }, - routeNamespace: "default", - routeSpec: gatev1.CommonRouteSpec{ - ParentRefs: []gatev1.ParentReference{ - { - SectionName: sectionNamePtr("bar"), - Name: "gateway", - Namespace: namespacePtr("default"), - Kind: kindPtr("Gateway"), - }, - }, - }, - wantAttach: false, + wantMatch: false, }, { - desc: "SectionName does not match a listener desc", - gateway: &gatev1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gateway", - Namespace: "default", - }, + desc: "SectionName does not match a listener", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", }, - listener: gatev1.Listener{ - Name: "foo", - }, - routeNamespace: "default", - routeSpec: gatev1.CommonRouteSpec{ - ParentRefs: []gatev1.ParentReference{ - { - SectionName: sectionNamePtr("bar"), - Name: "gateway", - Namespace: namespacePtr("default"), - Group: groupPtr(gatev1.GroupName), - Kind: kindPtr("Gateway"), - }, - }, - }, - wantAttach: false, - }, - { - desc: "Namespace does not match the Gateway namespace", - gateway: &gatev1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gateway", - Namespace: "default", - }, - }, - listener: gatev1.Listener{ - Name: "foo", - }, - routeNamespace: "default", - routeSpec: gatev1.CommonRouteSpec{ - ParentRefs: []gatev1.ParentReference{ - { - SectionName: sectionNamePtr("bar"), - Name: "gateway", - Namespace: namespacePtr("bar"), - Group: groupPtr(gatev1.GroupName), - Kind: kindPtr("Gateway"), - }, - }, - }, - wantAttach: false, - }, - { - desc: "Route namespace does not match the Gateway namespace", - gateway: &gatev1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gateway", - Namespace: "default", - }, - }, - listener: gatev1.Listener{ - Name: "foo", - }, - routeNamespace: "bar", - routeSpec: gatev1.CommonRouteSpec{ - ParentRefs: []gatev1.ParentReference{ - { - SectionName: sectionNamePtr("bar"), - Name: "gateway", - Group: groupPtr(gatev1.GroupName), - Kind: kindPtr("Gateway"), - }, - }, - }, - wantAttach: false, - }, - { - desc: "Unsupported Kind", - gateway: &gatev1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gateway", - Namespace: "default", - }, - }, - listener: gatev1.Listener{ - Name: "foo", - }, - routeNamespace: "default", - routeSpec: gatev1.CommonRouteSpec{ - ParentRefs: []gatev1.ParentReference{ - { - SectionName: sectionNamePtr("bar"), - Name: "gateway", - Namespace: namespacePtr("default"), - Kind: kindPtr("Gateway"), - Group: groupPtr(gatev1.GroupName), - }, - }, - }, - wantAttach: false, - }, - { - desc: "Route namespace matches the Gateway namespace", - gateway: &gatev1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gateway", - Namespace: "default", - }, - }, - listener: gatev1.Listener{ - Name: "foo", - }, - routeNamespace: "default", - routeSpec: gatev1.CommonRouteSpec{ - ParentRefs: []gatev1.ParentReference{ - { - SectionName: sectionNamePtr("foo"), - Name: "gateway", - Kind: kindPtr("Gateway"), - Group: groupPtr(gatev1.GroupName), - }, - }, - }, - wantAttach: true, - wantParentRef: gatev1.ParentReference{ - SectionName: sectionNamePtr("foo"), + parentRef: gatev1.ParentReference{ + SectionName: ptr.To(gatev1.SectionName("bar")), Name: "gateway", - Kind: kindPtr("Gateway"), - Group: groupPtr(gatev1.GroupName), + Namespace: ptr.To(gatev1.Namespace("default")), + Group: ptr.To(gatev1.Group(gatev1.GroupName)), + Kind: ptr.To(gatev1.Kind("Gateway")), }, + wantMatch: false, }, { - desc: "Namespace matches the Gateway namespace", - gateway: &gatev1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gateway", - Namespace: "default", - }, + desc: "Match", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", }, - listener: gatev1.Listener{ - Name: "foo", - }, - routeNamespace: "bar", - routeSpec: gatev1.CommonRouteSpec{ - ParentRefs: []gatev1.ParentReference{ - { - SectionName: sectionNamePtr("foo"), - Name: "gateway", - Namespace: namespacePtr("default"), - Kind: kindPtr("Gateway"), - Group: groupPtr(gatev1.GroupName), - }, - }, - }, - wantAttach: true, - wantParentRef: gatev1.ParentReference{ - SectionName: sectionNamePtr("foo"), + parentRef: gatev1.ParentReference{ + SectionName: ptr.To(gatev1.SectionName("foo")), Name: "gateway", - Namespace: namespacePtr("default"), - Kind: kindPtr("Gateway"), - Group: groupPtr(gatev1.GroupName), + Namespace: ptr.To(gatev1.Namespace("default")), + Group: ptr.To(gatev1.Group(gatev1.GroupName)), + Kind: ptr.To(gatev1.Kind("Gateway")), }, + wantMatch: true, }, { - desc: "Only one ParentRef matches the Gateway", - gateway: &gatev1.Gateway{ - ObjectMeta: metav1.ObjectMeta{ - Name: "gateway", - Namespace: "default", - }, + desc: "Match with route namespace defaulting", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", }, - listener: gatev1.Listener{ - Name: "foo", - }, - routeNamespace: "bar", - routeSpec: gatev1.CommonRouteSpec{ - ParentRefs: []gatev1.ParentReference{ - { - Name: "gateway2", - Namespace: namespacePtr("default"), - Kind: kindPtr("Gateway"), - Group: groupPtr(gatev1.GroupName), - }, - { - Name: "gateway", - Namespace: namespacePtr("default"), - Kind: kindPtr("Gateway"), - Group: groupPtr(gatev1.GroupName), - }, - }, - }, - wantAttach: true, - wantParentRef: gatev1.ParentReference{ - Name: "gateway", - Namespace: namespacePtr("default"), - Kind: kindPtr("Gateway"), - Group: groupPtr(gatev1.GroupName), + routeNamespace: "default", + parentRef: gatev1.ParentReference{ + SectionName: ptr.To(gatev1.SectionName("foo")), + Name: "gateway", + Group: ptr.To(gatev1.Group(gatev1.GroupName)), + Kind: ptr.To(gatev1.Kind("Gateway")), }, + wantMatch: true, }, } @@ -6276,103 +5660,104 @@ func Test_shouldAttach(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - gotParentRef, gotAttach := shouldAttach(test.gateway, test.listener, test.routeNamespace, test.routeSpec) - assert.Equal(t, test.wantAttach, gotAttach) - assert.Equal(t, test.wantParentRef, gotParentRef) + gotMatch := matchListener(test.gwListener, test.routeNamespace, test.parentRef) + assert.Equal(t, test.wantMatch, gotMatch) }) } } -func Test_matchingHostnames(t *testing.T) { +func Test_allowRoute(t *testing.T) { testCases := []struct { - desc string - listener gatev1.Listener - hostnames []gatev1.Hostname - want []gatev1.Hostname + desc string + gwListener gatewayListener + routeNamespace string + routeKind string + wantAllow bool }{ { - desc: "Empty", - }, - { - desc: "Only listener hostname", - listener: gatev1.Listener{ - Hostname: hostnamePtr("foo.com"), + desc: "Not allowed Kind", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", + AllowedRouteKinds: []string{ + "foo", + "bar", + }, }, - want: []gatev1.Hostname{"foo.com"}, + routeKind: "baz", + wantAllow: false, }, { - desc: "Only Route hostname", - hostnames: []gatev1.Hostname{"foo.com"}, - want: []gatev1.Hostname{"foo.com"}, - }, - { - desc: "Matching hostname", - listener: gatev1.Listener{ - Hostname: hostnamePtr("foo.com"), + desc: "Allowed Kind", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", + AllowedRouteKinds: []string{ + "foo", + "bar", + }, + AllowedNamespaces: []string{ + corev1.NamespaceAll, + }, }, - hostnames: []gatev1.Hostname{"foo.com"}, - want: []gatev1.Hostname{"foo.com"}, + routeKind: "bar", + wantAllow: true, }, { - desc: "Matching hostname with wildcard", - listener: gatev1.Listener{ - Hostname: hostnamePtr("*.foo.com"), + desc: "Not allowed namespace", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", + AllowedRouteKinds: []string{ + "foo", + }, + AllowedNamespaces: []string{ + "foo", + "bar", + }, }, - hostnames: []gatev1.Hostname{"*.foo.com"}, - want: []gatev1.Hostname{"*.foo.com"}, + routeKind: "foo", + routeNamespace: "baz", + wantAllow: false, }, { - desc: "Matching subdomain with listener wildcard", - listener: gatev1.Listener{ - Hostname: hostnamePtr("*.foo.com"), + desc: "Allowed namespace", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", + AllowedRouteKinds: []string{ + "foo", + }, + AllowedNamespaces: []string{ + "foo", + "bar", + }, }, - hostnames: []gatev1.Hostname{"bar.foo.com"}, - want: []gatev1.Hostname{"bar.foo.com"}, + routeKind: "foo", + routeNamespace: "foo", + wantAllow: true, }, { - desc: "Matching subdomain with route hostname wildcard", - listener: gatev1.Listener{ - Hostname: hostnamePtr("bar.foo.com"), + desc: "Allowed namespace", + gwListener: gatewayListener{ + Name: "foo", + GWName: "gateway", + GWNamespace: "default", + AllowedRouteKinds: []string{ + "foo", + }, + AllowedNamespaces: []string{ + corev1.NamespaceAll, + "bar", + }, }, - hostnames: []gatev1.Hostname{"*.foo.com"}, - want: []gatev1.Hostname{"bar.foo.com"}, - }, - { - desc: "Non matching root domain with listener wildcard", - listener: gatev1.Listener{ - Hostname: hostnamePtr("*.foo.com"), - }, - hostnames: []gatev1.Hostname{"foo.com"}, - }, - { - desc: "Non matching root domain with route hostname wildcard", - listener: gatev1.Listener{ - Hostname: hostnamePtr("foo.com"), - }, - hostnames: []gatev1.Hostname{"*.foo.com"}, - }, - { - desc: "Multiple route hostnames with one matching route hostname", - listener: gatev1.Listener{ - Hostname: hostnamePtr("*.foo.com"), - }, - hostnames: []gatev1.Hostname{"bar.com", "test.foo.com", "test.buz.com"}, - want: []gatev1.Hostname{"test.foo.com"}, - }, - { - desc: "Multiple route hostnames with non matching route hostname", - listener: gatev1.Listener{ - Hostname: hostnamePtr("*.fuz.com"), - }, - hostnames: []gatev1.Hostname{"bar.com", "test.foo.com", "test.buz.com"}, - }, - { - desc: "Multiple route hostnames with multiple matching route hostnames", - listener: gatev1.Listener{ - Hostname: hostnamePtr("*.foo.com"), - }, - hostnames: []gatev1.Hostname{"toto.foo.com", "test.foo.com", "test.buz.com"}, - want: []gatev1.Hostname{"toto.foo.com", "test.foo.com"}, + routeKind: "foo", + routeNamespace: "foo", + wantAllow: true, }, } @@ -6380,13 +5765,121 @@ func Test_matchingHostnames(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - got := matchingHostnames(test.listener, test.hostnames) + gotAllow := allowRoute(test.gwListener, test.routeNamespace, test.routeKind) + assert.Equal(t, test.wantAllow, gotAllow) + }) + } +} + +func Test_findMatchingHostnames(t *testing.T) { + testCases := []struct { + desc string + listenerHostname *gatev1.Hostname + routeHostnames []gatev1.Hostname + want []gatev1.Hostname + wantOk bool + }{ + { + desc: "Empty", + wantOk: true, + }, + { + desc: "Only listener hostname", + listenerHostname: ptr.To(gatev1.Hostname("foo.com")), + want: []gatev1.Hostname{"foo.com"}, + wantOk: true, + }, + { + desc: "Only Route hostname", + routeHostnames: []gatev1.Hostname{"foo.com"}, + want: []gatev1.Hostname{"foo.com"}, + wantOk: true, + }, + { + desc: "Matching hostname", + listenerHostname: ptr.To(gatev1.Hostname("foo.com")), + routeHostnames: []gatev1.Hostname{"foo.com"}, + want: []gatev1.Hostname{"foo.com"}, + wantOk: true, + }, + { + desc: "Matching hostname with wildcard", + listenerHostname: ptr.To(gatev1.Hostname("*.foo.com")), + routeHostnames: []gatev1.Hostname{"*.foo.com"}, + want: []gatev1.Hostname{"*.foo.com"}, + wantOk: true, + }, + { + desc: "Matching subdomain with listener wildcard", + listenerHostname: ptr.To(gatev1.Hostname("*.foo.com")), + routeHostnames: []gatev1.Hostname{"bar.foo.com"}, + want: []gatev1.Hostname{"bar.foo.com"}, + wantOk: true, + }, + { + desc: "Matching subsubdomain with listener wildcard", + listenerHostname: ptr.To(gatev1.Hostname("*.foo.com")), + routeHostnames: []gatev1.Hostname{"baz.bar.foo.com"}, + want: []gatev1.Hostname{"baz.bar.foo.com"}, + wantOk: true, + }, + { + desc: "Matching subdomain with route hostname wildcard", + listenerHostname: ptr.To(gatev1.Hostname("bar.foo.com")), + routeHostnames: []gatev1.Hostname{"*.foo.com"}, + want: []gatev1.Hostname{"bar.foo.com"}, + wantOk: true, + }, + { + desc: "Matching subsubdomain with route hostname wildcard", + listenerHostname: ptr.To(gatev1.Hostname("baz.bar.foo.com")), + routeHostnames: []gatev1.Hostname{"*.foo.com"}, + want: []gatev1.Hostname{"baz.bar.foo.com"}, + wantOk: true, + }, + { + desc: "Non matching root domain with listener wildcard", + listenerHostname: ptr.To(gatev1.Hostname("*.foo.com")), + routeHostnames: []gatev1.Hostname{"foo.com"}, + }, + { + desc: "Non matching root domain with route hostname wildcard", + listenerHostname: ptr.To(gatev1.Hostname("foo.com")), + routeHostnames: []gatev1.Hostname{"*.foo.com"}, + }, + { + desc: "Multiple route hostnames with one matching route hostname", + listenerHostname: ptr.To(gatev1.Hostname("*.foo.com")), + routeHostnames: []gatev1.Hostname{"bar.com", "test.foo.com", "test.buz.com"}, + want: []gatev1.Hostname{"test.foo.com"}, + wantOk: true, + }, + { + desc: "Multiple route hostnames with non matching route hostname", + listenerHostname: ptr.To(gatev1.Hostname("*.fuz.com")), + routeHostnames: []gatev1.Hostname{"bar.com", "test.foo.com", "test.buz.com"}, + }, + { + desc: "Multiple route hostnames with multiple matching route hostnames", + listenerHostname: ptr.To(gatev1.Hostname("*.foo.com")), + routeHostnames: []gatev1.Hostname{"toto.foo.com", "test.foo.com", "test.buz.com"}, + want: []gatev1.Hostname{"toto.foo.com", "test.foo.com"}, + wantOk: true, + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + got, ok := findMatchingHostnames(test.listenerHostname, test.routeHostnames) + assert.Equal(t, test.wantOk, ok) assert.Equal(t, test.want, got) }) } } -func Test_getAllowedRoutes(t *testing.T) { +func Test_allowedRouteKinds(t *testing.T) { testCases := []struct { desc string listener gatev1.Listener @@ -6400,10 +5893,10 @@ func Test_getAllowedRoutes(t *testing.T) { { desc: "Empty AllowedRoutes", supportedRouteKinds: []gatev1.RouteGroupKind{ - {Kind: kindTLSRoute, Group: groupPtr(gatev1.GroupName)}, + {Kind: kindTLSRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, }, wantKinds: []gatev1.RouteGroupKind{ - {Kind: kindTLSRoute, Group: groupPtr(gatev1.GroupName)}, + {Kind: kindTLSRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, }, }, { @@ -6411,12 +5904,12 @@ func Test_getAllowedRoutes(t *testing.T) { listener: gatev1.Listener{ AllowedRoutes: &gatev1.AllowedRoutes{ Kinds: []gatev1.RouteGroupKind{{ - Kind: kindTLSRoute, Group: groupPtr("foo"), + Kind: kindTLSRoute, Group: ptr.To(gatev1.Group("foo")), }}, }, }, supportedRouteKinds: []gatev1.RouteGroupKind{ - {Kind: kindTLSRoute, Group: groupPtr(gatev1.GroupName)}, + {Kind: kindTLSRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, }, wantErr: true, }, @@ -6430,7 +5923,7 @@ func Test_getAllowedRoutes(t *testing.T) { }, }, supportedRouteKinds: []gatev1.RouteGroupKind{ - {Kind: kindTLSRoute, Group: groupPtr(gatev1.GroupName)}, + {Kind: kindTLSRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, }, wantErr: true, }, @@ -6439,12 +5932,12 @@ func Test_getAllowedRoutes(t *testing.T) { listener: gatev1.Listener{ AllowedRoutes: &gatev1.AllowedRoutes{ Kinds: []gatev1.RouteGroupKind{{ - Kind: "foo", Group: groupPtr(gatev1.GroupName), + Kind: "foo", Group: ptr.To(gatev1.Group(gatev1.GroupName)), }}, }, }, supportedRouteKinds: []gatev1.RouteGroupKind{ - {Kind: kindTLSRoute, Group: groupPtr(gatev1.GroupName)}, + {Kind: kindTLSRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, }, wantErr: true, }, @@ -6453,15 +5946,15 @@ func Test_getAllowedRoutes(t *testing.T) { listener: gatev1.Listener{ AllowedRoutes: &gatev1.AllowedRoutes{ Kinds: []gatev1.RouteGroupKind{{ - Kind: kindTLSRoute, Group: groupPtr(gatev1.GroupName), + Kind: kindTLSRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName)), }}, }, }, supportedRouteKinds: []gatev1.RouteGroupKind{ - {Kind: kindTLSRoute, Group: groupPtr(gatev1.GroupName)}, + {Kind: kindTLSRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, }, wantKinds: []gatev1.RouteGroupKind{ - {Kind: kindTLSRoute, Group: groupPtr(gatev1.GroupName)}, + {Kind: kindTLSRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, }, }, { @@ -6469,20 +5962,20 @@ func Test_getAllowedRoutes(t *testing.T) { listener: gatev1.Listener{ AllowedRoutes: &gatev1.AllowedRoutes{ Kinds: []gatev1.RouteGroupKind{ - {Kind: kindTLSRoute, Group: groupPtr(gatev1.GroupName)}, - {Kind: kindTCPRoute, Group: groupPtr(gatev1.GroupName)}, - {Kind: kindTLSRoute, Group: groupPtr(gatev1.GroupName)}, - {Kind: kindTCPRoute, Group: groupPtr(gatev1.GroupName)}, + {Kind: kindTLSRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, + {Kind: kindTCPRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, + {Kind: kindTLSRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, + {Kind: kindTCPRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, }, }, }, supportedRouteKinds: []gatev1.RouteGroupKind{ - {Kind: kindTLSRoute, Group: groupPtr(gatev1.GroupName)}, - {Kind: kindTCPRoute, Group: groupPtr(gatev1.GroupName)}, + {Kind: kindTLSRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, + {Kind: kindTCPRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, }, wantKinds: []gatev1.RouteGroupKind{ - {Kind: kindTLSRoute, Group: groupPtr(gatev1.GroupName)}, - {Kind: kindTCPRoute, Group: groupPtr(gatev1.GroupName)}, + {Kind: kindTLSRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, + {Kind: kindTCPRoute, Group: ptr.To(gatev1.Group(gatev1.GroupName))}, }, }, } @@ -6491,7 +5984,7 @@ func Test_getAllowedRoutes(t *testing.T) { t.Run(test.desc, func(t *testing.T) { t.Parallel() - got, conditions := getAllowedRouteKinds(&gatev1.Gateway{}, test.listener, test.supportedRouteKinds) + got, conditions := allowedRouteKinds(&gatev1.Gateway{}, test.listener, test.supportedRouteKinds) if test.wantErr { require.NotEmpty(t, conditions, "no conditions") return @@ -6518,7 +6011,7 @@ func Test_makeListenerKey(t *testing.T) { listener: gatev1.Listener{ Port: 443, Protocol: gatev1.HTTPSProtocolType, - Hostname: hostnamePtr("www.example.com"), + Hostname: ptr.To(gatev1.Hostname("www.example.com")), }, expectedKey: "HTTPS|www.example.com|443", }, @@ -6668,7 +6161,7 @@ func Test_referenceGrantMatchesTo(t *testing.T) { { Group: "correct-group", Kind: "correct-kind", - Name: objectNamePtr("correct-name"), + Name: ptr.To(gatev1.ObjectName("correct-name")), }, }, }, @@ -6704,7 +6197,7 @@ func Test_referenceGrantMatchesTo(t *testing.T) { { Group: "", Kind: "correct-kind", - Name: objectNamePtr("correct-name"), + Name: ptr.To(gatev1.ObjectName("correct-name")), }, }, }, @@ -6722,7 +6215,7 @@ func Test_referenceGrantMatchesTo(t *testing.T) { { Group: "wrong-group", Kind: "correct-kind", - Name: objectNamePtr("correct-name"), + Name: ptr.To(gatev1.ObjectName("correct-name")), }, }, }, @@ -6740,7 +6233,7 @@ func Test_referenceGrantMatchesTo(t *testing.T) { { Group: "correct-group", Kind: "wrong-kind", - Name: objectNamePtr("correct-name"), + Name: ptr.To(gatev1.ObjectName("correct-name")), }, }, }, @@ -6758,7 +6251,7 @@ func Test_referenceGrantMatchesTo(t *testing.T) { { Group: "correct-group", Kind: "correct-kind", - Name: objectNamePtr("wrong-name"), + Name: ptr.To(gatev1.ObjectName("wrong-name")), }, }, }, @@ -6895,34 +6388,6 @@ func Test_gatewayAddresses(t *testing.T) { } } -func hostnamePtr(hostname gatev1.Hostname) *gatev1.Hostname { - return &hostname -} - -func groupPtr(group gatev1.Group) *gatev1.Group { - return &group -} - -func sectionNamePtr(sectionName gatev1.SectionName) *gatev1.SectionName { - return §ionName -} - -func namespacePtr(namespace gatev1.Namespace) *gatev1.Namespace { - return &namespace -} - -func kindPtr(kind gatev1.Kind) *gatev1.Kind { - return &kind -} - -func pathMatchTypePtr(p gatev1.PathMatchType) *gatev1.PathMatchType { return &p } - -func headerMatchTypePtr(h gatev1.HeaderMatchType) *gatev1.HeaderMatchType { return &h } - -func objectNamePtr(objectName gatev1.ObjectName) *gatev1.ObjectName { - return &objectName -} - // We cannot use the gateway-api fake.NewSimpleClientset due to Gateway being pluralized as "gatewaies" instead of "gateways". func newGatewaySimpleClientSet(t *testing.T, objects ...runtime.Object) *gatefake.Clientset { t.Helper() diff --git a/pkg/provider/kubernetes/gateway/tcproute.go b/pkg/provider/kubernetes/gateway/tcproute.go new file mode 100644 index 000000000..118ae8f1d --- /dev/null +++ b/pkg/provider/kubernetes/gateway/tcproute.go @@ -0,0 +1,296 @@ +package gateway + +import ( + "context" + "errors" + "fmt" + "net" + "strconv" + + "github.com/rs/zerolog/log" + "github.com/traefik/traefik/v3/pkg/config/dynamic" + "github.com/traefik/traefik/v3/pkg/provider" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ktypes "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + gatev1 "sigs.k8s.io/gateway-api/apis/v1" + gatev1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +func (p *Provider) loadTCPRoutes(ctx context.Context, client Client, gatewayListeners []gatewayListener, conf *dynamic.Configuration) { + logger := log.Ctx(ctx) + routes, err := client.ListTCPRoutes() + if err != nil { + logger.Error().Err(err).Msgf("Get TCPRoutes: %s", err) + } + + for _, route := range routes { + logger := log.Ctx(ctx).With().Str("tcproute", route.Name).Str("namespace", route.Namespace).Logger() + + var parentStatuses []gatev1alpha2.RouteParentStatus + for _, parentRef := range route.Spec.ParentRefs { + parentStatus := &gatev1alpha2.RouteParentStatus{ + ParentRef: parentRef, + ControllerName: controllerName, + Conditions: []metav1.Condition{ + { + Type: string(gatev1.RouteConditionAccepted), + Status: metav1.ConditionTrue, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonAccepted), + }, + }, + } + + var attachedListeners bool + for _, listener := range gatewayListeners { + if !matchListener(listener, route.Namespace, parentRef) { + continue + } + + if !allowRoute(listener, route.Namespace, kindTCPRoute) { + continue + } + + listener.Status.AttachedRoutes++ + attachedListeners = true + + resolveConditions := p.loadTCPRoute(client, listener, route, conf) + + // TODO: handle more accurately route conditions (in case of multiple listener matching). + for _, condition := range resolveConditions { + parentStatus.Conditions = appendCondition(parentStatus.Conditions, condition) + } + } + + if !attachedListeners { + parentStatus.Conditions = []metav1.Condition{ + { + Type: string(gatev1.RouteConditionAccepted), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonNoMatchingParent), + }, + } + } + + parentStatuses = append(parentStatuses, *parentStatus) + } + + routeStatus := gatev1alpha2.TCPRouteStatus{ + RouteStatus: gatev1alpha2.RouteStatus{ + Parents: parentStatuses, + }, + } + if err := client.UpdateTCPRouteStatus(ctx, ktypes.NamespacedName{Namespace: route.Namespace, Name: route.Name}, routeStatus); err != nil { + logger.Error(). + Err(err). + Msg("Unable to update TCPRoute status") + } + } +} + +func (p *Provider) loadTCPRoute(client Client, listener gatewayListener, route *gatev1alpha2.TCPRoute, conf *dynamic.Configuration) []metav1.Condition { + routeConditions := []metav1.Condition{ + { + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionTrue, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteConditionResolvedRefs), + }, + } + + router := dynamic.TCPRouter{ + Rule: "HostSNI(`*`)", + EntryPoints: []string{listener.EPName}, + RuleSyntax: "v3", + } + + if listener.Protocol == gatev1.TLSProtocolType && listener.TLS != nil { + // TODO support let's encrypt + router.TLS = &dynamic.RouterTCPTLSConfig{ + Passthrough: listener.TLS.Mode != nil && *listener.TLS.Mode == gatev1.TLSModePassthrough, + } + } + + // Adding the gateway desc and the entryPoint desc prevents overlapping of routers build from the same routes. + routerName := route.Name + "-" + listener.GWName + "-" + listener.EPName + routerKey := provider.Normalize(makeRouterKey("", makeID(route.Namespace, routerName))) + + var ruleServiceNames []string + for i, rule := range route.Spec.Rules { + if rule.BackendRefs == nil { + // Should not happen due to validation. + // https://github.com/kubernetes-sigs/gateway-api/blob/v0.4.0/apis/v1alpha2/tcproute_types.go#L76 + continue + } + + wrrService, subServices, err := loadTCPServices(client, route.Namespace, rule.BackendRefs) + if err != nil { + routeConditions = appendCondition(routeConditions, metav1.Condition{ + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonBackendNotFound), + Message: fmt.Sprintf("Cannot load TCPRoute service %s/%s: %v", route.Namespace, route.Name, err), + }) + return routeConditions + } + + for svcName, svc := range subServices { + conf.TCP.Services[svcName] = svc + } + + serviceName := fmt.Sprintf("%s-wrr-%d", routerKey, i) + conf.TCP.Services[serviceName] = wrrService + + ruleServiceNames = append(ruleServiceNames, serviceName) + } + + if len(ruleServiceNames) == 1 { + router.Service = ruleServiceNames[0] + conf.TCP.Routers[routerKey] = &router + return routeConditions + } + + routeServiceKey := routerKey + "-wrr" + routeService := &dynamic.TCPService{Weighted: &dynamic.TCPWeightedRoundRobin{}} + + for _, name := range ruleServiceNames { + service := dynamic.TCPWRRService{Name: name} + service.SetDefaults() + + routeService.Weighted.Services = append(routeService.Weighted.Services, service) + } + + conf.TCP.Services[routeServiceKey] = routeService + + router.Service = routeServiceKey + conf.TCP.Routers[routerKey] = &router + + return routeConditions +} + +// loadTCPServices is generating a WRR service, even when there is only one target. +func loadTCPServices(client Client, namespace string, backendRefs []gatev1.BackendRef) (*dynamic.TCPService, map[string]*dynamic.TCPService, error) { + services := map[string]*dynamic.TCPService{} + + wrrSvc := &dynamic.TCPService{ + Weighted: &dynamic.TCPWeightedRoundRobin{ + Services: []dynamic.TCPWRRService{}, + }, + } + + for _, backendRef := range backendRefs { + if backendRef.Group == nil || backendRef.Kind == nil { + // Should not happen as this is validated by kubernetes + continue + } + + if isInternalService(backendRef) { + return nil, nil, fmt.Errorf("traefik internal service %s is not allowed in a WRR loadbalancer", backendRef.Name) + } + + weight := int(ptr.Deref(backendRef.Weight, 1)) + + if isTraefikService(backendRef) { + wrrSvc.Weighted.Services = append(wrrSvc.Weighted.Services, dynamic.TCPWRRService{Name: string(backendRef.Name), Weight: &weight}) + continue + } + + if *backendRef.Group != "" && *backendRef.Group != groupCore && *backendRef.Kind != "Service" { + return nil, nil, fmt.Errorf("unsupported BackendRef %s/%s/%s", *backendRef.Group, *backendRef.Kind, backendRef.Name) + } + + svc := dynamic.TCPService{ + LoadBalancer: &dynamic.TCPServersLoadBalancer{}, + } + + service, exists, err := client.GetService(namespace, string(backendRef.Name)) + if err != nil { + return nil, nil, err + } + + if !exists { + return nil, nil, errors.New("service not found") + } + + if len(service.Spec.Ports) > 1 && backendRef.Port == nil { + // If the port is unspecified and the backend is a Service + // object consisting of multiple port definitions, the route + // must be dropped from the Gateway. The controller should + // raise the "ResolvedRefs" condition on the Gateway with the + // "DroppedRoutes" reason. The gateway status for this route + // should be updated with a condition that describes the error + // more specifically. + log.Error().Msg("A multiple ports Kubernetes Service cannot be used if unspecified backendRef.Port") + continue + } + + var portSpec corev1.ServicePort + var match bool + + for _, p := range service.Spec.Ports { + if backendRef.Port == nil || p.Port == int32(*backendRef.Port) { + portSpec = p + match = true + break + } + } + + if !match { + return nil, nil, errors.New("service port not found") + } + + endpoints, endpointsExists, endpointsErr := client.GetEndpoints(namespace, string(backendRef.Name)) + if endpointsErr != nil { + return nil, nil, endpointsErr + } + + if !endpointsExists { + return nil, nil, errors.New("endpoints not found") + } + + if len(endpoints.Subsets) == 0 { + return nil, nil, errors.New("subset not found") + } + + var port int32 + var portStr string + for _, subset := range endpoints.Subsets { + for _, p := range subset.Ports { + if portSpec.Name == p.Name { + port = p.Port + break + } + } + + if port == 0 { + return nil, nil, errors.New("cannot define a port") + } + + portStr = strconv.FormatInt(int64(port), 10) + for _, addr := range subset.Addresses { + svc.LoadBalancer.Servers = append(svc.LoadBalancer.Servers, dynamic.TCPServer{ + Address: net.JoinHostPort(addr.IP, portStr), + }) + } + } + + serviceName := provider.Normalize(makeID(service.Namespace, service.Name) + "-" + portStr) + services[serviceName] = &svc + + wrrSvc.Weighted.Services = append(wrrSvc.Weighted.Services, dynamic.TCPWRRService{Name: serviceName, Weight: &weight}) + } + + if len(wrrSvc.Weighted.Services) == 0 { + return nil, nil, errors.New("no service has been created") + } + + return wrrSvc, services, nil +} diff --git a/pkg/provider/kubernetes/gateway/tlsroute.go b/pkg/provider/kubernetes/gateway/tlsroute.go new file mode 100644 index 000000000..1eb30c788 --- /dev/null +++ b/pkg/provider/kubernetes/gateway/tlsroute.go @@ -0,0 +1,210 @@ +package gateway + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/rs/zerolog/log" + "github.com/traefik/traefik/v3/pkg/config/dynamic" + "github.com/traefik/traefik/v3/pkg/provider" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + ktypes "k8s.io/apimachinery/pkg/types" + gatev1 "sigs.k8s.io/gateway-api/apis/v1" + gatev1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +func (p *Provider) loadTLSRoutes(ctx context.Context, client Client, gatewayListeners []gatewayListener, conf *dynamic.Configuration) { + logger := log.Ctx(ctx) + routes, err := client.ListTLSRoutes() + if err != nil { + logger.Error().Err(err).Msgf("Get TLSRoutes: %s", err) + } + + for _, route := range routes { + logger := log.Ctx(ctx).With().Str("tlsroute", route.Name).Str("namespace", route.Namespace).Logger() + + var parentStatuses []gatev1alpha2.RouteParentStatus + for _, parentRef := range route.Spec.ParentRefs { + parentStatus := &gatev1alpha2.RouteParentStatus{ + ParentRef: parentRef, + ControllerName: controllerName, + Conditions: []metav1.Condition{ + { + Type: string(gatev1.RouteConditionAccepted), + Status: metav1.ConditionTrue, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonAccepted), + }, + }, + } + + var attachedListeners bool + for _, listener := range gatewayListeners { + if !matchListener(listener, route.Namespace, parentRef) { + continue + } + + if !allowRoute(listener, route.Namespace, kindTLSRoute) { + continue + } + + hostnames, ok := findMatchingHostnames(listener.Hostname, route.Spec.Hostnames) + if !ok { + continue + } + + listener.Status.AttachedRoutes++ + attachedListeners = true + + resolveConditions := p.loadTLSRoute(client, listener, route, hostnames, conf) + + // TODO: handle more accurately route conditions (in case of multiple listener matching). + for _, condition := range resolveConditions { + parentStatus.Conditions = appendCondition(parentStatus.Conditions, condition) + } + } + + if !attachedListeners { + parentStatus.Conditions = []metav1.Condition{ + { + Type: string(gatev1.RouteConditionAccepted), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonNoMatchingParent), + }, + } + } + + parentStatuses = append(parentStatuses, *parentStatus) + } + + routeStatus := gatev1alpha2.TLSRouteStatus{ + RouteStatus: gatev1alpha2.RouteStatus{ + Parents: parentStatuses, + }, + } + if err := client.UpdateTLSRouteStatus(ctx, ktypes.NamespacedName{Namespace: route.Namespace, Name: route.Name}, routeStatus); err != nil { + logger.Error(). + Err(err). + Msg("Unable to update TLSRoute status") + } + } +} + +func (p *Provider) loadTLSRoute(client Client, listener gatewayListener, route *gatev1alpha2.TLSRoute, hostnames []gatev1.Hostname, conf *dynamic.Configuration) []metav1.Condition { + routeConditions := []metav1.Condition{ + { + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionTrue, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteConditionResolvedRefs), + }, + } + + router := dynamic.TCPRouter{ + RuleSyntax: "v3", + Rule: hostSNIRule(hostnames), + EntryPoints: []string{listener.EPName}, + TLS: &dynamic.RouterTCPTLSConfig{ + Passthrough: listener.TLS.Mode != nil && *listener.TLS.Mode == gatev1.TLSModePassthrough, + }, + } + + // Adding the gateway desc and the entryPoint desc prevents overlapping of routers build from the same routes. + routerName := route.Name + "-" + listener.GWName + "-" + listener.EPName + routerKey := provider.Normalize(makeRouterKey(router.Rule, makeID(route.Namespace, routerName))) + + var ruleServiceNames []string + for i, routeRule := range route.Spec.Rules { + if len(routeRule.BackendRefs) == 0 { + // Should not happen due to validation. + // https://github.com/kubernetes-sigs/gateway-api/blob/v0.4.0/apis/v1alpha2/tlsroute_types.go#L120 + continue + } + + wrrService, subServices, err := loadTCPServices(client, route.Namespace, routeRule.BackendRefs) + if err != nil { + // update "ResolvedRefs" status true with "InvalidBackendRefs" reason + routeConditions = appendCondition(routeConditions, metav1.Condition{ + Type: string(gatev1.RouteConditionResolvedRefs), + Status: metav1.ConditionFalse, + ObservedGeneration: route.Generation, + LastTransitionTime: metav1.Now(), + Reason: string(gatev1.RouteReasonBackendNotFound), + Message: fmt.Sprintf("Cannot load TLSRoute service %s/%s: %v", route.Namespace, route.Name, err), + }) + continue + } + + for svcName, svc := range subServices { + conf.TCP.Services[svcName] = svc + } + + serviceName := fmt.Sprintf("%s-wrr-%d", routerKey, i) + conf.TCP.Services[serviceName] = wrrService + + ruleServiceNames = append(ruleServiceNames, serviceName) + } + + if len(ruleServiceNames) == 1 { + router.Service = ruleServiceNames[0] + conf.TCP.Routers[routerKey] = &router + + return routeConditions + } + + routeServiceKey := routerKey + "-wrr" + routeService := &dynamic.TCPService{Weighted: &dynamic.TCPWeightedRoundRobin{}} + + for _, name := range ruleServiceNames { + service := dynamic.TCPWRRService{Name: name} + service.SetDefaults() + + routeService.Weighted.Services = append(routeService.Weighted.Services, service) + } + + conf.TCP.Services[routeServiceKey] = routeService + + router.Service = routeServiceKey + conf.TCP.Routers[routerKey] = &router + + return routeConditions +} + +func hostSNIRule(hostnames []gatev1.Hostname) string { + rules := make([]string, 0, len(hostnames)) + uniqHostnames := map[gatev1.Hostname]struct{}{} + + for _, hostname := range hostnames { + if len(hostname) == 0 { + continue + } + + if _, exists := uniqHostnames[hostname]; exists { + continue + } + + host := string(hostname) + uniqHostnames[hostname] = struct{}{} + + wildcard := strings.Count(host, "*") + if wildcard == 0 { + rules = append(rules, fmt.Sprintf("HostSNI(`%s`)", host)) + continue + } + + host = strings.Replace(regexp.QuoteMeta(host), `\*\.`, `[a-z0-9-\.]+\.`, 1) + rules = append(rules, fmt.Sprintf("HostSNIRegexp(`^%s$`)", host)) + } + + if len(hostnames) == 0 || len(rules) == 0 { + return "HostSNI(`*`)" + } + + return strings.Join(rules, " || ") +} diff --git a/pkg/provider/kubernetes/gateway/tlsroute_test.go b/pkg/provider/kubernetes/gateway/tlsroute_test.go new file mode 100644 index 000000000..89a688e5e --- /dev/null +++ b/pkg/provider/kubernetes/gateway/tlsroute_test.go @@ -0,0 +1,66 @@ +package gateway + +import ( + "testing" + + "github.com/stretchr/testify/assert" + gatev1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func Test_hostSNIRule(t *testing.T) { + testCases := []struct { + desc string + hostnames []gatev1.Hostname + expectedRule string + expectError bool + }{ + { + desc: "Empty", + expectedRule: "HostSNI(`*`)", + }, + { + desc: "Empty hostname", + hostnames: []gatev1.Hostname{""}, + expectedRule: "HostSNI(`*`)", + }, + { + desc: "Supported wildcard", + hostnames: []gatev1.Hostname{"*.foo"}, + expectedRule: "HostSNIRegexp(`^[a-z0-9-\\.]+\\.foo$`)", + }, + { + desc: "Some empty hostnames", + hostnames: []gatev1.Hostname{"foo", "", "bar"}, + expectedRule: "HostSNI(`foo`) || HostSNI(`bar`)", + }, + { + desc: "Valid hostname", + hostnames: []gatev1.Hostname{"foo"}, + expectedRule: "HostSNI(`foo`)", + }, + { + desc: "Multiple valid hostnames", + hostnames: []gatev1.Hostname{"foo", "bar"}, + expectedRule: "HostSNI(`foo`) || HostSNI(`bar`)", + }, + { + desc: "Multiple valid hostnames with wildcard", + hostnames: []gatev1.Hostname{"bar.foo", "foo.foo", "*.foo"}, + expectedRule: "HostSNI(`bar.foo`) || HostSNI(`foo.foo`) || HostSNIRegexp(`^[a-z0-9-\\.]+\\.foo$`)", + }, + { + desc: "Multiple overlapping hostnames", + hostnames: []gatev1.Hostname{"foo", "bar", "foo", "baz"}, + expectedRule: "HostSNI(`foo`) || HostSNI(`bar`) || HostSNI(`baz`)", + }, + } + + for _, test := range testCases { + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + rule := hostSNIRule(test.hostnames) + assert.Equal(t, test.expectedRule, rule) + }) + } +}