Repository: envoyproxy/gateway

Issue:
xDS Updates are failing with Ray Services
*Description*:

We are using the kuberay to create Ray Clusters and expose the inbuilt dashboard at port 8265 using an HTTPRoute and 8266 for GRPCRoute.
 
The operator creates the k8s services bound to those ports when using the RayCluster CRD. The routes are something we are creating, we also bind it a Gateway resource just for Ray clusters and have merged gateways enabled.

When I dump the addresses that the Gateway pod sees, the ray service gets this data

```json
"destination": {
  "name": "httproute/ray-clusters/simple-dashboard/rule/0",
  "settings": [
    {
      "weight": 1,
      "protocol": "HTTP",
      "endpoints": [{ "host": "None", "port": 8265 }],
      "ipFamily": "IPv4"
    }
  ]
},
```

That None in there is failing the validation and as a result all further xDS updates are cancelled. 

We are not creating any Backend resources manually or any other resources.

It would be nice if this was logged and a metric emitted that some resources are not being updated vs failing the xDS send as it requires a lot of introspection to find that xDS updates have stopped going.

*Environment*:

Using 1.3.2 with Envoy 1.33.2

*Logs*:

```
2025-04-10T10:28:47.291Z        INFO    infrastructure  runner/runner.go:92     received an update      {"runner": "infrastructure"}
2025-04-10T10:28:47.291Z        ERROR   gateway-api     runner/runner.go:190    unable to validate xds ir, skipped sending it   {"runner": "gateway-api", "error": "field Address must be a valid IP or FQDN address\nfield Address must be a valid IP or FQDN address"}
2025-04-10T10:28:47.292Z        ERROR   watchable       message/watchutil.go:80 observed an error       {"runner": "gateway-api", "error": "field Address must be a valid IP or FQDN address\nfield Address must be a valid IP or FQDN address"
```

Hints from issue comments:
@rajatvig: __Update__

It is related to setting `routingType` to `Service` on the EnvoyProxy CRD. Setting it to `Endpoint` fixes the issue.@arkodg: is this a headless service with ClusterIP set to `None` ?
if so we can fix this by rejecting this in the gateway-api layer with a status

there should be metrics around xds translation failure for this case@rajatvig: Yes, these are `clusterIP: None` type services. Thanks for the hint on metrics for xds, will wire in alerts for this. @ShinyaIshitobi: I’m new here and this will be my first contribution.
I’d like to work on this, if that's alright.@arkodg: thanks @ShinyaIshitobi for picking this one up
https://github.com/envoyproxy/gateway/blob/527385328d14a9498bdd6500472e4ca48d554647/internal/gatewayapi/route.go#L1493 logic will need to be checked to make sure address is valid, else we need to push a status 

Solution patch:
diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go
index 9fbbdbe88..539edbf51 100644
--- a/internal/gatewayapi/route.go
+++ b/internal/gatewayapi/route.go
@@ -1401,6 +1401,11 @@ func validateDestinationSettings(destinationSettings *ir.DestinationSetting, end
 				status.RouteReasonUnsupportedAddressType)
 		}
 	case resource.KindService, resource.KindServiceImport:
