Repository: Kong/kubernetes-ingress-controller
Pull request #2418 (2022-04-18 19:33:51)

feat: enable multi-backendRefs for TCPRoute
**What this PR does / why we need it**:

Enables multi-backendRef support for `TCPRoute` to enable load-balancing.

**Which issue this PR fixes**

Partially https://github.com/Kong/kubernetes-ingress-controller/issues/2405

**PR Readiness Checklist**:

- [x] the `CHANGELOG.md` release notes have been updated


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a928d00b..da63ade32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -53,6 +53,8 @@
 
 #### Added
 
+- `TCPRoute` resources now support multiple backendRefs for load-balancing.
+  [#2405](https://github.com/Kong/kubernetes-ingress-controller/issues/2405)
 - `TCPRoute` resources are now supported.
   [#2086](https://github.com/Kong/kubernetes-ingress-controller/issues/2086)
 - `HTTPRoute` resources now support multiple `backendRefs` with a round-robin
@@ -61,6 +63,13 @@
   annotation is set). They also now support weights to enable more
   fine-tuning of the load-balancing between those backend services.
   [#2166](https://github.com/Kong/kubernetes-ingress-controller/issues/2166)
+- `Gateway` resources now honor [`listener.allowedRoutes.namespaces`
+  filters](https://gateway-api.sigs.k8s.io/v1alpha2/references/spec/#gateway.networking.k8s.io/v1alpha2.RouteNamespaces).
+  Note that the unmanaged Kong Gateway implementation populates listeners
+  automatically based on the Kong Service and Deployment, and user-provided
+  `allowedRoutes` filters are merged into generated listeners with the same
+  protocol.
+  [#2389](https://github.com/Kong/kubernetes-ingress-controller/issues/2389)
 
 ## [2.3.1]
 
diff --git a/internal/controllers/gateway/gateway_controller.go b/internal/controllers/gateway/gateway_controller.go
index c2bc1a76d..40ea6b41d 100644
--- a/internal/controllers/gateway/gateway_controller.go
+++ b/internal/controllers/gateway/gateway_controller.go
@@ -33,6 +33,7 @@ var (
 	// ManagedGatewaysUnsupported is an error used whenever a failure occurs
 	// due to a Gateway that is not properly configured for unmanaged mode.
 	ManagedGatewaysUnsupported = fmt.Errorf("invalid gateway spec: managed gateways are not currently supported") //nolint:revive
+	gatewayV1alpha2Group       = gatewayv1alpha2.Group(gatewayv1alpha2.GroupName)
 )
 
 // -----------------------------------------------------------------------------
@@ -50,7 +51,6 @@ type GatewayReconciler struct { //nolint:revive
 	PublishService  string
 	WatchNamespaces []string
 
-	allowedRoutes     gatewayv1alpha2.AllowedRoutes
 	publishServiceRef types.NamespacedName
 }
 
@@ -63,38 +63,6 @@ func (r *GatewayReconciler) SetupWithManager(mgr ctrl.Manager) error {
 		return err
 	}
 
-	// determine the AllowedRoutes for Gateways
-	group := gatewayv1alpha2.Group(gatewayv1alpha2.GroupName)
-	r.allowedRoutes.Kinds = []gatewayv1alpha2.RouteGroupKind{
-		{
-			Group: &group,
-			Kind:  gatewayv1alpha2.Kind("HTTPRoute"),
-		},
-		{
-			Group: &group,
-			Kind:  gatewayv1alpha2.Kind("TCPRoute"),
-		},
-		{
-			Group: &group,
-			Kind:  gatewayv1alpha2.Kind("UDPRoute"),
-		},
-	}
-
-	// determine whether AllowedRoutes will come from ALL namespaces, or specific ones
-	namespaces := gatewayv1alpha2.RouteNamespaces{}
-	if len(r.WatchNamespaces) == 0 {
-		fromAll := gatewayv1alpha2.NamespacesFromAll
-		namespaces.From = &fromAll
-	} else {
-		fromSelector := gatewayv1alpha2.NamespacesFromSelector
-		namespaces.From = &fromSelector
-		// TODO: this currently doesn't handle namespace restrictions and instead all
-		//       namespaces are currently allowed. This will be implemented in an
-		//       upcoming iteration.
-		//       See: https://github.com/Kong/kubernetes-ingress-controller/issues/2080
-	}
-	r.allowedRoutes.Namespaces = &namespaces
-
 	// generate the controller object and attach it to the manager and link the reconciler object
 	c, err := controller.New("gateway-controller", mgr, controller.Options{
 		Reconciler: r,
@@ -322,6 +290,10 @@ func (r *GatewayReconciler) reconcileUnmanagedGateway(ctx context.Context, log l
 		return ctrl.Result{}, r.Status().Update(ctx, pruneGatewayStatusConds(gateway))
 	}
 
+	if !areAllowedRoutesConsistentByProtocol(gateway.Spec.Listeners) {
+		return ctrl.Result{}, fmt.Errorf("all listeners for a protocol must use the same AllowedRoutes")
+	}
+
 	// When deployed on Kubernetes Kong can not be relied on for the address data needed for Gateway because
 	// it's commonly deployed in a way agnostic to the container network (e.g. it's simply configured with
 	// 0.0.0.0 as the address for its listeners internally). In order to get addresses we have to derive them
@@ -339,6 +311,8 @@ func (r *GatewayReconciler) reconcileUnmanagedGateway(ctx context.Context, log l
 		return ctrl.Result{}, err
 	}
 
+	gatewayListeners = mergeAllowedRoutes(gateway.Spec.Listeners, gatewayListeners)
+
 	debug(log, gateway, "updating the gateway if any changes to listeners occurred")
 	isChanged, err := r.updateAddressesAndListenersSpec(ctx, gateway, gatewayAddresses, gatewayListeners)
 	if err != nil {
@@ -436,6 +410,10 @@ func (r *GatewayReconciler) determineL4ListenersFromService(
 	// for all service types we're going to capture the ClusterIP
 	addresses := make([]gatewayv1alpha2.GatewayAddress, 0, len(svc.Spec.ClusterIPs))
 	listeners := make([]gatewayv1alpha2.Listener, 0, len(svc.Spec.Ports))
+	protocolToRouteGroupKind := map[corev1.Protocol]gatewayv1alpha2.RouteGroupKind{
+		corev1.ProtocolTCP: {Group: &gatewayV1alpha2Group, Kind: gatewayv1alpha2.Kind("TCPRoute")},
+		corev1.ProtocolUDP: {Group: &gatewayV1alpha2Group, Kind: gatewayv1alpha2.Kind("UDPRoute")},
+	}
 	for _, clusterIP := range svc.Spec.ClusterIPs {
 		addresses = append(addresses, gatewayv1alpha2.GatewayAddress{
 			Type:  &gatewayIPAddrType,
@@ -444,10 +422,14 @@ func (r *GatewayReconciler) determineL4ListenersFromService(
 
 		for _, port := range svc.Spec.Ports {
 			listeners = append(listeners, gatewayv1alpha2.Listener{
-				Name:          gatewayv1alpha2.SectionName(port.Name),
-				Protocol:      gatewayv1alpha2.ProtocolType(port.Protocol),
-				Port:          gatewayv1alpha2.PortNumber(port.Port),
-				AllowedRoutes: &r.allowedRoutes,
+				Name:     gatewayv1alpha2.SectionName(port.Name),
+				Protocol: gatewayv1alpha2.ProtocolType(port.Protocol),
+				Port:     gatewayv1alpha2.PortNumber(port.Port),
+				AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+					Kinds: []gatewayv1alpha2.RouteGroupKind{
+						protocolToRouteGroupKind[port.Protocol],
+					},
+				},
 			})
 		}
 	}
@@ -517,13 +499,28 @@ func (r *GatewayReconciler) determineListenersFromDataPlane(ctx context.Context,
 		if streamListener, ok := streamListenersMap[portMapper[int(listener.Port)]]; ok {
 			if streamListener.SSL {
 				listener.Protocol = gatewayv1alpha2.TLSProtocolType
+				listener.AllowedRoutes = &gatewayv1alpha2.AllowedRoutes{
+					Kinds: []gatewayv1alpha2.RouteGroupKind{
+						{Group: &gatewayV1alpha2Group, Kind: gatewayv1alpha2.Kind("TLSRoute")},
+					},
+				}
 			}
 		}
 		if proxyListener, ok := proxyListenersMap[portMapper[int(listener.Port)]]; ok {
 			if proxyListener.SSL {
 				listener.Protocol = gatewayv1alpha2.HTTPSProtocolType
+				listener.AllowedRoutes = &gatewayv1alpha2.AllowedRoutes{
+					Kinds: []gatewayv1alpha2.RouteGroupKind{
+						{Group: &gatewayV1alpha2Group, Kind: gatewayv1alpha2.Kind("HTTPRoute")},
+					},
+				}
 			} else {
 				listener.Protocol = gatewayv1alpha2.HTTPProtocolType
+				listener.AllowedRoutes = &gatewayv1alpha2.AllowedRoutes{
+					Kinds: []gatewayv1alpha2.RouteGroupKind{
+						{Group: &gatewayV1alpha2Group, Kind: gatewayv1alpha2.Kind("HTTPRoute")},
+					},
+				}
 			}
 		}
 		upgradedListeners = append(upgradedListeners, listener)
@@ -573,3 +570,47 @@ func (r *GatewayReconciler) updateAddressesAndListenersStatus(
 	}
 	return false, nil
 }
+
+// mergeAllowedRoutes takes two sets of listeners, scans the source for AllowedRoutes filters, and copies those into
+// listeners in the target set that share the same protocol as the source listener. As implemented, it makes no attempt
+// to account for same-protocol listeners in the source set having different AllowedRoutes: it assumes
+// areAllowedRoutesConsistentByProtocol has been run first because Kong would not support multiple listeners with
+// different protocols anyway
+func mergeAllowedRoutes(source, target []gatewayv1alpha2.Listener) (merged []gatewayv1alpha2.Listener) {
+	mappings := make(map[gatewayv1alpha2.ProtocolType]gatewayv1alpha2.RouteNamespaces, len(target))
+	for _, listener := range source {
+		mappings[listener.Protocol] = *listener.AllowedRoutes.Namespaces
+	}
+	for _, listener := range target {
+		if namespaces, exists := mappings[listener.Protocol]; exists {
+			if listener.AllowedRoutes == nil {
+				listener.AllowedRoutes = &gatewayv1alpha2.AllowedRoutes{}
+			}
+			listener.AllowedRoutes.Namespaces = &namespaces
+		}
+		merged = append(merged, listener)
+	}
+	return merged
+}
+
+// areAllowedRoutesConsistentByProtocol returns an error if a set of listeners includes multiple listeners for the same
+// protocol that do not use the same AllowedRoutes filters. Kong does not support limiting routes to a specific listen:
+// all routes are always served on all listens compatible with their protocol. As such, while we can filter the routes
+// we ingest, if we ingest routes from two incompatible filters, we will combine them into a single proxy configuration
+// It may be possible to write a new Kong plugin that checks the inbound port/address to de facto apply listen-based
+// filters in the future.
+func areAllowedRoutesConsistentByProtocol(listeners []gatewayv1alpha2.Listener) bool {
+	allowedByProtocol := make(map[gatewayv1alpha2.ProtocolType]gatewayv1alpha2.AllowedRoutes)
+	for _, listener := range listeners {
+		var allowed gatewayv1alpha2.AllowedRoutes
+		var exists bool
+		if allowed, exists = allowedByProtocol[listener.Protocol]; !exists {
+			allowedByProtocol[listener.Protocol] = *listener.AllowedRoutes
+		} else {
+			if !reflect.DeepEqual(allowed, *listener.AllowedRoutes) {
+				return false
+			}
+		}
+	}
+	return true
+}
diff --git a/internal/controllers/gateway/route_utils.go b/internal/controllers/gateway/route_utils.go
index f871ac9fe..fb7e034ba 100644
--- a/internal/controllers/gateway/route_utils.go
+++ b/internal/controllers/gateway/route_utils.go
@@ -5,7 +5,9 @@ import (
 	"fmt"
 	"reflect"
 
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
 )
@@ -86,11 +88,72 @@ func getSupportedGatewayForRoute(ctx context.Context, mgrc client.Client, obj cl
 		// if the GatewayClass matches this controller we're all set and this controller
 		// should reconcile this object.
 		if gatewayClass.Spec.ControllerName == ControllerName {
-			gateways = append(gateways, &gateway)
+			allowedNamespaces := make(map[string]interface{})
+			// set true if we find any AllowedRoutes. there may be none, in which case any namespace is permitted
+			filtered := false
+			for _, listener := range gateway.Spec.Listeners {
+				// TODO https://github.com/Kong/kubernetes-ingress-controller/issues/2408
+				// This currently only performs a baseline filter to ensure that routes cannot match based on namespace
+				// criteria on a listener that cannot possibly handle them (e.g. an HTTPRoute should not be included
+				// based on matching a filter for a UDP listener). This needs to be expanded to an allowedRoutes.kind
+				// implementation with default allowed kinds when there's no user-specified filter.
+				switch obj.(type) {
+				case *gatewayv1alpha2.HTTPRoute:
+					if !(listener.Protocol == gatewayv1alpha2.HTTPProtocolType || listener.Protocol == gatewayv1alpha2.HTTPSProtocolType) {
+						continue
+					}
+				case *gatewayv1alpha2.TCPRoute:
+					if listener.Protocol != gatewayv1alpha2.TCPProtocolType {
+						continue
+					}
+				case *gatewayv1alpha2.UDPRoute:
+					if listener.Protocol != gatewayv1alpha2.UDPProtocolType {
+						continue
+					}
+				case *gatewayv1alpha2.TLSRoute:
+					if listener.Protocol != gatewayv1alpha2.TLSProtocolType {
+						continue
+					}
+				default:
+					continue
+				}
+				if listener.AllowedRoutes != nil {
+					filtered = true
+					if *listener.AllowedRoutes.Namespaces.From == gatewayv1alpha2.NamespacesFromAll {
+						// we allow "all" by just stuffing the namespace we want to find into the map
+						allowedNamespaces[obj.GetNamespace()] = nil
+					} else if *listener.AllowedRoutes.Namespaces.From == gatewayv1alpha2.NamespacesFromSame {
+						allowedNamespaces[gateway.ObjectMeta.Namespace] = nil
+					} else if *listener.AllowedRoutes.Namespaces.From == gatewayv1alpha2.NamespacesFromSelector {
+						namespaces := &corev1.NamespaceList{}
+						selector, err := metav1.LabelSelectorAsSelector(listener.AllowedRoutes.Namespaces.Selector)
+						if err != nil {
+							return nil, fmt.Errorf("failed to convert LabelSelector to Selector for gateway %s",
+								gateway.ObjectMeta.Name)
+						}
+						err = mgrc.List(ctx, namespaces,
+							&client.ListOptions{LabelSelector: selector})
+						if err != nil {
+							return nil, fmt.Errorf("could not fetch allowed namespaces for gateway %s",
+								gateway.ObjectMeta.Name)
+						}
+						for _, allowed := range namespaces.Items {
+							allowedNamespaces[allowed.ObjectMeta.Name] = nil
+						}
+					}
+				}
+			}
+
+			_, allowedNamespace := allowedNamespaces[obj.GetNamespace()]
+			if !filtered || allowedNamespace {
+				gateways = append(gateways, &gateway)
+			}
 		}
 	}
 
 	if len(gateways) == 0 {
+		// TODO https://github.com/Kong/kubernetes-ingress-controller/issues/2417 separate out various rejected reasons
+		// and apply specific statuses for those failures in the Route controllers
 		return nil, fmt.Errorf(unsupportedGW)
 	}
 
diff --git a/internal/dataplane/parser/translate_tcproute.go b/internal/dataplane/parser/translate_tcproute.go
index bff3cdbc0..d4c52dfd9 100644
--- a/internal/dataplane/parser/translate_tcproute.go
+++ b/internal/dataplane/parser/translate_tcproute.go
@@ -68,11 +68,6 @@ func ingressRulesFromTCPRoute(result *ingressRules, tcproute *gatewayv1alpha2.TC
 			return fmt.Errorf("missing backendRef in rule")
 		}
 
-		// TODO: support multiple backend refs
-		if len(rule.BackendRefs) > 1 {
-			return fmt.Errorf("multiple backendRefs are not yet supported")
-		}
-
 		// determine the routes needed to route traffic to services for this rule
 		routes, err := generateKongRoutesFromTCPRouteRule(tcproute, ruleNumber, rule)
 		if err != nil {
@@ -80,7 +75,10 @@ func ingressRulesFromTCPRoute(result *ingressRules, tcproute *gatewayv1alpha2.TC
 		}
 
 		// create a service and attach the routes to it
-		service := generateKongServiceFromTCPRouteBackendRef(result, tcproute, rule.BackendRefs[0])
+		service, err := generateKongServiceFromTCPRouteBackendRef(result, tcproute, ruleNumber, rule.BackendRefs...)
+		if err != nil {
+			return err
+		}
 		service.Routes = append(service.Routes, routes...)
 
 		// cache the service to avoid duplicates in further loop iterations
@@ -96,8 +94,11 @@ func ingressRulesFromTCPRoute(result *ingressRules, tcproute *gatewayv1alpha2.TC
 
 // generateKongRoutesFromTCPRouteRule converts an TCPRoute rule to one or more
 // Kong Route objects to route traffic to services.
-func generateKongRoutesFromTCPRouteRule(tcproute *gatewayv1alpha2.TCPRoute, ruleNumber int,
-	rule gatewayv1alpha2.TCPRouteRule) ([]kongstate.Route, error) {
+func generateKongRoutesFromTCPRouteRule(
+	tcproute *gatewayv1alpha2.TCPRoute,
+	ruleNumber int,
+	rule gatewayv1alpha2.TCPRouteRule,
+) ([]kongstate.Route, error) {
 	// gather the k8s object information and hostnames from the tcproute
 	objectInfo := util.FromK8sObject(tcproute)
 
@@ -108,13 +109,11 @@ func generateKongRoutesFromTCPRouteRule(tcproute *gatewayv1alpha2.TCPRoute, rule
 		// We do not intend to implement these until they are stable per https://github.com/Kong/kubernetes-ingress-controller/issues/2087#issuecomment-1079053290
 		return routes, fmt.Errorf("TCPRoute Matches are not yet supported")
 	}
+
 	if len(rule.BackendRefs) == 0 {
 		return routes, fmt.Errorf("TCPRoute rules must include at least one backendRef")
 	}
-	if len(rule.BackendRefs) > 1 {
-		// TODO refactor around the solution used for https://github.com/Kong/kubernetes-ingress-controller/issues/2173
-		return routes, fmt.Errorf("Support for multiple backends in TCPRoute rules is not yet supported")
-	}
+
 	routeName := kong.String(fmt.Sprintf(
 		"tcproute.%s.%s.%d.%d",
 		tcproute.Namespace,
@@ -125,9 +124,13 @@ func generateKongRoutesFromTCPRouteRule(tcproute *gatewayv1alpha2.TCPRoute, rule
 
 	// for now, TCPRoutes provide no means of specifying a destination port other than the backend target port
 	// they will once https://gateway-api.sigs.k8s.io/geps/gep-957/ is stable. in the interim, this always uses the
-	// backend target TODO also needs support for multiple backends
 	var destinations []*kong.CIDRPort
-	destinations = append(destinations, &kong.CIDRPort{Port: kong.Int(int(*rule.BackendRefs[0].Port))})
+	for _, backendRef := range rule.BackendRefs {
+		destinations = append(destinations, &kong.CIDRPort{
+			Port: kong.Int(int(*backendRef.Port)),
+		})
+	}
+
 	r := kongstate.Route{
 		Ingress: objectInfo,
 		Route: kong.Route{
@@ -137,41 +140,52 @@ func generateKongRoutesFromTCPRouteRule(tcproute *gatewayv1alpha2.TCPRoute, rule
 		},
 	}
 
-	routes = append(routes, r)
-
-	return routes, nil
+	return append(routes, r), nil
 }
 
 // generateKongServiceFromTCPRouteBackendRef converts a provided backendRef for an TCPRoute
 // into a kong.Service so that routes for that object can be attached to the Service.
-// TODO add a generic backendRef handler for all GW routes. HTTPRoute needs a wrapper because it uses a special wrapped
-// type with filters. Deferred til after https://github.com/Kong/kubernetes-ingress-controller/issues/2166 though
-// we probably shouldn't see much change to the service (just the upstream it references in Host)
-func generateKongServiceFromTCPRouteBackendRef(result *ingressRules, tcproute *gatewayv1alpha2.TCPRoute, backendRef gatewayv1alpha2.BackendRef) kongstate.Service {
-	// determine the service namespace
-	// TODO: need to add validation to restrict namespaces in backendRefs
-	namespace := tcproute.Namespace
-	if backendRef.Namespace != nil {
-		namespace = string(*backendRef.Namespace)
+// TODO https://github.com/Kong/kubernetes-ingress-controller/issues/2421 add a generic backendRef handler for all GW routes
+func generateKongServiceFromTCPRouteBackendRef(
+	result *ingressRules,
+	tcproute *gatewayv1alpha2.TCPRoute,
+	ruleNumber int,
+	backendRefs ...gatewayv1alpha2.BackendRef,
+) (kongstate.Service, error) {
+	// at least one backendRef must be present
+	if len(backendRefs) == 0 {
+		return kongstate.Service{}, fmt.Errorf("no backendRefs present for TCPRoute: %s/%s", tcproute.Namespace, tcproute.Name)
 	}
 
-	// determine the name of the Service
-	serviceName := fmt.Sprintf("%s.%s.%d", namespace, backendRef.Name, *backendRef.Port)
-
-	// determine the Service port
-	port := kongstate.PortDef{
-		Mode:   kongstate.PortModeByNumber,
-		Number: int32(*backendRef.Port),
+	// create a kongstate backend for each TCPRoute backendRef
+	backends := make(kongstate.ServiceBackends, 0, len(backendRefs))
+	for _, backendRef := range backendRefs {
+		// convert each backendRef into a kongstate.ServiceBackend
+		backends = append(backends, kongstate.ServiceBackend{
+			Name: string(backendRef.Name),
+			PortDef: kongstate.PortDef{
+				Mode:   kongstate.PortModeByNumber,
+				Number: int32(*backendRef.Port),
+			},
+			Weight: backendRef.Weight,
+		})
 	}
 
+	// the service name needs to uniquely identify this service given it's list of
+	// one or more backends.
+	serviceName := fmt.Sprintf("%s.%d", getUniqueKongServiceNameForObject(tcproute), ruleNumber)
+
+	// the service host needs to be a resolvable name due to legacy logic so we'll
+	// use the anchor backendRef as the basis for the name
+	serviceHost := serviceName
+
 	// check if the service is already known, and if not create it
 	service, ok := result.ServiceNameToServices[serviceName]
 	if !ok {
 		service = kongstate.Service{
 			Service: kong.Service{
 				Name:           kong.String(serviceName),
-				Host:           kong.String(fmt.Sprintf("%s.%s.%s.svc", backendRef.Name, namespace, port.CanonicalString())),
-				Port:           kong.Int(int(*backendRef.Port)),
+				Host:           kong.String(serviceHost),
 				Protocol:       kong.String("tcp"),
 				ConnectTimeout: kong.Int(DefaultServiceTimeout),
 				ReadTimeout:    kong.Int(DefaultServiceTimeout),
@@ -179,12 +193,9 @@ func generateKongServiceFromTCPRouteBackendRef(result *ingressRules, tcproute *g
 				Retries:        kong.Int(DefaultRetries),
 			},
 			Namespace: tcproute.Namespace,
-			Backends: kongstate.ServiceBackends{{
-				Name:    string(backendRef.Name),
-				PortDef: port,
-			}},
+			Backends:  backends,
 		}
 	}
 
-	return service
+	return service, nil
 }


Test patch:
diff --git a/internal/controllers/gateway/gateway_controller_test.go b/internal/controllers/gateway/gateway_controller_test.go
index ade6d81c1..694e70815 100644
--- a/internal/controllers/gateway/gateway_controller_test.go
+++ b/internal/controllers/gateway/gateway_controller_test.go
@@ -754,6 +754,270 @@ func Test_areListenersEqual(t *testing.T) {
 	}
 
 	for _, input := range inputs {
-		assert.Equal(t, input.expected, areListenersEqual(input.l1, input.l2), input.message, input.l1, input.l2)
+		assert.Equal(t, input.expected, areListenersEqual(input.l1, input.l2), input.message)
+	}
+}
+
+func Test_mergeAllowedRoutes(t *testing.T) {
+	same := gatewayv1alpha2.NamespacesFromSame
+	all := gatewayv1alpha2.NamespacesFromAll
+	inputs := []struct {
+		message  string
+		expected []gatewayv1alpha2.Listener
+		l1       []gatewayv1alpha2.Listener
+		l2       []gatewayv1alpha2.Listener
+	}{
+		{
+			message:  "empty",
+			expected: []gatewayv1alpha2.Listener(nil), // TODO what the go-isms here, shouldn't this be empty array, not whatever this is?
+			l1:       []gatewayv1alpha2.Listener{},
+			l2:       []gatewayv1alpha2.Listener{},
+		},
+		{
+			message: "single",
+			expected: []gatewayv1alpha2.Listener{
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &same,
+						},
+					},
+				},
+			},
+			l1: []gatewayv1alpha2.Listener{
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &same,
+						},
+					},
+				},
+			},
+			l2: []gatewayv1alpha2.Listener{
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+				},
+			},
+		},
+		{
+			message: "multiple",
+			expected: []gatewayv1alpha2.Listener{
+				{
+					Name:     "one",
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &same,
+						},
+					},
+				},
+				{
+					Name:     "two",
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &same,
+						},
+					},
+				},
+				{
+					Name:     "three",
+					Protocol: gatewayv1alpha2.TCPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &all,
+						},
+					},
+				},
+			},
+			l1: []gatewayv1alpha2.Listener{
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &same,
+						},
+					},
+				},
+				{
+					Protocol: gatewayv1alpha2.TCPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &all,
+						},
+					},
+				},
+			},
+			l2: []gatewayv1alpha2.Listener{
+				{
+					Name:     "one",
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+				},
+				{
+					Name:     "two",
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+				},
+				{
+					Name:     "three",
+					Protocol: gatewayv1alpha2.TCPProtocolType,
+				},
+			},
+		},
+	}
+	for _, input := range inputs {
+		assert.Equal(t, input.expected, mergeAllowedRoutes(input.l1, input.l2), input.message)
+	}
+}
+
+func Test_areAllowedRoutesConsistentByProtocol(t *testing.T) {
+	same := gatewayv1alpha2.NamespacesFromSame
+	all := gatewayv1alpha2.NamespacesFromAll
+	selector := gatewayv1alpha2.NamespacesFromSelector
+
+	inputs := []struct {
+		expected bool
+		message  string
+		l        []gatewayv1alpha2.Listener
+	}{
+		{
+			expected: true,
+			message:  "empty",
+			l:        []gatewayv1alpha2.Listener{},
+		},
+		{
+			expected: true,
+			message:  "no intersect",
+			l: []gatewayv1alpha2.Listener{
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &same,
+						},
+					},
+				},
+				{
+					Protocol: gatewayv1alpha2.TCPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &all,
+						},
+					},
+				},
+			},
+		},
+		{
+			expected: true,
+			message:  "same allowed for each listener with same protocol",
+			l: []gatewayv1alpha2.Listener{
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &same,
+						},
+					},
+				},
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &same,
+						},
+					},
+				},
+				{
+					Protocol: gatewayv1alpha2.TCPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &all,
+						},
+					},
+				},
+			},
+		},
+		{
+			expected: false,
+			message:  "different allowed for listeners with same protocol",
+			l: []gatewayv1alpha2.Listener{
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &same,
+						},
+					},
+				},
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &all,
+						},
+					},
+				},
+			},
+		},
+		{
+			expected: true,
+			message:  "same selector",
+			l: []gatewayv1alpha2.Listener{
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &selector,
+							Selector: &metav1.LabelSelector{
+								MatchLabels: map[string]string{"key": "value"},
+							},
+						},
+					},
+				},
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &selector,
+							Selector: &metav1.LabelSelector{
+								MatchLabels: map[string]string{"key": "value"},
+							},
+						},
+					},
+				},
+			},
+		},
+		{
+			expected: false,
+			message:  "different selector",
+			l: []gatewayv1alpha2.Listener{
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &selector,
+							Selector: &metav1.LabelSelector{
+								MatchLabels: map[string]string{"key": "value"},
+							},
+						},
+					},
+				},
+				{
+					Protocol: gatewayv1alpha2.UDPProtocolType,
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &selector,
+							Selector: &metav1.LabelSelector{
+								MatchLabels: map[string]string{"key": "notvalue"},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+	for _, input := range inputs {
+		assert.Equal(t, input.expected, areAllowedRoutesConsistentByProtocol(input.l), input.message)
 	}
 }
diff --git a/test/integration/gateway_test.go b/test/integration/gateway_test.go
index ebdd93ae3..164976157 100644
--- a/test/integration/gateway_test.go
+++ b/test/integration/gateway_test.go
@@ -4,13 +4,18 @@
 package integration
 
 import (
+	"net/http"
 	"testing"
 	"time"
 
 	"github.com/google/uuid"
+	"github.com/kong/go-kong/kong"
+	"github.com/kong/kubernetes-testing-framework/pkg/clusters"
+	"github.com/kong/kubernetes-testing-framework/pkg/utils/kubernetes/generators"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2"
 	gatewayclient "sigs.k8s.io/gateway-api/pkg/client/clientset/versioned"
@@ -402,3 +407,275 @@ func TestUnmanagedGatewayClass(t *testing.T) {
 		return false
 	}, gatewayUpdateWaitTime, time.Second)
 }
