From 8c643ae1d7927619373ed7bf0d2cf7c8f158dda6 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Fri, 16 Jan 2026 09:11:09 +0100 Subject: [PATCH 01/19] feat: support port-based routing for Gateway API routes This change implements support for port-based routing in HTTPRoute and GRPCRoute by leveraging the 'server_port' variable in APISIX. When a route targets a specific Gateway listener (via 'sectionName' or matching), the translator now adds a condition to ensure the route only matches traffic on that specific port. Key changes: - Update translator to add 'server_port' matching variables to routes. - Enhance controller to track all matching listeners for a route. - Update E2E test framework to support multi-port APISIX and in-cluster testing. - Add comprehensive E2E tests for port-based routing scenarios. --- Makefile | 3 +- internal/adc/translator/httproute.go | 45 +++ internal/controller/context.go | 1 + internal/controller/grpcroute_controller.go | 7 +- internal/controller/httproute_controller.go | 8 + internal/controller/utils.go | 20 +- test/e2e/framework/manifests/apisix.yaml | 12 +- test/e2e/gatewayapi/httproute.go | 308 ++++++++++++++++++++ test/e2e/scaffold/k8s.go | 19 ++ test/e2e/scaffold/scaffold.go | 37 +++ 10 files changed, 455 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index 4249a3a432..e0fea5350c 100644 --- a/Makefile +++ b/Makefile @@ -185,9 +185,10 @@ kind-down: .PHONY: kind-load-images kind-load-images: pull-infra-images kind-load-ingress-image kind-load-adc-image - @kind load docker-image kennethreitz/httpbin:latest --name $(KIND_NAME) + @kind load docker-image kennethreitz/httpbin:latest --name $(KIND_NAME) @kind load docker-image jmalloc/echo-server:latest --name $(KIND_NAME) @kind load docker-image openresty/openresty:1.27.1.2-4-bullseye-fat --name $(KIND_NAME) + @kind load docker-image alpine/curl:latest --name $(KIND_NAME) .PHONY: kind-load-ingress-image kind-load-ingress-image: diff --git a/internal/adc/translator/httproute.go b/internal/adc/translator/httproute.go index 12b5c6ffbb..9769629ee7 100644 --- a/internal/adc/translator/httproute.go +++ b/internal/adc/translator/httproute.go @@ -699,6 +699,20 @@ func (t *Translator) TranslateHTTPRoute(tctx *provider.TranslateContext, httpRou routes = append(routes, route) } + + // Collect unique listener ports for port-based routing + listenerPorts := make(map[int32]struct{}) + for _, listener := range tctx.Listeners { + listenerPorts[int32(listener.Port)] = struct{}{} + } + + // If we have specific listener ports, add server_port matching + if len(listenerPorts) > 0 { + for _, route := range routes { + addServerPortVars(route, listenerPorts) + } + } + t.fillHTTPRoutePoliciesForHTTPRoute(tctx, routes, rule) service.Routes = routes @@ -848,3 +862,34 @@ func appProtocolToUpstreamScheme(appProtocol string) string { return "" } } + +func addServerPortVars(route *adctypes.Route, ports map[int32]struct{}) { + if len(ports) == 0 { + return + } + + // For single port, use exact match + if len(ports) == 1 { + for port := range ports { + portVar := []adctypes.StringOrSlice{ + {StrVal: "server_port"}, + {StrVal: "=="}, + {StrVal: fmt.Sprintf("%d", port)}, + } + route.Vars = append(route.Vars, portVar) + return + } + } + + // For multiple ports, use "in" operator + portList := make([]adctypes.StringOrSlice, 0, len(ports)) + for port := range ports { + portList = append(portList, adctypes.StringOrSlice{StrVal: fmt.Sprintf("%d", port)}) + } + portVar := []adctypes.StringOrSlice{ + {StrVal: "server_port"}, + {StrVal: "in"}, + {SliceVal: portList}, + } + route.Vars = append(route.Vars, portVar) +} diff --git a/internal/controller/context.go b/internal/controller/context.go index 5398f0448f..686dd046c7 100644 --- a/internal/controller/context.go +++ b/internal/controller/context.go @@ -27,6 +27,7 @@ type RouteParentRefContext struct { ListenerName string Listener *gatewayv1.Listener + Listeners []gatewayv1.Listener Conditions []metav1.Condition } diff --git a/internal/controller/grpcroute_controller.go b/internal/controller/grpcroute_controller.go index 3b4234171a..e1b9eceb7b 100644 --- a/internal/controller/grpcroute_controller.go +++ b/internal/controller/grpcroute_controller.go @@ -200,7 +200,12 @@ func (r *GRPCRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( acceptStatus.status = false acceptStatus.msg = err.Error() } - if gateway.Listener != nil { + // Populate listeners for port-based routing + // Use Listeners slice if available (multiple listener support) + if len(gateway.Listeners) > 0 { + tctx.Listeners = append(tctx.Listeners, gateway.Listeners...) + } else if gateway.Listener != nil { + // Fallback for backward compatibility tctx.Listeners = append(tctx.Listeners, *gateway.Listener) } } diff --git a/internal/controller/httproute_controller.go b/internal/controller/httproute_controller.go index 34615b9f55..d8d8f30be4 100644 --- a/internal/controller/httproute_controller.go +++ b/internal/controller/httproute_controller.go @@ -183,6 +183,14 @@ func (r *HTTPRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( acceptStatus.status = false acceptStatus.msg = err.Error() } + // Populate listeners for port-based routing + // Use Listeners slice if available (multiple listener support) + if len(gateway.Listeners) > 0 { + tctx.Listeners = append(tctx.Listeners, gateway.Listeners...) + } else if gateway.Listener != nil { + // Fallback for backward compatibility + tctx.Listeners = append(tctx.Listeners, *gateway.Listener) + } } var backendRefErr error diff --git a/internal/controller/utils.go b/internal/controller/utils.go index a00ef9165b..ac3dcd45ca 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -362,6 +362,10 @@ func ParseRouteParentRefs( reason := gatewayv1.RouteReasonNoMatchingParent var listenerName string var matchedListener gatewayv1.Listener + var matchedListeners []gatewayv1.Listener + + // Track if sectionName was explicitly specified + sectionNameSpecified := parentRef.SectionName != nil && *parentRef.SectionName != "" for _, listener := range gateway.Spec.Listeners { if parentRef.SectionName != nil { @@ -400,9 +404,19 @@ func ParseRouteParentRefs( // TODO: check if the listener status is programmed + if !matched { + // First match - store for backward compatibility + matchedListener = listener + } + + // Always add to the list of matched listeners + matchedListeners = append(matchedListeners, listener) matched = true - matchedListener = listener - break + + // Only break if sectionName was explicitly specified + if sectionNameSpecified { + break + } } if matched { @@ -410,6 +424,7 @@ func ParseRouteParentRefs( Gateway: &gateway, ListenerName: listenerName, Listener: &matchedListener, + Listeners: matchedListeners, Conditions: []metav1.Condition{{ Type: string(gatewayv1.RouteConditionAccepted), Status: metav1.ConditionTrue, @@ -422,6 +437,7 @@ func ParseRouteParentRefs( Gateway: &gateway, ListenerName: listenerName, Listener: &matchedListener, + Listeners: matchedListeners, Conditions: []metav1.Condition{{ Type: string(gatewayv1.RouteConditionAccepted), Status: metav1.ConditionFalse, diff --git a/test/e2e/framework/manifests/apisix.yaml b/test/e2e/framework/manifests/apisix.yaml index 310398e157..90f8845a5e 100644 --- a/test/e2e/framework/manifests/apisix.yaml +++ b/test/e2e/framework/manifests/apisix.yaml @@ -46,7 +46,10 @@ data: lua_shared_dict: standalone-config: 50m apisix: - proxy_mode: http&stream + proxy_mode: http&stream + node_listen: + - port: 9080 + - port: 9081 stream_proxy: # TCP/UDP proxy tcp: # TCP proxy port list - 9100 @@ -104,6 +107,9 @@ spec: - name: http containerPort: 9080 protocol: TCP + - name: http-alt + containerPort: 9081 + protocol: TCP - name: https containerPort: 9443 protocol: TCP @@ -151,6 +157,10 @@ spec: name: http protocol: TCP targetPort: 9080 + - port: 9081 + name: http-alt + protocol: TCP + targetPort: 9081 - port: {{ .ServiceHTTPSPort }} name: https protocol: TCP diff --git a/test/e2e/gatewayapi/httproute.go b/test/e2e/gatewayapi/httproute.go index a00d169328..8198d3bb37 100644 --- a/test/e2e/gatewayapi/httproute.go +++ b/test/e2e/gatewayapi/httproute.go @@ -2526,4 +2526,312 @@ spec: Expect(string(msg)).To(Equal(testMessage), "message content verification") }) }) + + Context("HTTPRoute with sectionName targeting different listeners", func() { + // Uses port 9080 (HTTP) and port 9081 (HTTP) + // Both ports are already exposed by the APISIX service + // Uses in-cluster curl to test server_port vars correctly + + var multiListenerGateway = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: %s +spec: + gatewayClassName: %s + listeners: + - name: http-main + protocol: HTTP + port: 9080 + - name: http-alt + protocol: HTTP + port: 9081 + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +` + + var routeForMainListener = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: route-main +spec: + parentRefs: + - name: %s + sectionName: http-main + rules: + - matches: + - path: + type: PathPrefix + value: /get + backendRefs: + - name: httpbin-service-e2e-test + port: 80 +` + + var routeForAltListener = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: route-alt +spec: + parentRefs: + - name: %s + sectionName: http-alt + rules: + - matches: + - path: + type: PathPrefix + value: /get + backendRefs: + - name: httpbin-service-e2e-test + port: 80 +` + + var routeNoSectionName = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: route-no-section +spec: + parentRefs: + - name: %s + rules: + - matches: + - path: + type: PathPrefix + value: /get + backendRefs: + - name: httpbin-service-e2e-test + port: 80 +` + + var routeInvalidSectionName = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: route-invalid-section +spec: + parentRefs: + - name: %s + sectionName: non-existent-listener + rules: + - matches: + - path: + type: PathPrefix + value: /get + backendRefs: + - name: httpbin-service-e2e-test + port: 80 +` + + var routeMultiParentRef = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: route-multi-parent +spec: + parentRefs: + - name: %s + sectionName: http-main + - name: %s + sectionName: http-alt + rules: + - matches: + - path: + type: PathPrefix + value: /get + backendRefs: + - name: httpbin-service-e2e-test + port: 80 +` + + // Get the APISIX service name from the deployer + getApisixServiceName := func() string { + // The APISIX service is named "apisix" (from framework.ProviderType) + return "apisix" + } + + // Run curl from within the cluster to the specified port + curlInCluster := func(port int, path string) (int, string, error) { + url := fmt.Sprintf("http://%s.%s.svc.cluster.local:%d%s", + getApisixServiceName(), s.Namespace(), port, path) + + // Note: curlimages/curl image already has curl as entrypoint, so we don't pass "curl" again + output, err := s.RunCurlFromK8s("-s", "-o", "/dev/null", "-w", "%{http_code}", url) + if err != nil { + return 0, "", err + } + statusCode := 0 + fmt.Sscanf(output, "%d", &statusCode) + return statusCode, output, nil + } + + BeforeEach(func() { + By("create GatewayProxy") + Expect(s.CreateResourceFromString(s.GetGatewayProxySpec())).NotTo(HaveOccurred()) + + By("create GatewayClass") + Expect(s.CreateResourceFromString(s.GetGatewayClassYaml())).NotTo(HaveOccurred()) + + s.RetryAssertion(func() string { + yaml, _ := s.GetResourceYaml("GatewayClass", s.Namespace()) + return yaml + }).Should(ContainSubstring(`status: "True"`)) + }) + + It("routes traffic to correct backend based on sectionName (using server_port vars)", func() { + gatewayName := s.Namespace() + + By("create Gateway with two listeners on different ports") + gateway := fmt.Sprintf(multiListenerGateway, gatewayName, s.Namespace()) + Expect(s.CreateResourceFromString(gateway)).NotTo(HaveOccurred()) + + s.RetryAssertion(func() string { + yaml, _ := s.GetResourceYaml("Gateway", gatewayName) + return yaml + }).Should(ContainSubstring(`status: "True"`)) + + By("create HTTPRoute targeting http-main listener (port 9080)") + routeMain := fmt.Sprintf(routeForMainListener, gatewayName) + s.ResourceApplied("HTTPRoute", "route-main", routeMain, 1) + + By("create HTTPRoute targeting http-alt listener (port 9100)") + routeAlt := fmt.Sprintf(routeForAltListener, gatewayName) + s.ResourceApplied("HTTPRoute", "route-alt", routeAlt, 1) + + By("wait for routes to be synced") + time.Sleep(5 * time.Second) + + By("verify route-main is accessible on port 9080 (via in-cluster curl)") + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9080, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK), + "route should be accessible on port 9080") + + By("verify route-alt is accessible on port 9081 (via in-cluster curl)") + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9081, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK), + "route should be accessible on port 9081") + + By("delete route-main and verify route-alt still works") + err := s.DeleteResourceFromString(routeMain) + Expect(err).NotTo(HaveOccurred()) + + // Port 9080 should now return 404 (route deleted) + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9080, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusNotFound), + "route should return 404 on port 9080 after deletion") + + // Port 9081 should still return 200 + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9081, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK), + "route should still return 200 on port 9081") + }) + + It("should match all listeners when sectionName is omitted", func() { + gatewayName := s.Namespace() + + By("create Gateway with two listeners") + gateway := fmt.Sprintf(multiListenerGateway, gatewayName, s.Namespace()) + Expect(s.CreateResourceFromString(gateway)).NotTo(HaveOccurred()) + + s.RetryAssertion(func() string { + yaml, _ := s.GetResourceYaml("Gateway", gatewayName) + return yaml + }).Should(ContainSubstring(`status: "True"`)) + + By("create HTTPRoute WITHOUT sectionName") + route := fmt.Sprintf(routeNoSectionName, gatewayName) + s.ResourceApplied("HTTPRoute", "route-no-section", route, 1) + + By("wait for route sync") + time.Sleep(5 * time.Second) + + By("verify route is accessible on port 9080") + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9080, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK), + "route should be accessible on port 9080") + + By("verify route is accessible on port 9081") + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9081, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK), + "route should be accessible on port 9081") + }) + + It("should not route traffic when sectionName references non-existent listener", func() { + gatewayName := s.Namespace() + + By("create Gateway with two listeners") + gateway := fmt.Sprintf(multiListenerGateway, gatewayName, s.Namespace()) + Expect(s.CreateResourceFromString(gateway)).NotTo(HaveOccurred()) + + s.RetryAssertion(func() string { + yaml, _ := s.GetResourceYaml("Gateway", gatewayName) + return yaml + }).Should(ContainSubstring(`status: "True"`)) + + By("create HTTPRoute with invalid sectionName") + route := fmt.Sprintf(routeInvalidSectionName, gatewayName) + Expect(s.CreateResourceFromString(route)).NotTo(HaveOccurred()) + + By("wait for reconciliation") + time.Sleep(5 * time.Second) + + By("verify route is NOT accessible on any port (no matching listener)") + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9080, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusNotFound), + "route should not be accessible when sectionName is invalid") + }) + + It("should route to multiple listeners via multiple parentRefs with sectionName", func() { + gatewayName := s.Namespace() + + By("create Gateway with two listeners") + gateway := fmt.Sprintf(multiListenerGateway, gatewayName, s.Namespace()) + Expect(s.CreateResourceFromString(gateway)).NotTo(HaveOccurred()) + + s.RetryAssertion(func() string { + yaml, _ := s.GetResourceYaml("Gateway", gatewayName) + return yaml + }).Should(ContainSubstring(`status: "True"`)) + + By("create HTTPRoute with multiple parentRefs targeting different listeners") + route := fmt.Sprintf(routeMultiParentRef, gatewayName, gatewayName) + s.ResourceApplied("HTTPRoute", "route-multi-parent", route, 1) + + By("wait for route sync") + time.Sleep(5 * time.Second) + + By("verify route is accessible on port 9080") + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9080, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK), + "route should be accessible on port 9080") + + By("verify route is accessible on port 9081") + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9081, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK), + "route should be accessible on port 9081") + }) + }) }) diff --git a/test/e2e/scaffold/k8s.go b/test/e2e/scaffold/k8s.go index 99fa57db23..98644b4f1b 100644 --- a/test/e2e/scaffold/k8s.go +++ b/test/e2e/scaffold/k8s.go @@ -268,6 +268,25 @@ func (s *Scaffold) RunDigDNSClientFromK8s(args ...string) (string, error) { return s.RunKubectlAndGetOutput(kubectlArgs...) } +// RunCurlFromK8s runs a curl command from a temporary pod inside the cluster. +// This is useful for making HTTP requests from within the cluster, avoiding +// port-forward limitations where server_port variables may not work correctly. +func (s *Scaffold) RunCurlFromK8s(args ...string) (string, error) { + kubectlArgs := []string{ + "run", + "curl-test", + "-i", + "--rm", + "--restart=Never", + "--image-pull-policy=IfNotPresent", + "--image=alpine/curl:flattened", + "--", + "curl", + } + kubectlArgs = append(kubectlArgs, args...) + return s.RunKubectlAndGetOutput(kubectlArgs...) +} + func (s *Scaffold) GetGatewayProxySpec() string { var gatewayProxyYaml = ` apiVersion: apisix.apache.org/v1alpha1 diff --git a/test/e2e/scaffold/scaffold.go b/test/e2e/scaffold/scaffold.go index af6d828943..bd34f7e7b9 100644 --- a/test/e2e/scaffold/scaffold.go +++ b/test/e2e/scaffold/scaffold.go @@ -313,6 +313,43 @@ func (s *Scaffold) NewAPISIXClientWithTLSProxy(host string) *httpexpect.Expect { }) } +// NewAPISIXClientForPort creates an HTTP client for a specific APISIX port. +// Uses existing tunnels if available, otherwise creates a new one. +func (s *Scaffold) NewAPISIXClientForPort(port int) (*httpexpect.Expect, error) { + // Check if we can reuse existing tunnels + switch port { + case 80: + return s.NewAPISIXClient(), nil + case 443: + return s.NewAPISIXHttpsClient(""), nil + case 9100: + return s.NewAPISIXClientOnTCPPort(), nil + } + + // Create new tunnel for custom port + serviceName := s.dataplaneService.Name + tunnel := k8s.NewTunnel(s.kubectlOptions, k8s.ResourceTypeService, serviceName, 0, port) + if err := tunnel.ForwardPortE(s.t); err != nil { + return nil, fmt.Errorf("failed to create tunnel for port %d: %w", port, err) + } + s.addFinalizers(tunnel.Close) + + u := url.URL{ + Scheme: "http", + Host: tunnel.Endpoint(), + } + return httpexpect.WithConfig(httpexpect.Config{ + BaseURL: u.String(), + Client: &http.Client{ + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, + Timeout: 3 * time.Second, + }, + Reporter: httpexpect.NewAssertReporter( + httpexpect.NewAssertReporter(GinkgoT()), + ), + }), nil +} + func (s *Scaffold) DefaultDataplaneResource() DataplaneResource { return s.Deployer.DefaultDataplaneResource() } From 7b31cf60af0ce63bbec7b95ccf3f3a6086c89b5c Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Fri, 16 Jan 2026 09:14:43 +0100 Subject: [PATCH 02/19] docs: update Gateway API port support documentation Reflect the new port-based routing capability in Gateway API documentation. - Updated concepts/gateway-api.md to mark listener port as partially supported. - Updated reference/example.md to explain that port is used for matching. --- docs/en/latest/concepts/gateway-api.md | 3 ++- docs/en/latest/reference/example.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/en/latest/concepts/gateway-api.md b/docs/en/latest/concepts/gateway-api.md index cefdde190e..7ad2bb87e3 100644 --- a/docs/en/latest/concepts/gateway-api.md +++ b/docs/en/latest/concepts/gateway-api.md @@ -78,7 +78,8 @@ The fields below are specified in the Gateway API specification but are either p | Fields | Status | Notes | |------------------------------------------------------|----------------------|------------------------------------------------------------------------------------------------| -| `spec.listeners[].port` | Not supported* | The configuration is required but ignored. This is due to limitations in the data plane: it cannot dynamically open new ports. Since the Ingress Controller does not manage the data plane deployment, it cannot automatically update the configuration or restart the data plane to apply port changes. | +| `spec.listeners[].port` | Partially supported | The configuration is used for routing matching (ensuring traffic matches the listener port). However, the controller cannot dynamically open new ports on the data plane. Users must ensure APISIX is configured to listen on the specified ports. | + | `spec.listeners[].tls.certificateRefs[].group` | Partially supported | Only `""` is supported; other group values cause validation failure. | | `spec.listeners[].tls.certificateRefs[].kind` | Partially supported | Only `Secret` is supported. | | `spec.listeners[].tls.mode` | Partially supported | `Terminate` is implemented; `Passthrough` is effectively unsupported for Gateway listeners. | diff --git a/docs/en/latest/reference/example.md b/docs/en/latest/reference/example.md index 8c219f8c3b..d0ec66b6ee 100644 --- a/docs/en/latest/reference/example.md +++ b/docs/en/latest/reference/example.md @@ -96,7 +96,7 @@ spec: ❶ The controller name should be customized if you are running multiple distinct instances of the APISIX Ingress Controller in the same cluster (not a single instance with multiple replicas). Each ingress controller instance must use a unique controllerName in its [configuration file](configuration-file.md), and the corresponding GatewayClass should reference that value. -❷ The `port` in the Gateway listener is required but ignored. This is due to limitations in the data plane: it cannot dynamically open new ports. Since the Ingress Controller does not manage the data plane deployment, it cannot automatically update the configuration or restart the data plane to apply port changes. +❷ The `port` in the Gateway listener is used for routing matching. However, the controller cannot dynamically open new ports on the data plane. You must ensure that the APISIX data plane is configured to listen on this port. ❸ API group of the referenced resource. From 587e27f0eb4e031a5aa0dcd471679f9d3bfb5541 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Fri, 16 Jan 2026 09:57:34 +0100 Subject: [PATCH 03/19] fix(test): check return error of fmt.Sscanf in E2E test This fixes a linting error flagged by errcheck where the return value of fmt.Sscanf was being ignored. --- test/e2e/gatewayapi/httproute.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/e2e/gatewayapi/httproute.go b/test/e2e/gatewayapi/httproute.go index 8198d3bb37..7d0a44ff22 100644 --- a/test/e2e/gatewayapi/httproute.go +++ b/test/e2e/gatewayapi/httproute.go @@ -2666,7 +2666,9 @@ spec: return 0, "", err } statusCode := 0 - fmt.Sscanf(output, "%d", &statusCode) + if _, err := fmt.Sscanf(output, "%d", &statusCode); err != nil { + return 0, output, err + } return statusCode, output, nil } From dd58606754756066b562b6aa4f0e1a8d1bfbc053 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Fri, 16 Jan 2026 09:59:21 +0100 Subject: [PATCH 04/19] fix(e2e): pull alpine/curl image in pull-infra-images target This ensures the alpine/curl image is present locally before attempting to load it into the Kind cluster, preventing build failures in CI. --- Makefile | 1 + 1 file changed, 1 insertion(+) diff --git a/Makefile b/Makefile index e0fea5350c..7a2545cd1d 100644 --- a/Makefile +++ b/Makefile @@ -205,6 +205,7 @@ pull-infra-images: @docker pull kennethreitz/httpbin:latest @docker pull jmalloc/echo-server:latest @docker pull openresty/openresty:1.27.1.2-4-bullseye-fat + @docker pull alpine/curl:latest ##@ Build From 8aac247d071b2fef7a81d089c58787c44a772520 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Fri, 16 Jan 2026 11:00:54 +0100 Subject: [PATCH 05/19] test: add unit tests for addServerPortVars and fix test issues - Add comprehensive unit tests for addServerPortVars function - Fix two-port test to use expectedPortList + ElementsMatch for non-deterministic map ordering - Consolidate redundant single-port tests (removed HTTP/HTTPS duplicates) - Fix image tag mismatch: alpine/curl:flattened -> alpine/curl:latest - Fix comment typo: port 9100 -> port 9081 Co-Authored-By: Claude --- internal/adc/translator/annotations_test.go | 102 ++++++++++++++++++++ test/e2e/gatewayapi/httproute.go | 2 +- test/e2e/scaffold/k8s.go | 2 +- 3 files changed, 104 insertions(+), 2 deletions(-) diff --git a/internal/adc/translator/annotations_test.go b/internal/adc/translator/annotations_test.go index 8c8b1b96ef..42789e6643 100644 --- a/internal/adc/translator/annotations_test.go +++ b/internal/adc/translator/annotations_test.go @@ -350,3 +350,105 @@ func TestTranslateIngressAnnotations(t *testing.T) { }) } } + +func TestAddServerPortVars(t *testing.T) { + tests := []struct { + name string + route *adctypes.Route + ports map[int32]struct{} + expected adctypes.Vars + expectedPortList []string // For tests with non-deterministic ordering + }{ + { + name: "empty ports map - no vars added", + route: &adctypes.Route{}, + ports: map[int32]struct{}{}, + expected: adctypes.Vars(nil), + }, + { + name: "single port - uses == operator", + route: &adctypes.Route{}, + ports: map[int32]struct{}{ + 9080: {}, + }, + expected: adctypes.Vars{ + { + {StrVal: "server_port"}, + {StrVal: "=="}, + {StrVal: "9080"}, + }, + }, + }, + { + name: "two ports - uses 'in' operator", + route: &adctypes.Route{}, + ports: map[int32]struct{}{ + 9080: {}, + 9081: {}, + }, + // Note: Map iteration order is non-deterministic in Go + expectedPortList: []string{"9080", "9081"}, + }, + { + name: "three ports - uses 'in' operator", + route: &adctypes.Route{}, + ports: map[int32]struct{}{ + 80: {}, + 443: {}, + 9080: {}, + }, + // Note: Map iteration order is non-deterministic in Go + expectedPortList: []string{"80", "443", "9080"}, + }, + { + name: "vars are appended - preserves existing vars", + route: &adctypes.Route{ + Vars: adctypes.Vars{ + { + {StrVal: "uri"}, + {StrVal: "~~"}, + {StrVal: "^/api"}, + }, + }, + }, + ports: map[int32]struct{}{ + 9080: {}, + }, + expected: adctypes.Vars{ + { + {StrVal: "uri"}, + {StrVal: "~~"}, + {StrVal: "^/api"}, + }, + { + {StrVal: "server_port"}, + {StrVal: "=="}, + {StrVal: "9080"}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + addServerPortVars(tt.route, tt.ports) + if tt.expectedPortList != nil { + // For tests with non-deterministic ordering, check structure and contents separately + // Verify structure: ["server_port", "in", [...]] + assert.Len(t, tt.route.Vars, 1, "should have exactly one var entry") + assert.Len(t, tt.route.Vars[0], 3, "var entry should have 3 elements") + assert.Equal(t, "server_port", tt.route.Vars[0][0].StrVal) + assert.Equal(t, "in", tt.route.Vars[0][1].StrVal) + assert.Empty(t, tt.route.Vars[0][2].StrVal, "StrVal should be empty when SliceVal is used") + // Verify all expected ports are present regardless of order + var portStrings []string + for _, s := range tt.route.Vars[0][2].SliceVal { + portStrings = append(portStrings, s.StrVal) + } + assert.ElementsMatch(t, tt.expectedPortList, portStrings, "port list should contain all expected ports") + } else { + assert.Equal(t, tt.expected, tt.route.Vars) + } + }) + } +} diff --git a/test/e2e/gatewayapi/httproute.go b/test/e2e/gatewayapi/httproute.go index 7d0a44ff22..d9da938999 100644 --- a/test/e2e/gatewayapi/httproute.go +++ b/test/e2e/gatewayapi/httproute.go @@ -2701,7 +2701,7 @@ spec: routeMain := fmt.Sprintf(routeForMainListener, gatewayName) s.ResourceApplied("HTTPRoute", "route-main", routeMain, 1) - By("create HTTPRoute targeting http-alt listener (port 9100)") + By("create HTTPRoute targeting http-alt listener (port 9081)") routeAlt := fmt.Sprintf(routeForAltListener, gatewayName) s.ResourceApplied("HTTPRoute", "route-alt", routeAlt, 1) diff --git a/test/e2e/scaffold/k8s.go b/test/e2e/scaffold/k8s.go index 98644b4f1b..3b56a5df58 100644 --- a/test/e2e/scaffold/k8s.go +++ b/test/e2e/scaffold/k8s.go @@ -279,7 +279,7 @@ func (s *Scaffold) RunCurlFromK8s(args ...string) (string, error) { "--rm", "--restart=Never", "--image-pull-policy=IfNotPresent", - "--image=alpine/curl:flattened", + "--image=alpine/curl:latest", "--", "curl", } From 5ba15c808ee49541541845e01c19344c3ad89b41 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Sat, 17 Jan 2026 16:39:21 +0100 Subject: [PATCH 06/19] fix: sort listener ports to prevent route config flapping --- internal/adc/translator/annotations_test.go | 72 ++++++++++----------- internal/adc/translator/httproute.go | 10 ++- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/internal/adc/translator/annotations_test.go b/internal/adc/translator/annotations_test.go index 42789e6643..20579cd08f 100644 --- a/internal/adc/translator/annotations_test.go +++ b/internal/adc/translator/annotations_test.go @@ -353,16 +353,15 @@ func TestTranslateIngressAnnotations(t *testing.T) { func TestAddServerPortVars(t *testing.T) { tests := []struct { - name string - route *adctypes.Route - ports map[int32]struct{} - expected adctypes.Vars - expectedPortList []string // For tests with non-deterministic ordering + name string + route *adctypes.Route + ports map[int32]struct{} + expected adctypes.Vars }{ { - name: "empty ports map - no vars added", - route: &adctypes.Route{}, - ports: map[int32]struct{}{}, + name: "empty ports map - no vars added", + route: &adctypes.Route{}, + ports: map[int32]struct{}{}, expected: adctypes.Vars(nil), }, { @@ -386,20 +385,37 @@ func TestAddServerPortVars(t *testing.T) { 9080: {}, 9081: {}, }, - // Note: Map iteration order is non-deterministic in Go - expectedPortList: []string{"9080", "9081"}, + expected: adctypes.Vars{ + { + {StrVal: "server_port"}, + {StrVal: "in"}, + {SliceVal: []adctypes.StringOrSlice{ + {StrVal: "9080"}, + {StrVal: "9081"}, + }}, + }, + }, }, { - name: "three ports - uses 'in' operator", - route: &adctypes.Route{}, - ports: map[int32]struct{}{ - 80: {}, - 443: {}, - 9080: {}, + name: "three ports - uses 'in' operator", + route: &adctypes.Route{}, + ports: map[int32]struct{}{ + 80: {}, + 443: {}, + 9080: {}, + }, + expected: adctypes.Vars{ + { + {StrVal: "server_port"}, + {StrVal: "in"}, + {SliceVal: []adctypes.StringOrSlice{ + {StrVal: "80"}, + {StrVal: "443"}, + {StrVal: "9080"}, + }}, + }, + }, }, - // Note: Map iteration order is non-deterministic in Go - expectedPortList: []string{"80", "443", "9080"}, - }, { name: "vars are appended - preserves existing vars", route: &adctypes.Route{ @@ -432,23 +448,7 @@ func TestAddServerPortVars(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { addServerPortVars(tt.route, tt.ports) - if tt.expectedPortList != nil { - // For tests with non-deterministic ordering, check structure and contents separately - // Verify structure: ["server_port", "in", [...]] - assert.Len(t, tt.route.Vars, 1, "should have exactly one var entry") - assert.Len(t, tt.route.Vars[0], 3, "var entry should have 3 elements") - assert.Equal(t, "server_port", tt.route.Vars[0][0].StrVal) - assert.Equal(t, "in", tt.route.Vars[0][1].StrVal) - assert.Empty(t, tt.route.Vars[0][2].StrVal, "StrVal should be empty when SliceVal is used") - // Verify all expected ports are present regardless of order - var portStrings []string - for _, s := range tt.route.Vars[0][2].SliceVal { - portStrings = append(portStrings, s.StrVal) - } - assert.ElementsMatch(t, tt.expectedPortList, portStrings, "port list should contain all expected ports") - } else { - assert.Equal(t, tt.expected, tt.route.Vars) - } + assert.Equal(t, tt.expected, tt.route.Vars) }) } } diff --git a/internal/adc/translator/httproute.go b/internal/adc/translator/httproute.go index 9769629ee7..a5ae031e55 100644 --- a/internal/adc/translator/httproute.go +++ b/internal/adc/translator/httproute.go @@ -20,6 +20,7 @@ package translator import ( "encoding/json" "fmt" + "sort" "strings" "github.com/pkg/errors" @@ -882,8 +883,15 @@ func addServerPortVars(route *adctypes.Route, ports map[int32]struct{}) { } // For multiple ports, use "in" operator - portList := make([]adctypes.StringOrSlice, 0, len(ports)) + // Sort ports for deterministic output + sortedPorts := make([]int, 0, len(ports)) for port := range ports { + sortedPorts = append(sortedPorts, int(port)) + } + sort.Ints(sortedPorts) + + portList := make([]adctypes.StringOrSlice, 0, len(ports)) + for _, port := range sortedPorts { portList = append(portList, adctypes.StringOrSlice{StrVal: fmt.Sprintf("%d", port)}) } portVar := []adctypes.StringOrSlice{ From d49a76753c076d3869ca617add4513adb11e220d Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Thu, 12 Feb 2026 22:24:56 +0100 Subject: [PATCH 07/19] fix(gateway-api): scope listener-port routing and hostname matching - inject server_port vars only for explicit sectionName or multi-port listeners - add server_port parity for GRPCRoute translation - use matched listeners for HTTPRoute hostname filtering and avoid zero-value listener fallback - add translator/controller unit tests and extend HTTPRoute/GRPCRoute e2e coverage --- internal/adc/translator/annotations_test.go | 55 +++++ internal/adc/translator/grpcroute.go | 13 ++ internal/adc/translator/grpcroute_test.go | 112 +++++++++++ internal/adc/translator/httproute.go | 19 +- internal/adc/translator/httproute_test.go | 123 ++++++++++++ internal/controller/utils.go | 67 +++---- internal/controller/utils_hostname_test.go | 212 ++++++++++++++++++++ test/e2e/gatewayapi/grpcroute.go | 103 ++++++++++ test/e2e/gatewayapi/httproute.go | 92 ++++++++- test/e2e/scaffold/grpc.go | 19 +- 10 files changed, 773 insertions(+), 42 deletions(-) create mode 100644 internal/adc/translator/grpcroute_test.go create mode 100644 internal/adc/translator/httproute_test.go create mode 100644 internal/controller/utils_hostname_test.go diff --git a/internal/adc/translator/annotations_test.go b/internal/adc/translator/annotations_test.go index 20579cd08f..84b4bb083e 100644 --- a/internal/adc/translator/annotations_test.go +++ b/internal/adc/translator/annotations_test.go @@ -21,6 +21,7 @@ import ( "github.com/incubator4/go-resty-expr/expr" "github.com/stretchr/testify/assert" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" adctypes "github.com/apache/apisix-ingress-controller/api/adc" "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations" @@ -452,3 +453,57 @@ func TestAddServerPortVars(t *testing.T) { }) } } + +func TestShouldInjectServerPortVars(t *testing.T) { + sectionName := gatewayv1.SectionName("http-main") + + tests := []struct { + name string + parentRefs []gatewayv1.ParentReference + ports map[int32]struct{} + expected bool + }{ + { + name: "empty listener ports", + ports: map[int32]struct{}{}, + expected: false, + }, + { + name: "single port without sectionName", + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + }, + ports: map[int32]struct{}{ + 9080: {}, + }, + expected: false, + }, + { + name: "single port with sectionName", + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", SectionName: §ionName}, + }, + ports: map[int32]struct{}{ + 9080: {}, + }, + expected: true, + }, + { + name: "multiple ports without sectionName", + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + }, + ports: map[int32]struct{}{ + 9080: {}, + 9081: {}, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, shouldInjectServerPortVars(tt.parentRefs, tt.ports)) + }) + } +} diff --git a/internal/adc/translator/grpcroute.go b/internal/adc/translator/grpcroute.go index abe6dfab09..a3f6712cae 100644 --- a/internal/adc/translator/grpcroute.go +++ b/internal/adc/translator/grpcroute.go @@ -308,6 +308,19 @@ func (t *Translator) TranslateGRPCRoute(tctx *provider.TranslateContext, grpcRou routes = append(routes, route) } + + // Collect unique listener ports for port-based routing. + listenerPorts := make(map[int32]struct{}) + for _, listener := range tctx.Listeners { + listenerPorts[int32(listener.Port)] = struct{}{} + } + + if shouldInjectServerPortVars(tctx.RouteParentRefs, listenerPorts) { + for _, route := range routes { + addServerPortVars(route, listenerPorts) + } + } + service.Routes = routes result.Services = append(result.Services, service) diff --git a/internal/adc/translator/grpcroute_test.go b/internal/adc/translator/grpcroute_test.go new file mode 100644 index 0000000000..7fd9ee3f63 --- /dev/null +++ b/internal/adc/translator/grpcroute_test.go @@ -0,0 +1,112 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package translator + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + adctypes "github.com/apache/apisix-ingress-controller/api/adc" + "github.com/apache/apisix-ingress-controller/internal/provider" +) + +func TestTranslateGRPCRouteServerPortVars(t *testing.T) { + sectionName := gatewayv1.SectionName("http-main") + + tests := []struct { + name string + parentRefs []gatewayv1.ParentReference + listeners []gatewayv1.Listener + expected adctypes.Vars + }{ + { + name: "no injection for single listener without sectionName", + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: nil, + }, + { + name: "injection for single listener with explicit sectionName", + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", SectionName: §ionName}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: adctypes.Vars{ + { + {StrVal: "server_port"}, + {StrVal: "=="}, + {StrVal: "9080"}, + }, + }, + }, + { + name: "injection for multiple listener ports", + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9081)}, + {Name: "http-alt", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: adctypes.Vars{ + { + {StrVal: "server_port"}, + {StrVal: "in"}, + {SliceVal: []adctypes.StringOrSlice{ + {StrVal: "9080"}, + {StrVal: "9081"}, + }}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tctx := provider.NewDefaultTranslateContext(context.Background()) + tctx.RouteParentRefs = tt.parentRefs + tctx.Listeners = tt.listeners + + grpcRoute := &gatewayv1.GRPCRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route", + Namespace: "default", + }, + Spec: gatewayv1.GRPCRouteSpec{ + Rules: []gatewayv1.GRPCRouteRule{ + {}, + }, + }, + } + + got, err := (&Translator{}).TranslateGRPCRoute(tctx, grpcRoute) + assert.NoError(t, err) + if assert.Len(t, got.Services, 1) && assert.Len(t, got.Services[0].Routes, 1) { + assert.Equal(t, tt.expected, got.Services[0].Routes[0].Vars) + } + }) + } +} diff --git a/internal/adc/translator/httproute.go b/internal/adc/translator/httproute.go index a5ae031e55..d56789ef8c 100644 --- a/internal/adc/translator/httproute.go +++ b/internal/adc/translator/httproute.go @@ -707,8 +707,9 @@ func (t *Translator) TranslateHTTPRoute(tctx *provider.TranslateContext, httpRou listenerPorts[int32(listener.Port)] = struct{}{} } - // If we have specific listener ports, add server_port matching - if len(listenerPorts) > 0 { + // Add server_port matching only when a route explicitly targets a listener + // or when multiple listener ports need to be disambiguated. + if shouldInjectServerPortVars(tctx.RouteParentRefs, listenerPorts) { for _, route := range routes { addServerPortVars(route, listenerPorts) } @@ -901,3 +902,17 @@ func addServerPortVars(route *adctypes.Route, ports map[int32]struct{}) { } route.Vars = append(route.Vars, portVar) } + +func shouldInjectServerPortVars(parentRefs []gatewayv1.ParentReference, ports map[int32]struct{}) bool { + if len(ports) == 0 { + return false + } + + for _, parentRef := range parentRefs { + if parentRef.SectionName != nil && *parentRef.SectionName != "" { + return true + } + } + + return len(ports) > 1 +} diff --git a/internal/adc/translator/httproute_test.go b/internal/adc/translator/httproute_test.go new file mode 100644 index 0000000000..c6b9216c16 --- /dev/null +++ b/internal/adc/translator/httproute_test.go @@ -0,0 +1,123 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You under the Apache License, Version 2.0 +// (the "License"); you may not use this file except in compliance with +// the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package translator + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + + adctypes "github.com/apache/apisix-ingress-controller/api/adc" + "github.com/apache/apisix-ingress-controller/internal/provider" +) + +func TestTranslateHTTPRouteServerPortVars(t *testing.T) { + sectionName := gatewayv1.SectionName("http-main") + pathMatchType := gatewayv1.PathMatchPathPrefix + pathValue := "/" + + tests := []struct { + name string + parentRefs []gatewayv1.ParentReference + listeners []gatewayv1.Listener + expected adctypes.Vars + }{ + { + name: "no injection for single listener without sectionName", + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: nil, + }, + { + name: "injection for single listener with explicit sectionName", + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", SectionName: §ionName}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: adctypes.Vars{ + { + {StrVal: "server_port"}, + {StrVal: "=="}, + {StrVal: "9080"}, + }, + }, + }, + { + name: "injection for multiple listener ports", + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9081)}, + {Name: "http-alt", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: adctypes.Vars{ + { + {StrVal: "server_port"}, + {StrVal: "in"}, + {SliceVal: []adctypes.StringOrSlice{ + {StrVal: "9080"}, + {StrVal: "9081"}, + }}, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tctx := provider.NewDefaultTranslateContext(context.Background()) + tctx.RouteParentRefs = tt.parentRefs + tctx.Listeners = tt.listeners + + httpRoute := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "route", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Rules: []gatewayv1.HTTPRouteRule{ + { + Matches: []gatewayv1.HTTPRouteMatch{ + { + Path: &gatewayv1.HTTPPathMatch{ + Type: &pathMatchType, + Value: &pathValue, + }, + }, + }, + }, + }, + }, + } + + got, err := (&Translator{}).TranslateHTTPRoute(tctx, httpRoute) + assert.NoError(t, err) + if assert.Len(t, got.Services, 1) && assert.Len(t, got.Services[0].Routes, 1) { + assert.Equal(t, tt.expected, got.Services[0].Routes[0].Vars) + } + }) + } +} diff --git a/internal/controller/utils.go b/internal/controller/utils.go index ac3dcd45ca..467b44f590 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -389,7 +389,6 @@ func ParseRouteParentRefs( continue } - listenerName = string(listener.Name) ok, err := routeMatchesListenerAllowedRoutes(ctx, mgrc, route, listener.AllowedRoutes, gateway.Namespace, parentRef.Namespace) if err != nil { log.Error(err, "failed matching listener to a route for gateway", @@ -404,6 +403,10 @@ func ParseRouteParentRefs( // TODO: check if the listener status is programmed + if sectionNameSpecified { + listenerName = string(listener.Name) + } + if !matched { // First match - store for backward compatibility matchedListener = listener @@ -436,7 +439,7 @@ func ParseRouteParentRefs( gateways = append(gateways, RouteParentRefContext{ Gateway: &gateway, ListenerName: listenerName, - Listener: &matchedListener, + Listener: nil, Listeners: matchedListeners, Conditions: []metav1.Condition{{ Type: string(gatewayv1.RouteConditionAccepted), @@ -1119,29 +1122,15 @@ func getUnionOfGatewayHostnames(gateways []RouteParentRefContext) ([]gatewayv1.H hostnames := make([]gatewayv1.Hostname, 0) for _, gateway := range gateways { - if gateway.ListenerName != "" { - // If a listener name is specified, only check that listener - for _, listener := range gateway.Gateway.Spec.Listeners { - if string(listener.Name) == gateway.ListenerName { - // If a listener does not specify a hostname, it can match any hostname - if listener.Hostname == nil { - return nil, true - } - hostnames = append(hostnames, *listener.Hostname) - break - } + for _, listener := range listenersForGatewayContext(gateway) { + // Only consider listeners that can effectively configure hostnames (HTTP, HTTPS, or TLS). + if !isListenerHostnameEffective(listener) { + continue } - } else { - // Otherwise, check all listeners - for _, listener := range gateway.Gateway.Spec.Listeners { - // Only consider listeners that can effectively configure hostnames (HTTP, HTTPS, or TLS) - if isListenerHostnameEffective(listener) { - if listener.Hostname == nil { - return nil, true - } - hostnames = append(hostnames, *listener.Hostname) - } + if listener.Hostname == nil { + return nil, true } + hostnames = append(hostnames, *listener.Hostname) } } @@ -1156,19 +1145,15 @@ func getUnionOfGatewayHostnames(gateways []RouteParentRefContext) ([]gatewayv1.H // - If none of the above, return an empty string func getMinimumHostnameIntersection(gateways []RouteParentRefContext, hostname gatewayv1.Hostname) gatewayv1.Hostname { for _, gateway := range gateways { - for _, listener := range gateway.Gateway.Spec.Listeners { - // If a listener name is specified, only check that listener - // If the listener name is not specified, check all listeners - if gateway.ListenerName == "" || gateway.ListenerName == string(listener.Name) { - if listener.Hostname == nil || *listener.Hostname == "" { - return hostname - } - if HostnamesMatch(string(*listener.Hostname), string(hostname)) { - return hostname - } - if HostnamesMatch(string(hostname), string(*listener.Hostname)) { - return *listener.Hostname - } + for _, listener := range listenersForGatewayContext(gateway) { + if listener.Hostname == nil || *listener.Hostname == "" { + return hostname + } + if HostnamesMatch(string(*listener.Hostname), string(hostname)) { + return hostname + } + if HostnamesMatch(string(hostname), string(*listener.Hostname)) { + return *listener.Hostname } } } @@ -1176,6 +1161,16 @@ func getMinimumHostnameIntersection(gateways []RouteParentRefContext, hostname g return "" } +func listenersForGatewayContext(gw RouteParentRefContext) []gatewayv1.Listener { + if len(gw.Listeners) > 0 { + return gw.Listeners + } + if gw.Listener != nil { + return []gatewayv1.Listener{*gw.Listener} + } + return nil +} + // isListenerHostnameEffective checks if a listener can specify a hostname to match the hostname in the request // Basically, check if the listener uses HTTP, HTTPS, or TLS protocol func isListenerHostnameEffective(listener gatewayv1.Listener) bool { diff --git a/internal/controller/utils_hostname_test.go b/internal/controller/utils_hostname_test.go new file mode 100644 index 0000000000..9e3779df1d --- /dev/null +++ b/internal/controller/utils_hostname_test.go @@ -0,0 +1,212 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package controller + +import ( + "testing" + + "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func TestListenersForGatewayContext(t *testing.T) { + hostname := gatewayv1.Hostname("example.com") + listener := gatewayv1.Listener{ + Name: "http", + Protocol: gatewayv1.HTTPProtocolType, + Port: gatewayv1.PortNumber(80), + Hostname: &hostname, + } + + tests := []struct { + name string + context RouteParentRefContext + expected []gatewayv1.Listener + }{ + { + name: "prefer listeners slice when present", + context: RouteParentRefContext{ + Listeners: []gatewayv1.Listener{ + listener, + { + Name: "https", + Protocol: gatewayv1.HTTPSProtocolType, + Port: gatewayv1.PortNumber(443), + }, + }, + Listener: &gatewayv1.Listener{ + Name: "ignored", + }, + }, + expected: []gatewayv1.Listener{ + listener, + { + Name: "https", + Protocol: gatewayv1.HTTPSProtocolType, + Port: gatewayv1.PortNumber(443), + }, + }, + }, + { + name: "fallback to single listener pointer", + context: RouteParentRefContext{ + Listener: &listener, + }, + expected: []gatewayv1.Listener{ + listener, + }, + }, + { + name: "no matched listeners", + context: RouteParentRefContext{}, + expected: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, listenersForGatewayContext(tt.context)) + }) + } +} + +func TestGetUnionOfGatewayHostnames(t *testing.T) { + fooHostname := gatewayv1.Hostname("foo.example.com") + barHostname := gatewayv1.Hostname("bar.example.com") + + t.Run("uses all matched listeners and ignores non-hostname-effective listeners", func(t *testing.T) { + gateways := []RouteParentRefContext{ + { + Listeners: []gatewayv1.Listener{ + {Name: "foo", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(80), Hostname: &fooHostname}, + {Name: "bar", Protocol: gatewayv1.HTTPSProtocolType, Port: gatewayv1.PortNumber(443), Hostname: &barHostname}, + {Name: "tcp", Protocol: gatewayv1.TCPProtocolType, Port: gatewayv1.PortNumber(9100)}, + }, + }, + } + + hostnames, matchAny := getUnionOfGatewayHostnames(gateways) + assert.False(t, matchAny) + assert.Equal(t, []gatewayv1.Hostname{fooHostname, barHostname}, hostnames) + }) + + t.Run("listener without hostname matches any hostname", func(t *testing.T) { + gateways := []RouteParentRefContext{ + { + Listeners: []gatewayv1.Listener{ + {Name: "http", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(80)}, + }, + }, + } + + hostnames, matchAny := getUnionOfGatewayHostnames(gateways) + assert.True(t, matchAny) + assert.Nil(t, hostnames) + }) +} + +func TestGetMinimumHostnameIntersection(t *testing.T) { + fooHostname := gatewayv1.Hostname("foo.example.com") + routeHostname := gatewayv1.Hostname("foo.example.com") + wildcardHostname := gatewayv1.Hostname("*.example.com") + + t.Run("matches across multiple listeners without sectionName", func(t *testing.T) { + gateways := []RouteParentRefContext{ + { + Listeners: []gatewayv1.Listener{ + {Name: "foo", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(80), Hostname: &fooHostname}, + {Name: "wildcard", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(80), Hostname: &wildcardHostname}, + }, + }, + } + + assert.Equal(t, routeHostname, getMinimumHostnameIntersection(gateways, routeHostname)) + }) + + t.Run("returns empty when there is no listener match", func(t *testing.T) { + gateways := []RouteParentRefContext{ + { + Listeners: []gatewayv1.Listener{ + {Name: "foo", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(80), Hostname: &fooHostname}, + }, + }, + } + unmatched := gatewayv1.Hostname("bar.example.com") + assert.Equal(t, gatewayv1.Hostname(""), getMinimumHostnameIntersection(gateways, unmatched)) + }) +} + +func TestFilterHostnamesWithMatchedListeners(t *testing.T) { + fooHostname := gatewayv1.Hostname("foo.example.com") + barHostname := gatewayv1.Hostname("bar.example.com") + + route := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{ + fooHostname, + barHostname, + }, + }, + } + + gateways := []RouteParentRefContext{ + { + Listeners: []gatewayv1.Listener{ + {Name: "foo", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(80), Hostname: &fooHostname}, + {Name: "bar", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(80), Hostname: &barHostname}, + }, + }, + } + + filtered, err := filterHostnames(gateways, route.DeepCopy()) + assert.NoError(t, err) + assert.Equal(t, []gatewayv1.Hostname{fooHostname, barHostname}, filtered.Spec.Hostnames) +} + +func TestFilterHostnamesNoMatchedListeners(t *testing.T) { + fooHostname := gatewayv1.Hostname("foo.example.com") + barHostname := gatewayv1.Hostname("bar.example.com") + + route := &gatewayv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + Namespace: "default", + }, + Spec: gatewayv1.HTTPRouteSpec{ + Hostnames: []gatewayv1.Hostname{ + barHostname, + }, + }, + } + + gateways := []RouteParentRefContext{ + { + Listeners: []gatewayv1.Listener{ + {Name: "foo", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(80), Hostname: &fooHostname}, + }, + }, + } + + _, err := filterHostnames(gateways, route.DeepCopy()) + assert.ErrorIs(t, err, ErrNoMatchingListenerHostname) +} diff --git a/test/e2e/gatewayapi/grpcroute.go b/test/e2e/gatewayapi/grpcroute.go index e4485fe08a..bbccd751f1 100644 --- a/test/e2e/gatewayapi/grpcroute.go +++ b/test/e2e/gatewayapi/grpcroute.go @@ -19,6 +19,7 @@ package gatewayapi import ( "fmt" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -282,6 +283,108 @@ spec: */ }) + Context("GRPCRoute with sectionName targeting different listeners", func() { + var multiListenerGateway = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: %s +spec: + gatewayClassName: %s + listeners: + - name: http-main + protocol: HTTP + port: 9080 + - name: http-alt + protocol: HTTP + port: 9081 + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +` + + var routeForMainListener = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: GRPCRoute +metadata: + name: grpc-route-main +spec: + parentRefs: + - name: %s + sectionName: http-main + rules: + - backendRefs: + - name: grpc-infra-backend-v1 + port: 8080 +` + + var routeForAltListener = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: GRPCRoute +metadata: + name: grpc-route-alt +spec: + parentRefs: + - name: %s + sectionName: http-alt + rules: + - backendRefs: + - name: grpc-infra-backend-v1 + port: 8080 +` + + It("routes to the configured listener ports when sectionName is set", func() { + gatewayName := "grpc-multi-listener" + + By("create Gateway with listeners on ports 9080 and 9081") + gateway := fmt.Sprintf(multiListenerGateway, gatewayName, s.Namespace()) + Expect(s.CreateResourceFromString(gateway)).NotTo(HaveOccurred()) + + s.RetryAssertion(func() string { + yaml, _ := s.GetResourceYaml("Gateway", gatewayName) + return yaml + }).Should(ContainSubstring(`status: "True"`)) + + By("create GRPCRoute targeting listener http-main") + routeMain := fmt.Sprintf(routeForMainListener, gatewayName) + s.ResourceApplied("GRPCRoute", "grpc-route-main", routeMain, 1) + + By("create GRPCRoute targeting listener http-alt") + routeAlt := fmt.Sprintf(routeForAltListener, gatewayName) + s.ResourceApplied("GRPCRoute", "grpc-route-alt", routeAlt, 1) + + By("verify both ports serve traffic before deletion") + Eventually(func() error { + return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ + EchoRequest: &pb.EchoRequest{}, + }, 9080) + }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).ShouldNot(HaveOccurred()) + + Eventually(func() error { + return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ + EchoRequest: &pb.EchoRequest{}, + }, 9081) + }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).ShouldNot(HaveOccurred()) + + By("delete route for 9080 and verify only 9081 keeps serving traffic") + Expect(s.DeleteResourceFromString(routeMain)).NotTo(HaveOccurred()) + + Eventually(func() error { + return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ + EchoRequest: &pb.EchoRequest{}, + }, 9080) + }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).Should(HaveOccurred()) + + Eventually(func() error { + return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ + EchoRequest: &pb.EchoRequest{}, + }, 9081) + }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).ShouldNot(HaveOccurred()) + }) + }) + // TODO: add BackendTrafficPolicy test /* Context("GRPCRoute With BackendTrafficPolicy", func() {}) diff --git a/test/e2e/gatewayapi/httproute.go b/test/e2e/gatewayapi/httproute.go index d9da938999..75ce29b961 100644 --- a/test/e2e/gatewayapi/httproute.go +++ b/test/e2e/gatewayapi/httproute.go @@ -2649,19 +2649,69 @@ spec: port: 80 ` + var multiListenerGatewayWithHostnames = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: %s +spec: + gatewayClassName: %s + listeners: + - name: http-main + protocol: HTTP + port: 9080 + hostname: api-main.example.com + - name: http-alt + protocol: HTTP + port: 9081 + hostname: api-alt.example.com + infrastructure: + parametersRef: + group: apisix.apache.org + kind: GatewayProxy + name: apisix-proxy-config +` + + var routeNoSectionNameWithHostnames = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: route-no-section-hostnames +spec: + parentRefs: + - name: %s + hostnames: + - api-main.example.com + - api-alt.example.com + rules: + - matches: + - path: + type: PathPrefix + value: /get + backendRefs: + - name: httpbin-service-e2e-test + port: 80 +` + // Get the APISIX service name from the deployer getApisixServiceName := func() string { // The APISIX service is named "apisix" (from framework.ProviderType) return "apisix" } - // Run curl from within the cluster to the specified port - curlInCluster := func(port int, path string) (int, string, error) { + // Run curl with explicit Host header from within the cluster. + curlInClusterWithHost := func(port int, path, host string) (int, string, error) { url := fmt.Sprintf("http://%s.%s.svc.cluster.local:%d%s", getApisixServiceName(), s.Namespace(), port, path) + args := []string{"-s", "-o", "/dev/null", "-w", "%{http_code}"} + if host != "" { + args = append(args, "-H", fmt.Sprintf("Host: %s", host)) + } + args = append(args, url) + // Note: curlimages/curl image already has curl as entrypoint, so we don't pass "curl" again - output, err := s.RunCurlFromK8s("-s", "-o", "/dev/null", "-w", "%{http_code}", url) + output, err := s.RunCurlFromK8s(args...) if err != nil { return 0, "", err } @@ -2672,6 +2722,11 @@ spec: return statusCode, output, nil } + // Run curl from within the cluster to the specified port. + curlInCluster := func(port int, path string) (int, string, error) { + return curlInClusterWithHost(port, path, "") + } + BeforeEach(func() { By("create GatewayProxy") Expect(s.CreateResourceFromString(s.GetGatewayProxySpec())).NotTo(HaveOccurred()) @@ -2775,6 +2830,37 @@ spec: "route should be accessible on port 9081") }) + It("should keep all matched hostnames when sectionName is omitted", func() { + gatewayName := s.Namespace() + + By("create Gateway with two listeners and distinct hostnames") + gateway := fmt.Sprintf(multiListenerGatewayWithHostnames, gatewayName, s.Namespace()) + Expect(s.CreateResourceFromString(gateway)).NotTo(HaveOccurred()) + + s.RetryAssertion(func() string { + yaml, _ := s.GetResourceYaml("Gateway", gatewayName) + return yaml + }).Should(ContainSubstring(`status: "True"`)) + + By("create HTTPRoute WITHOUT sectionName and with both hostnames") + route := fmt.Sprintf(routeNoSectionNameWithHostnames, gatewayName) + s.ResourceApplied("HTTPRoute", "route-no-section-hostnames", route, 1) + + By("verify first hostname is routable") + Eventually(func() (int, error) { + statusCode, _, err := curlInClusterWithHost(9080, "/get", "api-main.example.com") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK), + "api-main.example.com should be routable") + + By("verify second hostname is routable") + Eventually(func() (int, error) { + statusCode, _, err := curlInClusterWithHost(9081, "/get", "api-alt.example.com") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK), + "api-alt.example.com should be routable") + }) + It("should not route traffic when sectionName references non-existent listener", func() { gatewayName := s.Namespace() diff --git a/test/e2e/scaffold/grpc.go b/test/e2e/scaffold/grpc.go index cf79c5134f..66babedaac 100644 --- a/test/e2e/scaffold/grpc.go +++ b/test/e2e/scaffold/grpc.go @@ -21,6 +21,7 @@ import ( "strings" "time" + "github.com/gruntwork-io/terratest/modules/k8s" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" @@ -65,8 +66,24 @@ func (s *Scaffold) DeployGRPCBackend() { } func (s *Scaffold) RequestEchoBackend(exp ExpectedResponse) error { - endpoint := s.apisixTunnels.HTTP.Endpoint() + return s.requestEchoBackendWithEndpoint(exp, s.apisixTunnels.HTTP.Endpoint()) +} + +func (s *Scaffold) RequestEchoBackendOnPort(exp ExpectedResponse, port int) error { + if port == 80 { + return s.RequestEchoBackend(exp) + } + + tunnel := k8s.NewTunnel(s.kubectlOptions, k8s.ResourceTypeService, s.dataplaneService.Name, 0, port) + if err := tunnel.ForwardPortE(s.t); err != nil { + return err + } + defer tunnel.Close() + + return s.requestEchoBackendWithEndpoint(exp, tunnel.Endpoint()) +} +func (s *Scaffold) requestEchoBackendWithEndpoint(exp ExpectedResponse, endpoint string) error { endpoint = strings.Replace(endpoint, "localhost", "127.0.0.1", 1) dialOpts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())} From 1e39807cc448b8aa7372536a902cc202dc4068b2 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Mon, 23 Feb 2026 09:33:46 +0100 Subject: [PATCH 08/19] fix(gateway-api): deduplicate tctx.Listeners across overlapping parentRefs When an HTTPRoute/GRPCRoute has multiple parentRefs pointing at the same Gateway (e.g. two sectionNames, or one with and one without), the listeners collected from each RouteParentRefContext can overlap. The controllers were blindly appending them all, producing duplicate entries in tctx.Listeners and consequently duplicate hostnames in GRPC route translation. Add appendUniqueListeners helper that deduplicates by listener Name (unique within a Gateway per Gateway API spec) and use it in both httproute_controller and grpcroute_controller instead of plain append. --- internal/controller/grpcroute_controller.go | 4 +-- internal/controller/httproute_controller.go | 4 +-- internal/controller/utils.go | 17 +++++++++ internal/controller/utils_hostname_test.go | 38 +++++++++++++++++++++ 4 files changed, 59 insertions(+), 4 deletions(-) diff --git a/internal/controller/grpcroute_controller.go b/internal/controller/grpcroute_controller.go index e1b9eceb7b..6048beb5d8 100644 --- a/internal/controller/grpcroute_controller.go +++ b/internal/controller/grpcroute_controller.go @@ -203,10 +203,10 @@ func (r *GRPCRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Populate listeners for port-based routing // Use Listeners slice if available (multiple listener support) if len(gateway.Listeners) > 0 { - tctx.Listeners = append(tctx.Listeners, gateway.Listeners...) + tctx.Listeners = appendUniqueListeners(tctx.Listeners, gateway.Listeners...) } else if gateway.Listener != nil { // Fallback for backward compatibility - tctx.Listeners = append(tctx.Listeners, *gateway.Listener) + tctx.Listeners = appendUniqueListeners(tctx.Listeners, *gateway.Listener) } } diff --git a/internal/controller/httproute_controller.go b/internal/controller/httproute_controller.go index d8d8f30be4..dba764cb3c 100644 --- a/internal/controller/httproute_controller.go +++ b/internal/controller/httproute_controller.go @@ -186,10 +186,10 @@ func (r *HTTPRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Populate listeners for port-based routing // Use Listeners slice if available (multiple listener support) if len(gateway.Listeners) > 0 { - tctx.Listeners = append(tctx.Listeners, gateway.Listeners...) + tctx.Listeners = appendUniqueListeners(tctx.Listeners, gateway.Listeners...) } else if gateway.Listener != nil { // Fallback for backward compatibility - tctx.Listeners = append(tctx.Listeners, *gateway.Listener) + tctx.Listeners = appendUniqueListeners(tctx.Listeners, *gateway.Listener) } } diff --git a/internal/controller/utils.go b/internal/controller/utils.go index 467b44f590..6c7690a70b 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -1179,6 +1179,23 @@ func isListenerHostnameEffective(listener gatewayv1.Listener) bool { listener.Protocol == gatewayv1.TLSProtocolType } +// appendUniqueListeners appends listeners to the target slice, skipping any +// listener whose Name already exists in the target. Listener names are unique +// within a Gateway per the Gateway API specification. +func appendUniqueListeners(target []gatewayv1.Listener, source ...gatewayv1.Listener) []gatewayv1.Listener { + seen := make(map[gatewayv1.SectionName]struct{}, len(target)) + for _, l := range target { + seen[l.Name] = struct{}{} + } + for _, l := range source { + if _, exists := seen[l.Name]; !exists { + seen[l.Name] = struct{}{} + target = append(target, l) + } + } + return target +} + func isRouteAccepted(gateways []RouteParentRefContext) bool { for _, gateway := range gateways { for _, condition := range gateway.Conditions { diff --git a/internal/controller/utils_hostname_test.go b/internal/controller/utils_hostname_test.go index 9e3779df1d..b070512c5f 100644 --- a/internal/controller/utils_hostname_test.go +++ b/internal/controller/utils_hostname_test.go @@ -210,3 +210,41 @@ func TestFilterHostnamesNoMatchedListeners(t *testing.T) { _, err := filterHostnames(gateways, route.DeepCopy()) assert.ErrorIs(t, err, ErrNoMatchingListenerHostname) } + +func TestAppendUniqueListeners(t *testing.T) { + listenerA := gatewayv1.Listener{Name: "a", Port: 80} + listenerB := gatewayv1.Listener{Name: "b", Port: 81} + listenerA2 := gatewayv1.Listener{Name: "a", Port: 82} // Duplicate name, different port + + tests := []struct { + name string + target []gatewayv1.Listener + source []gatewayv1.Listener + expected []gatewayv1.Listener + }{ + { + name: "empty target, add listeners", + target: nil, + source: []gatewayv1.Listener{listenerA, listenerB}, + expected: []gatewayv1.Listener{listenerA, listenerB}, + }, + { + name: "duplicate names skipped", + target: []gatewayv1.Listener{listenerA}, + source: []gatewayv1.Listener{listenerA, listenerB}, + expected: []gatewayv1.Listener{listenerA, listenerB}, + }, + { + name: "mixed duplicates and new", + target: []gatewayv1.Listener{listenerA}, + source: []gatewayv1.Listener{listenerB, listenerA, listenerA2}, + expected: []gatewayv1.Listener{listenerA, listenerB}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, appendUniqueListeners(tt.target, tt.source...)) + }) + } +} From f4da8a608a280e6af18e2df66fdd5ff2bd13e181 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Tue, 24 Feb 2026 13:29:06 +0100 Subject: [PATCH 09/19] fix: failing e2e tests fro http routes with section names --- test/e2e/gatewayapi/httproute.go | 24 +++++++++++++++++++----- test/e2e/scaffold/k8s.go | 7 +++++-- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/test/e2e/gatewayapi/httproute.go b/test/e2e/gatewayapi/httproute.go index 75ce29b961..5bc85722ad 100644 --- a/test/e2e/gatewayapi/httproute.go +++ b/test/e2e/gatewayapi/httproute.go @@ -22,6 +22,8 @@ import ( "crypto/tls" "fmt" "net/http" + "regexp" + "strconv" "strings" "time" @@ -2695,8 +2697,21 @@ spec: // Get the APISIX service name from the deployer getApisixServiceName := func() string { - // The APISIX service is named "apisix" (from framework.ProviderType) - return "apisix" + return framework.ProviderType + } + + statusCodePattern := regexp.MustCompile(`\b([1-5][0-9]{2})\b`) + parseHTTPStatusCode := func(output string) (int, error) { + matches := statusCodePattern.FindAllString(strings.TrimSpace(output), -1) + if len(matches) == 0 { + return 0, fmt.Errorf("failed to parse HTTP status code from output: %q", output) + } + + code, err := strconv.Atoi(matches[len(matches)-1]) + if err != nil { + return 0, fmt.Errorf("failed converting status code from output %q: %w", output, err) + } + return code, nil } // Run curl with explicit Host header from within the cluster. @@ -2710,13 +2725,12 @@ spec: } args = append(args, url) - // Note: curlimages/curl image already has curl as entrypoint, so we don't pass "curl" again output, err := s.RunCurlFromK8s(args...) if err != nil { return 0, "", err } - statusCode := 0 - if _, err := fmt.Sscanf(output, "%d", &statusCode); err != nil { + statusCode, err := parseHTTPStatusCode(output) + if err != nil { return 0, output, err } return statusCode, output, nil diff --git a/test/e2e/scaffold/k8s.go b/test/e2e/scaffold/k8s.go index 3b56a5df58..72142a31b4 100644 --- a/test/e2e/scaffold/k8s.go +++ b/test/e2e/scaffold/k8s.go @@ -272,14 +272,17 @@ func (s *Scaffold) RunDigDNSClientFromK8s(args ...string) (string, error) { // This is useful for making HTTP requests from within the cluster, avoiding // port-forward limitations where server_port variables may not work correctly. func (s *Scaffold) RunCurlFromK8s(args ...string) (string, error) { + podName := fmt.Sprintf("curl-test-%d", time.Now().UnixNano()) kubectlArgs := []string{ "run", - "curl-test", - "-i", + podName, + "--attach=true", "--rm", "--restart=Never", "--image-pull-policy=IfNotPresent", "--image=alpine/curl:latest", + "--quiet", + "--command", "--", "curl", } From 3f7ba25b98cd20b60db39310ea330f34347f0a04 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Wed, 25 Feb 2026 07:54:40 +0100 Subject: [PATCH 10/19] refactor(translator): reduce cyclomatic complexity of TranslateHTTPRoute Extract backend-processing block into a new helper method translateBackendsToUpstreams to bring TranslateHTTPRoute's cyclomatic complexity from 31 down to 9, fixing the gocyclo lint failure. --- internal/adc/translator/httproute.go | 247 ++++++++++++++------------- 1 file changed, 128 insertions(+), 119 deletions(-) diff --git a/internal/adc/translator/httproute.go b/internal/adc/translator/httproute.go index d56789ef8c..bdcbff858e 100644 --- a/internal/adc/translator/httproute.go +++ b/internal/adc/translator/httproute.go @@ -525,6 +525,133 @@ func calculateHTTPRoutePriority(match *gatewayv1.HTTPRouteMatch, ruleIndex int, return priority } +// translateBackendsToUpstreams processes the BackendRefs of an HTTPRouteRule, +// builds upstreams, assigns them to the service (single upstream or traffic-split +// plugin for multiple), and injects fault-injection on backend errors. +func (t *Translator) translateBackendsToUpstreams( + tctx *provider.TranslateContext, + rule gatewayv1.HTTPRouteRule, + httpRoute *gatewayv1.HTTPRoute, + service *adctypes.Service, +) (enableWebsocket *bool, backendErr error) { + upstreams := make([]*adctypes.Upstream, 0) + weightedUpstreams := make([]adctypes.TrafficSplitConfigRuleWeightedUpstream, 0) + + for _, backend := range rule.BackendRefs { + if backend.Namespace == nil { + namespace := gatewayv1.Namespace(httpRoute.Namespace) + backend.Namespace = &namespace + } + upstream := adctypes.NewDefaultUpstream() + upNodes, protocol, err := t.translateBackendRef(tctx, backend.BackendRef, DefaultEndpointFilter) + if err != nil { + backendErr = err + continue + } + if len(upNodes) == 0 { + continue + } + if protocol == internaltypes.AppProtocolWS || protocol == internaltypes.AppProtocolWSS { + enableWebsocket = ptr.To(true) + } + + t.AttachBackendTrafficPolicyToUpstream(backend.BackendRef, tctx.BackendTrafficPolicies, upstream) + upstream.Nodes = upNodes + upstream.Scheme = appProtocolToUpstreamScheme(protocol) + var ( + kind string + port int32 + ) + if backend.Kind == nil { + kind = internaltypes.KindService + } else { + kind = string(*backend.Kind) + } + if backend.Port != nil { + port = int32(*backend.Port) + } + namespace := string(*backend.Namespace) + name := string(backend.Name) + upstreamName := adctypes.ComposeUpstreamNameForBackendRef(kind, namespace, name, port) + upstream.Name = upstreamName + upstream.ID = id.GenID(upstreamName) + upstreams = append(upstreams, upstream) + } + + // Handle multiple backends with traffic-split plugin + if len(upstreams) == 0 { + // Create a default upstream if no valid backends + service.Upstream = adctypes.NewDefaultUpstream() + } else if len(upstreams) == 1 { + // Single backend - use directly as service upstream + service.Upstream = upstreams[0] + // remove the id and name of the service.upstream, adc schema does not need id and name for it + service.Upstream.ID = "" + service.Upstream.Name = "" + } else { + // Multiple backends - use traffic-split plugin + service.Upstream = upstreams[0] + // remove the id and name of the service.upstream, adc schema does not need id and name for it + service.Upstream.ID = "" + service.Upstream.Name = "" + + upstreams = upstreams[1:] + + if len(upstreams) > 0 { + service.Upstreams = upstreams + } + + // Set weight in traffic-split for the default upstream + weight := apiv2.DefaultWeight + if rule.BackendRefs[0].Weight != nil { + weight = int(*rule.BackendRefs[0].Weight) + } + weightedUpstreams = append(weightedUpstreams, adctypes.TrafficSplitConfigRuleWeightedUpstream{ + Weight: weight, + }) + + // Set other upstreams in traffic-split using upstream_id + for i, upstream := range upstreams { + weight := apiv2.DefaultWeight + // get weight from the backend refs starting from the second backend + if i+1 < len(rule.BackendRefs) && rule.BackendRefs[i+1].Weight != nil { + weight = int(*rule.BackendRefs[i+1].Weight) + } + weightedUpstreams = append(weightedUpstreams, adctypes.TrafficSplitConfigRuleWeightedUpstream{ + UpstreamID: upstream.ID, + Weight: weight, + }) + } + + if len(weightedUpstreams) > 0 { + if service.Plugins == nil { + service.Plugins = make(map[string]any) + } + service.Plugins["traffic-split"] = &adctypes.TrafficSplitConfig{ + Rules: []adctypes.TrafficSplitConfigRule{ + { + WeightedUpstreams: weightedUpstreams, + }, + }, + } + } + } + + if backendErr != nil && (service.Upstream == nil || len(service.Upstream.Nodes) == 0) { + if service.Plugins == nil { + service.Plugins = make(map[string]any) + } + service.Plugins["fault-injection"] = map[string]any{ + "abort": map[string]any{ + "http_status": 500, + "body": "No existing backendRef provided", + }, + } + } + + return enableWebsocket, backendErr +} + func (t *Translator) TranslateHTTPRoute(tctx *provider.TranslateContext, httpRoute *gatewayv1.HTTPRoute) (*TranslateResult, error) { result := &TranslateResult{} @@ -545,125 +672,7 @@ func (t *Translator) TranslateHTTPRoute(tctx *provider.TranslateContext, httpRou service.ID = id.GenID(service.Name) service.Hosts = hosts - var ( - upstreams = make([]*adctypes.Upstream, 0) - weightedUpstreams = make([]adctypes.TrafficSplitConfigRuleWeightedUpstream, 0) - backendErr error - enableWebsocket *bool - ) - - for _, backend := range rule.BackendRefs { - if backend.Namespace == nil { - namespace := gatewayv1.Namespace(httpRoute.Namespace) - backend.Namespace = &namespace - } - upstream := adctypes.NewDefaultUpstream() - upNodes, protocol, err := t.translateBackendRef(tctx, backend.BackendRef, DefaultEndpointFilter) - if err != nil { - backendErr = err - continue - } - if len(upNodes) == 0 { - continue - } - if protocol == internaltypes.AppProtocolWS || protocol == internaltypes.AppProtocolWSS { - enableWebsocket = ptr.To(true) - } - - t.AttachBackendTrafficPolicyToUpstream(backend.BackendRef, tctx.BackendTrafficPolicies, upstream) - upstream.Nodes = upNodes - upstream.Scheme = appProtocolToUpstreamScheme(protocol) - var ( - kind string - port int32 - ) - if backend.Kind == nil { - kind = internaltypes.KindService - } else { - kind = string(*backend.Kind) - } - if backend.Port != nil { - port = int32(*backend.Port) - } - namespace := string(*backend.Namespace) - name := string(backend.Name) - upstreamName := adctypes.ComposeUpstreamNameForBackendRef(kind, namespace, name, port) - upstream.Name = upstreamName - upstream.ID = id.GenID(upstreamName) - upstreams = append(upstreams, upstream) - } - - // Handle multiple backends with traffic-split plugin - if len(upstreams) == 0 { - // Create a default upstream if no valid backends - upstream := adctypes.NewDefaultUpstream() - service.Upstream = upstream - } else if len(upstreams) == 1 { - // Single backend - use directly as service upstream - service.Upstream = upstreams[0] - // remove the id and name of the service.upstream, adc schema does not need id and name for it - service.Upstream.ID = "" - service.Upstream.Name = "" - } else { - // Multiple backends - use traffic-split plugin - service.Upstream = upstreams[0] - // remove the id and name of the service.upstream, adc schema does not need id and name for it - service.Upstream.ID = "" - service.Upstream.Name = "" - - upstreams = upstreams[1:] - - if len(upstreams) > 0 { - service.Upstreams = upstreams - } - - // Set weight in traffic-split for the default upstream - weight := apiv2.DefaultWeight - if rule.BackendRefs[0].Weight != nil { - weight = int(*rule.BackendRefs[0].Weight) - } - weightedUpstreams = append(weightedUpstreams, adctypes.TrafficSplitConfigRuleWeightedUpstream{ - Weight: weight, - }) - - // Set other upstreams in traffic-split using upstream_id - for i, upstream := range upstreams { - weight := apiv2.DefaultWeight - // get weight from the backend refs starting from the second backend - if i+1 < len(rule.BackendRefs) && rule.BackendRefs[i+1].Weight != nil { - weight = int(*rule.BackendRefs[i+1].Weight) - } - weightedUpstreams = append(weightedUpstreams, adctypes.TrafficSplitConfigRuleWeightedUpstream{ - UpstreamID: upstream.ID, - Weight: weight, - }) - } - - if len(weightedUpstreams) > 0 { - if service.Plugins == nil { - service.Plugins = make(map[string]any) - } - service.Plugins["traffic-split"] = &adctypes.TrafficSplitConfig{ - Rules: []adctypes.TrafficSplitConfigRule{ - { - WeightedUpstreams: weightedUpstreams, - }, - }, - } - } - } - - if backendErr != nil && (service.Upstream == nil || len(service.Upstream.Nodes) == 0) { - if service.Plugins == nil { - service.Plugins = make(map[string]any) - } - service.Plugins["fault-injection"] = map[string]any{ - "abort": map[string]any{ - "http_status": 500, - "body": "No existing backendRef provided", - }, - } - } + enableWebsocket, _ := t.translateBackendsToUpstreams(tctx, rule, httpRoute, service) t.fillPluginsFromHTTPRouteFilters(service.Plugins, httpRoute.GetNamespace(), rule.Filters, rule.Matches, tctx) From 1db1aa7308f248f0d9eefaf2a5231fa2759e0cd8 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Wed, 25 Feb 2026 15:03:22 +0100 Subject: [PATCH 11/19] feat(gateway-api): add listener_port_match_mode config option Introduces a configurable listener_port_match_mode setting (auto/explicit/off) that controls when server_port route vars are injected from Gateway listener ports, allowing operators to tune port-based routing behaviour without redeploying. - auto (default): inject when parentRefs explicitly target a listener via sectionName or port, or when multiple listener ports need disambiguation; preserves existing behaviour - explicit: inject only on explicit listener targeting (sectionName/port) - off: never inject server_port vars Also fixes hasExplicitListenerTarget to skip non-Gateway parentRefs (e.g. GAMMA Service mesh refs), preventing unintended server_port injection in mixed Gateway + Service parentRef routes. --- config/samples/config.yaml | 5 + docs/en/latest/concepts/gateway-api.md | 3 +- .../en/latest/reference/configuration-file.md | 6 + docs/en/latest/reference/example.md | 2 +- internal/adc/translator/annotations_test.go | 94 +++++++++++- internal/adc/translator/grpcroute.go | 2 +- internal/adc/translator/grpcroute_test.go | 142 ++++++++++++++---- internal/adc/translator/httproute.go | 16 +- internal/adc/translator/httproute_test.go | 130 +++++++++++++--- internal/adc/translator/translator.go | 61 +++++++- internal/controller/config/config.go | 12 +- internal/controller/config/config_test.go | 78 ++++++++++ internal/controller/config/types.go | 37 +++-- internal/manager/run.go | 7 +- internal/provider/apisix/provider.go | 2 +- internal/provider/options.go | 6 + test/e2e/gatewayapi/grpcroute.go | 78 ++++++++++ test/e2e/gatewayapi/httproute.go | 89 +++++++++++ 18 files changed, 681 insertions(+), 89 deletions(-) create mode 100644 internal/controller/config/config_test.go diff --git a/config/samples/config.yaml b/config/samples/config.yaml index 8e37192257..b9b9384b2f 100644 --- a/config/samples/config.yaml +++ b/config/samples/config.yaml @@ -34,6 +34,11 @@ exec_adc_timeout: 15s # The timeout for the ADC to execute. # The default value is 15 seconds. disable_gateway_api: false # Whether to disable the Gateway API support. # The default value is false. +listener_port_match_mode: "auto" # Mode for injecting server_port route vars from Gateway listener ports. + # - "auto": inject when parentRefs explicitly target listeners (sectionName/port) or when multiple listener ports are matched. + # - "explicit": inject only when parentRefs explicitly target listeners. + # - "off": never inject server_port vars. + # The default value is "auto". provider: type: "apisix" # Provider type. diff --git a/docs/en/latest/concepts/gateway-api.md b/docs/en/latest/concepts/gateway-api.md index 7ad2bb87e3..9b8ad48d50 100644 --- a/docs/en/latest/concepts/gateway-api.md +++ b/docs/en/latest/concepts/gateway-api.md @@ -78,8 +78,7 @@ The fields below are specified in the Gateway API specification but are either p | Fields | Status | Notes | |------------------------------------------------------|----------------------|------------------------------------------------------------------------------------------------| -| `spec.listeners[].port` | Partially supported | The configuration is used for routing matching (ensuring traffic matches the listener port). However, the controller cannot dynamically open new ports on the data plane. Users must ensure APISIX is configured to listen on the specified ports. | - +| `spec.listeners[].port` | Partially supported | Controls `server_port` route-var injection; behaviour is configured via [`listener_port_match_mode`](../reference/configuration-file.md) (`auto` / `explicit` / `off`). The controller cannot dynamically open data plane ports, so APISIX must already listen on the specified port. | | `spec.listeners[].tls.certificateRefs[].group` | Partially supported | Only `""` is supported; other group values cause validation failure. | | `spec.listeners[].tls.certificateRefs[].kind` | Partially supported | Only `Secret` is supported. | | `spec.listeners[].tls.mode` | Partially supported | `Terminate` is implemented; `Passthrough` is effectively unsupported for Gateway listeners. | diff --git a/docs/en/latest/reference/configuration-file.md b/docs/en/latest/reference/configuration-file.md index b01b0294b7..166570ab73 100644 --- a/docs/en/latest/reference/configuration-file.md +++ b/docs/en/latest/reference/configuration-file.md @@ -63,6 +63,12 @@ secure_metrics: false # The secure metrics configuration. exec_adc_timeout: 15s # The timeout for the ADC to execute. # The default value is 15 seconds. +listener_port_match_mode: "auto" # Mode for injecting server_port route vars from Gateway listener ports. + # - "auto": inject when parentRefs explicitly target listeners (sectionName/port) or when multiple listener ports are matched. + # - "explicit": inject only when parentRefs explicitly target listeners. + # - "off": never inject server_port vars. + # The default value is "auto". + provider: type: "apisix" # Provider type. # Value can be "apisix" or "apisix-standalone". diff --git a/docs/en/latest/reference/example.md b/docs/en/latest/reference/example.md index d0ec66b6ee..ef9ba9fa52 100644 --- a/docs/en/latest/reference/example.md +++ b/docs/en/latest/reference/example.md @@ -96,7 +96,7 @@ spec: ❶ The controller name should be customized if you are running multiple distinct instances of the APISIX Ingress Controller in the same cluster (not a single instance with multiple replicas). Each ingress controller instance must use a unique controllerName in its [configuration file](configuration-file.md), and the corresponding GatewayClass should reference that value. -❷ The `port` in the Gateway listener is used for routing matching. However, the controller cannot dynamically open new ports on the data plane. You must ensure that the APISIX data plane is configured to listen on this port. +❷ The `port` in the Gateway listener is used for routing matching based on `listener_port_match_mode` in the controller configuration (`auto`, `explicit`, or `off`). The controller cannot dynamically open new ports on the data plane, so ensure APISIX is configured to listen on the port. ❸ API group of the referenced resource. diff --git a/internal/adc/translator/annotations_test.go b/internal/adc/translator/annotations_test.go index 84b4bb083e..bab77a7d09 100644 --- a/internal/adc/translator/annotations_test.go +++ b/internal/adc/translator/annotations_test.go @@ -22,10 +22,12 @@ import ( "github.com/incubator4/go-resty-expr/expr" "github.com/stretchr/testify/assert" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + "k8s.io/utils/ptr" adctypes "github.com/apache/apisix-ingress-controller/api/adc" "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations" "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations/upstream" + "github.com/apache/apisix-ingress-controller/internal/controller/config" ) type mockParser struct { @@ -343,7 +345,7 @@ func TestTranslateIngressAnnotations(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - translator := &Translator{} + translator := &Translator{ListenerPortMatchMode: config.ListenerPortMatchModeAuto} result := translator.TranslateIngressAnnotations(tt.anno) assert.NotNil(t, result) @@ -456,20 +458,24 @@ func TestAddServerPortVars(t *testing.T) { func TestShouldInjectServerPortVars(t *testing.T) { sectionName := gatewayv1.SectionName("http-main") + port := gatewayv1.PortNumber(9080) tests := []struct { name string + mode config.ListenerPortMatchMode parentRefs []gatewayv1.ParentReference ports map[int32]struct{} expected bool }{ { name: "empty listener ports", + mode: config.ListenerPortMatchModeAuto, ports: map[int32]struct{}{}, expected: false, }, { name: "single port without sectionName", + mode: config.ListenerPortMatchModeAuto, parentRefs: []gatewayv1.ParentReference{ {Name: "gw"}, }, @@ -480,6 +486,7 @@ func TestShouldInjectServerPortVars(t *testing.T) { }, { name: "single port with sectionName", + mode: config.ListenerPortMatchModeAuto, parentRefs: []gatewayv1.ParentReference{ {Name: "gw", SectionName: §ionName}, }, @@ -490,6 +497,7 @@ func TestShouldInjectServerPortVars(t *testing.T) { }, { name: "multiple ports without sectionName", + mode: config.ListenerPortMatchModeAuto, parentRefs: []gatewayv1.ParentReference{ {Name: "gw"}, }, @@ -499,11 +507,93 @@ func TestShouldInjectServerPortVars(t *testing.T) { }, expected: true, }, + { + name: "explicit mode with multiple ports and no explicit target", + mode: config.ListenerPortMatchModeExplicit, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + }, + ports: map[int32]struct{}{ + 9080: {}, + 9081: {}, + }, + expected: false, + }, + { + name: "explicit mode with parentRef.port", + mode: config.ListenerPortMatchModeExplicit, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", Port: &port}, + }, + ports: map[int32]struct{}{ + 9080: {}, + }, + expected: true, + }, + { + name: "explicit mode with single port and no explicit target", + mode: config.ListenerPortMatchModeExplicit, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + }, + ports: map[int32]struct{}{ + 9080: {}, + }, + expected: false, + }, + { + name: "off mode ignores explicit target", + mode: config.ListenerPortMatchModeOff, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", SectionName: §ionName}, + }, + ports: map[int32]struct{}{ + 9080: {}, + 9081: {}, + }, + expected: false, + }, + { + name: "off mode ignores explicit parentRef.port target", + mode: config.ListenerPortMatchModeOff, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", Port: &port}, + }, + ports: map[int32]struct{}{ + 9080: {}, + }, + expected: false, + }, + { + name: "explicit mode: non-Gateway parentRef with port is not treated as explicit target", + mode: config.ListenerPortMatchModeExplicit, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + {Name: "svc", Kind: ptr.To(gatewayv1.Kind("Service")), Port: &port}, + }, + ports: map[int32]struct{}{ + 9080: {}, + }, + expected: false, + }, + { + name: "auto mode: non-Gateway parentRef with port does not trigger single-port injection", + mode: config.ListenerPortMatchModeAuto, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + {Name: "svc", Kind: ptr.To(gatewayv1.Kind("Service")), Port: &port}, + }, + ports: map[int32]struct{}{ + 9080: {}, + }, + expected: false, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, shouldInjectServerPortVars(tt.parentRefs, tt.ports)) + translator := &Translator{ListenerPortMatchMode: tt.mode} + assert.Equal(t, tt.expected, translator.shouldInjectServerPortVars(tt.parentRefs, tt.ports)) }) } } diff --git a/internal/adc/translator/grpcroute.go b/internal/adc/translator/grpcroute.go index a3f6712cae..631b34d57e 100644 --- a/internal/adc/translator/grpcroute.go +++ b/internal/adc/translator/grpcroute.go @@ -315,7 +315,7 @@ func (t *Translator) TranslateGRPCRoute(tctx *provider.TranslateContext, grpcRou listenerPorts[int32(listener.Port)] = struct{}{} } - if shouldInjectServerPortVars(tctx.RouteParentRefs, listenerPorts) { + if t.shouldInjectServerPortVars(tctx.RouteParentRefs, listenerPorts) { for _, route := range routes { addServerPortVars(route, listenerPorts) } diff --git a/internal/adc/translator/grpcroute_test.go b/internal/adc/translator/grpcroute_test.go index 7fd9ee3f63..f577c0764a 100644 --- a/internal/adc/translator/grpcroute_test.go +++ b/internal/adc/translator/grpcroute_test.go @@ -19,68 +19,157 @@ import ( "context" "testing" + "github.com/go-logr/logr" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" adctypes "github.com/apache/apisix-ingress-controller/api/adc" + "github.com/apache/apisix-ingress-controller/internal/controller/config" "github.com/apache/apisix-ingress-controller/internal/provider" ) -func TestTranslateGRPCRouteServerPortVars(t *testing.T) { - sectionName := gatewayv1.SectionName("http-main") +func TestTranslateGRPCRouteServerPortVarsByMode(t *testing.T) { + sectionName := gatewayv1.SectionName("grpc-main") + parentPort := gatewayv1.PortNumber(9080) + + singlePortVars := adctypes.Vars{ + { + {StrVal: "server_port"}, + {StrVal: "=="}, + {StrVal: "9080"}, + }, + } + multiPortVars := adctypes.Vars{ + { + {StrVal: "server_port"}, + {StrVal: "in"}, + {SliceVal: []adctypes.StringOrSlice{ + {StrVal: "9080"}, + {StrVal: "9081"}, + }}, + }, + } tests := []struct { name string + mode config.ListenerPortMatchMode parentRefs []gatewayv1.ParentReference listeners []gatewayv1.Listener expected adctypes.Vars }{ { - name: "no injection for single listener without sectionName", + name: "auto mode: no injection for single listener without explicit target", + mode: config.ListenerPortMatchModeAuto, parentRefs: []gatewayv1.ParentReference{ {Name: "gw"}, }, listeners: []gatewayv1.Listener{ - {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + {Name: "grpc-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, }, expected: nil, }, { - name: "injection for single listener with explicit sectionName", + name: "auto mode: inject for sectionName target", + mode: config.ListenerPortMatchModeAuto, parentRefs: []gatewayv1.ParentReference{ {Name: "gw", SectionName: §ionName}, }, listeners: []gatewayv1.Listener{ - {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + {Name: "grpc-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, }, - expected: adctypes.Vars{ - { - {StrVal: "server_port"}, - {StrVal: "=="}, - {StrVal: "9080"}, - }, + expected: singlePortVars, + }, + { + name: "auto mode: inject for port target", + mode: config.ListenerPortMatchModeAuto, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", Port: &parentPort}, }, + listeners: []gatewayv1.Listener{ + {Name: "grpc-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: singlePortVars, }, { - name: "injection for multiple listener ports", + name: "auto mode: inject for multiple listener ports", + mode: config.ListenerPortMatchModeAuto, parentRefs: []gatewayv1.ParentReference{ {Name: "gw"}, }, listeners: []gatewayv1.Listener{ - {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9081)}, - {Name: "http-alt", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, - }, - expected: adctypes.Vars{ - { - {StrVal: "server_port"}, - {StrVal: "in"}, - {SliceVal: []adctypes.StringOrSlice{ - {StrVal: "9080"}, - {StrVal: "9081"}, - }}, - }, + {Name: "grpc-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9081)}, + {Name: "grpc-alt", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: multiPortVars, + }, + { + name: "explicit mode: inject for sectionName target", + mode: config.ListenerPortMatchModeExplicit, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", SectionName: §ionName}, + }, + listeners: []gatewayv1.Listener{ + {Name: "grpc-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: singlePortVars, + }, + { + name: "explicit mode: inject for port target", + mode: config.ListenerPortMatchModeExplicit, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", Port: &parentPort}, + }, + listeners: []gatewayv1.Listener{ + {Name: "grpc-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: singlePortVars, + }, + { + name: "explicit mode: no injection for multiple listener ports without explicit target", + mode: config.ListenerPortMatchModeExplicit, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + }, + listeners: []gatewayv1.Listener{ + {Name: "grpc-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9081)}, + {Name: "grpc-alt", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: nil, + }, + { + name: "off mode: no injection even with sectionName target", + mode: config.ListenerPortMatchModeOff, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", SectionName: §ionName}, + }, + listeners: []gatewayv1.Listener{ + {Name: "grpc-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: nil, + }, + { + name: "off mode: no injection for multiple listener ports", + mode: config.ListenerPortMatchModeOff, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + }, + listeners: []gatewayv1.Listener{ + {Name: "grpc-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9081)}, + {Name: "grpc-alt", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: nil, + }, + { + name: "empty mode normalizes to auto", + mode: "", + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", Port: &parentPort}, + }, + listeners: []gatewayv1.Listener{ + {Name: "grpc-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, }, + expected: singlePortVars, }, } @@ -102,7 +191,8 @@ func TestTranslateGRPCRouteServerPortVars(t *testing.T) { }, } - got, err := (&Translator{}).TranslateGRPCRoute(tctx, grpcRoute) + translator := NewTranslator(logr.Discard(), tt.mode) + got, err := translator.TranslateGRPCRoute(tctx, grpcRoute) assert.NoError(t, err) if assert.Len(t, got.Services, 1) && assert.Len(t, got.Services[0].Routes, 1) { assert.Equal(t, tt.expected, got.Services[0].Routes[0].Vars) diff --git a/internal/adc/translator/httproute.go b/internal/adc/translator/httproute.go index bdcbff858e..4eeb3f6e9e 100644 --- a/internal/adc/translator/httproute.go +++ b/internal/adc/translator/httproute.go @@ -718,7 +718,7 @@ func (t *Translator) TranslateHTTPRoute(tctx *provider.TranslateContext, httpRou // Add server_port matching only when a route explicitly targets a listener // or when multiple listener ports need to be disambiguated. - if shouldInjectServerPortVars(tctx.RouteParentRefs, listenerPorts) { + if t.shouldInjectServerPortVars(tctx.RouteParentRefs, listenerPorts) { for _, route := range routes { addServerPortVars(route, listenerPorts) } @@ -911,17 +911,3 @@ func addServerPortVars(route *adctypes.Route, ports map[int32]struct{}) { } route.Vars = append(route.Vars, portVar) } - -func shouldInjectServerPortVars(parentRefs []gatewayv1.ParentReference, ports map[int32]struct{}) bool { - if len(ports) == 0 { - return false - } - - for _, parentRef := range parentRefs { - if parentRef.SectionName != nil && *parentRef.SectionName != "" { - return true - } - } - - return len(ports) > 1 -} diff --git a/internal/adc/translator/httproute_test.go b/internal/adc/translator/httproute_test.go index c6b9216c16..403c57dac8 100644 --- a/internal/adc/translator/httproute_test.go +++ b/internal/adc/translator/httproute_test.go @@ -19,27 +19,50 @@ import ( "context" "testing" + "github.com/go-logr/logr" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" adctypes "github.com/apache/apisix-ingress-controller/api/adc" + "github.com/apache/apisix-ingress-controller/internal/controller/config" "github.com/apache/apisix-ingress-controller/internal/provider" ) -func TestTranslateHTTPRouteServerPortVars(t *testing.T) { +func TestTranslateHTTPRouteServerPortVarsByMode(t *testing.T) { sectionName := gatewayv1.SectionName("http-main") + parentPort := gatewayv1.PortNumber(9080) pathMatchType := gatewayv1.PathMatchPathPrefix pathValue := "/" + singlePortVars := adctypes.Vars{ + { + {StrVal: "server_port"}, + {StrVal: "=="}, + {StrVal: "9080"}, + }, + } + multiPortVars := adctypes.Vars{ + { + {StrVal: "server_port"}, + {StrVal: "in"}, + {SliceVal: []adctypes.StringOrSlice{ + {StrVal: "9080"}, + {StrVal: "9081"}, + }}, + }, + } + tests := []struct { name string + mode config.ListenerPortMatchMode parentRefs []gatewayv1.ParentReference listeners []gatewayv1.Listener expected adctypes.Vars }{ { - name: "no injection for single listener without sectionName", + name: "auto mode: no injection for single listener without explicit target", + mode: config.ListenerPortMatchModeAuto, parentRefs: []gatewayv1.ParentReference{ {Name: "gw"}, }, @@ -49,23 +72,30 @@ func TestTranslateHTTPRouteServerPortVars(t *testing.T) { expected: nil, }, { - name: "injection for single listener with explicit sectionName", + name: "auto mode: inject for sectionName target", + mode: config.ListenerPortMatchModeAuto, parentRefs: []gatewayv1.ParentReference{ {Name: "gw", SectionName: §ionName}, }, listeners: []gatewayv1.Listener{ {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, }, - expected: adctypes.Vars{ - { - {StrVal: "server_port"}, - {StrVal: "=="}, - {StrVal: "9080"}, - }, + expected: singlePortVars, + }, + { + name: "auto mode: inject for port target", + mode: config.ListenerPortMatchModeAuto, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", Port: &parentPort}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, }, + expected: singlePortVars, }, { - name: "injection for multiple listener ports", + name: "auto mode: inject for multiple listener ports", + mode: config.ListenerPortMatchModeAuto, parentRefs: []gatewayv1.ParentReference{ {Name: "gw"}, }, @@ -73,16 +103,75 @@ func TestTranslateHTTPRouteServerPortVars(t *testing.T) { {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9081)}, {Name: "http-alt", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, }, - expected: adctypes.Vars{ - { - {StrVal: "server_port"}, - {StrVal: "in"}, - {SliceVal: []adctypes.StringOrSlice{ - {StrVal: "9080"}, - {StrVal: "9081"}, - }}, - }, + expected: multiPortVars, + }, + { + name: "explicit mode: inject for sectionName target", + mode: config.ListenerPortMatchModeExplicit, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", SectionName: §ionName}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: singlePortVars, + }, + { + name: "explicit mode: inject for port target", + mode: config.ListenerPortMatchModeExplicit, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", Port: &parentPort}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: singlePortVars, + }, + { + name: "explicit mode: no injection for multiple listener ports without explicit target", + mode: config.ListenerPortMatchModeExplicit, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9081)}, + {Name: "http-alt", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: nil, + }, + { + name: "off mode: no injection even with sectionName target", + mode: config.ListenerPortMatchModeOff, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", SectionName: §ionName}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: nil, + }, + { + name: "off mode: no injection for multiple listener ports", + mode: config.ListenerPortMatchModeOff, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw"}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9081)}, + {Name: "http-alt", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: nil, + }, + { + name: "empty mode normalizes to auto", + mode: "", + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw", Port: &parentPort}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http-main", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, }, + expected: singlePortVars, }, } @@ -113,7 +202,8 @@ func TestTranslateHTTPRouteServerPortVars(t *testing.T) { }, } - got, err := (&Translator{}).TranslateHTTPRoute(tctx, httpRoute) + translator := NewTranslator(logr.Discard(), tt.mode) + got, err := translator.TranslateHTTPRoute(tctx, httpRoute) assert.NoError(t, err) if assert.Len(t, got.Services, 1) && assert.Len(t, got.Services[0].Routes, 1) { assert.Equal(t, tt.expected, got.Services[0].Routes[0].Vars) diff --git a/internal/adc/translator/translator.go b/internal/adc/translator/translator.go index aeaef2509b..e294c7dad4 100644 --- a/internal/adc/translator/translator.go +++ b/internal/adc/translator/translator.go @@ -19,17 +19,72 @@ package translator import ( "github.com/go-logr/logr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" adctypes "github.com/apache/apisix-ingress-controller/api/adc" + "github.com/apache/apisix-ingress-controller/internal/controller/config" ) type Translator struct { - Log logr.Logger + Log logr.Logger + ListenerPortMatchMode config.ListenerPortMatchMode } -func NewTranslator(log logr.Logger) *Translator { +func normalizeMode(mode config.ListenerPortMatchMode) config.ListenerPortMatchMode { + switch mode { + case "", config.ListenerPortMatchModeAuto: + return config.ListenerPortMatchModeAuto + case config.ListenerPortMatchModeExplicit, config.ListenerPortMatchModeOff: + return mode + default: + return config.ListenerPortMatchModeAuto + } +} + +func NewTranslator(log logr.Logger, mode config.ListenerPortMatchMode) *Translator { return &Translator{ - Log: log.WithName("translator"), + Log: log.WithName("translator"), + ListenerPortMatchMode: normalizeMode(mode), + } +} + +func hasExplicitListenerTarget(parentRefs []gatewayv1.ParentReference) bool { + for _, parentRef := range parentRefs { + // Skip non-Gateway parentRefs (e.g. GAMMA Service mesh refs) — they + // are not relevant to listener port injection. + if parentRef.Kind != nil && *parentRef.Kind != "Gateway" { + continue + } + if parentRef.SectionName != nil && *parentRef.SectionName != "" { + return true + } + if parentRef.Port != nil { + return true + } + } + + return false +} + +func (t *Translator) shouldInjectServerPortVars(parentRefs []gatewayv1.ParentReference, ports map[int32]struct{}) bool { + if len(ports) == 0 { + return false + } + + explicit := hasExplicitListenerTarget(parentRefs) + + switch t.ListenerPortMatchMode { + case config.ListenerPortMatchModeOff: + if explicit { + t.Log.V(1).Info("listener_port_match_mode is 'off'; ignoring explicit listener targeting", "parent_refs", len(parentRefs)) + } + return false + case config.ListenerPortMatchModeExplicit: + return explicit + case config.ListenerPortMatchModeAuto: + return explicit || len(ports) > 1 + default: + return explicit || len(ports) > 1 } } diff --git a/internal/controller/config/config.go b/internal/controller/config/config.go index 31ec1783f8..2f885e0216 100644 --- a/internal/controller/config/config.go +++ b/internal/controller/config/config.go @@ -56,7 +56,8 @@ func NewDefaultConfig() *Config { SyncPeriod: types.TimeDuration{Duration: 1 * time.Hour}, InitSyncDelay: types.TimeDuration{Duration: 20 * time.Minute}, }, - Webhook: NewWebhookConfig(), + Webhook: NewWebhookConfig(), + ListenerPortMatchMode: ListenerPortMatchModeAuto, } } @@ -122,6 +123,15 @@ func (c *Config) Validate() error { if c.ControllerName == "" { return fmt.Errorf("controller_name is required") } + + if c.ListenerPortMatchMode != "" { + switch c.ListenerPortMatchMode { + case ListenerPortMatchModeAuto, ListenerPortMatchModeExplicit, ListenerPortMatchModeOff: + default: + return fmt.Errorf("invalid listener_port_match_mode: %q (must be auto, explicit, or off)", c.ListenerPortMatchMode) + } + } + if err := validateProvider(c.ProviderConfig); err != nil { return err } diff --git a/internal/controller/config/config_test.go b/internal/controller/config/config_test.go new file mode 100644 index 0000000000..97224dbf11 --- /dev/null +++ b/internal/controller/config/config_test.go @@ -0,0 +1,78 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +package config + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewDefaultConfigListenerPortMatchMode(t *testing.T) { + cfg := NewDefaultConfig() + assert.Equal(t, ListenerPortMatchModeAuto, cfg.ListenerPortMatchMode) +} + +func TestConfigValidateListenerPortMatchMode(t *testing.T) { + tests := []struct { + name string + mode ListenerPortMatchMode + expectErr bool + }{ + { + name: "default auto", + mode: ListenerPortMatchModeAuto, + expectErr: false, + }, + { + name: "explicit", + mode: ListenerPortMatchModeExplicit, + expectErr: false, + }, + { + name: "off", + mode: ListenerPortMatchModeOff, + expectErr: false, + }, + { + name: "empty mode is allowed", + mode: "", + expectErr: false, + }, + { + name: "invalid mode", + mode: "invalid", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := NewDefaultConfig() + cfg.ListenerPortMatchMode = tt.mode + + err := cfg.Validate() + if tt.expectErr { + assert.Error(t, err) + assert.ErrorContains(t, err, "invalid listener_port_match_mode") + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/internal/controller/config/types.go b/internal/controller/config/types.go index e00ef1f493..7e470d1c12 100644 --- a/internal/controller/config/types.go +++ b/internal/controller/config/types.go @@ -28,6 +28,14 @@ const ( ProviderTypeAPISIX ProviderType = "apisix" ) +type ListenerPortMatchMode string + +const ( + ListenerPortMatchModeAuto ListenerPortMatchMode = "auto" + ListenerPortMatchModeExplicit ListenerPortMatchMode = "explicit" + ListenerPortMatchModeOff ListenerPortMatchMode = "off" +) + const ( // IngressAPISIXLeader is the default election id for the controller // leader election. @@ -55,20 +63,21 @@ const ( // Config contains all config items which are necessary for // apisix-ingress-controller's running. type Config struct { - LogLevel string `json:"log_level" yaml:"log_level"` - ControllerName string `json:"controller_name" yaml:"controller_name"` - LeaderElectionID string `json:"leader_election_id" yaml:"leader_election_id"` - MetricsAddr string `json:"metrics_addr" yaml:"metrics_addr"` - ServerAddr string `json:"server_addr" yaml:"server_addr"` - EnableServer bool `json:"enable_server" yaml:"enable_server"` - EnableHTTP2 bool `json:"enable_http2" yaml:"enable_http2"` - ProbeAddr string `json:"probe_addr" yaml:"probe_addr"` - SecureMetrics bool `json:"secure_metrics" yaml:"secure_metrics"` - LeaderElection *LeaderElection `json:"leader_election" yaml:"leader_election"` - ExecADCTimeout types.TimeDuration `json:"exec_adc_timeout" yaml:"exec_adc_timeout"` - ProviderConfig ProviderConfig `json:"provider" yaml:"provider"` - Webhook *WebhookConfig `json:"webhook" yaml:"webhook"` - DisableGatewayAPI bool `json:"disable_gateway_api" yaml:"disable_gateway_api"` + LogLevel string `json:"log_level" yaml:"log_level"` + ControllerName string `json:"controller_name" yaml:"controller_name"` + LeaderElectionID string `json:"leader_election_id" yaml:"leader_election_id"` + MetricsAddr string `json:"metrics_addr" yaml:"metrics_addr"` + ServerAddr string `json:"server_addr" yaml:"server_addr"` + EnableServer bool `json:"enable_server" yaml:"enable_server"` + EnableHTTP2 bool `json:"enable_http2" yaml:"enable_http2"` + ProbeAddr string `json:"probe_addr" yaml:"probe_addr"` + SecureMetrics bool `json:"secure_metrics" yaml:"secure_metrics"` + LeaderElection *LeaderElection `json:"leader_election" yaml:"leader_election"` + ExecADCTimeout types.TimeDuration `json:"exec_adc_timeout" yaml:"exec_adc_timeout"` + ProviderConfig ProviderConfig `json:"provider" yaml:"provider"` + Webhook *WebhookConfig `json:"webhook" yaml:"webhook"` + DisableGatewayAPI bool `json:"disable_gateway_api" yaml:"disable_gateway_api"` + ListenerPortMatchMode ListenerPortMatchMode `json:"listener_port_match_mode" yaml:"listener_port_match_mode"` } type GatewayConfig struct { diff --git a/internal/manager/run.go b/internal/manager/run.go index 78bc30e506..315644dac2 100644 --- a/internal/manager/run.go +++ b/internal/manager/run.go @@ -190,9 +190,10 @@ func Run(ctx context.Context, logger logr.Logger) error { providerType := string(config.ControllerConfig.ProviderConfig.Type) providerOptions := &provider.Options{ - SyncTimeout: config.ControllerConfig.ExecADCTimeout.Duration, - SyncPeriod: config.ControllerConfig.ProviderConfig.SyncPeriod.Duration, - InitSyncDelay: config.ControllerConfig.ProviderConfig.InitSyncDelay.Duration, + SyncTimeout: config.ControllerConfig.ExecADCTimeout.Duration, + SyncPeriod: config.ControllerConfig.ProviderConfig.SyncPeriod.Duration, + InitSyncDelay: config.ControllerConfig.ProviderConfig.InitSyncDelay.Duration, + ListenerPortMatchMode: config.ControllerConfig.ListenerPortMatchMode, } provider, err := provider.New(providerType, logger, updater.Writer(), readier, providerOptions) if err != nil { diff --git a/internal/provider/apisix/provider.go b/internal/provider/apisix/provider.go index 029675e219..ef6e3fc835 100644 --- a/internal/provider/apisix/provider.go +++ b/internal/provider/apisix/provider.go @@ -86,7 +86,7 @@ func New(log logr.Logger, updater status.Updater, readier readiness.ReadinessMan return &apisixProvider{ client: cli, Options: o, - translator: translator.NewTranslator(log), + translator: translator.NewTranslator(log, o.ListenerPortMatchMode), updater: updater, readier: readier, syncCh: make(chan struct{}, 1), diff --git a/internal/provider/options.go b/internal/provider/options.go index dbb0760bf0..c47e7ce913 100644 --- a/internal/provider/options.go +++ b/internal/provider/options.go @@ -19,6 +19,8 @@ package provider import ( "time" + + "github.com/apache/apisix-ingress-controller/internal/controller/config" ) type Option interface { @@ -31,6 +33,7 @@ type Options struct { InitSyncDelay time.Duration DefaultBackendMode string DefaultResolveEndpoints bool + ListenerPortMatchMode config.ListenerPortMatchMode } func (o *Options) ApplyToList(lo *Options) { @@ -49,6 +52,9 @@ func (o *Options) ApplyToList(lo *Options) { if o.DefaultResolveEndpoints { lo.DefaultResolveEndpoints = o.DefaultResolveEndpoints } + if o.ListenerPortMatchMode != "" { + lo.ListenerPortMatchMode = o.ListenerPortMatchMode + } } func (o *Options) ApplyOptions(opts []Option) *Options { diff --git a/test/e2e/gatewayapi/grpcroute.go b/test/e2e/gatewayapi/grpcroute.go index bbccd751f1..99f0bcc835 100644 --- a/test/e2e/gatewayapi/grpcroute.go +++ b/test/e2e/gatewayapi/grpcroute.go @@ -335,6 +335,36 @@ spec: port: 8080 ` + var routeForMainListenerByPort = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: GRPCRoute +metadata: + name: grpc-route-port-main +spec: + parentRefs: + - name: %s + port: 9080 + rules: + - backendRefs: + - name: grpc-infra-backend-v1 + port: 8080 +` + + var routeForAltListenerByPort = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: GRPCRoute +metadata: + name: grpc-route-port-alt +spec: + parentRefs: + - name: %s + port: 9081 + rules: + - backendRefs: + - name: grpc-infra-backend-v1 + port: 8080 +` + It("routes to the configured listener ports when sectionName is set", func() { gatewayName := "grpc-multi-listener" @@ -377,6 +407,54 @@ spec: }, 9080) }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).Should(HaveOccurred()) + Eventually(func() error { + return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ + EchoRequest: &pb.EchoRequest{}, + }, 9081) + }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).ShouldNot(HaveOccurred()) + }) + It("routes to the configured listener ports when parentRef.port is set", func() { + gatewayName := "grpc-multi-listener-by-port" + + By("create Gateway with listeners on ports 9080 and 9081") + gateway := fmt.Sprintf(multiListenerGateway, gatewayName, s.Namespace()) + Expect(s.CreateResourceFromString(gateway)).NotTo(HaveOccurred()) + + s.RetryAssertion(func() string { + yaml, _ := s.GetResourceYaml("Gateway", gatewayName) + return yaml + }).Should(ContainSubstring(`status: "True"`)) + + By("create GRPCRoute targeting port 9080 via parentRef.port") + routeMain := fmt.Sprintf(routeForMainListenerByPort, gatewayName) + s.ResourceApplied("GRPCRoute", "grpc-route-port-main", routeMain, 1) + + By("create GRPCRoute targeting port 9081 via parentRef.port") + routeAlt := fmt.Sprintf(routeForAltListenerByPort, gatewayName) + s.ResourceApplied("GRPCRoute", "grpc-route-port-alt", routeAlt, 1) + + By("verify both ports serve traffic before deletion") + Eventually(func() error { + return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ + EchoRequest: &pb.EchoRequest{}, + }, 9080) + }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).ShouldNot(HaveOccurred()) + + Eventually(func() error { + return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ + EchoRequest: &pb.EchoRequest{}, + }, 9081) + }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).ShouldNot(HaveOccurred()) + + By("delete route for port 9080 and verify only port 9081 keeps serving traffic") + Expect(s.DeleteResourceFromString(routeMain)).NotTo(HaveOccurred()) + + Eventually(func() error { + return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ + EchoRequest: &pb.EchoRequest{}, + }, 9080) + }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).Should(HaveOccurred()) + Eventually(func() error { return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ EchoRequest: &pb.EchoRequest{}, diff --git a/test/e2e/gatewayapi/httproute.go b/test/e2e/gatewayapi/httproute.go index 5bc85722ad..e3188d31ee 100644 --- a/test/e2e/gatewayapi/httproute.go +++ b/test/e2e/gatewayapi/httproute.go @@ -2651,6 +2651,44 @@ spec: port: 80 ` + var routeForMainListenerByPort = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: route-port-main +spec: + parentRefs: + - name: %s + port: 9080 + rules: + - matches: + - path: + type: PathPrefix + value: /get + backendRefs: + - name: httpbin-service-e2e-test + port: 80 +` + + var routeForAltListenerByPort = ` +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: route-port-alt +spec: + parentRefs: + - name: %s + port: 9081 + rules: + - matches: + - path: + type: PathPrefix + value: /get + backendRefs: + - name: httpbin-service-e2e-test + port: 80 +` + var multiListenerGatewayWithHostnames = ` apiVersion: gateway.networking.k8s.io/v1 kind: Gateway @@ -2902,6 +2940,57 @@ spec: "route should not be accessible when sectionName is invalid") }) + It("routes traffic to correct backend based on parentRef.port (using server_port vars)", func() { + gatewayName := s.Namespace() + + By("create Gateway with two listeners on different ports") + gateway := fmt.Sprintf(multiListenerGateway, gatewayName, s.Namespace()) + Expect(s.CreateResourceFromString(gateway)).NotTo(HaveOccurred()) + + s.RetryAssertion(func() string { + yaml, _ := s.GetResourceYaml("Gateway", gatewayName) + return yaml + }).Should(ContainSubstring(`status: "True"`)) + + By("create HTTPRoute targeting port 9080 via parentRef.port") + routeMain := fmt.Sprintf(routeForMainListenerByPort, gatewayName) + s.ResourceApplied("HTTPRoute", "route-port-main", routeMain, 1) + + By("create HTTPRoute targeting port 9081 via parentRef.port") + routeAlt := fmt.Sprintf(routeForAltListenerByPort, gatewayName) + s.ResourceApplied("HTTPRoute", "route-port-alt", routeAlt, 1) + + By("verify route-port-main is accessible on port 9080") + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9080, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK), + "route should be accessible on port 9080") + + By("verify route-port-alt is accessible on port 9081") + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9081, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK), + "route should be accessible on port 9081") + + By("delete route-port-main and verify route-port-alt still works") + err := s.DeleteResourceFromString(routeMain) + Expect(err).NotTo(HaveOccurred()) + + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9080, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusNotFound), + "route should return 404 on port 9080 after deletion") + + Eventually(func() (int, error) { + statusCode, _, err := curlInCluster(9081, "/get") + return statusCode, err + }).WithTimeout(30*time.Second).ProbeEvery(time.Second).Should(Equal(http.StatusOK), + "route should still return 200 on port 9081") + }) + It("should route to multiple listeners via multiple parentRefs with sectionName", func() { gatewayName := s.Namespace() From 3dce4b2126278ef448ff4b89499375192a8fb908 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Wed, 4 Mar 2026 09:50:29 +0100 Subject: [PATCH 12/19] test(e2e): increase benchmark ADC sync timeout --- .github/workflows/benchmark-test.yml | 1 + test/e2e/framework/manifests/ingress.yaml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmark-test.yml b/.github/workflows/benchmark-test.yml index 565fb1d034..b3df680d26 100644 --- a/.github/workflows/benchmark-test.yml +++ b/.github/workflows/benchmark-test.yml @@ -109,5 +109,6 @@ jobs: PROVIDER_TYPE: ${{ matrix.provider_type }} TEST_LABEL: ${{ matrix.cases_subset }} TEST_ENV: CI + E2E_EXEC_ADC_TIMEOUT: "30s" run: | make benchmark-test diff --git a/test/e2e/framework/manifests/ingress.yaml b/test/e2e/framework/manifests/ingress.yaml index 2c240d7f6c..15fdf6305b 100644 --- a/test/e2e/framework/manifests/ingress.yaml +++ b/test/e2e/framework/manifests/ingress.yaml @@ -291,7 +291,7 @@ data: retry_period: 2s # retry_period is the time in seconds that the acting controller # will wait between tries of actions with the controller. disable: false # Whether to disable leader election. - exec_adc_timeout: 5s + exec_adc_timeout: {{ env "E2E_EXEC_ADC_TIMEOUT" | default "5s" }} provider: type: {{ .ProviderType | default "apisix" }} sync_period: {{ .ProviderSyncPeriod | default "0s" }} From 04c02fb671d3b998c38459b553718d6cdc73ce5a Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Thu, 5 Mar 2026 13:11:51 +0100 Subject: [PATCH 13/19] test(e2e): deduplicate grpcroute multi-listener checks --- test/e2e/gatewayapi/grpcroute.go | 130 +++++++++++++------------------ 1 file changed, 53 insertions(+), 77 deletions(-) diff --git a/test/e2e/gatewayapi/grpcroute.go b/test/e2e/gatewayapi/grpcroute.go index 99f0bcc835..01aab5e829 100644 --- a/test/e2e/gatewayapi/grpcroute.go +++ b/test/e2e/gatewayapi/grpcroute.go @@ -365,57 +365,25 @@ spec: port: 8080 ` - It("routes to the configured listener ports when sectionName is set", func() { - gatewayName := "grpc-multi-listener" - - By("create Gateway with listeners on ports 9080 and 9081") - gateway := fmt.Sprintf(multiListenerGateway, gatewayName, s.Namespace()) - Expect(s.CreateResourceFromString(gateway)).NotTo(HaveOccurred()) - - s.RetryAssertion(func() string { - yaml, _ := s.GetResourceYaml("Gateway", gatewayName) - return yaml - }).Should(ContainSubstring(`status: "True"`)) - - By("create GRPCRoute targeting listener http-main") - routeMain := fmt.Sprintf(routeForMainListener, gatewayName) - s.ResourceApplied("GRPCRoute", "grpc-route-main", routeMain, 1) - - By("create GRPCRoute targeting listener http-alt") - routeAlt := fmt.Sprintf(routeForAltListener, gatewayName) - s.ResourceApplied("GRPCRoute", "grpc-route-alt", routeAlt, 1) - - By("verify both ports serve traffic before deletion") - Eventually(func() error { - return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ - EchoRequest: &pb.EchoRequest{}, - }, 9080) - }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).ShouldNot(HaveOccurred()) - - Eventually(func() error { - return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ - EchoRequest: &pb.EchoRequest{}, - }, 9081) - }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).ShouldNot(HaveOccurred()) - - By("delete route for 9080 and verify only 9081 keeps serving traffic") - Expect(s.DeleteResourceFromString(routeMain)).NotTo(HaveOccurred()) - - Eventually(func() error { - return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ - EchoRequest: &pb.EchoRequest{}, - }, 9080) - }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).Should(HaveOccurred()) - - Eventually(func() error { + assertRouteReachabilityOnPort := func(port int, shouldSucceed bool) { + check := Eventually(func() error { return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ EchoRequest: &pb.EchoRequest{}, - }, 9081) - }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).ShouldNot(HaveOccurred()) - }) - It("routes to the configured listener ports when parentRef.port is set", func() { - gatewayName := "grpc-multi-listener-by-port" - + }, port) + }).WithTimeout(30 * time.Second).ProbeEvery(time.Second) + if shouldSucceed { + check.ShouldNot(HaveOccurred()) + return + } + check.Should(HaveOccurred()) + } + + runMultiListenerRouteTest := func( + gatewayName string, + routeMainTemplate, routeMainName, routeMainBy string, + routeAltTemplate, routeAltName, routeAltBy string, + deleteMainRouteBy string, + ) { By("create Gateway with listeners on ports 9080 and 9081") gateway := fmt.Sprintf(multiListenerGateway, gatewayName, s.Namespace()) Expect(s.CreateResourceFromString(gateway)).NotTo(HaveOccurred()) @@ -425,41 +393,49 @@ spec: return yaml }).Should(ContainSubstring(`status: "True"`)) - By("create GRPCRoute targeting port 9080 via parentRef.port") - routeMain := fmt.Sprintf(routeForMainListenerByPort, gatewayName) - s.ResourceApplied("GRPCRoute", "grpc-route-port-main", routeMain, 1) + By(routeMainBy) + routeMain := fmt.Sprintf(routeMainTemplate, gatewayName) + s.ResourceApplied("GRPCRoute", routeMainName, routeMain, 1) - By("create GRPCRoute targeting port 9081 via parentRef.port") - routeAlt := fmt.Sprintf(routeForAltListenerByPort, gatewayName) - s.ResourceApplied("GRPCRoute", "grpc-route-port-alt", routeAlt, 1) + By(routeAltBy) + routeAlt := fmt.Sprintf(routeAltTemplate, gatewayName) + s.ResourceApplied("GRPCRoute", routeAltName, routeAlt, 1) By("verify both ports serve traffic before deletion") - Eventually(func() error { - return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ - EchoRequest: &pb.EchoRequest{}, - }, 9080) - }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).ShouldNot(HaveOccurred()) + assertRouteReachabilityOnPort(9080, true) + assertRouteReachabilityOnPort(9081, true) - Eventually(func() error { - return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ - EchoRequest: &pb.EchoRequest{}, - }, 9081) - }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).ShouldNot(HaveOccurred()) - - By("delete route for port 9080 and verify only port 9081 keeps serving traffic") + By(deleteMainRouteBy) Expect(s.DeleteResourceFromString(routeMain)).NotTo(HaveOccurred()) - Eventually(func() error { - return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ - EchoRequest: &pb.EchoRequest{}, - }, 9080) - }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).Should(HaveOccurred()) + assertRouteReachabilityOnPort(9080, false) + assertRouteReachabilityOnPort(9081, true) + } - Eventually(func() error { - return s.RequestEchoBackendOnPort(scaffold.ExpectedResponse{ - EchoRequest: &pb.EchoRequest{}, - }, 9081) - }).WithTimeout(30 * time.Second).ProbeEvery(time.Second).ShouldNot(HaveOccurred()) + It("routes to the configured listener ports when sectionName is set", func() { + runMultiListenerRouteTest( + "grpc-multi-listener", + routeForMainListener, + "grpc-route-main", + "create GRPCRoute targeting listener http-main", + routeForAltListener, + "grpc-route-alt", + "create GRPCRoute targeting listener http-alt", + "delete route for 9080 and verify only 9081 keeps serving traffic", + ) + }) + + It("routes to the configured listener ports when parentRef.port is set", func() { + runMultiListenerRouteTest( + "grpc-multi-listener-by-port", + routeForMainListenerByPort, + "grpc-route-port-main", + "create GRPCRoute targeting port 9080 via parentRef.port", + routeForAltListenerByPort, + "grpc-route-port-alt", + "create GRPCRoute targeting port 9081 via parentRef.port", + "delete route for port 9080 and verify only port 9081 keeps serving traffic", + ) }) }) From 09a5ef9174dcd22f1c792fc8c6308e53be4f0642 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Fri, 6 Mar 2026 12:05:32 +0100 Subject: [PATCH 14/19] test: gofmt annotations translator test imports --- internal/adc/translator/annotations_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/adc/translator/annotations_test.go b/internal/adc/translator/annotations_test.go index bab77a7d09..7b4d1ec496 100644 --- a/internal/adc/translator/annotations_test.go +++ b/internal/adc/translator/annotations_test.go @@ -21,8 +21,8 @@ import ( "github.com/incubator4/go-resty-expr/expr" "github.com/stretchr/testify/assert" - gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" "k8s.io/utils/ptr" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" adctypes "github.com/apache/apisix-ingress-controller/api/adc" "github.com/apache/apisix-ingress-controller/internal/adc/translator/annotations" From c87094a1f416a2b29befe900cb4fc4f5ef856728 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Fri, 6 Mar 2026 12:09:45 +0100 Subject: [PATCH 15/19] fix: address PR review feedback for port match changes --- internal/adc/translator/httproute.go | 4 +++- test/e2e/scaffold/scaffold.go | 3 +++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/adc/translator/httproute.go b/internal/adc/translator/httproute.go index 4eeb3f6e9e..ae9a974b2f 100644 --- a/internal/adc/translator/httproute.go +++ b/internal/adc/translator/httproute.go @@ -557,7 +557,9 @@ func (t *Translator) translateBackendsToUpstreams( t.AttachBackendTrafficPolicyToUpstream(backend.BackendRef, tctx.BackendTrafficPolicies, upstream) upstream.Nodes = upNodes - upstream.Scheme = appProtocolToUpstreamScheme(protocol) + if upstream.Scheme == "" { + upstream.Scheme = appProtocolToUpstreamScheme(protocol) + } var ( kind string port int32 diff --git a/test/e2e/scaffold/scaffold.go b/test/e2e/scaffold/scaffold.go index bd34f7e7b9..ab88a1c16e 100644 --- a/test/e2e/scaffold/scaffold.go +++ b/test/e2e/scaffold/scaffold.go @@ -343,6 +343,9 @@ func (s *Scaffold) NewAPISIXClientForPort(port int) (*httpexpect.Expect, error) Client: &http.Client{ Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}, Timeout: 3 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, }, Reporter: httpexpect.NewAssertReporter( httpexpect.NewAssertReporter(GinkgoT()), From 91765207e8a5e6ff035554720a8d733f77d1480a Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Tue, 10 Mar 2026 09:59:51 +0100 Subject: [PATCH 16/19] Fix listener aggregation across gateways --- internal/adc/translator/grpcroute_test.go | 13 +++++++++++++ internal/adc/translator/httproute_test.go | 13 +++++++++++++ internal/controller/grpcroute_controller.go | 4 ++-- internal/controller/httproute_controller.go | 4 ++-- internal/controller/utils.go | 20 +++++--------------- internal/controller/utils_hostname_test.go | 17 +++++++++-------- 6 files changed, 44 insertions(+), 27 deletions(-) diff --git a/internal/adc/translator/grpcroute_test.go b/internal/adc/translator/grpcroute_test.go index f577c0764a..df95a35ad6 100644 --- a/internal/adc/translator/grpcroute_test.go +++ b/internal/adc/translator/grpcroute_test.go @@ -103,6 +103,19 @@ func TestTranslateGRPCRouteServerPortVarsByMode(t *testing.T) { }, expected: multiPortVars, }, + { + name: "auto mode: inject for multiple listener ports when listener names collide across gateways", + mode: config.ListenerPortMatchModeAuto, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw-a"}, + {Name: "gw-b"}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9081)}, + {Name: "http", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: multiPortVars, + }, { name: "explicit mode: inject for sectionName target", mode: config.ListenerPortMatchModeExplicit, diff --git a/internal/adc/translator/httproute_test.go b/internal/adc/translator/httproute_test.go index 403c57dac8..6905e96ed7 100644 --- a/internal/adc/translator/httproute_test.go +++ b/internal/adc/translator/httproute_test.go @@ -105,6 +105,19 @@ func TestTranslateHTTPRouteServerPortVarsByMode(t *testing.T) { }, expected: multiPortVars, }, + { + name: "auto mode: inject for multiple listener ports when listener names collide across gateways", + mode: config.ListenerPortMatchModeAuto, + parentRefs: []gatewayv1.ParentReference{ + {Name: "gw-a"}, + {Name: "gw-b"}, + }, + listeners: []gatewayv1.Listener{ + {Name: "http", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9081)}, + {Name: "http", Protocol: gatewayv1.HTTPProtocolType, Port: gatewayv1.PortNumber(9080)}, + }, + expected: multiPortVars, + }, { name: "explicit mode: inject for sectionName target", mode: config.ListenerPortMatchModeExplicit, diff --git a/internal/controller/grpcroute_controller.go b/internal/controller/grpcroute_controller.go index 6048beb5d8..e15e09865b 100644 --- a/internal/controller/grpcroute_controller.go +++ b/internal/controller/grpcroute_controller.go @@ -203,10 +203,10 @@ func (r *GRPCRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Populate listeners for port-based routing // Use Listeners slice if available (multiple listener support) if len(gateway.Listeners) > 0 { - tctx.Listeners = appendUniqueListeners(tctx.Listeners, gateway.Listeners...) + tctx.Listeners = appendListeners(tctx.Listeners, gateway.Listeners...) } else if gateway.Listener != nil { // Fallback for backward compatibility - tctx.Listeners = appendUniqueListeners(tctx.Listeners, *gateway.Listener) + tctx.Listeners = appendListeners(tctx.Listeners, *gateway.Listener) } } diff --git a/internal/controller/httproute_controller.go b/internal/controller/httproute_controller.go index dba764cb3c..c183587e49 100644 --- a/internal/controller/httproute_controller.go +++ b/internal/controller/httproute_controller.go @@ -186,10 +186,10 @@ func (r *HTTPRouteReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Populate listeners for port-based routing // Use Listeners slice if available (multiple listener support) if len(gateway.Listeners) > 0 { - tctx.Listeners = appendUniqueListeners(tctx.Listeners, gateway.Listeners...) + tctx.Listeners = appendListeners(tctx.Listeners, gateway.Listeners...) } else if gateway.Listener != nil { // Fallback for backward compatibility - tctx.Listeners = appendUniqueListeners(tctx.Listeners, *gateway.Listener) + tctx.Listeners = appendListeners(tctx.Listeners, *gateway.Listener) } } diff --git a/internal/controller/utils.go b/internal/controller/utils.go index 6c7690a70b..eb981beae6 100644 --- a/internal/controller/utils.go +++ b/internal/controller/utils.go @@ -1179,21 +1179,11 @@ func isListenerHostnameEffective(listener gatewayv1.Listener) bool { listener.Protocol == gatewayv1.TLSProtocolType } -// appendUniqueListeners appends listeners to the target slice, skipping any -// listener whose Name already exists in the target. Listener names are unique -// within a Gateway per the Gateway API specification. -func appendUniqueListeners(target []gatewayv1.Listener, source ...gatewayv1.Listener) []gatewayv1.Listener { - seen := make(map[gatewayv1.SectionName]struct{}, len(target)) - for _, l := range target { - seen[l.Name] = struct{}{} - } - for _, l := range source { - if _, exists := seen[l.Name]; !exists { - seen[l.Name] = struct{}{} - target = append(target, l) - } - } - return target +// appendListeners appends listeners without de-duplication. +// Route translation aggregates listeners across multiple Gateways, and listener +// names are only unique within a single Gateway. +func appendListeners(target []gatewayv1.Listener, source ...gatewayv1.Listener) []gatewayv1.Listener { + return append(target, source...) } func isRouteAccepted(gateways []RouteParentRefContext) bool { diff --git a/internal/controller/utils_hostname_test.go b/internal/controller/utils_hostname_test.go index b070512c5f..031b0fe5ab 100644 --- a/internal/controller/utils_hostname_test.go +++ b/internal/controller/utils_hostname_test.go @@ -211,10 +211,11 @@ func TestFilterHostnamesNoMatchedListeners(t *testing.T) { assert.ErrorIs(t, err, ErrNoMatchingListenerHostname) } -func TestAppendUniqueListeners(t *testing.T) { +func TestAppendListeners(t *testing.T) { listenerA := gatewayv1.Listener{Name: "a", Port: 80} listenerB := gatewayv1.Listener{Name: "b", Port: 81} - listenerA2 := gatewayv1.Listener{Name: "a", Port: 82} // Duplicate name, different port + listenerA2 := gatewayv1.Listener{Name: "a", Port: 82} + listenerA3 := gatewayv1.Listener{Name: "a", Port: 80} tests := []struct { name string @@ -229,22 +230,22 @@ func TestAppendUniqueListeners(t *testing.T) { expected: []gatewayv1.Listener{listenerA, listenerB}, }, { - name: "duplicate names skipped", + name: "preserves same listener names from different gateways", target: []gatewayv1.Listener{listenerA}, source: []gatewayv1.Listener{listenerA, listenerB}, - expected: []gatewayv1.Listener{listenerA, listenerB}, + expected: []gatewayv1.Listener{listenerA, listenerA, listenerB}, }, { - name: "mixed duplicates and new", + name: "preserves all listeners when names collide", target: []gatewayv1.Listener{listenerA}, - source: []gatewayv1.Listener{listenerB, listenerA, listenerA2}, - expected: []gatewayv1.Listener{listenerA, listenerB}, + source: []gatewayv1.Listener{listenerB, listenerA2, listenerA3}, + expected: []gatewayv1.Listener{listenerA, listenerB, listenerA2, listenerA3}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, appendUniqueListeners(tt.target, tt.source...)) + assert.Equal(t, tt.expected, appendListeners(tt.target, tt.source...)) }) } } From efbb50590bfba790eac083b42bd175da39f75c3b Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Wed, 11 Mar 2026 18:01:28 +0100 Subject: [PATCH 17/19] fix: implement PR commentS --- Makefile | 4 ++-- test/e2e/scaffold/k8s.go | 2 +- test/e2e/scaffold/scaffold.go | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 7a2545cd1d..94e22f4e54 100644 --- a/Makefile +++ b/Makefile @@ -188,7 +188,7 @@ kind-load-images: pull-infra-images kind-load-ingress-image kind-load-adc-image @kind load docker-image kennethreitz/httpbin:latest --name $(KIND_NAME) @kind load docker-image jmalloc/echo-server:latest --name $(KIND_NAME) @kind load docker-image openresty/openresty:1.27.1.2-4-bullseye-fat --name $(KIND_NAME) - @kind load docker-image alpine/curl:latest --name $(KIND_NAME) + @kind load docker-image alpine/curl:8.10.1 --name $(KIND_NAME) .PHONY: kind-load-ingress-image kind-load-ingress-image: @@ -205,7 +205,7 @@ pull-infra-images: @docker pull kennethreitz/httpbin:latest @docker pull jmalloc/echo-server:latest @docker pull openresty/openresty:1.27.1.2-4-bullseye-fat - @docker pull alpine/curl:latest + @docker pull alpine/curl:8.10.1 ##@ Build diff --git a/test/e2e/scaffold/k8s.go b/test/e2e/scaffold/k8s.go index 72142a31b4..8d29deec74 100644 --- a/test/e2e/scaffold/k8s.go +++ b/test/e2e/scaffold/k8s.go @@ -280,7 +280,7 @@ func (s *Scaffold) RunCurlFromK8s(args ...string) (string, error) { "--rm", "--restart=Never", "--image-pull-policy=IfNotPresent", - "--image=alpine/curl:latest", + "--image=alpine/curl:8.10.1", "--quiet", "--command", "--", diff --git a/test/e2e/scaffold/scaffold.go b/test/e2e/scaffold/scaffold.go index ab88a1c16e..d4498d02c7 100644 --- a/test/e2e/scaffold/scaffold.go +++ b/test/e2e/scaffold/scaffold.go @@ -314,7 +314,8 @@ func (s *Scaffold) NewAPISIXClientWithTLSProxy(host string) *httpexpect.Expect { } // NewAPISIXClientForPort creates an HTTP client for a specific APISIX port. -// Uses existing tunnels if available, otherwise creates a new one. +// For built-in ports (80, 443, 9100), it reuses the existing helpers/tunnels. +// For any other port, it creates a new tunnel for that call. func (s *Scaffold) NewAPISIXClientForPort(port int) (*httpexpect.Expect, error) { // Check if we can reuse existing tunnels switch port { From 6ac00eef7b5c60b12552c64cee21f620c8488967 Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Thu, 12 Mar 2026 14:09:01 +0100 Subject: [PATCH 18/19] fix: CURL image tag --- Makefile | 4 ++-- test/e2e/scaffold/k8s.go | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 94e22f4e54..4a61780048 100644 --- a/Makefile +++ b/Makefile @@ -188,7 +188,7 @@ kind-load-images: pull-infra-images kind-load-ingress-image kind-load-adc-image @kind load docker-image kennethreitz/httpbin:latest --name $(KIND_NAME) @kind load docker-image jmalloc/echo-server:latest --name $(KIND_NAME) @kind load docker-image openresty/openresty:1.27.1.2-4-bullseye-fat --name $(KIND_NAME) - @kind load docker-image alpine/curl:8.10.1 --name $(KIND_NAME) + @kind load docker-image alpine/curl:8.17.0 --name $(KIND_NAME) .PHONY: kind-load-ingress-image kind-load-ingress-image: @@ -205,7 +205,7 @@ pull-infra-images: @docker pull kennethreitz/httpbin:latest @docker pull jmalloc/echo-server:latest @docker pull openresty/openresty:1.27.1.2-4-bullseye-fat - @docker pull alpine/curl:8.10.1 + @docker pull alpine/curl:8.17.0 ##@ Build diff --git a/test/e2e/scaffold/k8s.go b/test/e2e/scaffold/k8s.go index 8d29deec74..99b0c42647 100644 --- a/test/e2e/scaffold/k8s.go +++ b/test/e2e/scaffold/k8s.go @@ -280,7 +280,7 @@ func (s *Scaffold) RunCurlFromK8s(args ...string) (string, error) { "--rm", "--restart=Never", "--image-pull-policy=IfNotPresent", - "--image=alpine/curl:8.10.1", + "--image=alpine/curl:8.17.0", "--quiet", "--command", "--", From c5438189e0312cbbb365f8406403d9c85b394bda Mon Sep 17 00:00:00 2001 From: Johannes Engler Date: Fri, 13 Mar 2026 08:51:20 +0100 Subject: [PATCH 19/19] fix(e2e): capture dig output from kubectl run --- test/e2e/scaffold/k8s.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/test/e2e/scaffold/k8s.go b/test/e2e/scaffold/k8s.go index 99b0c42647..30fab2fd4b 100644 --- a/test/e2e/scaffold/k8s.go +++ b/test/e2e/scaffold/k8s.go @@ -254,15 +254,19 @@ func (s *Scaffold) WaitUntilDeploymentAvailable(name string) { } func (s *Scaffold) RunDigDNSClientFromK8s(args ...string) (string, error) { + podName := fmt.Sprintf("dig-test-%d", time.Now().UnixNano()) kubectlArgs := []string{ "run", - "dig", - "-i", + podName, + "--attach=true", "--rm", "--restart=Never", "--image-pull-policy=IfNotPresent", "--image=toolbelt/dig", + "--quiet", + "--command", "--", + "dig", } kubectlArgs = append(kubectlArgs, args...) return s.RunKubectlAndGetOutput(kubectlArgs...)