+		if endpointRoutingDisabled && isHeadlessService(destinationSettings) {
+			return status.NewRouteStatusError(
+				fmt.Errorf("service %s is a headless Service, please set routingType=Endpoint", destinationSettings.Name),
+				status.RouteReasonUnsupportedSetting)
+		}
 		if !endpointRoutingDisabled && destinationSettings.AddressType != nil && *destinationSettings.AddressType == ir.MIXED {
 			return status.NewRouteStatusError(
 				fmt.Errorf("mixed endpointslice address type for the same backendRef is not supported"),
@@ -1411,6 +1416,17 @@ func validateDestinationSettings(destinationSettings *ir.DestinationSetting, end
 	return nil
 }
 
+// isHeadlessService reports true when any DestinationEndpoint corresponds to
+// a headless Kubernetes Service (ClusterIP="None").
+func isHeadlessService(ds *ir.DestinationSetting) bool {
+	for _, ep := range ds.Endpoints {
+		if ep.Host == "None" {
+			return true
+		}
+	}
+	return false
+}
+
 func (t *Translator) processServiceImportDestinationSetting(
 	name string,
 	backendRef gwapiv1.BackendObjectReference,
diff --git a/site/content/en/v1.3/tasks/operations/standalone-deployment-mode.md b/site/content/en/v1.3/tasks/operations/standalone-deployment-mode.md
index 102ad3323..1adbe723c 100644
--- a/site/content/en/v1.3/tasks/operations/standalone-deployment-mode.md
+++ b/site/content/en/v1.3/tasks/operations/standalone-deployment-mode.md
@@ -31,14 +31,14 @@ Create a local directory just for testing:
 mkdir -p /tmp/envoy-gateway-test
 ```
 
-As we do not provide the Envoy Gateway binary in latest release,
-you can compile this binary on your own from project by using command:
+You can download the Envoy Gateway binary from [Github release](https://github.com/envoyproxy/gateway/releases/tag/{{< yaml-version >}}),
+or compile the binary on your own from project by using command:
 
 ```shell
 make build
 ```
 
-The compiled binary lies in `bin/{os}/{arch}/envoy-gateway`.
+The compiled binary lies in `{project-home}/bin/{os}/{arch}/envoy-gateway`.
 
 ### Create Certificates
 



Test patch:
diff --git a/internal/gatewayapi/route_test.go b/internal/gatewayapi/route_test.go
index 763450397..131e67879 100644
--- a/internal/gatewayapi/route_test.go
+++ b/internal/gatewayapi/route_test.go
@@ -14,7 +14,10 @@ import (
 	discoveryv1 "k8s.io/api/discovery/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/utils/ptr"
+	gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
 
+	"github.com/envoyproxy/gateway/internal/gatewayapi/resource"
+	"github.com/envoyproxy/gateway/internal/gatewayapi/status"
 	"github.com/envoyproxy/gateway/internal/ir"
 )
 
@@ -226,3 +229,98 @@ func TestGetIREndpointsFromEndpointSlices(t *testing.T) {
 		})
 	}
 }
+
+func TestValidateDestinationSettings(t *testing.T) {
+	svcKind := gwapiv1.Kind(resource.KindService)
+
+	tests := []struct {
+		name                    string
+		ds                      *ir.DestinationSetting
+		endpointRoutingDisabled bool
+		kind                    *gwapiv1.Kind
+		wantErr                 bool
+		wantReason              gwapiv1.RouteConditionReason
+	}{
+		{
+			name: "headless service rejected when endpointRoutingDisabled=true",
+			ds: &ir.DestinationSetting{
+				Name:      "headless",
+				Endpoints: []*ir.DestinationEndpoint{{Host: "None"}},
+			},
+			endpointRoutingDisabled: true,
+			kind:                    &svcKind,
+			wantErr:                 true,
+			wantReason:              status.RouteReasonUnsupportedSetting,
+		},
+		{
+			name: "normal service allowed with ClusterIP routing",
+			ds: &ir.DestinationSetting{
+				Name:      "normal",
+				Endpoints: []*ir.DestinationEndpoint{{Host: "10.0.0.1"}},
+			},
+			endpointRoutingDisabled: true,
+			kind:                    &svcKind,
+			wantErr:                 false,
+		},
+		{
+			name: "mixed address type rejected when EndpointSlice routing",
+			ds: &ir.DestinationSetting{
+				Name:        "mixed",
+				Endpoints:   []*ir.DestinationEndpoint{{Host: "10.0.0.1"}},
+				AddressType: ptr.To(ir.MIXED),
+			},
+			endpointRoutingDisabled: false,
+			kind:                    &svcKind,
+			wantErr:                 true,
+			wantReason:              status.RouteReasonUnsupportedAddressType,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			err := validateDestinationSettings(tt.ds, tt.endpointRoutingDisabled, tt.kind)
+			if tt.wantErr {
+				require.Error(t, err)
+				require.Equal(t, tt.wantReason, err.Reason())
+			} else {
+				require.NoError(t, err)
+			}
+		})
+	}
+}
+
+func TestIsHeadlessService(t *testing.T) {
+	tests := []struct {
+		name      string
+		endpoints []*ir.DestinationEndpoint
+		want      bool
+	}{
+		{
+			name: "headless Service",
+			endpoints: []*ir.DestinationEndpoint{
+				{Host: "None"},
+			},
+			want: true,
+		},
+		{
+			name: "non headless Service with valid ClusterIP",
+			endpoints: []*ir.DestinationEndpoint{
+				{Host: "10.0.0.1"},
+			},
+			want: false,
+		},
+		{
+			name:      "empty slice",
+			endpoints: nil,
+			want:      false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			ds := &ir.DestinationSetting{Endpoints: tt.endpoints}
+			got := isHeadlessService(ds)
+			require.Equal(t, tt.want, got)
+		})
+	}
+}
diff --git a/internal/gatewayapi/testdata/httproute-with-headless-service-endpoint-routing.in.yaml b/internal/gatewayapi/testdata/httproute-with-headless-service-endpoint-routing.in.yaml
new file mode 100644
index 000000000..f8894d999
--- /dev/null
+++ b/internal/gatewayapi/testdata/httproute-with-headless-service-endpoint-routing.in.yaml
@@ -0,0 +1,47 @@
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    namespace: envoy-gateway
+    name: gateway-1
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - name: http
+      protocol: HTTP
+      port: 80
+      allowedRoutes:
+        namespaces:
+          from: All
+httpRoutes:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: HTTPRoute
+  metadata:
+    namespace: default
+    name: httproute-headless
+  spec:
+    parentRefs:
+    - name: gateway-1
+      namespace: envoy-gateway
+    rules:
+    - backendRefs:
+      - name: headless-service
+        port: 80
+services:
+- apiVersion: v1
+  kind: Service
+  metadata:
+    namespace: default
+    name: headless-service
+  spec:
+    clusterIP: None
+    ports:
+    - port: 80
+envoyProxyForGatewayClass:
+  apiVersion: gateway.envoyproxy.io/v1alpha1
+  kind: EnvoyProxy
+  metadata:
+    name: default
+    namespace: default
+  spec:
+    routingType: Endpoint
diff --git a/internal/gatewayapi/testdata/httproute-with-headless-service-endpoint-routing.out.yaml b/internal/gatewayapi/testdata/httproute-with-headless-service-endpoint-routing.out.yaml
new file mode 100644
index 000000000..062854db6
--- /dev/null
+++ b/internal/gatewayapi/testdata/httproute-with-headless-service-endpoint-routing.out.yaml
@@ -0,0 +1,135 @@
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    creationTimestamp: null
+    name: gateway-1
+    namespace: envoy-gateway
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - allowedRoutes:
+        namespaces:
+          from: All
+      name: http
+      port: 80
+      protocol: HTTP
+  status:
+    listeners:
+    - attachedRoutes: 1
+      conditions:
+      - lastTransitionTime: null
+        message: Sending translated listener configuration to the data plane
+        reason: Programmed
+        status: "True"
+        type: Programmed
+      - lastTransitionTime: null
+        message: Listener has been successfully translated
+        reason: Accepted
+        status: "True"
+        type: Accepted
+      - lastTransitionTime: null
+        message: Listener references have been resolved
+        reason: ResolvedRefs
+        status: "True"
+        type: ResolvedRefs
+      name: http
+      supportedKinds:
+      - group: gateway.networking.k8s.io
+        kind: HTTPRoute
+      - group: gateway.networking.k8s.io
+        kind: GRPCRoute
+httpRoutes:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: HTTPRoute
+  metadata:
+    creationTimestamp: null
+    name: httproute-headless
+    namespace: default
+  spec:
+    parentRefs:
+    - name: gateway-1
+      namespace: envoy-gateway
+    rules:
+    - backendRefs:
+      - name: headless-service
+        port: 80
+  status:
+    parents:
+    - conditions:
+      - lastTransitionTime: null
+        message: Route is accepted
+        reason: Accepted
+        status: "True"
+        type: Accepted
+      - lastTransitionTime: null
+        message: Resolved all the Object references for the Route
+        reason: ResolvedRefs
+        status: "True"
+        type: ResolvedRefs
+      controllerName: gateway.envoyproxy.io/gatewayclass-controller
+      parentRef:
+        name: gateway-1
+        namespace: envoy-gateway
+infraIR:
+  envoy-gateway/gateway-1:
+    proxy:
+      config:
+        apiVersion: gateway.envoyproxy.io/v1alpha1
+        kind: EnvoyProxy
+        metadata:
+          creationTimestamp: null
+          name: default
+          namespace: default
+        spec:
+          logging: {}
+          routingType: Endpoint
+        status: {}
+      listeners:
+      - address: null
+        name: envoy-gateway/gateway-1/http
+        ports:
+        - containerPort: 10080
+          name: http-80
+          protocol: HTTP
+          servicePort: 80
+      metadata:
+        labels:
+          gateway.envoyproxy.io/owning-gateway-name: gateway-1
+          gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway
+      name: envoy-gateway/gateway-1
+xdsIR:
+  envoy-gateway/gateway-1:
+    accessLog:
+      json:
+      - path: /dev/stdout
+    http:
+    - address: 0.0.0.0
+      hostnames:
+      - '*'
+      isHTTP2: false
+      metadata:
+        kind: Gateway
+        name: gateway-1
+        namespace: envoy-gateway
+        sectionName: http
+      name: envoy-gateway/gateway-1/http
+      path:
+        escapedSlashesAction: UnescapeAndRedirect
+        mergeSlashes: true
+      port: 10080
+      routes:
+      - directResponse:
+          statusCode: 500
+        hostname: '*'
+        isHTTP2: false
+        metadata:
+          kind: HTTPRoute
+          name: httproute-headless
+          namespace: default
+        name: httproute/default/httproute-headless/rule/0/match/-1/*
+    readyListener:
+      address: 0.0.0.0
+      ipFamily: IPv4
+      path: /ready
+      port: 19003
diff --git a/internal/gatewayapi/testdata/httproute-with-headless-service-service-routing.in.yaml b/internal/gatewayapi/testdata/httproute-with-headless-service-service-routing.in.yaml
new file mode 100644
index 000000000..24f19097a
--- /dev/null
+++ b/internal/gatewayapi/testdata/httproute-with-headless-service-service-routing.in.yaml
@@ -0,0 +1,47 @@
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    namespace: envoy-gateway
+    name: gateway-1
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - name: http
+      protocol: HTTP
+      port: 80
+      allowedRoutes:
+        namespaces:
+          from: All
+httpRoutes:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: HTTPRoute
+  metadata:
+    namespace: default
+    name: httproute-headless
+  spec:
+    parentRefs:
+    - name: gateway-1
+      namespace: envoy-gateway
+    rules:
+    - backendRefs:
+      - name: headless-service
+        port: 80
+services:
+- apiVersion: v1
+  kind: Service
+  metadata:
+    namespace: default
+    name: headless-service
+  spec:
+    clusterIP: None
+    ports:
+    - port: 80
+envoyProxyForGatewayClass:
+  apiVersion: gateway.envoyproxy.io/v1alpha1
+  kind: EnvoyProxy
+  metadata:
+    name: default
+    namespace: default
+  spec:
+    routingType: Service
diff --git a/internal/gatewayapi/testdata/httproute-with-headless-service-service-routing.out.yaml b/internal/gatewayapi/testdata/httproute-with-headless-service-service-routing.out.yaml
new file mode 100644
index 000000000..0643b11e0
--- /dev/null
+++ b/internal/gatewayapi/testdata/httproute-with-headless-service-service-routing.out.yaml
@@ -0,0 +1,136 @@
+gateways:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: Gateway
+  metadata:
+    creationTimestamp: null
+    name: gateway-1
+    namespace: envoy-gateway
+  spec:
+    gatewayClassName: envoy-gateway-class
+    listeners:
+    - allowedRoutes:
+        namespaces:
+          from: All
+      name: http
+      port: 80
+      protocol: HTTP
+  status:
+    listeners:
+    - attachedRoutes: 1
+      conditions:
+      - lastTransitionTime: null
+        message: Sending translated listener configuration to the data plane
+        reason: Programmed
+        status: "True"
+        type: Programmed
+      - lastTransitionTime: null
+        message: Listener has been successfully translated
+        reason: Accepted
+        status: "True"
+        type: Accepted
+      - lastTransitionTime: null
+        message: Listener references have been resolved
+        reason: ResolvedRefs
+        status: "True"
+        type: ResolvedRefs
+      name: http
+      supportedKinds:
+      - group: gateway.networking.k8s.io
+        kind: HTTPRoute
+      - group: gateway.networking.k8s.io
+        kind: GRPCRoute
+httpRoutes:
+- apiVersion: gateway.networking.k8s.io/v1
+  kind: HTTPRoute
+  metadata:
+    creationTimestamp: null
+    name: httproute-headless
+    namespace: default
+  spec:
+    parentRefs:
+    - name: gateway-1
+      namespace: envoy-gateway
+    rules:
+    - backendRefs:
+      - name: headless-service
+        port: 80
+  status:
+    parents:
+    - conditions:
+      - lastTransitionTime: null
+        message: Route is accepted
+        reason: Accepted
+        status: "True"
+        type: Accepted
+      - lastTransitionTime: null
+        message: 'Failed to process route rule 0 backendRef 0: service httproute/default/httproute-headless/rule/0/backend/0
+          is a headless Service, please set routingType=Endpoint.'
+        reason: UnsupportedValue
+        status: "False"
+        type: ResolvedRefs
+      controllerName: gateway.envoyproxy.io/gatewayclass-controller
+      parentRef:
+        name: gateway-1
+        namespace: envoy-gateway
+infraIR:
+  envoy-gateway/gateway-1:
+    proxy:
+      config:
+        apiVersion: gateway.envoyproxy.io/v1alpha1
+        kind: EnvoyProxy
+        metadata:
+          creationTimestamp: null
+          name: default
+          namespace: default
+        spec:
+          logging: {}
+          routingType: Service
+        status: {}
+      listeners:
+      - address: null
+        name: envoy-gateway/gateway-1/http
+        ports:
+        - containerPort: 10080
+          name: http-80
+          protocol: HTTP
+          servicePort: 80
+      metadata:
+        labels:
+          gateway.envoyproxy.io/owning-gateway-name: gateway-1
+          gateway.envoyproxy.io/owning-gateway-namespace: envoy-gateway
+      name: envoy-gateway/gateway-1
+xdsIR:
+  envoy-gateway/gateway-1:
+    accessLog:
+      json:
+      - path: /dev/stdout
+    http:
+    - address: 0.0.0.0
+      hostnames:
+      - '*'
+      isHTTP2: false
+      metadata:
+        kind: Gateway
+        name: gateway-1
+        namespace: envoy-gateway
+        sectionName: http
+      name: envoy-gateway/gateway-1/http
+      path:
+        escapedSlashesAction: UnescapeAndRedirect
+        mergeSlashes: true
+      port: 10080
+      routes:
+      - directResponse:
+          statusCode: 500
+        hostname: '*'
+        isHTTP2: false
+        metadata:
+          kind: HTTPRoute
+          name: httproute-headless
+          namespace: default
+        name: httproute/default/httproute-headless/rule/0/match/-1/*
+    readyListener:
+      address: 0.0.0.0
+      ipFamily: IPv4
+      path: /ready
+      port: 19003
diff --git a/site/content/en/latest/tasks/operations/standalone-deployment-mode.md b/site/content/en/latest/tasks/operations/standalone-deployment-mode.md
index 102ad3323..1adbe723c 100644
--- a/site/content/en/latest/tasks/operations/standalone-deployment-mode.md
+++ b/site/content/en/latest/tasks/operations/standalone-deployment-mode.md
@@ -31,14 +31,14 @@ Create a local directory just for testing:
 mkdir -p /tmp/envoy-gateway-test
 ```
 
-As we do not provide the Envoy Gateway binary in latest release,
-you can compile this binary on your own from project by using command:
+You can download the Envoy Gateway binary from [Github release](https://github.com/envoyproxy/gateway/releases/tag/{{< yaml-version >}}),
+or compile the binary on your own from project by using command:
 
 ```shell
 make build
 ```
 
-The compiled binary lies in `bin/{os}/{arch}/envoy-gateway`.
+The compiled binary lies in `{project-home}/bin/{os}/{arch}/envoy-gateway`.
 
 ### Create Certificates
 
diff --git a/test/fuzz/xds_fuzz_test.go b/test/fuzz/xds_fuzz_test.go
index 8f769ee6f..982198e2b 100644
--- a/test/fuzz/xds_fuzz_test.go
+++ b/test/fuzz/xds_fuzz_test.go
@@ -11,53 +11,36 @@ import (
 	fuzz "github.com/AdaLogics/go-fuzz-headers"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	gwapiv1 "sigs.k8s.io/gateway-api/apis/v1"
-	"sigs.k8s.io/yaml"
 
 	"github.com/envoyproxy/gateway/internal/cmd/egctl"
 	"github.com/envoyproxy/gateway/internal/gatewayapi/resource"
 )
 
 func FuzzGatewayAPIToXDS(f *testing.F) {
-	f.Fuzz(func(t *testing.T, data []byte) {
-		fc := fuzz.NewConsumer(data)
-		resources := &resource.Resources{}
-		if err := fc.GenerateStruct(resources); err != nil {
-			return
-		}
-		namespace, err := fc.GetString()
-		if err != nil {
-			return
-		}
-		dnsDomain, err := fc.GetString()
-		if err != nil {
-			return
-		}
-		resourceType, err := fc.GetString()
-		if err != nil {
-			return
-		}
-
-		// Populate default values
-		yamlBytes, err := yaml.Marshal(resources)
-		if err != nil {
-			return
-		}
-		addMissingResources, err := fc.GetBool()
-		if err != nil {
-			return
-		}
-		rs, err := resource.LoadResourcesFromYAMLBytes(yamlBytes, addMissingResources)
+	f.Fuzz(func(t *testing.T, b []byte) {
+		rs, err := resource.LoadResourcesFromYAMLBytes(b, true)
 		if err != nil {
 			return
 		}
+		fc := fuzz.NewConsumer(b)
+		namespace, _ := fc.GetString()
+		dnsDomain, _ := fc.GetString()
+		resourceType, _ := fc.GetString()
 
 		_, _ = egctl.TranslateGatewayAPIToXds(namespace, dnsDomain, resourceType, rs)
 	})
 }
 
 func FuzzGatewayClassToXDS(f *testing.F) {
-	f.Fuzz(func(t *testing.T, name, controllerName, namespace, dnsDomain, resourceType string) {
-		resources := &resource.Resources{
+	f.Fuzz(func(t *testing.T, b []byte) {
+		fc := fuzz.NewConsumer(b)
+		namespace, _ := fc.GetString()
+		name, _ := fc.GetString()
+		controllerName, _ := fc.GetString()
+		dnsDomain, _ := fc.GetString()
+		resourceType, _ := fc.GetString()
+
+		rs := &resource.Resources{
 			GatewayClass: &gwapiv1.GatewayClass{
 				ObjectMeta: metav1.ObjectMeta{
 					Name:      name,
@@ -69,6 +52,6 @@ func FuzzGatewayClassToXDS(f *testing.F) {
 			},
 		}
 
-		_, _ = egctl.TranslateGatewayAPIToXds(namespace, dnsDomain, resourceType, resources)
+		_, _ = egctl.TranslateGatewayAPIToXds(namespace, dnsDomain, resourceType, rs)
 	})
 }