+
+func TestGatewayFilters(t *testing.T) {
+	ns, cleanup := namespace(t)
+	defer cleanup()
+	other, err := clusters.GenerateNamespace(ctx, env.Cluster(), t.Name()+"other")
+	require.NoError(t, err)
+	defer func(t *testing.T) {
+		assert.NoError(t, clusters.CleanupGeneratedResources(ctx, env.Cluster(), t.Name()+"other"))
+	}(t)
+
+	t.Log("deploying a supported gatewayclass to the test cluster")
+	c, err := gatewayclient.NewForConfig(env.Cluster().Config())
+	require.NoError(t, err)
+	gwc := &gatewayv1alpha2.GatewayClass{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: uuid.NewString(),
+		},
+		Spec: gatewayv1alpha2.GatewayClassSpec{
+			ControllerName: gateway.ControllerName,
+		},
+	}
+	gwc, err = c.GatewayV1alpha2().GatewayClasses().Create(ctx, gwc, metav1.CreateOptions{})
+	require.NoError(t, err)
+
+	defer func() {
+		t.Log("cleaning up gatewayclasses")
+		if err := c.GatewayV1alpha2().GatewayClasses().Delete(ctx, gwc.Name, metav1.DeleteOptions{}); err != nil {
+			if !errors.IsNotFound(err) {
+				assert.NoError(t, err)
+			}
+		}
+	}()
+
+	t.Log("deploying a gateway that allows routes in all namespaces")
+	fromSame := gatewayv1alpha2.NamespacesFromSame
+	fromAll := gatewayv1alpha2.NamespacesFromAll
+	fromSelector := gatewayv1alpha2.NamespacesFromSelector
+	gw := &gatewayv1alpha2.Gateway{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "kong",
+			Annotations: map[string]string{
+				unmanagedAnnotation: "true", // trigger the unmanaged gateway mode
+			},
+		},
+		Spec: gatewayv1alpha2.GatewaySpec{
+			GatewayClassName: gatewayv1alpha2.ObjectName(gwc.Name),
+			Listeners: []gatewayv1alpha2.Listener{
+				{
+					Name:     "http",
+					Protocol: gatewayv1alpha2.HTTPProtocolType,
+					Port:     gatewayv1alpha2.PortNumber(80),
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &fromAll,
+						},
+					},
+				},
+				{
+					Name:     "https",
+					Protocol: gatewayv1alpha2.HTTPSProtocolType,
+					Port:     gatewayv1alpha2.PortNumber(443),
+					AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+						Namespaces: &gatewayv1alpha2.RouteNamespaces{
+							From: &fromAll,
+						},
+					},
+				},
+			},
+		},
+	}
+	gw, err = c.GatewayV1alpha2().Gateways(ns.Name).Create(ctx, gw, metav1.CreateOptions{})
+	require.NoError(t, err)
+
+	defer func() {
+		t.Log("cleaning up gateways")
+		if err := c.GatewayV1alpha2().Gateways(ns.Name).Delete(ctx, gw.Name, metav1.DeleteOptions{}); err != nil {
+			if !errors.IsNotFound(err) {
+				assert.NoError(t, err)
+			}
+		}
+	}()
+
+	t.Log("deploying a minimal HTTP container deployment to test Ingress routes")
+	container := generators.NewContainer("httpbin", httpBinImage, 80)
+	deploymentTemplate := generators.NewDeploymentForContainer(container)
+	deployment, err := env.Cluster().Client().AppsV1().Deployments(ns.Name).Create(ctx, deploymentTemplate, metav1.CreateOptions{})
+	require.NoError(t, err)
+	otherDeployment, err := env.Cluster().Client().AppsV1().Deployments(other.Name).Create(ctx, deploymentTemplate, metav1.CreateOptions{})
+	require.NoError(t, err)
+
+	defer func() {
+		t.Logf("cleaning up deployments %s/%s and %s/%s", ns.Name, deployment.Name, other.Name, otherDeployment.Name)
+		if err := env.Cluster().Client().AppsV1().Deployments(ns.Name).Delete(ctx, deployment.Name, metav1.DeleteOptions{}); err != nil {
+			if !errors.IsNotFound(err) {
+				assert.NoError(t, err)
+			}
+		}
+		if err := env.Cluster().Client().AppsV1().Deployments(other.Name).Delete(ctx, otherDeployment.Name, metav1.DeleteOptions{}); err != nil {
+			if !errors.IsNotFound(err) {
+				assert.NoError(t, err)
+			}
+		}
+	}()
+
+	t.Logf("exposing deployment %s via service", deployment.Name)
+	service := generators.NewServiceForDeployment(deployment, corev1.ServiceTypeLoadBalancer)
+	_, err = env.Cluster().Client().CoreV1().Services(ns.Name).Create(ctx, service, metav1.CreateOptions{})
+	require.NoError(t, err)
+	_, err = env.Cluster().Client().CoreV1().Services(other.Name).Create(ctx, service, metav1.CreateOptions{})
+	require.NoError(t, err)
+
+	defer func() {
+		t.Logf("cleaning up the services %s/%s and %s/%s", ns.Name, service.Name, other.Name, service.Name)
+		if err := env.Cluster().Client().CoreV1().Services(ns.Name).Delete(ctx, service.Name, metav1.DeleteOptions{}); err != nil {
+			if !errors.IsNotFound(err) {
+				assert.NoError(t, err)
+			}
+		}
+		if err := env.Cluster().Client().CoreV1().Services(other.Name).Delete(ctx, service.Name, metav1.DeleteOptions{}); err != nil {
+			if !errors.IsNotFound(err) {
+				assert.NoError(t, err)
+			}
+		}
+	}()
+
+	t.Logf("creating an httproute to access deployment %s via kong", deployment.Name)
+	httpPort := gatewayv1alpha2.PortNumber(80)
+	pathMatchPrefix := gatewayv1alpha2.PathMatchPathPrefix
+	refNamespace := gatewayv1alpha2.Namespace(gw.Namespace)
+	httprouteTemplate := &gatewayv1alpha2.HTTPRoute{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: uuid.NewString(),
+			Annotations: map[string]string{
+				annotations.AnnotationPrefix + annotations.StripPathKey: "true",
+			},
+		},
+		Spec: gatewayv1alpha2.HTTPRouteSpec{
+			CommonRouteSpec: gatewayv1alpha2.CommonRouteSpec{
+				ParentRefs: []gatewayv1alpha2.ParentReference{{
+					Name:      gatewayv1alpha2.ObjectName(gw.Name),
+					Namespace: &refNamespace,
+				}},
+			},
+			Rules: []gatewayv1alpha2.HTTPRouteRule{{
+				Matches: []gatewayv1alpha2.HTTPRouteMatch{
+					{
+						Path: &gatewayv1alpha2.HTTPPathMatch{
+							Type:  &pathMatchPrefix,
+							Value: kong.String("/httpbin"),
+						},
+					},
+				},
+				BackendRefs: []gatewayv1alpha2.HTTPBackendRef{{
+					BackendRef: gatewayv1alpha2.BackendRef{
+						BackendObjectReference: gatewayv1alpha2.BackendObjectReference{
+							Name: gatewayv1alpha2.ObjectName(service.Name),
+							Port: &httpPort,
+						},
+					},
+				}},
+			}},
+		},
+	}
+	httproute, err := c.GatewayV1alpha2().HTTPRoutes(ns.Name).Create(ctx, httprouteTemplate, metav1.CreateOptions{})
+	require.NoError(t, err)
+
+	otherRoute, err := c.GatewayV1alpha2().HTTPRoutes(other.Name).Create(ctx, httprouteTemplate, metav1.CreateOptions{})
+	require.NoError(t, err)
+	otherRoute.Spec.Rules[0].Matches[0].Path.Value = kong.String("/otherbin")
+	_, err = c.GatewayV1alpha2().HTTPRoutes(other.Name).Update(ctx, otherRoute, metav1.UpdateOptions{})
+	require.NoError(t, err)
+
+	defer func() {
+		t.Logf("cleaning up the httproute %s", httproute.Name)
+		if err := c.GatewayV1alpha2().HTTPRoutes(ns.Name).Delete(ctx, httproute.Name, metav1.DeleteOptions{}); err != nil {
+			if !errors.IsNotFound(err) {
+				assert.NoError(t, err)
+			}
+		}
+		if err := c.GatewayV1alpha2().HTTPRoutes(other.Name).Delete(ctx, httproute.Name, metav1.DeleteOptions{}); err != nil {
+			if !errors.IsNotFound(err) {
+				assert.NoError(t, err)
+			}
+		}
+	}()
+
+	t.Log("verifying that the Gateway gets linked to the route via status")
+	eventuallyGatewayIsLinkedInStatus(t, c, ns.Name, httproute.Name)
+
+	t.Log("waiting for routes from HTTPRoute to become operational")
+	eventuallyGETPath(t, "httpbin", http.StatusOK, "<title>httpbin.org</title>", emptyHeaderSet)
+	t.Log("waiting for routes from HTTPRoute in other namespace to become operational")
+	eventuallyGETPath(t, "otherbin", http.StatusOK, "<title>httpbin.org</title>", emptyHeaderSet)
+
+	t.Log("changing to the same namespace filter")
+	gw, err = c.GatewayV1alpha2().Gateways(ns.Name).Get(ctx, gw.Name, metav1.GetOptions{})
+	require.NoError(t, err)
+	gw.Spec.Listeners = []gatewayv1alpha2.Listener{
+		{
+			Name:     "http",
+			Protocol: gatewayv1alpha2.HTTPProtocolType,
+			Port:     gatewayv1alpha2.PortNumber(80),
+			AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+				Namespaces: &gatewayv1alpha2.RouteNamespaces{
+					From: &fromSame,
+				},
+			},
+		},
+		{
+			Name:     "https",
+			Protocol: gatewayv1alpha2.HTTPSProtocolType,
+			Port:     gatewayv1alpha2.PortNumber(443),
+			AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+				Namespaces: &gatewayv1alpha2.RouteNamespaces{
+					From: &fromSame,
+				},
+			},
+		},
+	}
+	_, err = c.GatewayV1alpha2().Gateways(ns.Name).Update(ctx, gw, metav1.UpdateOptions{})
+	require.NoError(t, err)
+
+	t.Log("confirming other namespace route becomes inaccessible")
+	eventuallyGETPath(t, "otherbin", http.StatusNotFound, "no Route matched", emptyHeaderSet)
+	t.Log("confirming same namespace route still operational")
+	eventuallyGETPath(t, "httpbin", http.StatusOK, "<title>httpbin.org</title>", emptyHeaderSet)
+
+	t.Log("changing to a selector filter")
+	gw, err = c.GatewayV1alpha2().Gateways(ns.Name).Get(ctx, gw.Name, metav1.GetOptions{})
+	require.NoError(t, err)
+
+	gw.Spec.Listeners = []gatewayv1alpha2.Listener{
+		{
+			Name:     "http",
+			Protocol: gatewayv1alpha2.HTTPProtocolType,
+			Port:     gatewayv1alpha2.PortNumber(80),
+			AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+				Namespaces: &gatewayv1alpha2.RouteNamespaces{
+					From: &fromSelector,
+					Selector: &metav1.LabelSelector{
+						MatchLabels: map[string]string{
+							clusters.TestResourceLabel: t.Name() + "other",
+						},
+					},
+				},
+			},
+		},
+		{
+			Name:     "https",
+			Protocol: gatewayv1alpha2.HTTPSProtocolType,
+			Port:     gatewayv1alpha2.PortNumber(443),
+			AllowedRoutes: &gatewayv1alpha2.AllowedRoutes{
+				Namespaces: &gatewayv1alpha2.RouteNamespaces{
+					From: &fromSelector,
+					Selector: &metav1.LabelSelector{
+						MatchLabels: map[string]string{
+							clusters.TestResourceLabel: t.Name() + "other",
+						},
+					},
+				},
+			},
+		},
+	}
+
+	_, err = c.GatewayV1alpha2().Gateways(ns.Name).Update(ctx, gw, metav1.UpdateOptions{})
+	require.NoError(t, err)
+
+	t.Log("confirming wrong selector namespace route becomes inaccessible")
+	eventuallyGETPath(t, "httpbin", http.StatusNotFound, "no Route matched", emptyHeaderSet)
+	t.Log("confirming right selector namespace route becomes operational")
+	eventuallyGETPath(t, "otherbin", http.StatusOK, "<title>httpbin.org</title>", emptyHeaderSet)
+}
diff --git a/test/integration/ingress_test.go b/test/integration/ingress_test.go
index 31df56c91..1d00f8ec8 100644
--- a/test/integration/ingress_test.go
+++ b/test/integration/ingress_test.go
@@ -402,17 +402,11 @@ func TestIngressNamespaces(t *testing.T) {
 
 	t.Log("creating extra testing namespaces")
 	elsewhereNamespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: extraIngressNamespace}}
