diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go index 9fbbdbe880..539edbf519 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/internal/gatewayapi/route_test.go b/internal/gatewayapi/route_test.go index 7634503971..131e678793 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 0000000000..f8894d999c --- /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 0000000000..062854db6d --- /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 0000000000..24f19097a9 --- /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 0000000000..0643b11e03 --- /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