diff --git a/conformance/conformance.go b/conformance/conformance.go index 642a6eb280..39a23ab39c 100644 --- a/conformance/conformance.go +++ b/conformance/conformance.go @@ -25,6 +25,7 @@ import ( "sigs.k8s.io/gateway-api/apis/v1alpha2" "sigs.k8s.io/gateway-api/apis/v1alpha3" "sigs.k8s.io/gateway-api/apis/v1beta1" + xv1alpha1 "sigs.k8s.io/gateway-api/apisx/v1alpha1" confv1 "sigs.k8s.io/gateway-api/conformance/apis/v1" "sigs.k8s.io/gateway-api/conformance/tests" conformanceconfig "sigs.k8s.io/gateway-api/conformance/utils/config" @@ -63,6 +64,7 @@ func DefaultOptions(t *testing.T) suite.ConformanceOptions { require.NoError(t, v1alpha3.Install(client.Scheme())) require.NoError(t, v1alpha2.Install(client.Scheme())) require.NoError(t, v1beta1.Install(client.Scheme())) + require.NoError(t, xv1alpha1.Install(client.Scheme())) require.NoError(t, v1.Install(client.Scheme())) require.NoError(t, apiextensionsv1.AddToScheme(client.Scheme())) diff --git a/conformance/tests/listenerset-hostname-conflict.go b/conformance/tests/listenerset-hostname-conflict.go new file mode 100644 index 0000000000..cad18912b0 --- /dev/null +++ b/conformance/tests/listenerset-hostname-conflict.go @@ -0,0 +1,203 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tests + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayxv1a1 "sigs.k8s.io/gateway-api/apisx/v1alpha1" + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" +) + +func init() { + ConformanceTests = append(ConformanceTests, ListenerSetHostnameConflict) +} + +var ListenerSetHostnameConflict = suite.ConformanceTest{ + ShortName: "ListenerSetHostnameConflict", + Description: "Listener Set listener with hostname conflicts to validate Listener Precedence", + Features: []features.FeatureName{ + features.SupportGateway, + features.SupportGatewayListenerSet, + features.SupportHTTPRoute, + }, + Manifests: []string{ + "tests/listenerset-hostname-conflict.yaml", + }, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + + kubernetes.NamespacesMustBeReady(t, suite.Client, suite.TimeoutConfig, []string{ns}) + + testCases := []http.ExpectedResponse{ + // Requests to the listeners without conflicts should work + { + Request: http.Request{Host: "gateway-listener.com", Path: "/gateway-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{Host: "listenerset-1-listener.com", Path: "/listenerset-1-route"}, + Backend: "infra-backend-v2", + Namespace: ns, + }, + { + Request: http.Request{Host: "listenerset-2-listener.com", Path: "/listenerset-2-route"}, + Backend: "infra-backend-v3", + Namespace: ns, + }, + // Requests to the listener with domain name conflict should work on the first listener (based on listener precedence - gateway listener) + { + Request: http.Request{Host: "hostname-conflict-listener-1.com", Path: "/gateway-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{Host: "hostname-conflict-listener-1.com", Path: "/listenerset-1-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "hostname-conflict-listener-1.com", Path: "/listenerset-2-route"}, + Response: http.Response{StatusCode: 404}, + }, + // Requests to the listener with domain name conflict should work on the first listener (based on listener precedence - alphabetic / creation time) + { + Request: http.Request{Host: "hostname-conflict-listener-2.com", Path: "/listenerset-1-route"}, + Backend: "infra-backend-v2", + Namespace: ns, + }, + { + Request: http.Request{Host: "hostname-conflict-listener-2.com", Path: "/listenerset-2-route"}, + Response: http.Response{StatusCode: 404}, + }, + } + + acceptedListenerConditions := []metav1.Condition{ + { + Type: string(gatewayv1.ListenerConditionResolvedRefs), + Status: metav1.ConditionTrue, + Reason: "", // any reason + }, + { + Type: string(gatewayv1.ListenerConditionAccepted), + Status: metav1.ConditionTrue, + Reason: "", // any reason + }, + { + Type: string(gatewayv1.ListenerConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: "", // any reason + }, + } + hostnameConflictedListenerConditions := []metav1.Condition{ + { + Type: string(gatewayv1.ListenerConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerReasonHostnameConflict), + }, + { + Type: string(gatewayv1.ListenerConditionProgrammed), + Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerReasonHostnameConflict), + }, + { + Type: string(gatewayv1.ListenerConditionConflicted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.ListenerReasonHostnameConflict), + }, + } + + // Gateway, route and conditions + gwNN := types.NamespacedName{Name: "gateway-with-listenerset-http-listener", Namespace: ns} + gwRoutes := []types.NamespacedName{ + {Name: "gateway-route", Namespace: ns}, + } + gwAddr := kubernetes.GatewayAndRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), &gatewayv1.HTTPRoute{}, false, gwRoutes...) + kubernetes.GatewayMustHaveCondition(t, suite.Client, suite.TimeoutConfig, gwNN, metav1.Condition{ + Type: string(gatewayv1.GatewayConditionAttachedListenerSets), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.GatewayReasonListenerSetsAttached), + }) + kubernetes.GatewayListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, gwNN, acceptedListenerConditions, "gateway-listener") + // The first conflicted listener is accepted based on Listener precedence + kubernetes.GatewayListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, gwNN, acceptedListenerConditions, "hostname-conflict-listener-1") + + // ListenerSet1, route and conditions + ls1NN := types.NamespacedName{Name: "listenerset-with-conflict-1", Namespace: ns} + ls1Routes := []types.NamespacedName{ + {Namespace: ns, Name: "listenerset-with-conflict-1-route"}, + } + for _, routeNN := range ls1Routes { + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, ls1NN) + } + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, ls1NN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayxv1a1.ListenerSetReasonListenersNotValid), + }) + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, ls1NN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: string(gatewayxv1a1.ListenerSetReasonListenersNotValid), + }) + kubernetes.ListenerSetListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, ls1NN, acceptedListenerConditions, "listenerset-1-listener") + // The conflicted listener should not be accepted + kubernetes.ListenerSetListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, ls1NN, hostnameConflictedListenerConditions, "hostname-conflict-listener-1") + // The first conflicted listener is accepted based on Listener precedence + kubernetes.ListenerSetListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, ls1NN, acceptedListenerConditions, "hostname-conflict-listener-2") + + // ListenerSet2, route and conditions + ls2NN := types.NamespacedName{Name: "listenerset-with-conflict-2", Namespace: ns} + ls2Routes := []types.NamespacedName{ + {Namespace: ns, Name: "listenerset-with-conflict-2-route"}, + } + for _, routeNN := range ls2Routes { + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, ls2NN) + } + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, ls2NN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayxv1a1.ListenerSetReasonListenersNotValid), + }) + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, ls2NN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: string(gatewayxv1a1.ListenerSetReasonListenersNotValid), + }) + kubernetes.ListenerSetListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, ls2NN, acceptedListenerConditions, "listenerset-2-listener") + // The conflicted listeners should not be accepted + kubernetes.ListenerSetListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, ls2NN, hostnameConflictedListenerConditions, "hostname-conflict-listener-1") + kubernetes.ListenerSetListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, ls2NN, hostnameConflictedListenerConditions, "hostname-conflict-listener-2") + + for i := range testCases { + // Declare tc here to avoid loop variable + // reuse issues across parallel tests. + 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/conformance/tests/listenerset-hostname-conflict.yaml b/conformance/tests/listenerset-hostname-conflict.yaml new file mode 100644 index 0000000000..385160d3ab --- /dev/null +++ b/conformance/tests/listenerset-hostname-conflict.yaml @@ -0,0 +1,151 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + allowedListeners: + namespaces: + from: Same + listeners: + - name: gateway-listener + port: 80 + protocol: HTTP + hostname: "gateway-listener.com" + allowedRoutes: + namespaces: + from: All + - name: hostname-conflict-listener-1 + port: 80 + protocol: HTTP + hostname: "hostname-conflict-listener-1.com" + allowedRoutes: + namespaces: + from: All +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: gateway-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + rules: + - matches: + - path: + type: PathPrefix + value: /gateway-route + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.networking.x-k8s.io/v1alpha1 +kind: XListenerSet +metadata: + name: listenerset-with-conflict-1 + namespace: gateway-conformance-infra +spec: + parentRef: + kind: Gateway + group: gateway.networking.k8s.io + name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + listeners: + - name: listenerset-1-listener + port: 80 + protocol: HTTP + hostname: "listenerset-1-listener.com" + allowedRoutes: + namespaces: + from: All + - name: hostname-conflict-listener-1 + port: 80 + protocol: HTTP + hostname: "hostname-conflict-listener-1.com" + allowedRoutes: + namespaces: + from: All + - name: hostname-conflict-listener-2 + port: 80 + protocol: HTTP + hostname: "hostname-conflict-listener-2.com" + allowedRoutes: + namespaces: + from: All +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: listenerset-with-conflict-1-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - kind: XListenerSet + group: gateway.networking.x-k8s.io + name: listenerset-with-conflict-1 + namespace: gateway-conformance-infra + rules: + - matches: + - path: + type: PathPrefix + value: /listenerset-1-route + backendRefs: + - name: infra-backend-v2 + port: 8080 +--- +apiVersion: gateway.networking.x-k8s.io/v1alpha1 +kind: XListenerSet +metadata: + name: listenerset-with-conflict-2 + namespace: gateway-conformance-infra +spec: + parentRef: + kind: Gateway + group: gateway.networking.k8s.io + name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + listeners: + - name: listenerset-2-listener + port: 80 + protocol: HTTP + hostname: "listenerset-2-listener.com" + allowedRoutes: + namespaces: + from: All + - name: hostname-conflict-listener-1 + port: 80 + protocol: HTTP + hostname: "hostname-conflict-listener-1.com" + allowedRoutes: + namespaces: + from: All + - name: hostname-conflict-listener-2 + port: 80 + protocol: HTTP + hostname: "hostname-conflict-listener-2.com" + allowedRoutes: + namespaces: + from: All +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: listenerset-with-conflict-2-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - kind: XListenerSet + group: gateway.networking.x-k8s.io + name: listenerset-with-conflict-2 + namespace: gateway-conformance-infra + rules: + - matches: + - path: + type: PathPrefix + value: /listenerset-2-route + backendRefs: + - name: infra-backend-v3 + port: 8080 diff --git a/conformance/tests/listenerset-http-listener-cross-namespace.go b/conformance/tests/listenerset-http-listener-cross-namespace.go new file mode 100644 index 0000000000..7210991bc3 --- /dev/null +++ b/conformance/tests/listenerset-http-listener-cross-namespace.go @@ -0,0 +1,203 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tests + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayxv1a1 "sigs.k8s.io/gateway-api/apisx/v1alpha1" + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" +) + +func init() { + ConformanceTests = append(ConformanceTests, ListenerSetCrossNamespace) +} + +var ListenerSetCrossNamespace = suite.ConformanceTest{ + ShortName: "ListenerSetCrossNamespace", + Description: "ListenerSet in a different namespace than the Gateway", + Features: []features.FeatureName{ + features.SupportGateway, + features.SupportGatewayListenerSet, + features.SupportHTTPRoute, + features.SupportReferenceGrant, + }, + Manifests: []string{ + "tests/listenerset-http-listener-cross-namespace.yaml", + }, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + + kubernetes.NamespacesMustBeReady(t, suite.Client, suite.TimeoutConfig, []string{ns}) + + testCases := []http.ExpectedResponse{ + // Requests to the route defined on the gateway (should only match routes attached to the gateway) + { + Request: http.Request{Host: "gateway-listener-1.com", Path: "/gateway-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{Host: "gateway-listener-2.com", Path: "/gateway-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{Host: "listenerset-1-listener-1.com", Path: "/gateway-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "listenerset-1-listener-2.com", Path: "/gateway-route"}, + Response: http.Response{StatusCode: 404}, + }, + // Requests to the route defined on the gateway that targets gateway-listener-2-listener + { + Request: http.Request{Host: "gateway-listener-1.com", Path: "/gateway-listener-2-listener-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "gateway-listener-2.com", Path: "/gateway-listener-2-listener-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{Host: "listenerset-1-listener-1.com", Path: "/gateway-listener-2-listener-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "listenerset-1-listener-2.com", Path: "/gateway-listener-2-listener-route"}, + Response: http.Response{StatusCode: 404}, + }, + // Requests to the route defined on the listener set (should only match listeners defined on the listenerset) + { + Request: http.Request{Host: "gateway-listener-1.com", Path: "/listenerset-1-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "gateway-listener-2.com", Path: "/listenerset-1-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "listenerset-1-listener-1.com", Path: "/listenerset-1-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{Host: "listenerset-1-listener-2.com", Path: "/listenerset-1-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + // Requests to the route defined on the listenerset that targets listenerset-1-listener-2-listener + { + Request: http.Request{Host: "gateway-listener-1.com", Path: "/listenerset-1-listener-2-listener-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "gateway-listener-2.com", Path: "/listenerset-1-listener-2-listener-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "listenerset-1-listener-1.com", Path: "/listenerset-1-listener-2-listener-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "listenerset-1-listener-2.com", Path: "/listenerset-1-listener-2-listener-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + // Requests to listenerset-2-listener-1-listener on the listener set in a non-allowed namespace should not work + { + Request: http.Request{Host: "listenerset-2-listener-1.com", Path: "/listenerset-2-route"}, + Response: http.Response{StatusCode: 404}, + }, + } + + gwNN := types.NamespacedName{Name: "gateway-with-listenerset-http-listener", Namespace: ns} + gwRoutes := []types.NamespacedName{ + {Namespace: "gateway-api-example-ns2", Name: "gateway-route"}, + {Namespace: "gateway-api-example-ns3", Name: "gateway-listener-2-route"}, + } + + // Gateway, routes and gateway conditions + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), gwRoutes...) + for _, routeNN := range gwRoutes { + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN) + } + kubernetes.GatewayMustHaveCondition(t, suite.Client, suite.TimeoutConfig, gwNN, metav1.Condition{ + Type: string(gatewayv1.GatewayConditionAttachedListenerSets), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.GatewayReasonListenerSetsAttached), + }) + + // Allowed ListenerSet, listenerSet routes and listenerSet conditions + lsNN := types.NamespacedName{Name: "listenerset-with-http-listener", Namespace: "gateway-api-example-ns1"} + lsRoutes := []types.NamespacedName{ + {Namespace: "gateway-api-example-ns4", Name: "listenerset-1-route"}, + {Namespace: "gateway-api-example-ns5", Name: "listenerset-1-listener-2-listener-route"}, + } + listenerSetGK := schema.GroupKind{ + Group: gatewayxv1a1.GroupVersion.Group, + Kind: "XListenerSet", + } + listenerSetRef := kubernetes.NewResourceRef(listenerSetGK, lsNN) + kubernetes.RoutesAndParentMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, listenerSetRef, &gatewayv1.HTTPRoute{}, lsRoutes...) + for _, routeNN := range lsRoutes { + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, lsNN) + } + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, lsNN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayxv1a1.ListenerSetReasonAccepted), + }) + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, lsNN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: string(gatewayxv1a1.ListenerSetReasonProgrammed), + }) + + // Disallowed ListenerSet, listenerSet routes and listenerSet conditions + disallowedLsNN := types.NamespacedName{Name: "listenerset-not-allowed", Namespace: "gateway-api-example-ns6"} + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, disallowedLsNN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(gatewayxv1a1.ListenerSetReasonNotAllowed), + }) + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, disallowedLsNN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionProgrammed), + Status: metav1.ConditionFalse, + Reason: string(gatewayxv1a1.ListenerSetReasonNotAllowed), + }) + + for i := range testCases { + // Declare tc here to avoid loop variable + // reuse issues across parallel tests. + 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/conformance/tests/listenerset-http-listener-cross-namespace.yaml b/conformance/tests/listenerset-http-listener-cross-namespace.yaml new file mode 100644 index 0000000000..a36da2d3eb --- /dev/null +++ b/conformance/tests/listenerset-http-listener-cross-namespace.yaml @@ -0,0 +1,233 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: gateway-listener-1-listener + port: 80 + protocol: HTTP + hostname: "gateway-listener-1.com" + allowedRoutes: + namespaces: + from: All + - name: gateway-listener-2-listener + port: 80 + protocol: HTTP + hostname: "gateway-listener-2.com" + allowedRoutes: + namespaces: + from: All + allowedListeners: + namespaces: + from: Selector + selector: + matchLabels: + allowed: ns +--- +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-api-example-ns1 + labels: + allowed: ns +--- +apiVersion: gateway.networking.x-k8s.io/v1alpha1 +kind: XListenerSet +metadata: + name: listenerset-with-http-listener + namespace: gateway-api-example-ns1 +spec: + parentRef: + kind: Gateway + group: gateway.networking.k8s.io + name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + listeners: + - name: listenerset-1-listener-1-listener + port: 80 + protocol: HTTP + hostname: "listenerset-1-listener-1.com" + allowedRoutes: + namespaces: + from: All + - name: listenerset-1-listener-2-listener + port: 80 + protocol: HTTP + hostname: "listenerset-1-listener-2.com" + allowedRoutes: + namespaces: + from: All +--- +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-api-example-ns2 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: gateway-route + namespace: gateway-api-example-ns2 +spec: + parentRefs: + - name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + rules: + - matches: + - path: + type: PathPrefix + value: /gateway-route + backendRefs: + - name: infra-backend-v1 + namespace: gateway-conformance-infra + port: 8080 +--- +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-api-example-ns3 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: gateway-listener-2-route + namespace: gateway-api-example-ns3 +spec: + parentRefs: + - name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + sectionName: gateway-listener-2-listener + rules: + - matches: + - path: + type: PathPrefix + value: /gateway-listener-2-listener-route + backendRefs: + - name: infra-backend-v1 + namespace: gateway-conformance-infra + port: 8080 +--- +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-api-example-ns4 +--- +kind: ReferenceGrant +apiVersion: gateway.networking.k8s.io/v1beta1 +metadata: + name: reference-grant + namespace: gateway-conformance-infra +spec: + from: + - group: gateway.networking.k8s.io + kind: HTTPRoute + namespace: gateway-api-example-ns2 + - group: gateway.networking.k8s.io + kind: HTTPRoute + namespace: gateway-api-example-ns3 + - group: gateway.networking.k8s.io + kind: HTTPRoute + namespace: gateway-api-example-ns4 + - group: gateway.networking.k8s.io + kind: HTTPRoute + namespace: gateway-api-example-ns5 + to: + - group: "" + kind: "Service" +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: listenerset-1-route + namespace: gateway-api-example-ns4 +spec: + parentRefs: + - kind: XListenerSet + group: gateway.networking.x-k8s.io + name: listenerset-with-http-listener + namespace: gateway-api-example-ns1 + rules: + - matches: + - path: + type: PathPrefix + value: /listenerset-1-route + backendRefs: + - name: infra-backend-v1 + namespace: gateway-conformance-infra + port: 8080 +--- +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-api-example-ns5 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: listenerset-1-listener-2-listener-route + namespace: gateway-api-example-ns5 +spec: + parentRefs: + - kind: XListenerSet + group: gateway.networking.x-k8s.io + name: listenerset-with-http-listener + namespace: gateway-api-example-ns1 + sectionName: listenerset-1-listener-2-listener + rules: + - matches: + - path: + type: PathPrefix + value: /listenerset-1-listener-2-listener-route + backendRefs: + - name: infra-backend-v1 + namespace: gateway-conformance-infra + port: 8080 +--- +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-api-example-ns6 +--- +apiVersion: gateway.networking.x-k8s.io/v1alpha1 +kind: XListenerSet +metadata: + name: listenerset-not-allowed + namespace: gateway-api-example-ns6 +spec: + parentRef: + kind: Gateway + group: gateway.networking.k8s.io + name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + listeners: + - name: listenerset-2-listener-1-listener + port: 80 + protocol: HTTP + hostname: "listenerset-2-listener-1.com" + allowedRoutes: + namespaces: + from: All +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: listenerset-2-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - kind: XListenerSet + group: gateway.networking.x-k8s.io + name: listenerset-not-allowed + namespace: gateway-api-example-ns6 + rules: + - matches: + - path: + type: PathPrefix + value: /listenerset-2-route + backendRefs: + - name: infra-backend-v1 + namespace: gateway-conformance-infra + port: 8080 diff --git a/conformance/tests/listenerset-not-allowed.go b/conformance/tests/listenerset-not-allowed.go new file mode 100644 index 0000000000..9018619bc1 --- /dev/null +++ b/conformance/tests/listenerset-not-allowed.go @@ -0,0 +1,108 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tests + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayxv1a1 "sigs.k8s.io/gateway-api/apisx/v1alpha1" + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" +) + +func init() { + ConformanceTests = append(ConformanceTests, ListenerSetNotAllowed) +} + +var ListenerSetNotAllowed = suite.ConformanceTest{ + ShortName: "ListenerSetNotAllowed", + Description: "Listener Set not allowed on the Gateway", + Features: []features.FeatureName{ + features.SupportGateway, + features.SupportGatewayListenerSet, + features.SupportHTTPRoute, + }, + Manifests: []string{ + "tests/listenerset-not-allowed.yaml", + }, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + + kubernetes.NamespacesMustBeReady(t, suite.Client, suite.TimeoutConfig, []string{ns}) + + testCases := []http.ExpectedResponse{ + // Requests to the listener defined on the gateway should work + { + Request: http.Request{Host: "example.com", Path: "/route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + // Requests to the listenerset listeners should fail + { + Request: http.Request{Host: "foo.com", Path: "/route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "bar.com", Path: "/route"}, + Response: http.Response{StatusCode: 404}, + }, + } + + gwNN := types.NamespacedName{Name: "gateway-does-not-allow-listenerset", Namespace: ns} + gwRoutes := []types.NamespacedName{ + {Name: "attaches-to-all-listeners", Namespace: ns}, + } + + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), gwRoutes...) + for _, routeNN := range gwRoutes { + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN) + } + + kubernetes.GatewayMustHaveCondition(t, suite.Client, suite.TimeoutConfig, gwNN, metav1.Condition{ + Type: string(gatewayv1.GatewayConditionAttachedListenerSets), + Status: metav1.ConditionUnknown, + Reason: string(gatewayv1.GatewayReasonListenerSetsNotAllowed), + }) + disallowedLsNN := types.NamespacedName{Name: "listenerset-not-allowed", Namespace: ns} + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, disallowedLsNN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(gatewayxv1a1.ListenerSetReasonNotAllowed), + }) + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, disallowedLsNN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionProgrammed), + Status: metav1.ConditionFalse, + Reason: string(gatewayxv1a1.ListenerSetReasonNotAllowed), + }) + + for i := range testCases { + // Declare tc here to avoid loop variable + // reuse issues across parallel tests. + 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/conformance/tests/listenerset-not-allowed.yaml b/conformance/tests/listenerset-not-allowed.yaml new file mode 100644 index 0000000000..395691051e --- /dev/null +++ b/conformance/tests/listenerset-not-allowed.yaml @@ -0,0 +1,64 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-does-not-allow-listenerset + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: example-com + port: 80 + protocol: HTTP + hostname: "example.com" + allowedRoutes: + namespaces: + from: All +--- +apiVersion: gateway.networking.x-k8s.io/v1alpha1 +kind: XListenerSet +metadata: + name: listenerset-not-allowed + namespace: gateway-conformance-infra +spec: + parentRef: + kind: Gateway + group: gateway.networking.k8s.io + name: gateway-does-not-allow-listenerset + namespace: gateway-conformance-infra + listeners: + - name: foo-com + port: 80 + protocol: HTTP + hostname: "foo.com" + allowedRoutes: + namespaces: + from: All + - name: bar-com + port: 80 + protocol: HTTP + hostname: "bar.com" + allowedRoutes: + namespaces: + from: All +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: attaches-to-all-listeners + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: gateway-does-not-allow-listenerset + namespace: gateway-conformance-infra + - kind: XListenerSet + group: gateway.networking.x-k8s.io + name: listenerset-not-allowed + namespace: gateway-conformance-infra + rules: + - matches: + - path: + type: PathPrefix + value: /route + backendRefs: + - name: infra-backend-v1 + port: 8080 diff --git a/conformance/tests/listenerset-protocol-conflict.go b/conformance/tests/listenerset-protocol-conflict.go new file mode 100644 index 0000000000..ab8a48dd0d --- /dev/null +++ b/conformance/tests/listenerset-protocol-conflict.go @@ -0,0 +1,202 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tests + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayxv1a1 "sigs.k8s.io/gateway-api/apisx/v1alpha1" + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" +) + +func init() { + ConformanceTests = append(ConformanceTests, ListenerSetProtocolConflict) +} + +var ListenerSetProtocolConflict = suite.ConformanceTest{ + ShortName: "ListenerSetProtocolConflict", + Description: "Listener Set listener with protocol conflicts to validate Listener Precedence", + Features: []features.FeatureName{ + features.SupportGateway, + features.SupportGatewayListenerSet, + features.SupportHTTPRoute, + }, + Manifests: []string{ + "tests/listenerset-protocol-conflict.yaml", + }, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + + kubernetes.NamespacesMustBeReady(t, suite.Client, suite.TimeoutConfig, []string{ns}) + + testCases := []http.ExpectedResponse{ + // Requests to the listeners without conflicts should work + { + Request: http.Request{Host: "gateway-listener.com", Path: "/gateway-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{Host: "listenerset-1-listener.com", Path: "/listenerset-1-route"}, + Backend: "infra-backend-v2", + Namespace: ns, + }, + { + Request: http.Request{Host: "listenerset-2-listener.com", Path: "/listenerset-2-route"}, + Backend: "infra-backend-v3", + Namespace: ns, + }, + // Requests to the listener with protocol conflict should work on the first listener (based on listener precedence - gateway listener) + { + Request: http.Request{Host: "protocol-conflict-listener-1.com", Path: "/gateway-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{Host: "protocol-conflict-listener-1.com", Path: "/listenerset-1-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "protocol-conflict-listener-1.com", Path: "/listenerset-2-route"}, + Response: http.Response{StatusCode: 404}, + }, + // Requests to the listener with protocol conflict should work on the first listener (based on listener precedence - alphabetic / creation time) + { + Request: http.Request{Host: "protocol-conflict-listener-2.com", Path: "/listenerset-1-route"}, + Backend: "infra-backend-v2", + Namespace: ns, + }, + { + Request: http.Request{Host: "protocol-conflict-listener-2.com", Path: "/listenerset-2-route"}, + Response: http.Response{StatusCode: 404}, + }, + } + + acceptedListenerConditions := []metav1.Condition{ + { + Type: string(gatewayv1.ListenerConditionResolvedRefs), + Status: metav1.ConditionTrue, + Reason: "", // any reason + }, + { + Type: string(gatewayv1.ListenerConditionAccepted), + Status: metav1.ConditionTrue, + Reason: "", // any reason + }, + { + Type: string(gatewayv1.ListenerConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: "", // any reason + }, + } + protocolConflictedListenerConditions := []metav1.Condition{ + { + Type: string(gatewayv1.ListenerConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerReasonProtocolConflict), + }, + { + Type: string(gatewayv1.ListenerConditionProgrammed), + Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerReasonProtocolConflict), + }, + { + Type: string(gatewayv1.ListenerConditionConflicted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.ListenerReasonProtocolConflict), + }, + } + + // Gateway, route and conditions + gwNN := types.NamespacedName{Name: "gateway-with-listenerset-http-listener", Namespace: ns} + gwRoutes := []types.NamespacedName{ + {Name: "gateway-route", Namespace: ns}, + } + gwAddr := kubernetes.GatewayAndRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), &gatewayv1.HTTPRoute{}, false, gwRoutes...) + kubernetes.GatewayMustHaveCondition(t, suite.Client, suite.TimeoutConfig, gwNN, metav1.Condition{ + Type: string(gatewayv1.GatewayConditionAttachedListenerSets), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.GatewayReasonListenerSetsAttached), + }) + kubernetes.GatewayListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, gwNN, acceptedListenerConditions, "gateway-listener") + // The first conflicted listener is accepted based on Listener precedence + kubernetes.GatewayListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, gwNN, acceptedListenerConditions, "protocol-conflict-listener-1") + + // ListenerSet1, route and conditions + ls1NN := types.NamespacedName{Name: "listenerset-with-conflict-1", Namespace: ns} + ls1Routes := []types.NamespacedName{ + {Namespace: ns, Name: "listenerset-with-conflict-1-route"}, + } + for _, routeNN := range ls1Routes { + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, ls1NN) + } + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, ls1NN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayxv1a1.ListenerSetReasonListenersNotValid), + }) + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, ls1NN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: string(gatewayxv1a1.ListenerSetReasonListenersNotValid), + }) + kubernetes.ListenerSetListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, ls1NN, acceptedListenerConditions, "listenerset-1-listener") + // The conflicted listener should not be accepted + kubernetes.ListenerSetListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, ls1NN, protocolConflictedListenerConditions, "protocol-conflict-listener-1") + // The first conflicted listener is accepted based on Listener precedence + kubernetes.ListenerSetListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, ls1NN, acceptedListenerConditions, "protocol-conflict-listener-2") + + // ListenerSet2, route and conditions + ls2NN := types.NamespacedName{Name: "listenerset-with-conflict-2", Namespace: ns} + ls2Routes := []types.NamespacedName{ + {Namespace: ns, Name: "listenerset-with-conflict-2-route"}, + } + for _, routeNN := range ls2Routes { + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, ls2NN) + } + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, ls2NN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayxv1a1.ListenerSetReasonListenersNotValid), + }) + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, ls2NN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: string(gatewayxv1a1.ListenerSetReasonListenersNotValid), + }) + kubernetes.ListenerSetListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, ls2NN, acceptedListenerConditions, "listenerset-2-listener") + // The conflicted listeners should not be accepted + kubernetes.ListenerSetListenersMustHaveConditions(t, suite.Client, suite.TimeoutConfig, ls2NN, protocolConflictedListenerConditions, "protocol-conflict-listener-2") + + for i := range testCases { + // Declare tc here to avoid loop variable + // reuse issues across parallel tests. + 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/conformance/tests/listenerset-protocol-conflict.yaml b/conformance/tests/listenerset-protocol-conflict.yaml new file mode 100644 index 0000000000..6157b52d58 --- /dev/null +++ b/conformance/tests/listenerset-protocol-conflict.yaml @@ -0,0 +1,142 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + allowedListeners: + namespaces: + from: Same + listeners: + - name: gateway-listener + port: 80 + protocol: HTTP + hostname: "gateway-listener.com" + allowedRoutes: + namespaces: + from: All + - name: protocol-conflict-listener-1 + port: 80 + protocol: HTTP + hostname: "protocol-conflict-listener-1.com" + allowedRoutes: + namespaces: + from: All +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: gateway-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + rules: + - matches: + - path: + type: PathPrefix + value: /gateway-route + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.networking.x-k8s.io/v1alpha1 +kind: XListenerSet +metadata: + name: listenerset-with-conflict-1 + namespace: gateway-conformance-infra +spec: + parentRef: + kind: Gateway + group: gateway.networking.k8s.io + name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + listeners: + - name: listenerset-1-listener + port: 80 + protocol: HTTP + hostname: "listenerset-1-listener.com" + allowedRoutes: + namespaces: + from: All + - name: protocol-conflict-listener-1 + port: 80 + protocol: TCP + allowedRoutes: + namespaces: + from: All + - name: protocol-conflict-listener-2 + port: 80 + protocol: HTTP + hostname: "protocol-conflict-listener-2.com" + allowedRoutes: + namespaces: + from: All +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: listenerset-with-conflict-1-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - kind: XListenerSet + group: gateway.networking.x-k8s.io + name: listenerset-with-conflict-1 + namespace: gateway-conformance-infra + rules: + - matches: + - path: + type: PathPrefix + value: /listenerset-1-route + backendRefs: + - name: infra-backend-v2 + port: 8080 +--- +apiVersion: gateway.networking.x-k8s.io/v1alpha1 +kind: XListenerSet +metadata: + name: listenerset-with-conflict-2 + namespace: gateway-conformance-infra +spec: + parentRef: + kind: Gateway + group: gateway.networking.k8s.io + name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + listeners: + - name: listenerset-2-listener + port: 80 + protocol: HTTP + hostname: "listenerset-2-listener.com" + allowedRoutes: + namespaces: + from: All + - name: protocol-conflict-listener-2 + port: 80 + protocol: TCP + allowedRoutes: + namespaces: + from: All +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: listenerset-with-conflict-2-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - kind: XListenerSet + group: gateway.networking.x-k8s.io + name: listenerset-with-conflict-2 + namespace: gateway-conformance-infra + rules: + - matches: + - path: + type: PathPrefix + value: /listenerset-2-route + backendRefs: + - name: infra-backend-v3 + port: 8080 diff --git a/conformance/tests/listenerset-same-namespace.go b/conformance/tests/listenerset-same-namespace.go new file mode 100644 index 0000000000..6f46e15f48 --- /dev/null +++ b/conformance/tests/listenerset-same-namespace.go @@ -0,0 +1,202 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tests + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayxv1a1 "sigs.k8s.io/gateway-api/apisx/v1alpha1" + "sigs.k8s.io/gateway-api/conformance/utils/http" + "sigs.k8s.io/gateway-api/conformance/utils/kubernetes" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/pkg/features" +) + +func init() { + ConformanceTests = append(ConformanceTests, ListenerSetSameNamespace) +} + +var ListenerSetSameNamespace = suite.ConformanceTest{ + ShortName: "ListenerSetSameNamespace", + Description: "ListenerSet in the same namespace as the Gateway", + Features: []features.FeatureName{ + features.SupportGateway, + features.SupportGatewayListenerSet, + features.SupportHTTPRoute, + }, + Manifests: []string{ + "tests/listenerset-same-namespace.yaml", + }, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + + kubernetes.NamespacesMustBeReady(t, suite.Client, suite.TimeoutConfig, []string{ns}) + + testCases := []http.ExpectedResponse{ + // Requests to the route defined on the gateway (should only match routes attached to the gateway) + { + Request: http.Request{Host: "gateway-listener-1.com", Path: "/gateway-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{Host: "gateway-listener-2.com", Path: "/gateway-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{Host: "listenerset-1-listener-1.com", Path: "/gateway-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "listenerset-1-listener-2.com", Path: "/gateway-route"}, + Response: http.Response{StatusCode: 404}, + }, + // Requests to the route defined on the gateway that targets gateway-listener-2-listener + { + Request: http.Request{Host: "gateway-listener-1.com", Path: "/gateway-listener-2-listener-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "gateway-listener-2.com", Path: "/gateway-listener-2-listener-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{Host: "listenerset-1-listener-1.com", Path: "/gateway-listener-2-listener-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "listenerset-1-listener-2.com", Path: "/gateway-listener-2-listener-route"}, + Response: http.Response{StatusCode: 404}, + }, + // Requests to the route defined on the listener set (should only match listeners defined on the listenerset) + { + Request: http.Request{Host: "gateway-listener-1.com", Path: "/listenerset-1-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "gateway-listener-2.com", Path: "/listenerset-1-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "listenerset-1-listener-1.com", Path: "/listenerset-1-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + { + Request: http.Request{Host: "listenerset-1-listener-2.com", Path: "/listenerset-1-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + // Requests to the route defined on the listenerset that targets listenerset-1-listener-2-listener + { + Request: http.Request{Host: "gateway-listener-1.com", Path: "/listenerset-1-listener-2-listener-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "gateway-listener-2.com", Path: "/listenerset-1-listener-2-listener-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "listenerset-1-listener-1.com", Path: "/listenerset-1-listener-2-listener-route"}, + Response: http.Response{StatusCode: 404}, + }, + { + Request: http.Request{Host: "listenerset-1-listener-2.com", Path: "/listenerset-1-listener-2-listener-route"}, + Backend: "infra-backend-v1", + Namespace: ns, + }, + // Requests to listenerset-2-listener-1-listener on the listener set in a different namespace should not work + { + Request: http.Request{Host: "listenerset-2-listener-1.com", Path: "/listenerset-2-route"}, + Response: http.Response{StatusCode: 404}, + }, + } + + gwNN := types.NamespacedName{Name: "gateway-with-listenerset-http-listener", Namespace: ns} + gwRoutes := []types.NamespacedName{ + {Namespace: ns, Name: "gateway-route"}, + {Namespace: ns, Name: "gateway-listener-2-route"}, + } + + // Gateway, route and conditions + gwAddr := kubernetes.GatewayAndHTTPRoutesMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, kubernetes.NewGatewayRef(gwNN), gwRoutes...) + for _, routeNN := range gwRoutes { + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, gwNN) + } + kubernetes.GatewayMustHaveCondition(t, suite.Client, suite.TimeoutConfig, gwNN, metav1.Condition{ + Type: string(gatewayv1.GatewayConditionAttachedListenerSets), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.GatewayReasonListenerSetsAttached), + }) + + // Allowed ListenerSet, route and conditions + lsNN := types.NamespacedName{Name: "listenerset-with-http-listener", Namespace: ns} + lsRoutes := []types.NamespacedName{ + {Namespace: ns, Name: "listenerset-1-route"}, + {Namespace: ns, Name: "listenerset-1-listener-2-listener-route"}, + } + listenerSetGK := schema.GroupKind{ + Group: gatewayxv1a1.GroupVersion.Group, + Kind: "XListenerSet", + } + listenerSetRef := kubernetes.NewResourceRef(listenerSetGK, lsNN) + kubernetes.RoutesAndParentMustBeAccepted(t, suite.Client, suite.TimeoutConfig, suite.ControllerName, listenerSetRef, &gatewayv1.HTTPRoute{}, lsRoutes...) + for _, routeNN := range lsRoutes { + kubernetes.HTTPRouteMustHaveResolvedRefsConditionsTrue(t, suite.Client, suite.TimeoutConfig, routeNN, lsNN) + } + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, lsNN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayxv1a1.ListenerSetReasonAccepted), + }) + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, lsNN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: string(gatewayxv1a1.ListenerSetReasonProgrammed), + }) + + // Disallowed ListenerSet, route and conditions + disallowedLsNN := types.NamespacedName{Name: "listenerset-not-allowed", Namespace: "gateway-api-example-ns1"} + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, disallowedLsNN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(gatewayxv1a1.ListenerSetReasonNotAllowed), + }) + kubernetes.ListenerSetMustHaveCondition(t, suite.Client, suite.TimeoutConfig, disallowedLsNN, metav1.Condition{ + Type: string(gatewayxv1a1.ListenerSetConditionProgrammed), + Status: metav1.ConditionFalse, + Reason: string(gatewayxv1a1.ListenerSetReasonNotAllowed), + }) + + for i := range testCases { + // Declare tc here to avoid loop variable + // reuse issues across parallel tests. + 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/conformance/tests/listenerset-same-namespace.yaml b/conformance/tests/listenerset-same-namespace.yaml new file mode 100644 index 0000000000..d7ab5400d6 --- /dev/null +++ b/conformance/tests/listenerset-same-namespace.yaml @@ -0,0 +1,175 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: gateway-listener-1-listener + port: 80 + protocol: HTTP + hostname: "gateway-listener-1.com" + allowedRoutes: + namespaces: + from: All + - name: gateway-listener-2-listener + port: 80 + protocol: HTTP + hostname: "gateway-listener-2.com" + allowedRoutes: + namespaces: + from: All + allowedListeners: + namespaces: + from: Same +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: gateway-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + rules: + - matches: + - path: + type: PathPrefix + value: /gateway-route + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: gateway-listener-2-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + sectionName: gateway-listener-2-listener + rules: + - matches: + - path: + type: PathPrefix + value: /gateway-listener-2-listener-route + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.networking.x-k8s.io/v1alpha1 +kind: XListenerSet +metadata: + name: listenerset-with-http-listener + namespace: gateway-conformance-infra +spec: + parentRef: + kind: Gateway + group: gateway.networking.k8s.io + name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + listeners: + - name: listenerset-1-listener-1-listener + port: 80 + protocol: HTTP + hostname: "listenerset-1-listener-1.com" + allowedRoutes: + namespaces: + from: All + - name: listenerset-1-listener-2-listener + port: 80 + protocol: HTTP + hostname: "listenerset-1-listener-2.com" + allowedRoutes: + namespaces: + from: All +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: listenerset-1-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - kind: XListenerSet + group: gateway.networking.x-k8s.io + name: listenerset-with-http-listener + namespace: gateway-conformance-infra + rules: + - matches: + - path: + type: PathPrefix + value: /listenerset-1-route + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: listenerset-1-listener-2-listener-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - kind: XListenerSet + group: gateway.networking.x-k8s.io + name: listenerset-with-http-listener + namespace: gateway-conformance-infra + sectionName: listenerset-1-listener-2-listener + rules: + - matches: + - path: + type: PathPrefix + value: /listenerset-1-listener-2-listener-route + backendRefs: + - name: infra-backend-v1 + port: 8080 +--- +apiVersion: v1 +kind: Namespace +metadata: + name: gateway-api-example-ns1 +--- +apiVersion: gateway.networking.x-k8s.io/v1alpha1 +kind: XListenerSet +metadata: + name: listenerset-not-allowed + namespace: gateway-api-example-ns1 +spec: + parentRef: + kind: Gateway + group: gateway.networking.k8s.io + name: gateway-with-listenerset-http-listener + namespace: gateway-conformance-infra + listeners: + - name: listenerset-2-listener-1-listener + port: 80 + protocol: HTTP + hostname: "listenerset-2-listener-1.com" + allowedRoutes: + namespaces: + from: All +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: listenerset-2-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - kind: XListenerSet + group: gateway.networking.x-k8s.io + name: listenerset-not-allowed + namespace: gateway-api-example-ns1 + rules: + - matches: + - path: + type: PathPrefix + value: /listenerset-2-route + backendRefs: + - name: infra-backend-v1 + port: 8080 diff --git a/conformance/utils/config/timeout.go b/conformance/utils/config/timeout.go index 9a7c1626da..b05fa3383d 100644 --- a/conformance/utils/config/timeout.go +++ b/conformance/utils/config/timeout.go @@ -52,6 +52,15 @@ type TimeoutConfig struct { // Max value for conformant implementation: None GatewayListenersMustHaveConditions time.Duration + // ListenerSetMustHaveCondition represents the maximum amount of time for a + // ListenerSet to have the supplied Condition. + // Max value for conformant implementation: None + ListenerSetMustHaveCondition time.Duration + + // ListenerSetListenersMustHaveConditions represents the maximum time for a ListenerSet to have all listeners with a specific condition. + // Max value for conformant implementation: None + ListenerSetListenersMustHaveConditions time.Duration + // GWCMustBeAccepted represents the maximum time for a GatewayClass to have an Accepted condition set to true. // Max value for conformant implementation: None GWCMustBeAccepted time.Duration @@ -107,25 +116,27 @@ type TimeoutConfig struct { // DefaultTimeoutConfig populates a TimeoutConfig with the default values. func DefaultTimeoutConfig() TimeoutConfig { return TimeoutConfig{ - CreateTimeout: 60 * time.Second, - DeleteTimeout: 10 * time.Second, - GetTimeout: 10 * time.Second, - GatewayMustHaveAddress: 180 * time.Second, - GatewayMustHaveCondition: 180 * time.Second, - GatewayStatusMustHaveListeners: 60 * time.Second, - GatewayListenersMustHaveConditions: 60 * time.Second, - GWCMustBeAccepted: 180 * time.Second, - HTTPRouteMustNotHaveParents: 60 * time.Second, - HTTPRouteMustHaveCondition: 60 * time.Second, - TLSRouteMustHaveCondition: 60 * time.Second, - RouteMustHaveParents: 60 * time.Second, - ManifestFetchTimeout: 10 * time.Second, - MaxTimeToConsistency: 30 * time.Second, - NamespacesMustBeReady: 300 * time.Second, - RequestTimeout: 10 * time.Second, - LatestObservedGenerationSet: 60 * time.Second, - DefaultTestTimeout: 60 * time.Second, - RequiredConsecutiveSuccesses: 3, + CreateTimeout: 60 * time.Second, + DeleteTimeout: 10 * time.Second, + GetTimeout: 10 * time.Second, + GatewayMustHaveAddress: 180 * time.Second, + GatewayMustHaveCondition: 180 * time.Second, + GatewayStatusMustHaveListeners: 60 * time.Second, + GatewayListenersMustHaveConditions: 60 * time.Second, + ListenerSetMustHaveCondition: 180 * time.Second, + ListenerSetListenersMustHaveConditions: 60 * time.Second, + GWCMustBeAccepted: 180 * time.Second, + HTTPRouteMustNotHaveParents: 60 * time.Second, + HTTPRouteMustHaveCondition: 60 * time.Second, + TLSRouteMustHaveCondition: 60 * time.Second, + RouteMustHaveParents: 60 * time.Second, + ManifestFetchTimeout: 10 * time.Second, + MaxTimeToConsistency: 30 * time.Second, + NamespacesMustBeReady: 300 * time.Second, + RequestTimeout: 10 * time.Second, + LatestObservedGenerationSet: 60 * time.Second, + DefaultTestTimeout: 60 * time.Second, + RequiredConsecutiveSuccesses: 3, } } diff --git a/conformance/utils/kubernetes/helpers.go b/conformance/utils/kubernetes/helpers.go index d32899e20e..02505c1280 100644 --- a/conformance/utils/kubernetes/helpers.go +++ b/conformance/utils/kubernetes/helpers.go @@ -22,6 +22,7 @@ import ( "fmt" "net" "reflect" + "slices" "strconv" "strings" "testing" @@ -31,12 +32,15 @@ import ( v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/gateway-api/apis/v1alpha2" + gatewayxv1a1 "sigs.k8s.io/gateway-api/apisx/v1alpha1" "sigs.k8s.io/gateway-api/conformance/utils/config" "sigs.k8s.io/gateway-api/conformance/utils/tlog" ) @@ -47,6 +51,8 @@ import ( // tests which validate fixing broken Gateways, e.t.c. const GatewayExcludedFromReadinessChecks = "gateway-api/skip-this-for-readiness" +const GatewayKind = gatewayv1.Kind("Gateway") + // GatewayRef is a tiny type for specifying an HTTP Route ParentRef without // relying on a specific api version. type GatewayRef struct { @@ -63,8 +69,7 @@ func NewGatewayRef(nn types.NamespacedName, listenerNames ...string) GatewayRef } for _, listener := range listenerNames { - sectionName := gatewayv1.SectionName(listener) - listeners = append(listeners, §ionName) + listeners = append(listeners, ptr.To(gatewayv1.SectionName(listener))) } return GatewayRef{ NamespacedName: nn, @@ -72,6 +77,32 @@ func NewGatewayRef(nn types.NamespacedName, listenerNames ...string) GatewayRef } } +// ResourceRef is a tiny type for specifying an HTTP Route ParentRef without +// relying on a specific api version. +type ResourceRef struct { + types.NamespacedName + ListenerNames []*gatewayv1.SectionName + GroupKind schema.GroupKind +} + +// NewResourceRef creates a ResourceRef resource. ListenerNames are optional. +func NewResourceRef(gk schema.GroupKind, nn types.NamespacedName, listenerNames ...string) ResourceRef { + ref := NewGatewayRef(nn, listenerNames...) + return ResourceRef{ + NamespacedName: ref.NamespacedName, + GroupKind: gk, + ListenerNames: ref.listenerNames, + } +} + +func resourceRefFromGatewayRef(gwRef GatewayRef, gk schema.GroupKind) ResourceRef { + return ResourceRef{ + NamespacedName: gwRef.NamespacedName, + GroupKind: gk, + ListenerNames: gwRef.listenerNames, + } +} + // GWCMustHaveAcceptedConditionTrue waits until the specified GatewayClass has an Accepted condition set with a status value equal to True. func GWCMustHaveAcceptedConditionTrue(t *testing.T, c client.Client, timeoutConfig config.TimeoutConfig, gwcName string) string { return gwcMustBeAccepted(t, c, timeoutConfig, gwcName, string(metav1.ConditionTrue)) @@ -294,6 +325,49 @@ func GatewayMustHaveCondition( require.NoErrorf(t, waitErr, "error waiting for Gateway status to have a Condition matching expectations") } +// ListenerSetMustHaveCondition checks that the supplied ListenerSet has the supplied Condition, +// halting after the specified timeout is exceeded. +func ListenerSetMustHaveCondition( + t *testing.T, + client client.Client, + timeoutConfig config.TimeoutConfig, + lsNN types.NamespacedName, + expectedCondition metav1.Condition, +) { + t.Helper() + + waitErr := wait.PollUntilContextTimeout( + context.Background(), + 1*time.Second, + timeoutConfig.ListenerSetMustHaveCondition, + true, + func(ctx context.Context) (bool, error) { + ls := &gatewayxv1a1.XListenerSet{} + err := client.Get(ctx, lsNN, ls) + if err != nil { + return false, fmt.Errorf("error fetching ListenerSet: %w", err) + } + + if err := ConditionsHaveLatestObservedGeneration(ls, ls.Status.Conditions); err != nil { + return false, err + } + + if findConditionInList(t, + ls.Status.Conditions, + expectedCondition.Type, + string(expectedCondition.Status), + expectedCondition.Reason, + ) { + return true, nil + } + + return false, nil + }, + ) + + require.NoErrorf(t, waitErr, "error waiting for ListenerSet %s status to have a Condition matching expectations", lsNN.String()) +} + // MeshNamespacesMustBeReady waits until all Pods are marked Ready. This is // intended to be used for mesh tests and does not require any Gateways to // exist. This will cause the test to halt if the specified timeout is exceeded. @@ -337,7 +411,6 @@ func MeshNamespacesMustBeReady(t *testing.T, c client.Client, timeoutConfig conf func GatewayAndRoutesMustBeAccepted(t *testing.T, c client.Client, timeoutConfig config.TimeoutConfig, controllerName string, gw GatewayRef, routeType any, usePort bool, routeNNs ...types.NamespacedName) string { t.Helper() - RouteTypeMustHaveParentsField(t, routeType) gwAddr, err := WaitForGatewayAddress(t, c, timeoutConfig, gw) require.NoErrorf(t, err, "timed out waiting for Gateway address to be assigned") @@ -347,55 +420,12 @@ func GatewayAndRoutesMustBeAccepted(t *testing.T, c client.Client, timeoutConfig gwAddr, _, _ = net.SplitHostPort(gwAddr) } - ns := gatewayv1.Namespace(gw.Namespace) - kind := gatewayv1.Kind("Gateway") - - for _, routeNN := range routeNNs { - namespaceRequired := true - if routeNN.Namespace == gw.Namespace { - namespaceRequired = false - } - - var parents []gatewayv1.RouteParentStatus - for _, listener := range gw.listenerNames { - parents = append(parents, gatewayv1.RouteParentStatus{ - ParentRef: gatewayv1.ParentReference{ - Group: (*gatewayv1.Group)(&gatewayv1.GroupVersion.Group), - Kind: &kind, - Name: gatewayv1.ObjectName(gw.Name), - Namespace: &ns, - SectionName: listener, - }, - ControllerName: gatewayv1.GatewayController(controllerName), - Conditions: []metav1.Condition{{ - Type: string(gatewayv1.RouteConditionAccepted), - Status: metav1.ConditionTrue, - Reason: string(gatewayv1.RouteReasonAccepted), - }}, - }) - } - RouteMustHaveParents(t, c, timeoutConfig, routeNN, parents, namespaceRequired, routeType) - } - - requiredListenerConditions := []metav1.Condition{ - { - Type: string(gatewayv1.ListenerConditionResolvedRefs), - Status: metav1.ConditionTrue, - Reason: "", // any reason - }, - { - Type: string(gatewayv1.ListenerConditionAccepted), - Status: metav1.ConditionTrue, - Reason: "", // any reason - }, - { - Type: string(gatewayv1.ListenerConditionProgrammed), - Status: metav1.ConditionTrue, - Reason: "", // any reason - }, - } - GatewayListenersMustHaveConditions(t, c, timeoutConfig, gw.NamespacedName, requiredListenerConditions) - + resourceRef := resourceRefFromGatewayRef(gw, + schema.GroupKind{ + Group: gatewayv1.GroupVersion.Group, + Kind: "Gateway", + }) + RoutesAndParentMustBeAccepted(t, c, timeoutConfig, controllerName, resourceRef, routeType, routeNNs...) return gwAddr } @@ -472,11 +502,19 @@ func getGatewayStatus(ctx context.Context, t *testing.T, client client.Client, g return gw, nil } -// GatewayListenersMustHaveConditions checks if every listener of the specified gateway has all -// the specified conditions. -func GatewayListenersMustHaveConditions(t *testing.T, client client.Client, timeoutConfig config.TimeoutConfig, gwName types.NamespacedName, conditions []metav1.Condition) { +// GatewayListenersMustHaveConditions checks if the specified listeners on the specified gateway have all +// the specified conditions. If no listener is specified, it checks all listeners on the gateway. +func GatewayListenersMustHaveConditions(t *testing.T, client client.Client, timeoutConfig config.TimeoutConfig, gwName types.NamespacedName, conditions []metav1.Condition, listenerNames ...gatewayv1.SectionName) { t.Helper() + matchListenerSubset := len(listenerNames) != 0 + matchedListeners := make(map[gatewayv1.SectionName]struct{}) + if matchListenerSubset { + for _, listenerName := range listenerNames { + matchedListeners[listenerName] = struct{}{} + } + } + waitErr := wait.PollUntilContextTimeout(context.Background(), 1*time.Second, timeoutConfig.GatewayListenersMustHaveConditions, true, func(ctx context.Context) (bool, error) { var gw gatewayv1.Gateway if err := client.Get(ctx, gwName, &gw); err != nil { @@ -485,17 +523,28 @@ func GatewayListenersMustHaveConditions(t *testing.T, client client.Client, time for _, condition := range conditions { for _, listener := range gw.Status.Listeners { + // Skip if the listener is not in listenerNames + if matchListenerSubset && !slices.Contains(listenerNames, listener.Name) { + continue + } + if !findConditionInList(t, listener.Conditions, condition.Type, string(condition.Status), condition.Reason) { tlog.Logf(t, "gateway %s doesn't have %s condition set to %s on %s listener", gwName, condition.Type, condition.Status, listener.Name) return false, nil } + + delete(matchedListeners, listener.Name) } } + if len(matchedListeners) != 0 { + return false, nil + } + return true, nil }) - require.NoErrorf(t, waitErr, "error waiting for Gateway status to have conditions matching expectations on all listeners") + require.NoErrorf(t, waitErr, "error waiting for Gateway status to have conditions matching expectations on the listeners") } // GatewayMustHaveZeroRoutes validates that the gateway has zero routes attached. The status @@ -683,11 +732,11 @@ func parentsForRouteMatch(t *testing.T, routeName types.NamespacedName, expected return false } if !reflect.DeepEqual(aParent.ParentRef.Group, eParent.ParentRef.Group) { - tlog.Logf(t, "Route %s expected ParentReference.Group to be %v, got %v", routeName, eParent.ParentRef.Group, aParent.ParentRef.Group) + tlog.Logf(t, "Route %s expected ParentReference.Group to be %v, got %v", routeName, ptr.Deref(eParent.ParentRef.Group, gatewayv1.Group(gatewayv1.GroupVersion.Group)), ptr.Deref(eParent.ParentRef.Group, gatewayv1.Group(gatewayv1.GroupVersion.Group))) return false } if !reflect.DeepEqual(aParent.ParentRef.Kind, eParent.ParentRef.Kind) { - tlog.Logf(t, "Route %s expected ParentReference.Kind to be %v, got %v", routeName, eParent.ParentRef.Kind, aParent.ParentRef.Kind) + tlog.Logf(t, "Route %s expected ParentReference.Kind to be %v, got %v", routeName, ptr.Deref(eParent.ParentRef.Kind, GatewayKind), ptr.Deref(eParent.ParentRef.Kind, GatewayKind)) return false } if aParent.ParentRef.Name != eParent.ParentRef.Name { @@ -799,7 +848,7 @@ func GatewayAndTLSRoutesMustBeAccepted(t *testing.T, c client.Client, timeoutCon require.NoErrorf(t, err, "timed out waiting for Gateway address to be assigned") ns := gatewayv1.Namespace(gw.Namespace) - kind := gatewayv1.Kind("Gateway") + kind := GatewayKind for _, routeNN := range routeNNs { namespaceRequired := true @@ -869,6 +918,132 @@ func TLSRouteMustHaveCondition(t *testing.T, client client.Client, timeoutConfig require.NoErrorf(t, waitErr, "error waiting for TLSRoute status to have a Condition matching expectations") } +// RoutesAndParentMustBeAccepted waits until: +// 1. The route has a ParentRef referring to the parent. +// 2. All the parent's listeners have the following conditions set to true: +// - ListenerConditionResolvedRefs +// - ListenerConditionAccepted +// - ListenerConditionProgrammed +// +// The test will fail if these conditions are not met before the timeouts. +func RoutesAndParentMustBeAccepted(t *testing.T, c client.Client, timeoutConfig config.TimeoutConfig, controllerName string, resource ResourceRef, routeType any, routeNNs ...types.NamespacedName) { + t.Helper() + + RouteTypeMustHaveParentsField(t, routeType) + + ns := gatewayv1.Namespace(resource.Namespace) + + for _, routeNN := range routeNNs { + namespaceRequired := true + if routeNN.Namespace == resource.Namespace { + namespaceRequired = false + } + + var parents []gatewayv1.RouteParentStatus + for _, listener := range resource.ListenerNames { + parents = append(parents, gatewayv1.RouteParentStatus{ + ParentRef: gatewayv1.ParentReference{ + Group: (*gatewayv1.Group)(&resource.GroupKind.Group), + Kind: (*gatewayv1.Kind)(&resource.GroupKind.Kind), + Name: gatewayv1.ObjectName(resource.Name), + Namespace: &ns, + SectionName: listener, + }, + ControllerName: gatewayv1.GatewayController(controllerName), + Conditions: []metav1.Condition{{ + Type: string(gatewayv1.RouteConditionAccepted), + Status: metav1.ConditionTrue, + Reason: string(gatewayv1.RouteReasonAccepted), + }}, + }) + } + RouteMustHaveParents(t, c, timeoutConfig, routeNN, parents, namespaceRequired, routeType) + } + + requiredListenerConditions := []metav1.Condition{ + { + Type: string(gatewayv1.ListenerConditionResolvedRefs), + Status: metav1.ConditionTrue, + Reason: "", // any reason + }, + { + Type: string(gatewayv1.ListenerConditionAccepted), + Status: metav1.ConditionTrue, + Reason: "", // any reason + }, + { + Type: string(gatewayv1.ListenerConditionProgrammed), + Status: metav1.ConditionTrue, + Reason: "", // any reason + }, + } + ResourceListenersMustHaveConditions(t, c, timeoutConfig, resource, requiredListenerConditions) +} + +// ResourceListenersMustHaveConditions checks if every listener of the specified resource has all +// the specified conditions. +func ResourceListenersMustHaveConditions(t *testing.T, client client.Client, timeoutConfig config.TimeoutConfig, resource ResourceRef, conditions []metav1.Condition) { + t.Helper() + + switch resource.GroupKind.Kind { + case "Gateway": + GatewayListenersMustHaveConditions(t, client, timeoutConfig, resource.NamespacedName, conditions) + case "XListenerSet": + ListenerSetListenersMustHaveConditions(t, client, timeoutConfig, resource.NamespacedName, conditions) + default: + tlog.Errorf(t, "received unsupported resource kind %s. Supported kinds are `Gateway` and `XListenerSet`", resource.GroupKind.Kind) + } +} + +// ListenerSetListenersMustHaveConditions checks if the specified listeners on the specified listenerSet have all +// the specified conditions. If no listener is specified, it checks all listeners on the listenerSet. +func ListenerSetListenersMustHaveConditions(t *testing.T, client client.Client, timeoutConfig config.TimeoutConfig, lsName types.NamespacedName, conditions []metav1.Condition, listenerNames ...gatewayv1.SectionName) { + t.Helper() + + matchListenerSubset := len(listenerNames) != 0 + matchedListeners := make(map[gatewayv1.SectionName]struct{}) + if matchListenerSubset { + for _, listenerName := range listenerNames { + matchedListeners[listenerName] = struct{}{} + } + } + + waitErr := wait.PollUntilContextTimeout(context.Background(), 1*time.Second, timeoutConfig.ListenerSetListenersMustHaveConditions, true, func(ctx context.Context) (bool, error) { + var parent gatewayxv1a1.XListenerSet + if err := client.Get(ctx, lsName, &parent); err != nil { + return false, fmt.Errorf("error fetching Gateway: %w", err) + } + + if err := ConditionsHaveLatestObservedGeneration(&parent, parent.Status.Conditions); err != nil { + return false, err + } + + for _, condition := range conditions { + for _, listener := range parent.Status.Listeners { + // Skip if the listener is not in listenerNames + if matchListenerSubset && !slices.Contains(listenerNames, listener.Name) { + continue + } + + if !findConditionInList(t, listener.Conditions, condition.Type, string(condition.Status), condition.Reason) { + tlog.Logf(t, "listener set %s doesn't have %s condition set to %s on %s listener", lsName.Name, condition.Type, condition.Status, listener.Name) + return false, nil + } + + delete(matchedListeners, listener.Name) + } + } + + if len(matchedListeners) != 0 { + return false, nil + } + + return true, nil + }) + + require.NoErrorf(t, waitErr, "error waiting for ListenerSet status to have conditions matching expectations on the listeners") +} + // TODO(mikemorris): this and parentsMatch could possibly be rewritten as a generic function? func listenersMatch(t *testing.T, expected, actual []gatewayv1.ListenerStatus) bool { t.Helper() diff --git a/pkg/features/gateway.go b/pkg/features/gateway.go index e6cf715f7e..7b47bf0a7e 100644 --- a/pkg/features/gateway.go +++ b/pkg/features/gateway.go @@ -64,6 +64,10 @@ const ( // SupportGatewayAddressEmpty option indicates support for an empty // spec.addresses.value field SupportGatewayAddressEmpty FeatureName = "GatewayAddressEmpty" + + // SupportGatewayListenerSet option indicates support for a Gateway + // with ListenerSets + SupportGatewayListenerSet FeatureName = "GatewayListenerSet" ) var ( @@ -92,6 +96,11 @@ var ( Name: SupportGatewayAddressEmpty, Channel: FeatureChannelStandard, } + // GatewayListenerSetFeature contains metadata for the SupportGatewayListenerSet feature. + GatewayListenerSetFeature = Feature{ + Name: SupportGatewayListenerSet, + Channel: FeatureChannelExperimental, + } ) // GatewayExtendedFeatures are extra generic features that implementations may @@ -102,4 +111,5 @@ var GatewayExtendedFeatures = sets.New( GatewayHTTPListenerIsolationFeature, GatewayInfrastructurePropagationFeature, GatewayEmptyAddressFeature, + GatewayListenerSetFeature, )