From ecb2a85d76fbaae514894760135446ad0c26b114 Mon Sep 17 00:00:00 2001 From: Teo Zhuo Yang Date: Fri, 27 Mar 2026 15:49:39 +0800 Subject: [PATCH 01/11] feat: support HTTPURLRewriteFilter hostname.type: Backend for k8s service backend Signed-off-by: Teo Zhuo Yang --- internal/cmd/egctl/translate.go | 4 + internal/gatewayapi/route.go | 49 +++++ internal/gatewayapi/route_test.go | 49 +++++ internal/gatewayapi/runner/runner.go | 1 + ...e-with-urlrewrite-hostname-filter.out.yaml | 1 + internal/gatewayapi/translator.go | 3 + internal/xds/translator/route.go | 2 +- ...tproute-rewrite-host-custom-dnsdomain.yaml | 32 +++ test/e2e/testdata/httproute-rewrite-host.yaml | 89 +++++--- test/e2e/tests/httproute_rewrite_host.go | 26 +++ ...httproute_rewrite_host_custom_dnsdomain.go | 198 ++++++++++++++++++ 11 files changed, 422 insertions(+), 32 deletions(-) create mode 100644 test/e2e/testdata/httproute-rewrite-host-custom-dnsdomain.yaml create mode 100644 test/e2e/tests/httproute_rewrite_host_custom_dnsdomain.go diff --git a/internal/cmd/egctl/translate.go b/internal/cmd/egctl/translate.go index 62d6e0f5ee..d18811aa7c 100644 --- a/internal/cmd/egctl/translate.go +++ b/internal/cmd/egctl/translate.go @@ -25,6 +25,7 @@ import ( egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" "github.com/envoyproxy/gateway/api/v1alpha1/validation" + "github.com/envoyproxy/gateway/internal/envoygateway/config" "github.com/envoyproxy/gateway/internal/gatewayapi" "github.com/envoyproxy/gateway/internal/gatewayapi/resource" "github.com/envoyproxy/gateway/internal/gatewayapi/status" @@ -292,6 +293,7 @@ func translateGatewayAPIToIR(resources *resource.Resources) (*gatewayapi.Transla EndpointRoutingDisabled: true, EnvoyPatchPolicyEnabled: true, BackendEnabled: true, + DNSDomain: config.DefaultDNSDomain, // Discard logs during translation for egctl command to avoid polluting output Logger: logging.DefaultLogger(io.Discard, egv1a1.LogLevelInfo), } @@ -322,6 +324,7 @@ func translateGatewayAPIToGatewayAPI(resources *resource.Resources) (resource.Re EndpointRoutingDisabled: true, EnvoyPatchPolicyEnabled: true, BackendEnabled: true, + DNSDomain: config.DefaultDNSDomain, Logger: logging.DefaultLogger(io.Discard, egv1a1.LogLevelInfo), } gRes, _ := gTranslator.Translate(resources) @@ -362,6 +365,7 @@ func TranslateGatewayAPIToXds(namespace, dnsDomain, resourceType string, resourc EndpointRoutingDisabled: opts.EndpointRoutingDisabled, EnvoyPatchPolicyEnabled: opts.EnvoyPatchPolicyEnabled, BackendEnabled: opts.BackendEnabled, + DNSDomain: dnsDomain, Logger: logging.DefaultLogger(io.Discard, egv1a1.LogLevelInfo), } gRes, _ := gTranslator.Translate(resources) diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go index aa3e205054..ba23f200ff 100644 --- a/internal/gatewayapi/route.go +++ b/internal/gatewayapi/route.go @@ -25,6 +25,7 @@ import ( mcsapiv1a1 "sigs.k8s.io/mcs-api/pkg/apis/v1alpha1" egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" + "github.com/envoyproxy/gateway/internal/envoygateway/config" "github.com/envoyproxy/gateway/internal/gatewayapi/resource" "github.com/envoyproxy/gateway/internal/gatewayapi/status" "github.com/envoyproxy/gateway/internal/ir" @@ -307,6 +308,10 @@ func (t *Translator) processHTTPRouteRules(httpRoute *HTTPRouteContext, parentRe backendRefNames[i] = fmt.Sprintf("%s/%s", backendNamespace, rule.BackendRefs[i].Name) } + if usesBackendHostRewrite(httpFiltersContext.URLRewrite) { + t.applyServiceBackendHostnames(allDs) + } + // process each IR route generated for this rule, and set its destination destination := &ir.RouteDestination{ Settings: allDs, @@ -1942,6 +1947,9 @@ func (t *Translator) processDestination(name string, backendRefContext BackendRe if filtersErr != nil { return emptyDS, nil, status.NewRouteStatusError(filtersErr, status.RouteReasonInvalidBackendFilters) } + if ds.Filters != nil && usesBackendHostRewrite(ds.Filters.URLRewrite) { + t.applyServiceBackendHostname(ds) + } if err := validateDestinationSettings(ds, t.IsServiceRouting(envoyProxy, btpRoutingType), backendRef.Kind); err != nil { return emptyDS, nil, err @@ -1971,6 +1979,47 @@ func validateDestinationSettings(destinationSettings *ir.DestinationSetting, isS return nil } +func usesBackendHostRewrite(urlRewrite *ir.URLRewrite) bool { + return urlRewrite != nil && urlRewrite.Host != nil && ptr.Deref(urlRewrite.Host.Backend, false) +} + +func (t *Translator) applyServiceBackendHostnames(settings []*ir.DestinationSetting) { + for _, setting := range settings { + t.applyServiceBackendHostname(setting) + } +} + +func (t *Translator) applyServiceBackendHostname(setting *ir.DestinationSetting) { + if setting == nil { + return + } + if setting.Metadata == nil { + return + } + + if setting.Metadata.Kind != "" { // if the kind is not empty, it means the destination setting is not a service + return + } + if setting.Metadata.Name == "" || setting.Metadata.Namespace == "" { + return + } + + hostname := fmt.Sprintf("%s.%s.svc.%s", setting.Metadata.Name, setting.Metadata.Namespace, t.dnsDomain()) + for _, endpoint := range setting.Endpoints { + if endpoint == nil { + continue + } + endpoint.Hostname = ptr.To(hostname) + } +} + +func (t *Translator) dnsDomain() string { + if t.DNSDomain != "" { + return t.DNSDomain + } + return config.DefaultDNSDomain +} + // isServiceHeadless reports true when a Kubernetes Service is headless. func isServiceHeadless(service *corev1.Service) bool { if service == nil { diff --git a/internal/gatewayapi/route_test.go b/internal/gatewayapi/route_test.go index 2634831398..8e21cab8b2 100644 --- a/internal/gatewayapi/route_test.go +++ b/internal/gatewayapi/route_test.go @@ -449,3 +449,52 @@ func TestIsServiceHeadless(t *testing.T) { }) } } + +func TestApplyServiceBackendHostname(t *testing.T) { + + t.Run("uses default cluster domain", func(t *testing.T) { + translator := &Translator{} + setting := &ir.DestinationSetting{ + Metadata: &ir.ResourceMetadata{ + Name: "service-1", + Namespace: "default", + }, + Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}, + } + + translator.applyServiceBackendHostname(setting) + + require.Equal(t, ptr.To("service-1.default.svc.cluster.local"), setting.Endpoints[0].Hostname) + }) + + t.Run("uses configured dns domain", func(t *testing.T) { + translator := &Translator{DNSDomain: "example.internal"} + setting := &ir.DestinationSetting{ + Metadata: &ir.ResourceMetadata{ + Name: "service-1", + Namespace: "default", + }, + Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}, + } + + translator.applyServiceBackendHostname(setting) + + require.Equal(t, ptr.To("service-1.default.svc.example.internal"), setting.Endpoints[0].Hostname) + }) + + t.Run("ignores non-service backends", func(t *testing.T) { + translator := &Translator{} + setting := &ir.DestinationSetting{ + Metadata: &ir.ResourceMetadata{ + Kind: egv1a1.KindBackend, + Name: "backend-1", + Namespace: "default", + }, + Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}, + } + + translator.applyServiceBackendHostname(setting) + + require.Nil(t, setting.Endpoints[0].Hostname) + }) +} diff --git a/internal/gatewayapi/runner/runner.go b/internal/gatewayapi/runner/runner.go index 370879ab01..473cfb9bec 100644 --- a/internal/gatewayapi/runner/runner.go +++ b/internal/gatewayapi/runner/runner.go @@ -267,6 +267,7 @@ func (r *Runner) subscribeAndTranslate(sub <-chan watchable.Snapshot[string, *re EnvoyPatchPolicyEnabled: r.EnvoyGateway.ExtensionAPIs != nil && r.EnvoyGateway.ExtensionAPIs.EnableEnvoyPatchPolicy, BackendEnabled: r.EnvoyGateway.ExtensionAPIs != nil && r.EnvoyGateway.ExtensionAPIs.EnableBackend, ControllerNamespace: r.ControllerNamespace, + DNSDomain: r.DNSDomain, GatewayNamespaceMode: r.EnvoyGateway.GatewayNamespaceMode(), MergeGateways: gatewayapi.IsMergeGatewaysEnabled(resources), WasmCache: r.wasmCache, diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.out.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.out.yaml index badf61f847..7adda14973 100644 --- a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.out.yaml +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.out.yaml @@ -365,6 +365,7 @@ xdsIR: - addressType: IP endpoints: - host: 7.7.7.7 + hostname: service-1.default.svc.cluster.local port: 8080 metadata: kind: Service diff --git a/internal/gatewayapi/translator.go b/internal/gatewayapi/translator.go index 19dc472683..b16c3262e5 100644 --- a/internal/gatewayapi/translator.go +++ b/internal/gatewayapi/translator.go @@ -108,6 +108,9 @@ type Translator struct { // ControllerNamespace is the namespace that Envoy Gateway controller runs in. ControllerNamespace string + // DNSDomain is the DNS domain used by Kubernetes services. + DNSDomain string + // WasmCache is the cache for Wasm modules. WasmCache wasm.Cache diff --git a/internal/xds/translator/route.go b/internal/xds/translator/route.go index 531d6f2269..fde591bb43 100644 --- a/internal/xds/translator/route.go +++ b/internal/xds/translator/route.go @@ -569,7 +569,7 @@ func buildXdsURLRewriteAction(route *ir.HTTPRoute, urlRewrite *ir.URLRewrite, pa if urlRewrite.Host != nil { // For DFP use cases, route-level host literal/header rewrites are not used, and instead DFP per-filter config is used, see here: // https://www.envoyproxy.io/docs/envoy/latest/api-v3/extensions/filters/http/dynamic_forward_proxy/v3/dynamic_forward_proxy.proto#envoy-v3-api-msg-extensions-filters-http-dynamic-forward-proxy-v3-perrouteconfig - // Auto Host rewrites are only supported for strict/logical DNS clusters, so not relevant for DFP, see here: + // Auto Host rewrites require strict/logical DNS clusters, or endpoint hostnames, so not relevant for DFP, see here: // https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-field-config-route-v3-routeaction-auto-host-rewrite if !route.IsDynamicResolverRoute() { switch { diff --git a/test/e2e/testdata/httproute-rewrite-host-custom-dnsdomain.yaml b/test/e2e/testdata/httproute-rewrite-host-custom-dnsdomain.yaml new file mode 100644 index 0000000000..f861f4952a --- /dev/null +++ b/test/e2e/testdata/httproute-rewrite-host-custom-dnsdomain.yaml @@ -0,0 +1,32 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: rewrite-host-custom-dnsdomain + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /backend-service-custom-dnsdomain + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: backend-host-rewrite-custom-dnsdomain + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: HTTPRouteFilter +metadata: + name: backend-host-rewrite-custom-dnsdomain + namespace: gateway-conformance-infra +spec: + urlRewrite: + hostname: + type: Backend diff --git a/test/e2e/testdata/httproute-rewrite-host.yaml b/test/e2e/testdata/httproute-rewrite-host.yaml index 871b2008b3..8c0826a137 100644 --- a/test/e2e/testdata/httproute-rewrite-host.yaml +++ b/test/e2e/testdata/httproute-rewrite-host.yaml @@ -5,35 +5,62 @@ metadata: namespace: gateway-conformance-infra spec: parentRefs: - - name: same-namespace + - name: same-namespace rules: - - matches: - - path: - type: PathPrefix - value: /header - filters: - - type: ExtensionRef - extensionRef: - group: gateway.envoyproxy.io - kind: HTTPRouteFilter - name: header-host-rewrite - backendRefs: - - name: infra-backend-v1 - port: 8080 - - matches: - - path: - type: PathPrefix - value: /backend - filters: - - type: ExtensionRef - extensionRef: - group: gateway.envoyproxy.io - kind: HTTPRouteFilter - name: backend-host-rewrite - backendRefs: - - group: gateway.envoyproxy.io - kind: Backend - name: backend-fqdn + - matches: + - path: + type: PathPrefix + value: /header + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: header-host-rewrite + backendRefs: + - name: infra-backend-v1 + port: 8080 + - matches: + - path: + type: PathPrefix + value: /backend + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: backend-host-rewrite + backendRefs: + - group: gateway.envoyproxy.io + kind: Backend + name: backend-fqdn + - matches: + - path: + type: PathPrefix + value: /backend-service + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: backend-host-rewrite + backendRefs: + - name: infra-backend-v1 + port: 8080 + - matches: + - path: + type: PathPrefix + value: /backend-service-2 + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: backend-host-rewrite + backendRefs: + - name: infra-backend-v1 + kind: Service # make sure explict service kind also works + port: 8080 --- apiVersion: gateway.envoyproxy.io/v1alpha1 kind: HTTPRouteFilter @@ -63,6 +90,6 @@ metadata: namespace: gateway-conformance-infra spec: endpoints: - - fqdn: - hostname: infra-backend-v1.gateway-conformance-infra.svc.cluster.local - port: 8080 + - fqdn: + hostname: infra-backend-v1.gateway-conformance-infra.svc.cluster.local + port: 8080 diff --git a/test/e2e/tests/httproute_rewrite_host.go b/test/e2e/tests/httproute_rewrite_host.go index 49353e4afe..243dfe8f27 100644 --- a/test/e2e/tests/httproute_rewrite_host.go +++ b/test/e2e/tests/httproute_rewrite_host.go @@ -62,6 +62,32 @@ var HTTPRouteRewriteHostHeader = suite.ConformanceTest{ Backend: "infra-backend-v1", Namespace: ns, }, + { + Request: http.Request{ + Path: "/backend-service", + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Path: "/backend-service", + Host: "infra-backend-v1.gateway-conformance-infra.svc.cluster.local", + }, + }, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{ + Path: "/backend-service-2", + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Path: "/backend-service-2", + Host: "infra-backend-v1.gateway-conformance-infra.svc.cluster.local", + }, + }, + Backend: "infra-backend-v1", + Namespace: ns, + }, } for i := range testCases { // Declare tc here to avoid loop variable diff --git a/test/e2e/tests/httproute_rewrite_host_custom_dnsdomain.go b/test/e2e/tests/httproute_rewrite_host_custom_dnsdomain.go new file mode 100644 index 0000000000..d800da9f57 --- /dev/null +++ b/test/e2e/tests/httproute_rewrite_host_custom_dnsdomain.go @@ -0,0 +1,198 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +//go:build e2e + +package tests + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" +) + +const ( + customDNSDomainEnvName = "KUBERNETES_CLUSTER_DOMAIN" + customHostRewriteDomain = "example.internal" +) + +type deploymentEnvState struct { + Value string + Found bool +} + +func init() { + ConformanceTests = append(ConformanceTests, HTTPRouteRewriteHostHeaderCustomDNSDomain) +} + +var HTTPRouteRewriteHostHeaderCustomDNSDomain = suite.ConformanceTest{ + ShortName: "HTTPRouteRewriteHostHeaderCustomDNSDomain", + Description: "An HTTPRoute with backend host rewrite uses the configured DNS domain", + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + originalState := setEnvoyGatewayClusterDomain(t, suite, customHostRewriteDomain) + defer restoreEnvoyGatewayClusterDomain(t, suite, originalState) + + suite.Applier.MustApplyWithCleanup(t, suite.Client, suite.TimeoutConfig, "testdata/httproute-rewrite-host-custom-dnsdomain.yaml", true) + + ns := ConformanceInfraNamespace + routeNN := types.NamespacedName{Name: "rewrite-host-custom-dnsdomain", Namespace: ns} + gwNN := SameNamespaceGateway + gwAddr := kubernetes.GatewayAndRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), &gwapiv1.HTTPRoute{}, false, routeNN) + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN) + + expectedResponse := http.ExpectedResponse{ + Request: http.Request{ + Path: "/backend-service-custom-dnsdomain", + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Path: "/backend-service-custom-dnsdomain", + Host: fmt.Sprintf("infra-backend-v1.%s.svc.%s", ns, customHostRewriteDomain), + }, + }, + Backend: "infra-backend-v1", + Namespace: ns, + } + + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expectedResponse) + }, +} + +func setEnvoyGatewayClusterDomain(t *testing.T, suite *suite.ConformanceTestSuite, value string) deploymentEnvState { + t.Helper() + + deploymentNN := types.NamespacedName{Name: "envoy-gateway", Namespace: "envoy-gateway-system"} + var originalState deploymentEnvState + + for i := 0; i < 5; i++ { + deployment := &appsv1.Deployment{} + err := suite.Client.Get(context.Background(), deploymentNN, deployment) + require.NoError(t, err) + + originalState = getDeploymentEnvState(deployment.Spec.Template.Spec.Containers[0].Env, customDNSDomainEnvName) + if originalState.Found && originalState.Value == value { + waitForEnvoyGatewayRollout(t, suite, deploymentNN, value) + return originalState + } + + upsertDeploymentEnv(&deployment.Spec.Template.Spec.Containers[0].Env, customDNSDomainEnvName, value) + err = suite.Client.Update(context.Background(), deployment) + if err == nil { + waitForEnvoyGatewayRollout(t, suite, deploymentNN, value) + return originalState + } + if !apierrors.IsConflict(err) { + require.NoError(t, err) + } + } + + t.Fatalf("failed to update %s on envoy-gateway deployment after retries", customDNSDomainEnvName) + return deploymentEnvState{} +} + +func restoreEnvoyGatewayClusterDomain(t *testing.T, suite *suite.ConformanceTestSuite, state deploymentEnvState) { + t.Helper() + + deploymentNN := types.NamespacedName{Name: "envoy-gateway", Namespace: "envoy-gateway-system"} + for i := 0; i < 5; i++ { + deployment := &appsv1.Deployment{} + err := suite.Client.Get(context.Background(), deploymentNN, deployment) + require.NoError(t, err) + + if state.Found { + upsertDeploymentEnv(&deployment.Spec.Template.Spec.Containers[0].Env, customDNSDomainEnvName, state.Value) + } else { + removeDeploymentEnv(&deployment.Spec.Template.Spec.Containers[0].Env, customDNSDomainEnvName) + } + + err = suite.Client.Update(context.Background(), deployment) + if err == nil { + expected := "" + if state.Found { + expected = state.Value + } + waitForEnvoyGatewayRollout(t, suite, deploymentNN, expected) + return + } + if !apierrors.IsConflict(err) { + require.NoError(t, err) + } + } + + t.Fatalf("failed to restore %s on envoy-gateway deployment after retries", customDNSDomainEnvName) +} + +func waitForEnvoyGatewayRollout(t *testing.T, suite *suite.ConformanceTestSuite, deploymentNN types.NamespacedName, expectedDNSDomain string) { + t.Helper() + + require.Eventually(t, func() bool { + deployment := &appsv1.Deployment{} + if err := suite.Client.Get(context.Background(), deploymentNN, deployment); err != nil { + return false + } + + envState := getDeploymentEnvState(deployment.Spec.Template.Spec.Containers[0].Env, customDNSDomainEnvName) + if expectedDNSDomain == "" { + if envState.Found { + return false + } + } else if !envState.Found || envState.Value != expectedDNSDomain { + return false + } + + replicas := int32(1) + if deployment.Spec.Replicas != nil { + replicas = *deployment.Spec.Replicas + } + + return deployment.Generation <= deployment.Status.ObservedGeneration && + deployment.Status.UpdatedReplicas == replicas && + deployment.Status.ReadyReplicas == replicas && + deployment.Status.AvailableReplicas == replicas + }, 2*time.Minute, 2*time.Second) + + WaitForPods(t, suite.Client, deploymentNN.Namespace, map[string]string{"control-plane": "envoy-gateway"}, corev1.PodRunning, &PodReady) +} + +func getDeploymentEnvState(envs []corev1.EnvVar, name string) deploymentEnvState { + for _, env := range envs { + if env.Name == name { + return deploymentEnvState{Value: env.Value, Found: true} + } + } + return deploymentEnvState{} +} + +func upsertDeploymentEnv(envs *[]corev1.EnvVar, name, value string) { + for i := range *envs { + if (*envs)[i].Name == name { + (*envs)[i].Value = value + (*envs)[i].ValueFrom = nil + return + } + } + *envs = append(*envs, corev1.EnvVar{Name: name, Value: value}) +} + +func removeDeploymentEnv(envs *[]corev1.EnvVar, name string) { + filtered := (*envs)[:0] + for _, env := range *envs { + if env.Name != name { + filtered = append(filtered, env) + } + } + *envs = filtered +} From 806e05f5e26f9f03eeba9c2eabb824345f1cc0ae Mon Sep 17 00:00:00 2001 From: Teo Zhuo Yang Date: Fri, 27 Mar 2026 15:54:19 +0800 Subject: [PATCH 02/11] add release notes Signed-off-by: Teo Zhuo Yang --- release-notes/current.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release-notes/current.yaml b/release-notes/current.yaml index a42f658728..ad88516961 100644 --- a/release-notes/current.yaml +++ b/release-notes/current.yaml @@ -38,6 +38,7 @@ new features: | Added support for retry budget in BackendTrafficPolicy. Added support for BackendUtilization load balancing policy in BackendTrafficPolicy. Added support for upstrean access log. + Added HTTPURLRewriteFilter `hostname.type: Backend` support for k8s service backends. bug fixes: | Rejected ClientTrafficPolicy if invalid TLS cipher suites are configured. @@ -57,7 +58,6 @@ bug fixes: | Fixed per-endpoint hostname override not working because the auto-generated wildcard hostname. Fixed Basic Authentication failing when htpasswd secrets use CRLF line endings by normalizing to LF before passing to Envoy. - # Enhancements that improve performance. performance improvements: | Reduce chances of listener drain due to Lua policy updates by migrating to LuaPerRoute. From 6cb6abc3e3749893174a4d1faee569e53deadb33 Mon Sep 17 00:00:00 2001 From: Teo Zhuo Yang Date: Fri, 3 Apr 2026 00:41:14 +0800 Subject: [PATCH 03/11] fix empty resource metadata for service Signed-off-by: Teo Zhuo Yang --- internal/gatewayapi/listener_test.go | 2 +- internal/gatewayapi/route.go | 17 +++++++++++------ internal/gatewayapi/route_test.go | 12 ++++++++++++ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/internal/gatewayapi/listener_test.go b/internal/gatewayapi/listener_test.go index 512ddae8a1..3cef1cf7de 100644 --- a/internal/gatewayapi/listener_test.go +++ b/internal/gatewayapi/listener_test.go @@ -1395,7 +1395,7 @@ func TestProcessBackendRefsBackendTLSPolicy(t *testing.T) { }, } serviceEndpoints := []*ir.DestinationEndpoint{{Host: "7.7.7.7", Port: 4317}} - serviceMetadata := &ir.ResourceMetadata{Name: serviceName, Namespace: ns, SectionName: "4317"} + serviceMetadata := &ir.ResourceMetadata{Kind: resource.KindService, Name: serviceName, Namespace: ns, SectionName: "4317"} servicePolicyTLS := &ir.TLSUpstreamConfig{ SNI: ptr.To("otel-svc.example.com"), UseSystemTrustStore: true, CACertificate: &ir.TLSCACertificate{Name: "otel-svc-tls/test-ns-ca"}, SubjectAltNames: []ir.SubjectAltName{}, diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go index ba23f200ff..8f51785d6f 100644 --- a/internal/gatewayapi/route.go +++ b/internal/gatewayapi/route.go @@ -1313,12 +1313,17 @@ func (t *Translator) processHTTPRouteParentRefListener(route RouteContext, route return hasHostnameIntersection } -func buildResourceMetadata(resource client.Object, sectionName *gwapiv1.SectionName) *ir.ResourceMetadata { +func buildResourceMetadata(obj client.Object, sectionName *gwapiv1.SectionName) *ir.ResourceMetadata { + kind := obj.GetObjectKind().GroupVersionKind().Kind + if _, ok := obj.(*corev1.Service); ok && kind == "" { + kind = resource.KindService + } + metadata := &ir.ResourceMetadata{ - Kind: resource.GetObjectKind().GroupVersionKind().Kind, - Name: resource.GetName(), - Namespace: resource.GetNamespace(), - Annotations: ir.MapToSlice(filterEGPrefix(resource.GetAnnotations())), + Kind: kind, + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + Annotations: ir.MapToSlice(filterEGPrefix(obj.GetAnnotations())), } if sectionName != nil { metadata.SectionName = string(*sectionName) @@ -1997,7 +2002,7 @@ func (t *Translator) applyServiceBackendHostname(setting *ir.DestinationSetting) return } - if setting.Metadata.Kind != "" { // if the kind is not empty, it means the destination setting is not a service + if setting.Metadata.Kind != resource.KindService { return } if setting.Metadata.Name == "" || setting.Metadata.Namespace == "" { diff --git a/internal/gatewayapi/route_test.go b/internal/gatewayapi/route_test.go index 8e21cab8b2..246f30faf2 100644 --- a/internal/gatewayapi/route_test.go +++ b/internal/gatewayapi/route_test.go @@ -451,11 +451,22 @@ func TestIsServiceHeadless(t *testing.T) { } func TestApplyServiceBackendHostname(t *testing.T) { + t.Run("build metadata infers service kind from typed object", func(t *testing.T) { + service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service-1", Namespace: "default"}} + + metadata := buildResourceMetadata(service, ptr.To(gwapiv1.SectionName("8080"))) + + require.Equal(t, resource.KindService, metadata.Kind) + require.Equal(t, "service-1", metadata.Name) + require.Equal(t, "default", metadata.Namespace) + require.Equal(t, "8080", metadata.SectionName) + }) t.Run("uses default cluster domain", func(t *testing.T) { translator := &Translator{} setting := &ir.DestinationSetting{ Metadata: &ir.ResourceMetadata{ + Kind: resource.KindService, Name: "service-1", Namespace: "default", }, @@ -471,6 +482,7 @@ func TestApplyServiceBackendHostname(t *testing.T) { translator := &Translator{DNSDomain: "example.internal"} setting := &ir.DestinationSetting{ Metadata: &ir.ResourceMetadata{ + Kind: resource.KindService, Name: "service-1", Namespace: "default", }, From bafdd3cb27c8e94faf6aa38269d399d7d136fda8 Mon Sep 17 00:00:00 2001 From: Teo Zhuo Yang Date: Fri, 3 Apr 2026 01:11:44 +0800 Subject: [PATCH 04/11] fix yaml lint Signed-off-by: Teo Zhuo Yang --- test/e2e/testdata/httproute-rewrite-host.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e/testdata/httproute-rewrite-host.yaml b/test/e2e/testdata/httproute-rewrite-host.yaml index 8c0826a137..8ad0c083f3 100644 --- a/test/e2e/testdata/httproute-rewrite-host.yaml +++ b/test/e2e/testdata/httproute-rewrite-host.yaml @@ -59,7 +59,7 @@ spec: name: backend-host-rewrite backendRefs: - name: infra-backend-v1 - kind: Service # make sure explict service kind also works + kind: Service # make sure explict service kind also works port: 8080 --- apiVersion: gateway.envoyproxy.io/v1alpha1 From caf4e7812578fc574781d124f4ba62fa924619a0 Mon Sep 17 00:00:00 2001 From: Teo Zhuo Yang Date: Fri, 3 Apr 2026 11:15:01 +0800 Subject: [PATCH 05/11] fix test coverage Signed-off-by: Teo Zhuo Yang --- internal/gatewayapi/route_test.go | 80 +++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/internal/gatewayapi/route_test.go b/internal/gatewayapi/route_test.go index 246f30faf2..28179b5eef 100644 --- a/internal/gatewayapi/route_test.go +++ b/internal/gatewayapi/route_test.go @@ -462,6 +462,15 @@ func TestApplyServiceBackendHostname(t *testing.T) { require.Equal(t, "8080", metadata.SectionName) }) + t.Run("ignores missing metadata", func(t *testing.T) { + translator := &Translator{} + setting := &ir.DestinationSetting{Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}} + + translator.applyServiceBackendHostname(setting) + + require.Nil(t, setting.Endpoints[0].Hostname) + }) + t.Run("uses default cluster domain", func(t *testing.T) { translator := &Translator{} setting := &ir.DestinationSetting{ @@ -494,6 +503,52 @@ func TestApplyServiceBackendHostname(t *testing.T) { require.Equal(t, ptr.To("service-1.default.svc.example.internal"), setting.Endpoints[0].Hostname) }) + t.Run("ignores missing name", func(t *testing.T) { + translator := &Translator{} + setting := &ir.DestinationSetting{ + Metadata: &ir.ResourceMetadata{ + Kind: resource.KindService, + Namespace: "default", + }, + Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}, + } + + translator.applyServiceBackendHostname(setting) + + require.Nil(t, setting.Endpoints[0].Hostname) + }) + + t.Run("ignores missing namespace", func(t *testing.T) { + translator := &Translator{} + setting := &ir.DestinationSetting{ + Metadata: &ir.ResourceMetadata{ + Kind: resource.KindService, + Name: "service-1", + }, + Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}, + } + + translator.applyServiceBackendHostname(setting) + + require.Nil(t, setting.Endpoints[0].Hostname) + }) + + t.Run("skips nil endpoints and updates valid endpoints", func(t *testing.T) { + translator := &Translator{} + setting := &ir.DestinationSetting{ + Metadata: &ir.ResourceMetadata{ + Kind: resource.KindService, + Name: "service-1", + Namespace: "default", + }, + Endpoints: []*ir.DestinationEndpoint{nil, &ir.DestinationEndpoint{Host: "10.0.0.1", Port: 8080}}, + } + + translator.applyServiceBackendHostname(setting) + + require.Equal(t, ptr.To("service-1.default.svc.cluster.local"), setting.Endpoints[1].Hostname) + }) + t.Run("ignores non-service backends", func(t *testing.T) { translator := &Translator{} setting := &ir.DestinationSetting{ @@ -509,4 +564,29 @@ func TestApplyServiceBackendHostname(t *testing.T) { require.Nil(t, setting.Endpoints[0].Hostname) }) + + t.Run("applies hostnames across settings slice", func(t *testing.T) { + translator := &Translator{} + serviceSetting := &ir.DestinationSetting{ + Metadata: &ir.ResourceMetadata{ + Kind: resource.KindService, + Name: "service-1", + Namespace: "default", + }, + Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}, + } + backendSetting := &ir.DestinationSetting{ + Metadata: &ir.ResourceMetadata{ + Kind: egv1a1.KindBackend, + Name: "backend-1", + Namespace: "default", + }, + Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.2", Port: 8080}}, + } + + translator.applyServiceBackendHostnames([]*ir.DestinationSetting{serviceSetting, backendSetting, nil}) + + require.Equal(t, ptr.To("service-1.default.svc.cluster.local"), serviceSetting.Endpoints[0].Hostname) + require.Nil(t, backendSetting.Endpoints[0].Hostname) + }) } From 62471961640a8db29057ee3000649a6e3517a129 Mon Sep 17 00:00:00 2001 From: Teo Zhuo Yang Date: Mon, 6 Apr 2026 16:44:27 +0800 Subject: [PATCH 06/11] fix golint again Signed-off-by: Teo Zhuo Yang --- internal/gatewayapi/route_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/gatewayapi/route_test.go b/internal/gatewayapi/route_test.go index 28179b5eef..61fe342765 100644 --- a/internal/gatewayapi/route_test.go +++ b/internal/gatewayapi/route_test.go @@ -541,7 +541,7 @@ func TestApplyServiceBackendHostname(t *testing.T) { Name: "service-1", Namespace: "default", }, - Endpoints: []*ir.DestinationEndpoint{nil, &ir.DestinationEndpoint{Host: "10.0.0.1", Port: 8080}}, + Endpoints: []*ir.DestinationEndpoint{nil, {Host: "10.0.0.1", Port: 8080}}, } translator.applyServiceBackendHostname(setting) From d04c2c66e1ebed2069d120c2d12963f0b2db18a4 Mon Sep 17 00:00:00 2001 From: Teo Zhuo Yang Date: Tue, 5 May 2026 17:25:42 +0800 Subject: [PATCH 07/11] introduce new BTP knob to control hostname Signed-off-by: Teo Zhuo Yang --- api/v1alpha1/backendtrafficpolicy_types.go | 6 + api/v1alpha1/shared_types.go | 20 ++ api/v1alpha1/zz_generated.deepcopy.go | 20 ++ ....envoyproxy.io_backendtrafficpolicies.yaml | 15 ++ ....envoyproxy.io_backendtrafficpolicies.yaml | 15 ++ internal/gatewayapi/backendtrafficpolicy.go | 138 ++++++++++++ .../gatewayapi/backendtrafficpolicy_test.go | 160 ++++++++++++++ internal/gatewayapi/contexts.go | 19 +- internal/gatewayapi/ext_service.go | 2 +- internal/gatewayapi/globalresources.go | 2 +- internal/gatewayapi/listener.go | 2 +- internal/gatewayapi/route.go | 86 ++++---- internal/gatewayapi/route_test.go | 170 ++++++++------- ...te-with-urlrewrite-hostname-filter.in.yaml | 13 ++ ...e-with-urlrewrite-hostname-filter.out.yaml | 33 +++ internal/gatewayapi/translator.go | 8 + site/content/en/latest/api/extension_types.md | 30 +++ .../backendtrafficpolicy_test.go | 28 +++ .../httproute-backend-endpoint-hostname.yaml | 68 ++++++ ...tproute-rewrite-host-custom-dnsdomain.yaml | 32 --- test/e2e/testdata/httproute-rewrite-host.yaml | 27 --- .../httproute_backend_endpoint_hostname.go | 75 +++++++ test/e2e/tests/httproute_rewrite_host.go | 26 --- ...httproute_rewrite_host_custom_dnsdomain.go | 198 ------------------ 24 files changed, 762 insertions(+), 431 deletions(-) create mode 100644 test/e2e/testdata/httproute-backend-endpoint-hostname.yaml delete mode 100644 test/e2e/testdata/httproute-rewrite-host-custom-dnsdomain.yaml create mode 100644 test/e2e/tests/httproute_backend_endpoint_hostname.go delete mode 100644 test/e2e/tests/httproute_rewrite_host_custom_dnsdomain.go diff --git a/api/v1alpha1/backendtrafficpolicy_types.go b/api/v1alpha1/backendtrafficpolicy_types.go index b548416d86..fd3bb77cf5 100644 --- a/api/v1alpha1/backendtrafficpolicy_types.go +++ b/api/v1alpha1/backendtrafficpolicy_types.go @@ -136,6 +136,12 @@ type BackendTrafficPolicySpec struct { // // +optional RoutingType *RoutingType `json:"routingType,omitempty"` + + // EndpointHostname configures the hostname value attached to backend endpoints. + // If unset, no hostname is attached to Kubernetes Service endpoints. + // + // +optional + EndpointHostname *BackendEndpointHostname `json:"endpointHostname,omitempty"` } type BackendTelemetry struct { diff --git a/api/v1alpha1/shared_types.go b/api/v1alpha1/shared_types.go index 39e1850fc1..f570de9812 100644 --- a/api/v1alpha1/shared_types.go +++ b/api/v1alpha1/shared_types.go @@ -676,6 +676,26 @@ type ClusterSettings struct { HTTP2 *HTTP2Settings `json:"http2,omitempty"` } +// BackendEndpointHostnameType defines how endpoint hostnames should be populated. +// +// +kubebuilder:validation:Enum=None;KubernetesService +type BackendEndpointHostnameType string + +const ( + // BackendEndpointHostnameTypeNone does not attach hostnames to backend endpoints. + BackendEndpointHostnameTypeNone BackendEndpointHostnameType = "None" + // BackendEndpointHostnameTypeKubernetesService uses the Kubernetes Service FQDN. + BackendEndpointHostnameTypeKubernetesService BackendEndpointHostnameType = "KubernetesService" +) + +// BackendEndpointHostname configures hostnames attached to backend endpoints. +type BackendEndpointHostname struct { + // Type determines how endpoint hostnames should be populated. + // + // +kubebuilder:validation:Required + Type BackendEndpointHostnameType `json:"type"` +} + // CIDR defines a CIDR Address range. // A CIDR can be an IPv4 address range such as "192.168.1.0/24" or an IPv6 address range such as "2001:0db8:11a3:09d7::/64". // +kubebuilder:validation:Pattern=`((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\/([0-9]+))|((([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))\/([0-9]+))` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index d48547d0f5..f2f4d7d7d4 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -431,6 +431,21 @@ func (in *BackendEndpoint) DeepCopy() *BackendEndpoint { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *BackendEndpointHostname) DeepCopyInto(out *BackendEndpointHostname) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendEndpointHostname. +func (in *BackendEndpointHostname) DeepCopy() *BackendEndpointHostname { + if in == nil { + return nil + } + out := new(BackendEndpointHostname) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BackendList) DeepCopyInto(out *BackendList) { *out = *in @@ -802,6 +817,11 @@ func (in *BackendTrafficPolicySpec) DeepCopyInto(out *BackendTrafficPolicySpec) *out = new(RoutingType) **out = **in } + if in.EndpointHostname != nil { + in, out := &in.EndpointHostname, &out.EndpointHostname + *out = new(BackendEndpointHostname) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendTrafficPolicySpec. diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index 55db8ada46..dc52c54496 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -335,6 +335,21 @@ spec: Defaults to true. type: boolean type: object + endpointHostname: + description: |- + EndpointHostname configures the hostname value attached to backend endpoints. + If unset, no hostname is attached to Kubernetes Service endpoints. + properties: + type: + description: Type determines how endpoint hostnames should be + populated. + enum: + - None + - KubernetesService + type: string + required: + - type + type: object faultInjection: description: |- FaultInjection defines the fault injection policy to be applied. This configuration can be used to diff --git a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index def08ab203..2431cba469 100644 --- a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -334,6 +334,21 @@ spec: Defaults to true. type: boolean type: object + endpointHostname: + description: |- + EndpointHostname configures the hostname value attached to backend endpoints. + If unset, no hostname is attached to Kubernetes Service endpoints. + properties: + type: + description: Type determines how endpoint hostnames should be + populated. + enum: + - None + - KubernetesService + type: string + required: + - type + type: object faultInjection: description: |- FaultInjection defines the fault injection policy to be applied. This configuration can be used to diff --git a/internal/gatewayapi/backendtrafficpolicy.go b/internal/gatewayapi/backendtrafficpolicy.go index c570f764eb..6124cec41b 100644 --- a/internal/gatewayapi/backendtrafficpolicy.go +++ b/internal/gatewayapi/backendtrafficpolicy.go @@ -51,6 +51,15 @@ type BTPRoutingTypeIndex struct { gatewayLevel map[btpRoutingKey]*egv1a1.RoutingType } +// BTPEndpointHostnameIndex holds EndpointHostname values from BackendTrafficPolicies. +// This avoids an O(BTPs) lookup for every iteration of processDestination. +type BTPEndpointHostnameIndex struct { + routeRuleLevel map[btpRoutingKey]*egv1a1.BackendEndpointHostname + routeLevel map[btpRoutingKey]*egv1a1.BackendEndpointHostname + listenerLevel map[btpRoutingKey]*egv1a1.BackendEndpointHostname + gatewayLevel map[btpRoutingKey]*egv1a1.BackendEndpointHostname +} + // BuildBTPRoutingTypeIndex builds a pre-computed index of RoutingType values // from BackendTrafficPolicies, organized by priority-level. // BTPs are pre-sorted by the provider layer, so first-write-wins respects priority. @@ -64,6 +73,16 @@ func hasBTPRoutingType(btps []*egv1a1.BackendTrafficPolicy) bool { return false } +func hasBTPEndpointHostname(btps []*egv1a1.BackendTrafficPolicy) bool { + for _, btp := range btps { + if btp.Spec.EndpointHostname != nil { + return true + } + } + + return false +} + func BuildBTPRoutingTypeIndex( btps []*egv1a1.BackendTrafficPolicy, routes []client.Object, @@ -125,6 +144,66 @@ func BuildBTPRoutingTypeIndex( return idx } +func BuildBTPEndpointHostnameIndex( + btps []*egv1a1.BackendTrafficPolicy, + routes []client.Object, + gateways []*GatewayContext, +) *BTPEndpointHostnameIndex { + idx := &BTPEndpointHostnameIndex{ + routeRuleLevel: make(map[btpRoutingKey]*egv1a1.BackendEndpointHostname), + routeLevel: make(map[btpRoutingKey]*egv1a1.BackendEndpointHostname), + listenerLevel: make(map[btpRoutingKey]*egv1a1.BackendEndpointHostname), + gatewayLevel: make(map[btpRoutingKey]*egv1a1.BackendEndpointHostname), + } + + allTargets := make([]client.Object, 0, len(routes)+len(gateways)) + allTargets = append(allTargets, routes...) + for _, gw := range gateways { + allTargets = append(allTargets, gw) + } + + for _, btp := range btps { + if btp.Spec.EndpointHostname == nil { + continue + } + + refs := getPolicyTargetRefs(btp.Spec.PolicyTargetReferences, allTargets, btp.Namespace) + for _, ref := range refs { + kind := string(ref.Kind) + key := btpRoutingKey{ + Kind: kind, + Namespace: btp.Namespace, + Name: string(ref.Name), + SectionName: string(ptr.Deref(ref.SectionName, "")), + } + + if kind == resource.KindGateway { + if ref.SectionName != nil { + if _, exists := idx.listenerLevel[key]; !exists { + idx.listenerLevel[key] = btp.Spec.EndpointHostname + } + } else { + if _, exists := idx.gatewayLevel[key]; !exists { + idx.gatewayLevel[key] = btp.Spec.EndpointHostname + } + } + } else { + if ref.SectionName != nil { + if _, exists := idx.routeRuleLevel[key]; !exists { + idx.routeRuleLevel[key] = btp.Spec.EndpointHostname + } + } else { + if _, exists := idx.routeLevel[key]; !exists { + idx.routeLevel[key] = btp.Spec.EndpointHostname + } + } + } + } + } + + return idx +} + // LookupBTPRoutingType resolves the RoutingType for a specific route rule // and gateway/listener combination by checking the index in // priority order: routeRule > route > listener > gateway. @@ -189,6 +268,65 @@ func (idx *BTPRoutingTypeIndex) LookupBTPRoutingType( return nil } +// LookupBTPEndpointHostname resolves the EndpointHostname for a specific route rule +// and gateway/listener combination by checking the index in +// priority order: routeRule > route > listener > gateway. +func (idx *BTPEndpointHostnameIndex) LookupBTPEndpointHostname( + routeKind gwapiv1.Kind, + routeNN types.NamespacedName, + gatewayNN types.NamespacedName, + listenerName *gwapiv1.SectionName, + routeRuleName *gwapiv1.SectionName, +) *egv1a1.BackendEndpointHostname { + if idx == nil { + return nil + } + + if routeRuleName != nil { + key := btpRoutingKey{ + Kind: string(routeKind), + Namespace: routeNN.Namespace, + Name: routeNN.Name, + SectionName: string(*routeRuleName), + } + if eh, ok := idx.routeRuleLevel[key]; ok { + return eh + } + } + + routeKey := btpRoutingKey{ + Kind: string(routeKind), + Namespace: routeNN.Namespace, + Name: routeNN.Name, + } + if eh, ok := idx.routeLevel[routeKey]; ok { + return eh + } + + if listenerName != nil { + listenerKey := btpRoutingKey{ + Kind: resource.KindGateway, + Namespace: gatewayNN.Namespace, + Name: gatewayNN.Name, + SectionName: string(*listenerName), + } + if eh, ok := idx.listenerLevel[listenerKey]; ok { + return eh + } + } + + gwKey := btpRoutingKey{ + Kind: resource.KindGateway, + Namespace: gatewayNN.Namespace, + Name: gatewayNN.Name, + } + if eh, ok := idx.gatewayLevel[gwKey]; ok { + return eh + } + + return nil +} + // deprecatedFieldsUsedInBackendTrafficPolicy returns a map of deprecated field paths to their alternatives. func deprecatedFieldsUsedInBackendTrafficPolicy(policy *egv1a1.BackendTrafficPolicy) map[string]string { deprecatedFields := make(map[string]string) diff --git a/internal/gatewayapi/backendtrafficpolicy_test.go b/internal/gatewayapi/backendtrafficpolicy_test.go index 4bf8ca9a1f..80b6a762c6 100644 --- a/internal/gatewayapi/backendtrafficpolicy_test.go +++ b/internal/gatewayapi/backendtrafficpolicy_test.go @@ -1750,3 +1750,163 @@ func TestBTPRoutingTypeIndex(t *testing.T) { }) } } + +func TestBTPEndpointHostnameIndex(t *testing.T) { + kubernetesService := &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeKubernetesService, + } + none := &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeNone, + } + + defaultHTTPRoute := &gwapiv1.HTTPRoute{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "route-1", + }, + } + defaultGateway := &GatewayContext{ + Gateway: &gwapiv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "gateway-1", + }, + }, + } + + routeNN := types.NamespacedName{Namespace: "default", Name: "route-1"} + gatewayNN := types.NamespacedName{Namespace: "default", Name: "gateway-1"} + + tests := []struct { + name string + btps []*egv1a1.BackendTrafficPolicy + listenerName *gwapiv1.SectionName + routeRuleName *gwapiv1.SectionName + expected *egv1a1.BackendEndpointHostname + }{ + { + name: "no BTPs", + expected: nil, + }, + { + name: "route has priority over gateway", + btps: []*egv1a1.BackendTrafficPolicy{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "btp-gateway"}, + Spec: egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("Gateway"), + Name: gwapiv1.ObjectName("gateway-1"), + }, + }, + }, + EndpointHostname: none, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "btp-route"}, + Spec: egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("HTTPRoute"), + Name: gwapiv1.ObjectName("route-1"), + }, + }, + }, + EndpointHostname: kubernetesService, + }, + }, + }, + expected: kubernetesService, + }, + { + name: "route rule has priority over route", + btps: []*egv1a1.BackendTrafficPolicy{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "btp-route"}, + Spec: egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("HTTPRoute"), + Name: gwapiv1.ObjectName("route-1"), + }, + }, + }, + EndpointHostname: kubernetesService, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "btp-rule"}, + Spec: egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("HTTPRoute"), + Name: gwapiv1.ObjectName("route-1"), + }, + SectionName: ptr.To(gwapiv1.SectionName("rule-0")), + }, + }, + EndpointHostname: none, + }, + }, + }, + routeRuleName: ptr.To(gwapiv1.SectionName("rule-0")), + expected: none, + }, + { + name: "listener has priority over gateway", + btps: []*egv1a1.BackendTrafficPolicy{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "btp-gateway"}, + Spec: egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("Gateway"), + Name: gwapiv1.ObjectName("gateway-1"), + }, + }, + }, + EndpointHostname: none, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "btp-listener"}, + Spec: egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{ + Group: gwapiv1.Group("gateway.networking.k8s.io"), + Kind: gwapiv1.Kind("Gateway"), + Name: gwapiv1.ObjectName("gateway-1"), + }, + SectionName: ptr.To(gwapiv1.SectionName("http")), + }, + }, + EndpointHostname: kubernetesService, + }, + }, + }, + listenerName: ptr.To(gwapiv1.SectionName("http")), + expected: kubernetesService, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + idx := BuildBTPEndpointHostnameIndex(tt.btps, []client.Object{defaultHTTPRoute}, []*GatewayContext{defaultGateway}) + got := idx.LookupBTPEndpointHostname("HTTPRoute", routeNN, gatewayNN, tt.listenerName, tt.routeRuleName) + require.Equal(t, tt.expected, got) + }) + } +} diff --git a/internal/gatewayapi/contexts.go b/internal/gatewayapi/contexts.go index 38edbc3917..9e6ce232f0 100644 --- a/internal/gatewayapi/contexts.go +++ b/internal/gatewayapi/contexts.go @@ -850,15 +850,16 @@ type backendServiceKey struct { } type TranslatorContext struct { - NamespaceMap map[types.NamespacedName]*corev1.Namespace - ServiceMap map[types.NamespacedName]*corev1.Service - ServiceImportMap map[types.NamespacedName]*mcsapiv1a1.ServiceImport - BackendMap map[types.NamespacedName]*egv1a1.Backend - SecretMap map[types.NamespacedName]*corev1.Secret - ConfigMapMap map[types.NamespacedName]*corev1.ConfigMap - ClusterTrustBundleMap map[types.NamespacedName]*certificatesv1b1.ClusterTrustBundle - EndpointSliceMap map[backendServiceKey][]*discoveryv1.EndpointSlice - BTPRoutingTypeIndex *BTPRoutingTypeIndex + NamespaceMap map[types.NamespacedName]*corev1.Namespace + ServiceMap map[types.NamespacedName]*corev1.Service + ServiceImportMap map[types.NamespacedName]*mcsapiv1a1.ServiceImport + BackendMap map[types.NamespacedName]*egv1a1.Backend + SecretMap map[types.NamespacedName]*corev1.Secret + ConfigMapMap map[types.NamespacedName]*corev1.ConfigMap + ClusterTrustBundleMap map[types.NamespacedName]*certificatesv1b1.ClusterTrustBundle + EndpointSliceMap map[backendServiceKey][]*discoveryv1.EndpointSlice + BTPRoutingTypeIndex *BTPRoutingTypeIndex + BTPEndpointHostnameIndex *BTPEndpointHostnameIndex } func (t *TranslatorContext) GetNamespace(name string) *corev1.Namespace { diff --git a/internal/gatewayapi/ext_service.go b/internal/gatewayapi/ext_service.go index 2f5e6ccf72..610b923b5c 100644 --- a/internal/gatewayapi/ext_service.go +++ b/internal/gatewayapi/ext_service.go @@ -110,7 +110,7 @@ func (t *Translator) processExtServiceDestination( switch KindDerefOr(backendRef.Kind, resource.KindService) { case resource.KindService: - ds, err = t.processServiceDestinationSetting(settingName, backendRef.BackendObjectReference, backendNamespace, protocol, gtwCtx.envoyProxy, nil) + ds, err = t.processServiceDestinationSetting(settingName, backendRef.BackendObjectReference, backendNamespace, protocol, gtwCtx.envoyProxy, nil, nil) if err != nil { return nil, err } diff --git a/internal/gatewayapi/globalresources.go b/internal/gatewayapi/globalresources.go index 1000890724..3c6f31ae10 100644 --- a/internal/gatewayapi/globalresources.go +++ b/internal/gatewayapi/globalresources.go @@ -85,7 +85,7 @@ func (t *Translator) processServiceClusterForGateway(gateway *GatewayContext, re Namespace: NamespacePtr(svcCluster.Namespace), Port: PortNumPtr(svcCluster.Spec.Ports[0].Port), } - dst, err := t.processServiceDestinationSetting(irKey, bRef, svcCluster.Namespace, ir.AppProtocol(svcCluster.Spec.Ports[0].Protocol), resources.EnvoyProxyForGatewayClass, nil) + dst, err := t.processServiceDestinationSetting(irKey, bRef, svcCluster.Namespace, ir.AppProtocol(svcCluster.Spec.Ports[0].Protocol), resources.EnvoyProxyForGatewayClass, nil, nil) if err != nil { return "", nil } diff --git a/internal/gatewayapi/listener.go b/internal/gatewayapi/listener.go index 5861da7ad1..444d4deefb 100644 --- a/internal/gatewayapi/listener.go +++ b/internal/gatewayapi/listener.go @@ -1154,7 +1154,7 @@ func (t *Translator) processBackendRefsForTelemetry(name string, backendCluster if err := t.validateBackendRefService(ref.BackendObjectReference, ns, corev1.ProtocolTCP); err != nil { return nil, nil, err } - ds, err = t.processServiceDestinationSetting(name, ref.BackendObjectReference, ns, ir.TCP, envoyProxy, nil) + ds, err = t.processServiceDestinationSetting(name, ref.BackendObjectReference, ns, ir.TCP, envoyProxy, nil, nil) if err != nil { return nil, nil, err } diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go index 6adf8677bd..ad89c8f4d2 100644 --- a/internal/gatewayapi/route.go +++ b/internal/gatewayapi/route.go @@ -313,10 +313,6 @@ func (t *Translator) processHTTPRouteRules(httpRoute *HTTPRouteContext, parentRe backendRefNames[i] = fmt.Sprintf("%s/%s", backendNamespace, rule.BackendRefs[i].Name) } - if usesBackendHostRewrite(httpFiltersContext.URLRewrite) { - t.applyServiceBackendHostnames(allDs) - } - // process each IR route generated for this rule, and set its destination destination := &ir.RouteDestination{ Settings: allDs, @@ -1898,6 +1894,16 @@ func (t *Translator) processDestination(name string, backendRefContext BackendRe routeRuleName, ) } + var btpEndpointHostname *egv1a1.BackendEndpointHostname + if gatewayCtx != nil { + btpEndpointHostname = t.BTPEndpointHostnameIndex.LookupBTPEndpointHostname( + route.GetRouteType(), + types.NamespacedName{Namespace: route.GetNamespace(), Name: route.GetName()}, + types.NamespacedName{Namespace: gatewayCtx.GetNamespace(), Name: gatewayCtx.GetName()}, + parentRef.SectionName, + routeRuleName, + ) + } protocol := inspectAppProtocolByRouteKind(routeType) @@ -1927,7 +1933,7 @@ func (t *Translator) processDestination(name string, backendRefContext BackendRe return emptyDS, nil, err } case resource.KindService: - ds, err = t.processServiceDestinationSetting(name, backendRef.BackendObjectReference, backendNamespace, protocol, envoyProxy, btpRoutingType) + ds, err = t.processServiceDestinationSetting(name, backendRef.BackendObjectReference, backendNamespace, protocol, envoyProxy, btpRoutingType, btpEndpointHostname) if err != nil { return emptyDS, nil, err } @@ -1968,10 +1974,6 @@ func (t *Translator) processDestination(name string, backendRefContext BackendRe if filtersErr != nil { return emptyDS, nil, status.NewRouteStatusError(filtersErr, status.RouteReasonInvalidBackendFilters) } - if ds.Filters != nil && usesBackendHostRewrite(ds.Filters.URLRewrite) { - t.applyServiceBackendHostname(ds) - } - if err := validateDestinationSettings(ds, t.IsServiceRouting(envoyProxy, btpRoutingType), backendRef.Kind); err != nil { return emptyDS, nil, err } @@ -2000,40 +2002,6 @@ func validateDestinationSettings(destinationSettings *ir.DestinationSetting, isS return nil } -func usesBackendHostRewrite(urlRewrite *ir.URLRewrite) bool { - return urlRewrite != nil && urlRewrite.Host != nil && ptr.Deref(urlRewrite.Host.Backend, false) -} - -func (t *Translator) applyServiceBackendHostnames(settings []*ir.DestinationSetting) { - for _, setting := range settings { - t.applyServiceBackendHostname(setting) - } -} - -func (t *Translator) applyServiceBackendHostname(setting *ir.DestinationSetting) { - if setting == nil { - return - } - if setting.Metadata == nil { - return - } - - if setting.Metadata.Kind != resource.KindService { - return - } - if setting.Metadata.Name == "" || setting.Metadata.Namespace == "" { - return - } - - hostname := fmt.Sprintf("%s.%s.svc.%s", setting.Metadata.Name, setting.Metadata.Namespace, t.dnsDomain()) - for _, endpoint := range setting.Endpoints { - if endpoint == nil { - continue - } - endpoint.Hostname = ptr.To(hostname) - } -} - func (t *Translator) dnsDomain() string { if t.DNSDomain != "" { return t.DNSDomain @@ -2085,7 +2053,7 @@ func (t *Translator) processServiceImportDestinationSetting( useEndpointRouting := !t.IsServiceRouting(envoyProxy, btpRoutingType) || isHeadless if useEndpointRouting { endpointSlices := t.GetEndpointSlicesForBackend(backendNamespace, string(backendRef.Name), resource.KindServiceImport) - endpoints, addrType = getIREndpointsFromEndpointSlices(endpointSlices, servicePort.Name, getServicePortProtocol(servicePort.Protocol)) + endpoints, addrType = getIREndpointsFromEndpointSlices(endpointSlices, servicePort.Name, getServicePortProtocol(servicePort.Protocol), nil) if len(endpoints) == 0 { return nil, status.NewRouteStatusError( fmt.Errorf("no ready endpoints for the related ServiceImport %s/%s", backendNamespace, backendRef.Name), @@ -2116,6 +2084,7 @@ func (t *Translator) processServiceDestinationSetting( protocol ir.AppProtocol, envoyProxy *egv1a1.EnvoyProxy, btpRoutingType *egv1a1.RoutingType, + btpEndpointHostname *egv1a1.BackendEndpointHostname, ) (*ir.DestinationSetting, status.Error) { var ( endpoints []*ir.DestinationEndpoint @@ -2140,9 +2109,10 @@ func (t *Translator) processServiceDestinationSetting( // Route to endpoints by default, or if service routing is enabled but service is headless useEndpointRouting := !t.IsServiceRouting(envoyProxy, btpRoutingType) || isHeadless + endpointHostname := t.serviceEndpointHostname(service, btpEndpointHostname) if useEndpointRouting { endpointSlices := t.GetEndpointSlicesForBackend(backendNamespace, string(backendRef.Name), KindDerefOr(backendRef.Kind, resource.KindService)) - endpoints, addrType = getIREndpointsFromEndpointSlices(endpointSlices, servicePort.Name, getServicePortProtocol(servicePort.Protocol)) + endpoints, addrType = getIREndpointsFromEndpointSlices(endpointSlices, servicePort.Name, getServicePortProtocol(servicePort.Protocol), endpointHostname) if len(endpoints) == 0 { return nil, status.NewRouteStatusError( fmt.Errorf("no ready endpoints for the related Service %s/%s", backendNamespace, backendRef.Name), @@ -2151,7 +2121,7 @@ func (t *Translator) processServiceDestinationSetting( } } else { // Use Service ClusterIP routing - ep := ir.NewDestEndpoint(nil, service.Spec.ClusterIP, uint32(*backendRef.Port), false, nil) + ep := ir.NewDestEndpoint(endpointHostname, service.Spec.ClusterIP, uint32(*backendRef.Port), false, nil) endpoints = append(endpoints, ep) } @@ -2165,6 +2135,22 @@ func (t *Translator) processServiceDestinationSetting( }, nil } +func (t *Translator) serviceEndpointHostname(service *corev1.Service, endpointHostname *egv1a1.BackendEndpointHostname) *string { + if service == nil || endpointHostname == nil { + return nil + } + + switch endpointHostname.Type { + case egv1a1.BackendEndpointHostnameTypeKubernetesService: + if service.Name == "" || service.Namespace == "" { + return nil + } + return ptr.To(fmt.Sprintf("%s.%s.svc.%s", service.Name, service.Namespace, t.dnsDomain())) + default: + return nil + } +} + func getBackendFilters(routeType gwapiv1.Kind, backendRefContext BackendRefContext) (backendFilters any) { filters := backendRefContext.GetFilters() if filters == nil { @@ -2362,7 +2348,7 @@ func (t *Translator) processAllowedListenersForParentRefs( return relevantRoute } -func getIREndpointsFromEndpointSlices(endpointSlices []*discoveryv1.EndpointSlice, portName string, portProtocol corev1.Protocol) ([]*ir.DestinationEndpoint, *ir.DestinationAddressType) { +func getIREndpointsFromEndpointSlices(endpointSlices []*discoveryv1.EndpointSlice, portName string, portProtocol corev1.Protocol, endpointHostname *string) ([]*ir.DestinationEndpoint, *ir.DestinationAddressType) { var ( dstEndpoints []*ir.DestinationEndpoint dstAddrType *ir.DestinationAddressType @@ -2375,7 +2361,7 @@ func getIREndpointsFromEndpointSlices(endpointSlices []*discoveryv1.EndpointSlic } else { addrTypeMap[ir.IP]++ } - endpoints := getIREndpointsFromEndpointSlice(endpointSlice, portName, portProtocol) + endpoints := getIREndpointsFromEndpointSlice(endpointSlice, portName, portProtocol, endpointHostname) dstEndpoints = append(dstEndpoints, endpoints...) } @@ -2393,7 +2379,7 @@ func getIREndpointsFromEndpointSlices(endpointSlices []*discoveryv1.EndpointSlic return dstEndpoints, dstAddrType } -func getIREndpointsFromEndpointSlice(endpointSlice *discoveryv1.EndpointSlice, portName string, portProtocol corev1.Protocol) []*ir.DestinationEndpoint { +func getIREndpointsFromEndpointSlice(endpointSlice *discoveryv1.EndpointSlice, portName string, portProtocol corev1.Protocol, endpointHostname *string) []*ir.DestinationEndpoint { var endpoints []*ir.DestinationEndpoint for _, endpoint := range endpointSlice.Endpoints { for _, endpointPort := range endpointSlice.Ports { @@ -2415,7 +2401,7 @@ func getIREndpointsFromEndpointSlice(endpointSlice *discoveryv1.EndpointSlice, p } for _, address := range endpoint.Addresses { - ep := ir.NewDestEndpoint(nil, address, uint32(*endpointPort.Port), draining, endpoint.Zone) + ep := ir.NewDestEndpoint(endpointHostname, address, uint32(*endpointPort.Port), draining, endpoint.Zone) endpoints = append(endpoints, ep) } diff --git a/internal/gatewayapi/route_test.go b/internal/gatewayapi/route_test.go index 61fe342765..29dbcf9e73 100644 --- a/internal/gatewayapi/route_test.go +++ b/internal/gatewayapi/route_test.go @@ -13,6 +13,7 @@ import ( corev1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" "k8s.io/utils/ptr" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" @@ -211,7 +212,7 @@ func TestGetIREndpointsFromEndpointSlices(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - endpoints, addrType := getIREndpointsFromEndpointSlices(tt.endpointSlices, tt.portName, tt.portProtocol) + endpoints, addrType := getIREndpointsFromEndpointSlices(tt.endpointSlices, tt.portName, tt.portProtocol, nil) fmt.Printf("Test case: %s\n", tt.name) fmt.Printf("Number of endpoints: %d\n", len(endpoints)) @@ -450,7 +451,7 @@ func TestIsServiceHeadless(t *testing.T) { } } -func TestApplyServiceBackendHostname(t *testing.T) { +func TestServiceEndpointHostname(t *testing.T) { t.Run("build metadata infers service kind from typed object", func(t *testing.T) { service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service-1", Namespace: "default"}} @@ -462,131 +463,128 @@ func TestApplyServiceBackendHostname(t *testing.T) { require.Equal(t, "8080", metadata.SectionName) }) - t.Run("ignores missing metadata", func(t *testing.T) { + t.Run("nil setting returns nil", func(t *testing.T) { translator := &Translator{} - setting := &ir.DestinationSetting{Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}} + service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service-1", Namespace: "default"}} - translator.applyServiceBackendHostname(setting) + hostname := translator.serviceEndpointHostname(service, nil) - require.Nil(t, setting.Endpoints[0].Hostname) + require.Nil(t, hostname) }) - t.Run("uses default cluster domain", func(t *testing.T) { + t.Run("none type returns nil", func(t *testing.T) { translator := &Translator{} - setting := &ir.DestinationSetting{ - Metadata: &ir.ResourceMetadata{ - Kind: resource.KindService, - Name: "service-1", - Namespace: "default", - }, - Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}, + service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service-1", Namespace: "default"}} + setting := &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeNone, } - translator.applyServiceBackendHostname(setting) + hostname := translator.serviceEndpointHostname(service, setting) - require.Equal(t, ptr.To("service-1.default.svc.cluster.local"), setting.Endpoints[0].Hostname) + require.Nil(t, hostname) }) - t.Run("uses configured dns domain", func(t *testing.T) { - translator := &Translator{DNSDomain: "example.internal"} - setting := &ir.DestinationSetting{ - Metadata: &ir.ResourceMetadata{ - Kind: resource.KindService, - Name: "service-1", - Namespace: "default", - }, - Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}, + t.Run("kubernetes service uses default cluster domain", func(t *testing.T) { + translator := &Translator{} + service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service-1", Namespace: "default"}} + setting := &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeKubernetesService, } - translator.applyServiceBackendHostname(setting) + hostname := translator.serviceEndpointHostname(service, setting) - require.Equal(t, ptr.To("service-1.default.svc.example.internal"), setting.Endpoints[0].Hostname) + require.Equal(t, ptr.To("service-1.default.svc.cluster.local"), hostname) }) - t.Run("ignores missing name", func(t *testing.T) { - translator := &Translator{} - setting := &ir.DestinationSetting{ - Metadata: &ir.ResourceMetadata{ - Kind: resource.KindService, - Namespace: "default", - }, - Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}, + t.Run("kubernetes service uses configured dns domain", func(t *testing.T) { + translator := &Translator{DNSDomain: "example.internal"} + service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service-1", Namespace: "default"}} + setting := &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeKubernetesService, } - translator.applyServiceBackendHostname(setting) + hostname := translator.serviceEndpointHostname(service, setting) - require.Nil(t, setting.Endpoints[0].Hostname) + require.Equal(t, ptr.To("service-1.default.svc.example.internal"), hostname) }) - t.Run("ignores missing namespace", func(t *testing.T) { + t.Run("kubernetes service ignores missing service name", func(t *testing.T) { translator := &Translator{} - setting := &ir.DestinationSetting{ - Metadata: &ir.ResourceMetadata{ - Kind: resource.KindService, - Name: "service-1", - }, - Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}, + service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Namespace: "default"}} + setting := &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeKubernetesService, } - translator.applyServiceBackendHostname(setting) + hostname := translator.serviceEndpointHostname(service, setting) - require.Nil(t, setting.Endpoints[0].Hostname) + require.Nil(t, hostname) }) - t.Run("skips nil endpoints and updates valid endpoints", func(t *testing.T) { + t.Run("kubernetes service ignores missing service namespace", func(t *testing.T) { translator := &Translator{} - setting := &ir.DestinationSetting{ - Metadata: &ir.ResourceMetadata{ - Kind: resource.KindService, - Name: "service-1", - Namespace: "default", - }, - Endpoints: []*ir.DestinationEndpoint{nil, {Host: "10.0.0.1", Port: 8080}}, + service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service-1"}} + setting := &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeKubernetesService, } - translator.applyServiceBackendHostname(setting) + hostname := translator.serviceEndpointHostname(service, setting) - require.Equal(t, ptr.To("service-1.default.svc.cluster.local"), setting.Endpoints[1].Hostname) + require.Nil(t, hostname) }) - t.Run("ignores non-service backends", func(t *testing.T) { - translator := &Translator{} - setting := &ir.DestinationSetting{ - Metadata: &ir.ResourceMetadata{ - Kind: egv1a1.KindBackend, - Name: "backend-1", - Namespace: "default", - }, - Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}, - } - - translator.applyServiceBackendHostname(setting) - - require.Nil(t, setting.Endpoints[0].Hostname) + t.Run("endpoint slices use resolved hostname", func(t *testing.T) { + endpointSlices := []*discoveryv1.EndpointSlice{{ + AddressType: discoveryv1.AddressTypeIPv4, + Endpoints: []discoveryv1.Endpoint{{ + Addresses: []string{"10.0.0.1"}, + Conditions: discoveryv1.EndpointConditions{ + Ready: ptr.To(true), + }, + }}, + Ports: []discoveryv1.EndpointPort{{ + Name: ptr.To("http"), + Protocol: ptr.To(corev1.ProtocolTCP), + Port: ptr.To[int32](8080), + }}, + }} + + endpoints, _ := getIREndpointsFromEndpointSlices(endpointSlices, "http", corev1.ProtocolTCP, ptr.To("service-1.default.svc.cluster.local")) + + require.Len(t, endpoints, 1) + require.Equal(t, ptr.To("service-1.default.svc.cluster.local"), endpoints[0].Hostname) }) - t.Run("applies hostnames across settings slice", func(t *testing.T) { + t.Run("cluster ip endpoint uses resolved hostname", func(t *testing.T) { translator := &Translator{} - serviceSetting := &ir.DestinationSetting{ - Metadata: &ir.ResourceMetadata{ - Kind: resource.KindService, - Name: "service-1", - Namespace: "default", + port := int32(8080) + backendRef := gwapiv1.BackendObjectReference{ + Name: "service-1", + Port: ptr.To(gwapiv1.PortNumber(port)), + } + service := &corev1.Service{ + ObjectMeta: metav1.ObjectMeta{Name: "service-1", Namespace: "default"}, + Spec: corev1.ServiceSpec{ + ClusterIP: "10.0.0.1", + Ports: []corev1.ServicePort{{ + Name: "http", + Port: port, + }}, }, - Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1", Port: 8080}}, } - backendSetting := &ir.DestinationSetting{ - Metadata: &ir.ResourceMetadata{ - Kind: egv1a1.KindBackend, - Name: "backend-1", - Namespace: "default", + translator.TranslatorContext = &TranslatorContext{ + ServiceMap: map[types.NamespacedName]*corev1.Service{ + {Namespace: "default", Name: "service-1"}: service, }, - Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.2", Port: 8080}}, } + setting := &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeKubernetesService, + } + serviceRouting := egv1a1.ServiceRoutingType - translator.applyServiceBackendHostnames([]*ir.DestinationSetting{serviceSetting, backendSetting, nil}) + ds, err := translator.processServiceDestinationSetting("test", backendRef, "default", ir.HTTP, nil, &serviceRouting, setting) - require.Equal(t, ptr.To("service-1.default.svc.cluster.local"), serviceSetting.Endpoints[0].Hostname) - require.Nil(t, backendSetting.Endpoints[0].Hostname) + require.NoError(t, err) + require.Len(t, ds.Endpoints, 1) + require.Equal(t, ptr.To("service-1.default.svc.cluster.local"), ds.Endpoints[0].Hostname) }) } diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.in.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.in.yaml index f39c951e5f..b8782872e0 100644 --- a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.in.yaml +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.in.yaml @@ -145,3 +145,16 @@ httpFilters: urlRewrite: hostname: type: Backend +backendTrafficPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + name: endpoint-hostname + namespace: default + spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-2 + endpointHostname: + type: KubernetesService diff --git a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.out.yaml b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.out.yaml index 7adda14973..eefde6013b 100644 --- a/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.out.yaml +++ b/internal/gatewayapi/testdata/httproute-with-urlrewrite-hostname-filter.out.yaml @@ -1,3 +1,31 @@ +backendTrafficPolicies: +- apiVersion: gateway.envoyproxy.io/v1alpha1 + kind: BackendTrafficPolicy + metadata: + name: endpoint-hostname + namespace: default + spec: + endpointHostname: + type: KubernetesService + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: httproute-2 + status: + ancestors: + - ancestorRef: + group: gateway.networking.k8s.io + kind: Gateway + name: gateway-1 + namespace: envoy-gateway + sectionName: http + conditions: + - lastTransitionTime: null + message: Policy has been accepted. + reason: Accepted + status: "True" + type: Accepted + controllerName: gateway.envoyproxy.io/gatewayclass-controller gateways: - apiVersion: gateway.networking.k8s.io/v1 kind: Gateway @@ -381,11 +409,16 @@ xdsIR: kind: HTTPRoute name: httproute-2 namespace: default + policies: + - kind: BackendTrafficPolicy + name: endpoint-hostname + namespace: default name: httproute/default/httproute-2/rule/0/match/0/gateway_envoyproxy_io pathMatch: distinct: false name: "" prefix: /valid-backend + traffic: {} urlRewrite: host: backend: true diff --git a/internal/gatewayapi/translator.go b/internal/gatewayapi/translator.go index 8917be0c57..d9118aa72e 100644 --- a/internal/gatewayapi/translator.go +++ b/internal/gatewayapi/translator.go @@ -263,6 +263,14 @@ func (t *Translator) Translate(resources *resource.Resources) (*TranslateResult, acceptedGateways, ) } + t.BTPEndpointHostnameIndex = nil + if hasBTPEndpointHostname(resources.BackendTrafficPolicies) { + t.BTPEndpointHostnameIndex = BuildBTPEndpointHostnameIndex( + resources.BackendTrafficPolicies, + routesToObjects(resources), + acceptedGateways, + ) + } // Process ListenerSets and attach them to the relevant Gateways t.ProcessListenerSets(resources.ListenerSets, acceptedGateways) diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index a46c9b0f95..fa39f52df3 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -369,6 +369,35 @@ _Appears in:_ | `zone` | _string_ | false | | Zone defines the service zone of the backend endpoint. | +#### BackendEndpointHostname + + + +BackendEndpointHostname configures hostnames attached to backend endpoints. + +_Appears in:_ +- [BackendTrafficPolicySpec](#backendtrafficpolicyspec) + +| Field | Type | Required | Default | Description | +| --- | --- | --- | --- | --- | +| `type` | _[BackendEndpointHostnameType](#backendendpointhostnametype)_ | true | | Type determines how endpoint hostnames should be populated. | + + +#### BackendEndpointHostnameType + +_Underlying type:_ _string_ + +BackendEndpointHostnameType defines how endpoint hostnames should be populated. + +_Appears in:_ +- [BackendEndpointHostname](#backendendpointhostname) + +| Value | Description | +| ----- | ----------- | +| `None` | BackendEndpointHostnameTypeNone does not attach hostnames to backend endpoints.
| +| `KubernetesService` | BackendEndpointHostnameTypeKubernetesService uses the Kubernetes Service FQDN.
| + + #### BackendMetrics @@ -551,6 +580,7 @@ _Appears in:_ | `requestBuffer` | _[RequestBuffer](#requestbuffer)_ | false | | RequestBuffer allows the gateway to buffer and fully receive each request from a client before continuing to send the request
upstream to the backends. This can be helpful to shield your backend servers from slow clients, and also to enforce a maximum size per request
as any requests larger than the buffer size will be rejected.
This can have a negative performance impact so should only be enabled when necessary.
When enabling this option, you should also configure your connection buffer size to account for these request buffers. There will also be an
increase in memory usage for Envoy that should be accounted for in your deployment settings.
Request buffering is incompatible with streaming APIs and protocol upgrades such as gRPC streaming and WebSocket. Do not enable this option
on routes that need those protocols, because requests can hang instead of being forwarded upstream. | | `telemetry` | _[BackendTelemetry](#backendtelemetry)_ | false | | Telemetry configures the telemetry settings for the policy target (Gateway or xRoute).
This will override the telemetry settings in the EnvoyProxy resource. | | `routingType` | _[RoutingType](#routingtype)_ | false | | RoutingType can be set to "Service" to use the Service Cluster IP for routing to the backend,
or it can be set to "Endpoint" to use Endpoint routing.
When specified, this overrides the EnvoyProxy-level setting for the relevant targetRefs.
If not specified, the EnvoyProxy-level setting is used. | +| `endpointHostname` | _[BackendEndpointHostname](#backendendpointhostname)_ | false | | EndpointHostname configures the hostname value attached to backend endpoints.
If unset, no hostname is attached to Kubernetes Service endpoints. | #### BackendType diff --git a/test/cel-validation/backendtrafficpolicy_test.go b/test/cel-validation/backendtrafficpolicy_test.go index a731def5c4..ebfae077df 100644 --- a/test/cel-validation/backendtrafficpolicy_test.go +++ b/test/cel-validation/backendtrafficpolicy_test.go @@ -2956,6 +2956,34 @@ func TestBackendTrafficPolicyTarget(t *testing.T) { }, wantErrors: []string{"either compression or compressor can be set, not both"}, }, + { + desc: "valid endpoint hostname none", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{Group: "gateway.networking.k8s.io", Kind: "Gateway", Name: "eg"}, + }, + }, + EndpointHostname: &egv1a1.BackendEndpointHostname{Type: egv1a1.BackendEndpointHostnameTypeNone}, + } + }, + wantErrors: []string{}, + }, + { + desc: "valid endpoint hostname kubernetes service", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{Group: "gateway.networking.k8s.io", Kind: "Gateway", Name: "eg"}, + }, + }, + EndpointHostname: &egv1a1.BackendEndpointHostname{Type: egv1a1.BackendEndpointHostnameTypeKubernetesService}, + } + }, + wantErrors: []string{}, + }, } for _, tc := range cases { diff --git a/test/e2e/testdata/httproute-backend-endpoint-hostname.yaml b/test/e2e/testdata/httproute-backend-endpoint-hostname.yaml new file mode 100644 index 0000000000..3c0b8b4732 --- /dev/null +++ b/test/e2e/testdata/httproute-backend-endpoint-hostname.yaml @@ -0,0 +1,68 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: backend-endpoint-hostname-with-btp + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /backend-endpoint-hostname-with-btp + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: backend-endpoint-hostname-rewrite + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: backend-endpoint-hostname-without-btp + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /backend-endpoint-hostname-without-btp + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: backend-endpoint-hostname-rewrite + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: HTTPRouteFilter +metadata: + name: backend-endpoint-hostname-rewrite + namespace: gateway-conformance-infra +spec: + urlRewrite: + hostname: + type: Backend +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: backend-endpoint-hostname + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: backend-endpoint-hostname-with-btp + endpointHostname: + type: KubernetesService diff --git a/test/e2e/testdata/httproute-rewrite-host-custom-dnsdomain.yaml b/test/e2e/testdata/httproute-rewrite-host-custom-dnsdomain.yaml deleted file mode 100644 index f861f4952a..0000000000 --- a/test/e2e/testdata/httproute-rewrite-host-custom-dnsdomain.yaml +++ /dev/null @@ -1,32 +0,0 @@ -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: rewrite-host-custom-dnsdomain - namespace: gateway-conformance-infra -spec: - parentRefs: - - name: same-namespace - rules: - - matches: - - path: - type: PathPrefix - value: /backend-service-custom-dnsdomain - filters: - - type: ExtensionRef - extensionRef: - group: gateway.envoyproxy.io - kind: HTTPRouteFilter - name: backend-host-rewrite-custom-dnsdomain - backendRefs: - - name: infra-backend-v1 - port: 8080 ---- -apiVersion: gateway.envoyproxy.io/v1alpha1 -kind: HTTPRouteFilter -metadata: - name: backend-host-rewrite-custom-dnsdomain - namespace: gateway-conformance-infra -spec: - urlRewrite: - hostname: - type: Backend diff --git a/test/e2e/testdata/httproute-rewrite-host.yaml b/test/e2e/testdata/httproute-rewrite-host.yaml index 8ad0c083f3..29660d8462 100644 --- a/test/e2e/testdata/httproute-rewrite-host.yaml +++ b/test/e2e/testdata/httproute-rewrite-host.yaml @@ -34,33 +34,6 @@ spec: - group: gateway.envoyproxy.io kind: Backend name: backend-fqdn - - matches: - - path: - type: PathPrefix - value: /backend-service - filters: - - type: ExtensionRef - extensionRef: - group: gateway.envoyproxy.io - kind: HTTPRouteFilter - name: backend-host-rewrite - backendRefs: - - name: infra-backend-v1 - port: 8080 - - matches: - - path: - type: PathPrefix - value: /backend-service-2 - filters: - - type: ExtensionRef - extensionRef: - group: gateway.envoyproxy.io - kind: HTTPRouteFilter - name: backend-host-rewrite - backendRefs: - - name: infra-backend-v1 - kind: Service # make sure explict service kind also works - port: 8080 --- apiVersion: gateway.envoyproxy.io/v1alpha1 kind: HTTPRouteFilter diff --git a/test/e2e/tests/httproute_backend_endpoint_hostname.go b/test/e2e/tests/httproute_backend_endpoint_hostname.go new file mode 100644 index 0000000000..ef137f771f --- /dev/null +++ b/test/e2e/tests/httproute_backend_endpoint_hostname.go @@ -0,0 +1,75 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +//go:build e2e + +package tests + +import ( + "testing" + + "k8s.io/apimachinery/pkg/types" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" +) + +func init() { + ConformanceTests = append(ConformanceTests, HTTPRouteBackendEndpointHostname) +} + +var HTTPRouteBackendEndpointHostname = suite.ConformanceTest{ + ShortName: "HTTPRouteBackendEndpointHostname", + Description: "An HTTPRoute with backend host rewrite uses BackendTrafficPolicy endpoint hostnames for Service backends", + Manifests: []string{"testdata/httproute-backend-endpoint-hostname.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} + withBTPRouteNN := types.NamespacedName{Name: "backend-endpoint-hostname-with-btp", Namespace: ns} + withoutBTPRouteNN := types.NamespacedName{Name: "backend-endpoint-hostname-without-btp", Namespace: ns} + gwAddr := kubernetes.GatewayAndRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), &gwapiv1.HTTPRoute{}, false, withBTPRouteNN, withoutBTPRouteNN) + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, withBTPRouteNN, gwNN) + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, withoutBTPRouteNN, gwNN) + + testCases := []http.ExpectedResponse{ + { + Request: http.Request{ + Host: "example.com", + Path: "/backend-endpoint-hostname-with-btp", + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Path: "/backend-endpoint-hostname-with-btp", + Host: "infra-backend-v1.gateway-conformance-infra.svc.cluster.local", + }, + }, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{ + Host: "example.com", + Path: "/backend-endpoint-hostname-without-btp", + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Path: "/backend-endpoint-hostname-without-btp", + Host: "example.com", + }, + }, + Backend: "infra-backend-v1", + Namespace: ns, + }, + } + for i := range testCases { + tc := testCases[i] + t.Run(tc.GetTestCaseName(i), func(t *testing.T) { + t.Parallel() + http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, tc) + }) + } + }, +} diff --git a/test/e2e/tests/httproute_rewrite_host.go b/test/e2e/tests/httproute_rewrite_host.go index 243dfe8f27..49353e4afe 100644 --- a/test/e2e/tests/httproute_rewrite_host.go +++ b/test/e2e/tests/httproute_rewrite_host.go @@ -62,32 +62,6 @@ var HTTPRouteRewriteHostHeader = suite.ConformanceTest{ Backend: "infra-backend-v1", Namespace: ns, }, - { - Request: http.Request{ - Path: "/backend-service", - }, - ExpectedRequest: &http.ExpectedRequest{ - Request: http.Request{ - Path: "/backend-service", - Host: "infra-backend-v1.gateway-conformance-infra.svc.cluster.local", - }, - }, - Backend: "infra-backend-v1", - Namespace: ns, - }, - { - Request: http.Request{ - Path: "/backend-service-2", - }, - ExpectedRequest: &http.ExpectedRequest{ - Request: http.Request{ - Path: "/backend-service-2", - Host: "infra-backend-v1.gateway-conformance-infra.svc.cluster.local", - }, - }, - Backend: "infra-backend-v1", - Namespace: ns, - }, } for i := range testCases { // Declare tc here to avoid loop variable diff --git a/test/e2e/tests/httproute_rewrite_host_custom_dnsdomain.go b/test/e2e/tests/httproute_rewrite_host_custom_dnsdomain.go deleted file mode 100644 index d800da9f57..0000000000 --- a/test/e2e/tests/httproute_rewrite_host_custom_dnsdomain.go +++ /dev/null @@ -1,198 +0,0 @@ -// Copyright Envoy Gateway Authors -// SPDX-License-Identifier: Apache-2.0 -// The full text of the Apache license is available in the LICENSE file at -// the root of the repo. - -//go:build e2e - -package tests - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/stretchr/testify/require" - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/types" - gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" - "sigs.k8s.io/gateway-api/conformance/utils/http" - "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" - "sigs.k8s.io/gateway-api/conformance/utils/suite" -) - -const ( - customDNSDomainEnvName = "KUBERNETES_CLUSTER_DOMAIN" - customHostRewriteDomain = "example.internal" -) - -type deploymentEnvState struct { - Value string - Found bool -} - -func init() { - ConformanceTests = append(ConformanceTests, HTTPRouteRewriteHostHeaderCustomDNSDomain) -} - -var HTTPRouteRewriteHostHeaderCustomDNSDomain = suite.ConformanceTest{ - ShortName: "HTTPRouteRewriteHostHeaderCustomDNSDomain", - Description: "An HTTPRoute with backend host rewrite uses the configured DNS domain", - Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { - originalState := setEnvoyGatewayClusterDomain(t, suite, customHostRewriteDomain) - defer restoreEnvoyGatewayClusterDomain(t, suite, originalState) - - suite.Applier.MustApplyWithCleanup(t, suite.Client, suite.TimeoutConfig, "testdata/httproute-rewrite-host-custom-dnsdomain.yaml", true) - - ns := ConformanceInfraNamespace - routeNN := types.NamespacedName{Name: "rewrite-host-custom-dnsdomain", Namespace: ns} - gwNN := SameNamespaceGateway - gwAddr := kubernetes.GatewayAndRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), &gwapiv1.HTTPRoute{}, false, routeNN) - kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN) - - expectedResponse := http.ExpectedResponse{ - Request: http.Request{ - Path: "/backend-service-custom-dnsdomain", - }, - ExpectedRequest: &http.ExpectedRequest{ - Request: http.Request{ - Path: "/backend-service-custom-dnsdomain", - Host: fmt.Sprintf("infra-backend-v1.%s.svc.%s", ns, customHostRewriteDomain), - }, - }, - Backend: "infra-backend-v1", - Namespace: ns, - } - - http.MakeRequestAndExpectEventuallyConsistentResponse(t, suite.RoundTripper, suite.TimeoutConfig, gwAddr, expectedResponse) - }, -} - -func setEnvoyGatewayClusterDomain(t *testing.T, suite *suite.ConformanceTestSuite, value string) deploymentEnvState { - t.Helper() - - deploymentNN := types.NamespacedName{Name: "envoy-gateway", Namespace: "envoy-gateway-system"} - var originalState deploymentEnvState - - for i := 0; i < 5; i++ { - deployment := &appsv1.Deployment{} - err := suite.Client.Get(context.Background(), deploymentNN, deployment) - require.NoError(t, err) - - originalState = getDeploymentEnvState(deployment.Spec.Template.Spec.Containers[0].Env, customDNSDomainEnvName) - if originalState.Found && originalState.Value == value { - waitForEnvoyGatewayRollout(t, suite, deploymentNN, value) - return originalState - } - - upsertDeploymentEnv(&deployment.Spec.Template.Spec.Containers[0].Env, customDNSDomainEnvName, value) - err = suite.Client.Update(context.Background(), deployment) - if err == nil { - waitForEnvoyGatewayRollout(t, suite, deploymentNN, value) - return originalState - } - if !apierrors.IsConflict(err) { - require.NoError(t, err) - } - } - - t.Fatalf("failed to update %s on envoy-gateway deployment after retries", customDNSDomainEnvName) - return deploymentEnvState{} -} - -func restoreEnvoyGatewayClusterDomain(t *testing.T, suite *suite.ConformanceTestSuite, state deploymentEnvState) { - t.Helper() - - deploymentNN := types.NamespacedName{Name: "envoy-gateway", Namespace: "envoy-gateway-system"} - for i := 0; i < 5; i++ { - deployment := &appsv1.Deployment{} - err := suite.Client.Get(context.Background(), deploymentNN, deployment) - require.NoError(t, err) - - if state.Found { - upsertDeploymentEnv(&deployment.Spec.Template.Spec.Containers[0].Env, customDNSDomainEnvName, state.Value) - } else { - removeDeploymentEnv(&deployment.Spec.Template.Spec.Containers[0].Env, customDNSDomainEnvName) - } - - err = suite.Client.Update(context.Background(), deployment) - if err == nil { - expected := "" - if state.Found { - expected = state.Value - } - waitForEnvoyGatewayRollout(t, suite, deploymentNN, expected) - return - } - if !apierrors.IsConflict(err) { - require.NoError(t, err) - } - } - - t.Fatalf("failed to restore %s on envoy-gateway deployment after retries", customDNSDomainEnvName) -} - -func waitForEnvoyGatewayRollout(t *testing.T, suite *suite.ConformanceTestSuite, deploymentNN types.NamespacedName, expectedDNSDomain string) { - t.Helper() - - require.Eventually(t, func() bool { - deployment := &appsv1.Deployment{} - if err := suite.Client.Get(context.Background(), deploymentNN, deployment); err != nil { - return false - } - - envState := getDeploymentEnvState(deployment.Spec.Template.Spec.Containers[0].Env, customDNSDomainEnvName) - if expectedDNSDomain == "" { - if envState.Found { - return false - } - } else if !envState.Found || envState.Value != expectedDNSDomain { - return false - } - - replicas := int32(1) - if deployment.Spec.Replicas != nil { - replicas = *deployment.Spec.Replicas - } - - return deployment.Generation <= deployment.Status.ObservedGeneration && - deployment.Status.UpdatedReplicas == replicas && - deployment.Status.ReadyReplicas == replicas && - deployment.Status.AvailableReplicas == replicas - }, 2*time.Minute, 2*time.Second) - - WaitForPods(t, suite.Client, deploymentNN.Namespace, map[string]string{"control-plane": "envoy-gateway"}, corev1.PodRunning, &PodReady) -} - -func getDeploymentEnvState(envs []corev1.EnvVar, name string) deploymentEnvState { - for _, env := range envs { - if env.Name == name { - return deploymentEnvState{Value: env.Value, Found: true} - } - } - return deploymentEnvState{} -} - -func upsertDeploymentEnv(envs *[]corev1.EnvVar, name, value string) { - for i := range *envs { - if (*envs)[i].Name == name { - (*envs)[i].Value = value - (*envs)[i].ValueFrom = nil - return - } - } - *envs = append(*envs, corev1.EnvVar{Name: name, Value: value}) -} - -func removeDeploymentEnv(envs *[]corev1.EnvVar, name string) { - filtered := (*envs)[:0] - for _, env := range *envs { - if env.Name != name { - filtered = append(filtered, env) - } - } - *envs = filtered -} From 30aa2ebc3b2b24edd4eece285ed7c5b036a8c91d Mon Sep 17 00:00:00 2001 From: Teo Zhuo Yang Date: Fri, 8 May 2026 13:42:37 +0800 Subject: [PATCH 08/11] fix Signed-off-by: Teo Zhuo Yang --- internal/gatewayapi/backendtrafficpolicy_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/internal/gatewayapi/backendtrafficpolicy_test.go b/internal/gatewayapi/backendtrafficpolicy_test.go index 95fab8e7ab..81715d26eb 100644 --- a/internal/gatewayapi/backendtrafficpolicy_test.go +++ b/internal/gatewayapi/backendtrafficpolicy_test.go @@ -18,6 +18,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + "k8s.io/utils/ptr" + egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" "github.com/envoyproxy/gateway/internal/ir" ) From 648c03663971269fb1e36957ad06183f5722fdd4 Mon Sep 17 00:00:00 2001 From: Teo Zhuo Yang Date: Fri, 8 May 2026 16:50:43 +0800 Subject: [PATCH 09/11] add static type, fix test Signed-off-by: Teo Zhuo Yang --- api/v1alpha1/shared_types.go | 16 ++++- api/v1alpha1/zz_generated.deepcopy.go | 7 +- ....envoyproxy.io_backendtrafficpolicies.yaml | 14 ++++ ....envoyproxy.io_backendtrafficpolicies.yaml | 14 ++++ internal/gatewayapi/route.go | 9 ++- internal/gatewayapi/route_test.go | 41 ++++++++++- site/content/en/latest/api/extension_types.md | 2 + .../backendtrafficpolicy_test.go | 68 +++++++++++++++++++ .../httproute-backend-endpoint-hostname.yaml | 37 ++++++++++ .../httproute_backend_endpoint_hostname.go | 18 ++++- test/helm/gateway-crds-helm/all.out.yaml | 29 ++++++++ test/helm/gateway-crds-helm/e2e.out.yaml | 29 ++++++++ .../envoy-gateway-crds.out.yaml | 29 ++++++++ 13 files changed, 307 insertions(+), 6 deletions(-) diff --git a/api/v1alpha1/shared_types.go b/api/v1alpha1/shared_types.go index ef8755870d..b8848a286e 100644 --- a/api/v1alpha1/shared_types.go +++ b/api/v1alpha1/shared_types.go @@ -695,7 +695,7 @@ type ClusterSettings struct { // BackendEndpointHostnameType defines how endpoint hostnames should be populated. // -// +kubebuilder:validation:Enum=None;KubernetesService +// +kubebuilder:validation:Enum=None;KubernetesService;Static type BackendEndpointHostnameType string const ( @@ -703,14 +703,28 @@ const ( BackendEndpointHostnameTypeNone BackendEndpointHostnameType = "None" // BackendEndpointHostnameTypeKubernetesService uses the Kubernetes Service FQDN. BackendEndpointHostnameTypeKubernetesService BackendEndpointHostnameType = "KubernetesService" + // BackendEndpointHostnameTypeStatic uses a user-specified static hostname. + BackendEndpointHostnameTypeStatic BackendEndpointHostnameType = "Static" ) // BackendEndpointHostname configures hostnames attached to backend endpoints. +// +// +kubebuilder:validation:XValidation:message="hostname must be set when type is Static",rule="self.type == 'Static' ? has(self.hostname) : true" +// +kubebuilder:validation:XValidation:message="hostname must not be set when type is not Static",rule="self.type != 'Static' ? !has(self.hostname) : true" type BackendEndpointHostname struct { // Type determines how endpoint hostnames should be populated. // // +kubebuilder:validation:Required Type BackendEndpointHostnameType `json:"type"` + + // Hostname is a custom static hostname to attach to backend endpoints. + // This field is required when type is "Static" and must not be set for other types. + // + // +optional + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$` + Hostname *string `json:"hostname,omitempty"` } // CIDR defines a CIDR Address range. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 2f18bc06a8..86211adc43 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -504,6 +504,11 @@ func (in *BackendEndpoint) DeepCopy() *BackendEndpoint { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *BackendEndpointHostname) DeepCopyInto(out *BackendEndpointHostname) { *out = *in + if in.Hostname != nil { + in, out := &in.Hostname, &out.Hostname + *out = new(string) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendEndpointHostname. @@ -900,7 +905,7 @@ func (in *BackendTrafficPolicySpec) DeepCopyInto(out *BackendTrafficPolicySpec) if in.EndpointHostname != nil { in, out := &in.EndpointHostname, &out.EndpointHostname *out = new(BackendEndpointHostname) - **out = **in + (*in).DeepCopyInto(*out) } } diff --git a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index 21e628edec..6374f50b31 100644 --- a/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-crds-helm/templates/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -545,16 +545,30 @@ spec: EndpointHostname configures the hostname value attached to backend endpoints. If unset, no hostname is attached to Kubernetes Service endpoints. properties: + hostname: + description: |- + Hostname is a custom static hostname to attach to backend endpoints. + This field is required when type is "Static" and must not be set for other types. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string type: description: Type determines how endpoint hostnames should be populated. enum: - None - KubernetesService + - Static type: string required: - type type: object + x-kubernetes-validations: + - message: hostname must be set when type is Static + rule: 'self.type == ''Static'' ? has(self.hostname) : true' + - message: hostname must not be set when type is not Static + rule: 'self.type != ''Static'' ? !has(self.hostname) : true' faultInjection: description: |- FaultInjection defines the fault injection policy to be applied. This configuration can be used to diff --git a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml index 3d105605d2..88811a0db9 100644 --- a/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml +++ b/charts/gateway-helm/charts/crds/crds/generated/gateway.envoyproxy.io_backendtrafficpolicies.yaml @@ -544,16 +544,30 @@ spec: EndpointHostname configures the hostname value attached to backend endpoints. If unset, no hostname is attached to Kubernetes Service endpoints. properties: + hostname: + description: |- + Hostname is a custom static hostname to attach to backend endpoints. + This field is required when type is "Static" and must not be set for other types. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string type: description: Type determines how endpoint hostnames should be populated. enum: - None - KubernetesService + - Static type: string required: - type type: object + x-kubernetes-validations: + - message: hostname must be set when type is Static + rule: 'self.type == ''Static'' ? has(self.hostname) : true' + - message: hostname must not be set when type is not Static + rule: 'self.type != ''Static'' ? !has(self.hostname) : true' faultInjection: description: |- FaultInjection defines the fault injection policy to be applied. This configuration can be used to diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go index de7784f84c..9193ef0dca 100644 --- a/internal/gatewayapi/route.go +++ b/internal/gatewayapi/route.go @@ -2141,16 +2141,21 @@ func (t *Translator) processServiceDestinationSetting( } func (t *Translator) serviceEndpointHostname(service *corev1.Service, endpointHostname *egv1a1.BackendEndpointHostname) *string { - if service == nil || endpointHostname == nil { + if endpointHostname == nil { return nil } switch endpointHostname.Type { case egv1a1.BackendEndpointHostnameTypeKubernetesService: - if service.Name == "" || service.Namespace == "" { + if service == nil || service.Name == "" || service.Namespace == "" { return nil } return ptr.To(fmt.Sprintf("%s.%s.svc.%s", service.Name, service.Namespace, t.dnsDomain())) + case egv1a1.BackendEndpointHostnameTypeStatic: + if endpointHostname.Hostname == nil || *endpointHostname.Hostname == "" { + return nil + } + return endpointHostname.Hostname default: return nil } diff --git a/internal/gatewayapi/route_test.go b/internal/gatewayapi/route_test.go index 4213b60b45..ad3686d9e7 100644 --- a/internal/gatewayapi/route_test.go +++ b/internal/gatewayapi/route_test.go @@ -557,9 +557,10 @@ func TestServiceEndpointHostname(t *testing.T) { t.Run("cluster ip endpoint uses resolved hostname", func(t *testing.T) { translator := &Translator{} port := int32(8080) + portNum := gwapiv1.PortNumber(port) backendRef := gwapiv1.BackendObjectReference{ Name: "service-1", - Port: new(gwapiv1.PortNumber(port)), + Port: &portNum, } service := &corev1.Service{ ObjectMeta: metav1.ObjectMeta{Name: "service-1", Namespace: "default"}, @@ -587,4 +588,42 @@ func TestServiceEndpointHostname(t *testing.T) { require.Len(t, ds.Endpoints, 1) require.Equal(t, new("service-1.default.svc.cluster.local"), ds.Endpoints[0].Hostname) }) + + t.Run("static type returns specified hostname", func(t *testing.T) { + translator := &Translator{} + service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service-1", Namespace: "default"}} + setting := &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeStatic, + Hostname: new("custom-static.example.com"), + } + + hostname := translator.serviceEndpointHostname(service, setting) + + require.Equal(t, new("custom-static.example.com"), hostname) + }) + + t.Run("static type with nil hostname returns nil", func(t *testing.T) { + translator := &Translator{} + service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service-1", Namespace: "default"}} + setting := &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeStatic, + Hostname: nil, + } + + hostname := translator.serviceEndpointHostname(service, setting) + + require.Nil(t, hostname) + }) + + t.Run("static type ignores nil service", func(t *testing.T) { + translator := &Translator{} + setting := &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeStatic, + Hostname: new("custom-static.example.com"), + } + + hostname := translator.serviceEndpointHostname(nil, setting) + + require.Equal(t, new("custom-static.example.com"), hostname) + }) } diff --git a/site/content/en/latest/api/extension_types.md b/site/content/en/latest/api/extension_types.md index a4000c1705..e14d1e0e0c 100644 --- a/site/content/en/latest/api/extension_types.md +++ b/site/content/en/latest/api/extension_types.md @@ -422,6 +422,7 @@ _Appears in:_ | Field | Type | Required | Default | Description | | --- | --- | --- | --- | --- | | `type` | _[BackendEndpointHostnameType](#backendendpointhostnametype)_ | true | | Type determines how endpoint hostnames should be populated. | +| `hostname` | _string_ | false | | Hostname is a custom static hostname to attach to backend endpoints.
This field is required when type is "Static" and must not be set for other types. | #### BackendEndpointHostnameType @@ -437,6 +438,7 @@ _Appears in:_ | ----- | ----------- | | `None` | BackendEndpointHostnameTypeNone does not attach hostnames to backend endpoints.
| | `KubernetesService` | BackendEndpointHostnameTypeKubernetesService uses the Kubernetes Service FQDN.
| +| `Static` | BackendEndpointHostnameTypeStatic uses a user-specified static hostname.
| #### BackendMetrics diff --git a/test/cel-validation/backendtrafficpolicy_test.go b/test/cel-validation/backendtrafficpolicy_test.go index d96fb4dd5b..e84203d4fa 100644 --- a/test/cel-validation/backendtrafficpolicy_test.go +++ b/test/cel-validation/backendtrafficpolicy_test.go @@ -17,6 +17,7 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" @@ -3395,6 +3396,73 @@ func TestBackendTrafficPolicyTarget(t *testing.T) { }, wantErrors: []string{}, }, + { + desc: "valid endpoint hostname static with hostname", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{Group: "gateway.networking.k8s.io", Kind: "Gateway", Name: "eg"}, + }, + }, + EndpointHostname: &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeStatic, + Hostname: ptr.To("custom-static.example.com"), + }, + } + }, + wantErrors: []string{}, + }, + { + desc: "invalid endpoint hostname static without hostname", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{Group: "gateway.networking.k8s.io", Kind: "Gateway", Name: "eg"}, + }, + }, + EndpointHostname: &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeStatic, + }, + } + }, + wantErrors: []string{"hostname must be set when type is Static"}, + }, + { + desc: "invalid endpoint hostname none with hostname", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{Group: "gateway.networking.k8s.io", Kind: "Gateway", Name: "eg"}, + }, + }, + EndpointHostname: &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeNone, + Hostname: ptr.To("custom-static.example.com"), + }, + } + }, + wantErrors: []string{"hostname must not be set when type is not Static"}, + }, + { + desc: "invalid endpoint hostname kubernetes service with hostname", + mutate: func(btp *egv1a1.BackendTrafficPolicy) { + btp.Spec = egv1a1.BackendTrafficPolicySpec{ + PolicyTargetReferences: egv1a1.PolicyTargetReferences{ + TargetRef: &gwapiv1.LocalPolicyTargetReferenceWithSectionName{ + LocalPolicyTargetReference: gwapiv1.LocalPolicyTargetReference{Group: "gateway.networking.k8s.io", Kind: "Gateway", Name: "eg"}, + }, + }, + EndpointHostname: &egv1a1.BackendEndpointHostname{ + Type: egv1a1.BackendEndpointHostnameTypeKubernetesService, + Hostname: ptr.To("custom-static.example.com"), + }, + } + }, + wantErrors: []string{"hostname must not be set when type is not Static"}, + }, { desc: "valid bandwidthLimit with request and response set to different limits", mutate: func(btp *egv1a1.BackendTrafficPolicy) { diff --git a/test/e2e/testdata/httproute-backend-endpoint-hostname.yaml b/test/e2e/testdata/httproute-backend-endpoint-hostname.yaml index 3c0b8b4732..0cc81cedd1 100644 --- a/test/e2e/testdata/httproute-backend-endpoint-hostname.yaml +++ b/test/e2e/testdata/httproute-backend-endpoint-hostname.yaml @@ -66,3 +66,40 @@ spec: name: backend-endpoint-hostname-with-btp endpointHostname: type: KubernetesService +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: backend-endpoint-hostname-static + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: same-namespace + rules: + - matches: + - path: + type: PathPrefix + value: /backend-endpoint-hostname-static + filters: + - type: ExtensionRef + extensionRef: + group: gateway.envoyproxy.io + kind: HTTPRouteFilter + name: backend-endpoint-hostname-rewrite + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.envoyproxy.io/v1alpha1 +kind: BackendTrafficPolicy +metadata: + name: backend-endpoint-hostname-static + namespace: gateway-conformance-infra +spec: + targetRefs: + - group: gateway.networking.k8s.io + kind: HTTPRoute + name: backend-endpoint-hostname-static + endpointHostname: + type: Static + hostname: custom-static.example.com diff --git a/test/e2e/tests/httproute_backend_endpoint_hostname.go b/test/e2e/tests/httproute_backend_endpoint_hostname.go index ef137f771f..9f5f2e4118 100644 --- a/test/e2e/tests/httproute_backend_endpoint_hostname.go +++ b/test/e2e/tests/httproute_backend_endpoint_hostname.go @@ -30,9 +30,11 @@ var HTTPRouteBackendEndpointHostname = suite.ConformanceTest{ gwNN := types.NamespacedName{Name: "same-namespace", Namespace: ns} withBTPRouteNN := types.NamespacedName{Name: "backend-endpoint-hostname-with-btp", Namespace: ns} withoutBTPRouteNN := types.NamespacedName{Name: "backend-endpoint-hostname-without-btp", Namespace: ns} - gwAddr := kubernetes.GatewayAndRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), &gwapiv1.HTTPRoute{}, false, withBTPRouteNN, withoutBTPRouteNN) + staticBTPRouteNN := types.NamespacedName{Name: "backend-endpoint-hostname-static", Namespace: ns} + gwAddr := kubernetes.GatewayAndRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), &gwapiv1.HTTPRoute{}, false, withBTPRouteNN, withoutBTPRouteNN, staticBTPRouteNN) kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, withBTPRouteNN, gwNN) kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, withoutBTPRouteNN, gwNN) + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, staticBTPRouteNN, gwNN) testCases := []http.ExpectedResponse{ { @@ -63,6 +65,20 @@ var HTTPRouteBackendEndpointHostname = suite.ConformanceTest{ Backend: "infra-backend-v1", Namespace: ns, }, + { + Request: http.Request{ + Host: "example.com", + Path: "/backend-endpoint-hostname-static", + }, + ExpectedRequest: &http.ExpectedRequest{ + Request: http.Request{ + Path: "/backend-endpoint-hostname-static", + Host: "custom-static.example.com", + }, + }, + Backend: "infra-backend-v1", + Namespace: ns, + }, } for i := range testCases { tc := testCases[i] diff --git a/test/helm/gateway-crds-helm/all.out.yaml b/test/helm/gateway-crds-helm/all.out.yaml index 676b9a9a51..f5a06d2446 100644 --- a/test/helm/gateway-crds-helm/all.out.yaml +++ b/test/helm/gateway-crds-helm/all.out.yaml @@ -23067,6 +23067,35 @@ spec: Defaults to true. type: boolean type: object + endpointHostname: + description: |- + EndpointHostname configures the hostname value attached to backend endpoints. + If unset, no hostname is attached to Kubernetes Service endpoints. + properties: + hostname: + description: |- + Hostname is a custom static hostname to attach to backend endpoints. + This field is required when type is "Static" and must not be set for other types. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + type: + description: Type determines how endpoint hostnames should be + populated. + enum: + - None + - KubernetesService + - Static + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: hostname must be set when type is Static + rule: 'self.type == ''Static'' ? has(self.hostname) : true' + - message: hostname must not be set when type is not Static + rule: 'self.type != ''Static'' ? !has(self.hostname) : true' faultInjection: description: |- FaultInjection defines the fault injection policy to be applied. This configuration can be used to diff --git a/test/helm/gateway-crds-helm/e2e.out.yaml b/test/helm/gateway-crds-helm/e2e.out.yaml index e5e4437175..03ac2bff45 100644 --- a/test/helm/gateway-crds-helm/e2e.out.yaml +++ b/test/helm/gateway-crds-helm/e2e.out.yaml @@ -1040,6 +1040,35 @@ spec: Defaults to true. type: boolean type: object + endpointHostname: + description: |- + EndpointHostname configures the hostname value attached to backend endpoints. + If unset, no hostname is attached to Kubernetes Service endpoints. + properties: + hostname: + description: |- + Hostname is a custom static hostname to attach to backend endpoints. + This field is required when type is "Static" and must not be set for other types. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + type: + description: Type determines how endpoint hostnames should be + populated. + enum: + - None + - KubernetesService + - Static + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: hostname must be set when type is Static + rule: 'self.type == ''Static'' ? has(self.hostname) : true' + - message: hostname must not be set when type is not Static + rule: 'self.type != ''Static'' ? !has(self.hostname) : true' faultInjection: description: |- FaultInjection defines the fault injection policy to be applied. This configuration can be used to diff --git a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml index d21bbcbfd0..573dc1167c 100644 --- a/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml +++ b/test/helm/gateway-crds-helm/envoy-gateway-crds.out.yaml @@ -1040,6 +1040,35 @@ spec: Defaults to true. type: boolean type: object + endpointHostname: + description: |- + EndpointHostname configures the hostname value attached to backend endpoints. + If unset, no hostname is attached to Kubernetes Service endpoints. + properties: + hostname: + description: |- + Hostname is a custom static hostname to attach to backend endpoints. + This field is required when type is "Static" and must not be set for other types. + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + type: + description: Type determines how endpoint hostnames should be + populated. + enum: + - None + - KubernetesService + - Static + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: hostname must be set when type is Static + rule: 'self.type == ''Static'' ? has(self.hostname) : true' + - message: hostname must not be set when type is not Static + rule: 'self.type != ''Static'' ? !has(self.hostname) : true' faultInjection: description: |- FaultInjection defines the fault injection policy to be applied. This configuration can be used to From a20f01eb3075f1ea7fc12977b90f4d4185c572d1 Mon Sep 17 00:00:00 2001 From: Teo Zhuo Yang Date: Fri, 8 May 2026 17:14:34 +0800 Subject: [PATCH 10/11] revert: drop unrelated Service kind defaulting in buildResourceMetadata The Service GVK fallback and its associated tests are unrelated to the endpointHostname BTP feature introduced in this PR. Signed-off-by: Teo Zhuo Yang --- internal/gatewayapi/listener_test.go | 2 +- internal/gatewayapi/route.go | 15 +++++---------- internal/gatewayapi/route_test.go | 11 ----------- 3 files changed, 6 insertions(+), 22 deletions(-) diff --git a/internal/gatewayapi/listener_test.go b/internal/gatewayapi/listener_test.go index 8ff19ef431..ca753c1bbf 100644 --- a/internal/gatewayapi/listener_test.go +++ b/internal/gatewayapi/listener_test.go @@ -1432,7 +1432,7 @@ func TestProcessBackendRefsBackendTLSPolicy(t *testing.T) { }, } serviceEndpoints := []*ir.DestinationEndpoint{{Host: "7.7.7.7", Port: 4317}} - serviceMetadata := &ir.ResourceMetadata{Kind: resource.KindService, Name: serviceName, Namespace: ns, SectionName: "4317"} + serviceMetadata := &ir.ResourceMetadata{Name: serviceName, Namespace: ns, SectionName: "4317"} servicePolicyTLS := &ir.TLSUpstreamConfig{ SNI: new("otel-svc.example.com"), UseSystemTrustStore: true, CACertificate: &ir.TLSCACertificate{Name: "otel-svc-tls/test-ns-ca"}, SubjectAltNames: []ir.SubjectAltName{}, diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go index 9193ef0dca..1abf8d4f65 100644 --- a/internal/gatewayapi/route.go +++ b/internal/gatewayapi/route.go @@ -1327,17 +1327,12 @@ func (t *Translator) processHTTPRouteParentRefListener(route RouteContext, route return hasHostnameIntersection } -func buildResourceMetadata(obj client.Object, sectionName *gwapiv1.SectionName) *ir.ResourceMetadata { - kind := obj.GetObjectKind().GroupVersionKind().Kind - if _, ok := obj.(*corev1.Service); ok && kind == "" { - kind = resource.KindService - } - +func buildResourceMetadata(resource client.Object, sectionName *gwapiv1.SectionName) *ir.ResourceMetadata { metadata := &ir.ResourceMetadata{ - Kind: kind, - Name: obj.GetName(), - Namespace: obj.GetNamespace(), - Annotations: ir.MapToSlice(filterEGPrefix(obj.GetAnnotations())), + Kind: resource.GetObjectKind().GroupVersionKind().Kind, + Name: resource.GetName(), + Namespace: resource.GetNamespace(), + Annotations: ir.MapToSlice(filterEGPrefix(resource.GetAnnotations())), } if sectionName != nil { metadata.SectionName = string(*sectionName) diff --git a/internal/gatewayapi/route_test.go b/internal/gatewayapi/route_test.go index ad3686d9e7..71e57b44ca 100644 --- a/internal/gatewayapi/route_test.go +++ b/internal/gatewayapi/route_test.go @@ -452,17 +452,6 @@ func TestIsServiceHeadless(t *testing.T) { } func TestServiceEndpointHostname(t *testing.T) { - t.Run("build metadata infers service kind from typed object", func(t *testing.T) { - service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service-1", Namespace: "default"}} - - metadata := buildResourceMetadata(service, new(gwapiv1.SectionName("8080"))) - - require.Equal(t, resource.KindService, metadata.Kind) - require.Equal(t, "service-1", metadata.Name) - require.Equal(t, "default", metadata.Namespace) - require.Equal(t, "8080", metadata.SectionName) - }) - t.Run("nil setting returns nil", func(t *testing.T) { translator := &Translator{} service := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: "service-1", Namespace: "default"}} From d1f621b0dd8c27fe9e354e6b23c5f61e6888dd7a Mon Sep 17 00:00:00 2001 From: Teo Zhuo Yang Date: Fri, 8 May 2026 17:44:43 +0800 Subject: [PATCH 11/11] fix lint and remove ptr Signed-off-by: Teo Zhuo Yang --- internal/gatewayapi/backendtrafficpolicy_test.go | 10 ++++------ internal/gatewayapi/route.go | 2 +- internal/gatewayapi/route_test.go | 9 ++++----- test/cel-validation/backendtrafficpolicy_test.go | 7 +++---- 4 files changed, 12 insertions(+), 16 deletions(-) diff --git a/internal/gatewayapi/backendtrafficpolicy_test.go b/internal/gatewayapi/backendtrafficpolicy_test.go index 81715d26eb..56a0cf75b4 100644 --- a/internal/gatewayapi/backendtrafficpolicy_test.go +++ b/internal/gatewayapi/backendtrafficpolicy_test.go @@ -18,8 +18,6 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" - "k8s.io/utils/ptr" - egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" "github.com/envoyproxy/gateway/internal/ir" ) @@ -1853,14 +1851,14 @@ func TestBTPEndpointHostnameIndex(t *testing.T) { Kind: gwapiv1.Kind("HTTPRoute"), Name: gwapiv1.ObjectName("route-1"), }, - SectionName: ptr.To(gwapiv1.SectionName("rule-0")), + SectionName: new(gwapiv1.SectionName("rule-0")), }, }, EndpointHostname: none, }, }, }, - routeRuleName: ptr.To(gwapiv1.SectionName("rule-0")), + routeRuleName: new(gwapiv1.SectionName("rule-0")), expected: none, }, { @@ -1891,14 +1889,14 @@ func TestBTPEndpointHostnameIndex(t *testing.T) { Kind: gwapiv1.Kind("Gateway"), Name: gwapiv1.ObjectName("gateway-1"), }, - SectionName: ptr.To(gwapiv1.SectionName("http")), + SectionName: new(gwapiv1.SectionName("http")), }, }, EndpointHostname: kubernetesService, }, }, }, - listenerName: ptr.To(gwapiv1.SectionName("http")), + listenerName: new(gwapiv1.SectionName("http")), expected: kubernetesService, }, } diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go index 1abf8d4f65..1b51490f59 100644 --- a/internal/gatewayapi/route.go +++ b/internal/gatewayapi/route.go @@ -2145,7 +2145,7 @@ func (t *Translator) serviceEndpointHostname(service *corev1.Service, endpointHo if service == nil || service.Name == "" || service.Namespace == "" { return nil } - return ptr.To(fmt.Sprintf("%s.%s.svc.%s", service.Name, service.Namespace, t.dnsDomain())) + return new(fmt.Sprintf("%s.%s.svc.%s", service.Name, service.Namespace, t.dnsDomain())) case egv1a1.BackendEndpointHostnameTypeStatic: if endpointHostname.Hostname == nil || *endpointHostname.Hostname == "" { return nil diff --git a/internal/gatewayapi/route_test.go b/internal/gatewayapi/route_test.go index 71e57b44ca..22592b4535 100644 --- a/internal/gatewayapi/route_test.go +++ b/internal/gatewayapi/route_test.go @@ -14,7 +14,6 @@ import ( discoveryv1 "k8s.io/api/discovery/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" - gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" egv1a1 "github.com/envoyproxy/gateway/api/v1alpha1" @@ -531,9 +530,9 @@ func TestServiceEndpointHostname(t *testing.T) { }, }}, Ports: []discoveryv1.EndpointPort{{ - Name: new("http"), - Protocol: new(corev1.ProtocolTCP), - Port: new(int32(8080)), + Name: new("http"), + Protocol: new(corev1.ProtocolTCP), + Port: new(int32(8080)), }}, }} @@ -546,7 +545,7 @@ func TestServiceEndpointHostname(t *testing.T) { t.Run("cluster ip endpoint uses resolved hostname", func(t *testing.T) { translator := &Translator{} port := int32(8080) - portNum := gwapiv1.PortNumber(port) + portNum := port backendRef := gwapiv1.BackendObjectReference{ Name: "service-1", Port: &portNum, diff --git a/test/cel-validation/backendtrafficpolicy_test.go b/test/cel-validation/backendtrafficpolicy_test.go index e84203d4fa..4f5c4a6c3e 100644 --- a/test/cel-validation/backendtrafficpolicy_test.go +++ b/test/cel-validation/backendtrafficpolicy_test.go @@ -17,7 +17,6 @@ import ( apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/utils/ptr" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" @@ -3407,7 +3406,7 @@ func TestBackendTrafficPolicyTarget(t *testing.T) { }, EndpointHostname: &egv1a1.BackendEndpointHostname{ Type: egv1a1.BackendEndpointHostnameTypeStatic, - Hostname: ptr.To("custom-static.example.com"), + Hostname: new("custom-static.example.com"), }, } }, @@ -3440,7 +3439,7 @@ func TestBackendTrafficPolicyTarget(t *testing.T) { }, EndpointHostname: &egv1a1.BackendEndpointHostname{ Type: egv1a1.BackendEndpointHostnameTypeNone, - Hostname: ptr.To("custom-static.example.com"), + Hostname: new("custom-static.example.com"), }, } }, @@ -3457,7 +3456,7 @@ func TestBackendTrafficPolicyTarget(t *testing.T) { }, EndpointHostname: &egv1a1.BackendEndpointHostname{ Type: egv1a1.BackendEndpointHostnameTypeKubernetesService, - Hostname: ptr.To("custom-static.example.com"), + Hostname: new("custom-static.example.com"), }, } },