-	nowhere := "nowhere"
-	nowhereNamespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nowhere}}
-	_, err := env.Cluster().Client().CoreV1().Namespaces().Create(ctx, nowhereNamespace, metav1.CreateOptions{})
-	require.NoError(t, err)
-	_, err = env.Cluster().Client().CoreV1().Namespaces().Create(ctx, elsewhereNamespace, metav1.CreateOptions{})
+	_, err := env.Cluster().Client().CoreV1().Namespaces().Create(ctx, elsewhereNamespace, metav1.CreateOptions{})
 	require.NoError(t, err)
 	defer func() {
 		t.Logf("cleaning up namespace %s", elsewhereNamespace.Name)
 		assert.NoError(t, env.Cluster().Client().CoreV1().Namespaces().Delete(ctx, elsewhereNamespace.Name, metav1.DeleteOptions{}))
-		t.Logf("cleaning up namespace %s", nowhereNamespace.Name)
-		assert.NoError(t, env.Cluster().Client().CoreV1().Namespaces().Delete(ctx, nowhereNamespace.Name, metav1.DeleteOptions{}))
 	}()
 
 	t.Log("deploying a minimal HTTP container deployment to test Ingress routes")
@@ -420,26 +414,20 @@ func TestIngressNamespaces(t *testing.T) {
 	deployment := generators.NewDeploymentForContainer(container)
 	elsewhereDeployment, err := env.Cluster().Client().AppsV1().Deployments(extraIngressNamespace).Create(ctx, deployment, metav1.CreateOptions{})
 	require.NoError(t, err)
-	nowhereDeployment, err := env.Cluster().Client().AppsV1().Deployments(nowhere).Create(ctx, deployment, metav1.CreateOptions{})
-	require.NoError(t, err)
 
 	defer func() {
 		t.Logf("cleaning up the deployment %s", elsewhereDeployment.Name)
 		assert.NoError(t, env.Cluster().Client().AppsV1().Deployments(extraIngressNamespace).Delete(ctx, elsewhereDeployment.Name, metav1.DeleteOptions{}))
-		assert.NoError(t, env.Cluster().Client().AppsV1().Deployments(nowhere).Delete(ctx, nowhereDeployment.Name, metav1.DeleteOptions{}))
 	}()
 
 	t.Logf("exposing deployment %s via service", deployment.Name)
 	service := generators.NewServiceForDeployment(deployment, corev1.ServiceTypeLoadBalancer)
 	_, err = env.Cluster().Client().CoreV1().Services(extraIngressNamespace).Create(ctx, service, metav1.CreateOptions{})
 	require.NoError(t, err)
-	_, err = env.Cluster().Client().CoreV1().Services(nowhere).Create(ctx, service, metav1.CreateOptions{})
-	require.NoError(t, err)
 
 	defer func() {
 		t.Logf("cleaning up the service %s", service.Name)
 		assert.NoError(t, env.Cluster().Client().CoreV1().Services(extraIngressNamespace).Delete(ctx, service.Name, metav1.DeleteOptions{}))
-		assert.NoError(t, env.Cluster().Client().CoreV1().Services(nowhere).Delete(ctx, service.Name, metav1.DeleteOptions{}))
 	}()
 
 	t.Logf("creating an ingress for service %s with ingress.class %s", service.Name, ingressClass)
@@ -449,12 +437,7 @@ func TestIngressNamespaces(t *testing.T) {
 		annotations.IngressClassKey: ingressClass,
 		"konghq.com/strip-path":     "true",
 	}, service)
-	nowhereIngress := generators.NewIngressForServiceWithClusterVersion(kubernetesVersion, "/nowhere", map[string]string{
-		annotations.IngressClassKey: ingressClass,
-		"konghq.com/strip-path":     "true",
-	}, service)
 	require.NoError(t, clusters.DeployIngress(ctx, env.Cluster(), extraIngressNamespace, elsewhereIngress))
-	require.NoError(t, clusters.DeployIngress(ctx, env.Cluster(), nowhere, nowhereIngress))
 
 	defer func() {
 		t.Log("ensuring that Ingress resources are cleaned up")
@@ -463,11 +446,6 @@ func TestIngressNamespaces(t *testing.T) {
 				require.NoError(t, err)
 			}
 		}
-		if err := clusters.DeleteIngress(ctx, env.Cluster(), nowhere, nowhereIngress); err != nil {
-			if !errors.IsNotFound(err) {
-				require.NoError(t, err)
-			}
-		}
 	}()
 
 	t.Log("waiting for routes from Ingress to be operational")
@@ -489,16 +467,6 @@ func TestIngressNamespaces(t *testing.T) {
 		}
 		return false
 	}, ingressWait, waitTick)
-
-	require.Eventually(t, func() bool {
-		resp, err := httpc.Get(fmt.Sprintf("%s/nowhere", proxyURL))
-		if err != nil {
-			t.Logf("WARNING: error while waiting for %s: %v", proxyURL, err)
-			return false
-		}
-		defer resp.Body.Close()
-		return expect404WithNoRoute(t, proxyURL.String(), resp)
-	}, ingressWait, waitTick)
 }
 
 func TestIngressStatusUpdatesExtended(t *testing.T) {
diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go
index cb235e43e..e1dcc1266 100644
--- a/test/integration/suite_test.go
+++ b/test/integration/suite_test.go
@@ -148,7 +148,6 @@ func TestMain(m *testing.M) {
 			fmt.Sprintf("--ingress-class=%s", ingressClass),
 			fmt.Sprintf("--admission-webhook-cert=%s", testutils.KongSystemServiceCert),
 			fmt.Sprintf("--admission-webhook-key=%s", testutils.KongSystemServiceKey),
-			fmt.Sprintf("--watch-namespace=%s", watchNamespaces),
 			fmt.Sprintf("--admission-webhook-listen=%s:%d", testutils.AdmissionWebhookListenHost, testutils.AdmissionWebhookListenPort),
 			"--profiling",
 			"--dump-config",
diff --git a/test/integration/tcproute_test.go b/test/integration/tcproute_test.go
index 2bfec9ca6..947772e39 100644
--- a/test/integration/tcproute_test.go
+++ b/test/integration/tcproute_test.go
@@ -93,46 +93,79 @@ func TestTCPRouteEssentials(t *testing.T) {
 		}
 	}()
 
-	container := generators.NewContainer("tcpecho", tcpEchoImage, tcpEchoPort)
+	t.Log("creating a tcpecho pod to test TCPRoute traffic routing")
+	container1 := generators.NewContainer("tcpecho-1", tcpEchoImage, tcpEchoPort)
 	// go-echo sends a "Running on Pod <UUID>." immediately on connecting
-	testUUID := uuid.NewString()
-	container.Env = []corev1.EnvVar{
+	testUUID1 := uuid.NewString()
+	container1.Env = []corev1.EnvVar{
 		{
 			Name:  "POD_NAME",
-			Value: testUUID,
+			Value: testUUID1,
 		},
 	}
-	deployment := generators.NewDeploymentForContainer(container)
-	deployment, err = env.Cluster().Client().AppsV1().Deployments(ns.Name).Create(ctx, deployment, metav1.CreateOptions{})
+	deployment1 := generators.NewDeploymentForContainer(container1)
+	deployment1, err = env.Cluster().Client().AppsV1().Deployments(ns.Name).Create(ctx, deployment1, metav1.CreateOptions{})
+	require.NoError(t, err)
+
+	t.Log("creating an additional tcpecho pod to test TCPRoute multiple backendRef loadbalancing")
+	container2 := generators.NewContainer("tcpecho-2", tcpEchoImage, tcpEchoPort)
+	// go-echo sends a "Running on Pod <UUID>." immediately on connecting
+	testUUID2 := uuid.NewString()
+	container2.Env = []corev1.EnvVar{
+		{
+			Name:  "POD_NAME",
+			Value: testUUID2,
+		},
+	}
+	deployment2 := generators.NewDeploymentForContainer(container2)
+	deployment2, err = env.Cluster().Client().AppsV1().Deployments(ns.Name).Create(ctx, deployment2, metav1.CreateOptions{})
 	require.NoError(t, err)
 
 	defer func() {
-		t.Logf("cleaning up the deployment %s", deployment.Name)
-		assert.NoError(t, env.Cluster().Client().AppsV1().Deployments(ns.Name).Delete(ctx, deployment.Name, metav1.DeleteOptions{}))
+		t.Logf("cleaning up the deployments %s/%s and %s/%s", deployment1.Namespace, deployment1.Name, deployment2.Namespace, deployment2.Name)
+		assert.NoError(t, env.Cluster().Client().AppsV1().Deployments(ns.Name).Delete(ctx, deployment1.Name, metav1.DeleteOptions{}))
+		assert.NoError(t, env.Cluster().Client().AppsV1().Deployments(ns.Name).Delete(ctx, deployment2.Name, metav1.DeleteOptions{}))
 	}()
 
-	t.Logf("exposing deployment %s via service", deployment.Name)
-	service := generators.NewServiceForDeployment(deployment, corev1.ServiceTypeLoadBalancer)
+	t.Logf("exposing deployment %s/%s via service", deployment1.Namespace, deployment1.Name)
+	service1 := generators.NewServiceForDeployment(deployment1, corev1.ServiceTypeLoadBalancer)
+	// we have to override the ports so that we can map the default TCP port from
+	// the Kong Gateway deployment to the tcpecho port, as this is what will be
+	// used to route the traffic at the Gateway (at the time of writing, the
+	// Kong Gateway doesn't support an API for dynamically adding these ports. The
+	// ports must be added manually to the config or ENV).
+	service1.Spec.Ports = []corev1.ServicePort{{
+		Name:       "tcp",
+		Protocol:   corev1.ProtocolTCP,
+		Port:       ktfkong.DefaultTCPServicePort,
+		TargetPort: intstr.FromInt(tcpEchoPort),
+	}}
+	service1, err = env.Cluster().Client().CoreV1().Services(ns.Name).Create(ctx, service1, metav1.CreateOptions{})
+	assert.NoError(t, err)
+
+	t.Logf("exposing deployment %s/%s via service", deployment2.Namespace, deployment2.Name)
+	service2 := generators.NewServiceForDeployment(deployment2, corev1.ServiceTypeLoadBalancer)
 	// we have to override the ports so that we can map the default TCP port from
 	// the Kong Gateway deployment to the tcpecho port, as this is what will be
 	// used to route the traffic at the Gateway (at the time of writing, the
 	// Kong Gateway doesn't support an API for dynamically adding these ports. The
 	// ports must be added manually to the config or ENV).
-	service.Spec.Ports = []corev1.ServicePort{{
+	service2.Spec.Ports = []corev1.ServicePort{{
 		Name:       "tcp",
 		Protocol:   corev1.ProtocolTCP,
 		Port:       ktfkong.DefaultTCPServicePort,
 		TargetPort: intstr.FromInt(tcpEchoPort),
 	}}
-	service, err = env.Cluster().Client().CoreV1().Services(ns.Name).Create(ctx, service, metav1.CreateOptions{})
+	service2, err = env.Cluster().Client().CoreV1().Services(ns.Name).Create(ctx, service2, metav1.CreateOptions{})
 	assert.NoError(t, err)
 
 	defer func() {
-		t.Logf("cleaning up the service %s", service.Name)
-		assert.NoError(t, env.Cluster().Client().CoreV1().Services(ns.Name).Delete(ctx, service.Name, metav1.DeleteOptions{}))
+		t.Logf("cleaning up the service %s", service1.Name)
+		assert.NoError(t, env.Cluster().Client().CoreV1().Services(ns.Name).Delete(ctx, service1.Name, metav1.DeleteOptions{}))
+		assert.NoError(t, env.Cluster().Client().CoreV1().Services(ns.Name).Delete(ctx, service2.Name, metav1.DeleteOptions{}))
 	}()
 
-	t.Logf("creating a tcproute to access deployment %s via kong", deployment.Name)
+	t.Logf("creating a tcproute to access deployment %s via kong", deployment1.Name)
 	tcpPortDefault := gatewayv1alpha2.PortNumber(ktfkong.DefaultTCPServicePort)
 	tcproute := &gatewayv1alpha2.TCPRoute{
 		ObjectMeta: metav1.ObjectMeta{
@@ -148,7 +181,7 @@ func TestTCPRouteEssentials(t *testing.T) {
 			Rules: []gatewayv1alpha2.TCPRouteRule{{
 				BackendRefs: []gatewayv1alpha2.BackendRef{{
 					BackendObjectReference: gatewayv1alpha2.BackendObjectReference{
-						Name: gatewayv1alpha2.ObjectName(service.Name),
+						Name: gatewayv1alpha2.ObjectName(service1.Name),
 						Port: &tcpPortDefault,
 					},
 				}},
@@ -172,7 +205,7 @@ func TestTCPRouteEssentials(t *testing.T) {
 
 	t.Log("verifying that the tcpecho is responding properly")
 	require.Eventually(t, func() bool {
-		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID)
+		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID1)
 		return err == nil && responded == true
 	}, ingressWait, waitTick)
 
@@ -191,7 +224,7 @@ func TestTCPRouteEssentials(t *testing.T) {
 
 	t.Log("verifying that the tcpecho is no longer responding")
 	require.Eventually(t, func() bool {
-		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID)
+		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID1)
 		return responded == false && errors.Is(err, io.EOF)
 	}, ingressWait, waitTick)
 
@@ -209,7 +242,7 @@ func TestTCPRouteEssentials(t *testing.T) {
 
 	t.Log("verifying that putting the parentRefs back results in the routes becoming available again")
 	require.Eventually(t, func() bool {
-		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID)
+		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID1)
 		return err == nil && responded == true
 	}, ingressWait, waitTick)
 
@@ -222,7 +255,7 @@ func TestTCPRouteEssentials(t *testing.T) {
 
 	t.Log("verifying that the data-plane configuration from the TCPRoute gets dropped with the GatewayClass now removed")
 	require.Eventually(t, func() bool {
-		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID)
+		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID1)
 		return responded == false && errors.Is(err, io.EOF)
 	}, ingressWait, waitTick)
 
@@ -243,7 +276,7 @@ func TestTCPRouteEssentials(t *testing.T) {
 
 	t.Log("verifying that creating the GatewayClass again triggers reconciliation of TCPRoutes and the route becomes available again")
 	require.Eventually(t, func() bool {
-		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID)
+		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID1)
 		return err == nil && responded == true
 	}, ingressWait, waitTick)
 
@@ -256,7 +289,7 @@ func TestTCPRouteEssentials(t *testing.T) {
 
 	t.Log("verifying that the data-plane configuration from the TCPRoute gets dropped with the Gateway now removed")
 	require.Eventually(t, func() bool {
-		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID)
+		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID1)
 		return responded == false && errors.Is(err, io.EOF)
 	}, ingressWait, waitTick)
 
@@ -285,7 +318,7 @@ func TestTCPRouteEssentials(t *testing.T) {
 
 	t.Log("verifying that creating the Gateway again triggers reconciliation of TCPRoutes and the route becomes available again")
 	require.Eventually(t, func() bool {
-		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID)
+		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID1)
 		return err == nil && responded == true
 	}, ingressWait, waitTick)
 
@@ -298,7 +331,7 @@ func TestTCPRouteEssentials(t *testing.T) {
 
 	t.Log("verifying that the data-plane configuration from the TCPRoute does not get orphaned with the GatewayClass and Gateway gone")
 	require.Eventually(t, func() bool {
-		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID)
+		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID1)
 		return responded == false && errors.Is(err, io.EOF)
 	}, ingressWait, waitTick)
 
@@ -339,7 +372,41 @@ func TestTCPRouteEssentials(t *testing.T) {
 
 	t.Log("verifying that creating the Gateway again triggers reconciliation of TCPRoutes and the route becomes available again")
 	require.Eventually(t, func() bool {
-		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID)
+		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID1)
+		return err == nil && responded == true
+	}, ingressWait, waitTick)
+
+	t.Log("adding an additional backendRef to the TCPRoute")
+	require.Eventually(t, func() bool {
+		tcproute, err = c.GatewayV1alpha2().TCPRoutes(ns.Name).Get(ctx, tcproute.Name, metav1.GetOptions{})
+		require.NoError(t, err)
+
+		tcproute.Spec.Rules[0].BackendRefs = []gatewayv1alpha2.BackendRef{
+			{
+				BackendObjectReference: gatewayv1alpha2.BackendObjectReference{
+					Name: gatewayv1alpha2.ObjectName(service1.Name),
+					Port: &tcpPortDefault,
+				},
+			},
+			{
+				BackendObjectReference: gatewayv1alpha2.BackendObjectReference{
+					Name: gatewayv1alpha2.ObjectName(service2.Name),
+					Port: &tcpPortDefault,
+				},
+			},
+		}
+
+		tcproute, err = c.GatewayV1alpha2().TCPRoutes(ns.Name).Update(ctx, tcproute, metav1.UpdateOptions{})
+		return err == nil
+	}, ingressWait, waitTick)
+
+	t.Log("verifying that the TCPRoute is now load-balanced between two services")
+	require.Eventually(t, func() bool {
+		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID1)
+		return err == nil && responded == true
+	}, ingressWait, waitTick)
+	require.Eventually(t, func() bool {
+		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID2)
 		return err == nil && responded == true
 	}, ingressWait, waitTick)
 
@@ -352,7 +419,7 @@ func TestTCPRouteEssentials(t *testing.T) {
 
 	t.Log("verifying that the data-plane configuration from the TCPRoute does not get orphaned with the GatewayClass and Gateway gone")
 	require.Eventually(t, func() bool {
-		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID)
+		responded, err := tcpEchoResponds(fmt.Sprintf("%s:%d", proxyURL.Hostname(), ktfkong.DefaultTCPServicePort), testUUID1)
 		return responded == false && errors.Is(err, io.EOF)
 	}, ingressWait, waitTick)
 }