diff --git a/conformance/tests/tlsroute-invalid-no-matching-listener-hostname.go b/conformance/tests/tlsroute-invalid-no-matching-listener-hostname.go new file mode 100644 index 0000000000..ade6ae4607 --- /dev/null +++ b/conformance/tests/tlsroute-invalid-no-matching-listener-hostname.go @@ -0,0 +1,81 @@ +/* +Copyright 2026 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" + + v1 "sigs.k8s.io/gateway-api/apis/v1" + "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, TLSRouteInvalidNoMatchingListenerHostname) +} + +var TLSRouteInvalidNoMatchingListenerHostname = suite.ConformanceTest{ + ShortName: "TLSRouteInvalidNoMatchingListenerHostname", + Description: "A TLSRoute with a hostname that does not match the Gateway listener hostname should set Accepted=False with Reason=NoMatchingListenerHostname", + Features: []features.FeatureName{ + features.SupportGateway, + features.SupportTLSRoute, + }, + Manifests: []string{"tests/tlsroute-invalid-no-matching-listener-hostname.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + route1NN := types.NamespacedName{Name: "tlsroute-hostname-mismatch-1", Namespace: ns} + route2NN := types.NamespacedName{Name: "tlsroute-hostname-mismatch-2", Namespace: ns} + exactGwNN := types.NamespacedName{Name: "gateway-tls-exact-hostname", Namespace: ns} + wildcardGwNN := types.NamespacedName{Name: "gateway-tls-wildcard-hostname", Namespace: ns} + kubernetes.NamespacesMustBeReady(t, suite.Client, suite.TimeoutConfig, []string{ns}) + + acceptedCond := metav1.Condition{ + Type: string(v1.RouteConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(v1.RouteReasonNoMatchingListenerHostname), + } + + t.Run("TLSRoute 1 has Accepted=False for exact hostname Gateway", func(t *testing.T) { + kubernetes.TLSRouteMustHaveCondition(t, suite.Client, suite.TimeoutConfig, route1NN, exactGwNN, acceptedCond) + }) + + t.Run("TLSRoute 2 has Accepted=False for wildcard hostname Gateway", func(t *testing.T) { + kubernetes.TLSRouteMustHaveCondition(t, suite.Client, suite.TimeoutConfig, route2NN, wildcardGwNN, acceptedCond) + }) + + t.Run("TLSRoute 1 must have no accepted parents", func(t *testing.T) { + kubernetes.TLSRouteMustHaveNoAcceptedParents(t, suite.Client, suite.TimeoutConfig, route1NN) + }) + + t.Run("TLSRoute 2 must have no accepted parents", func(t *testing.T) { + kubernetes.TLSRouteMustHaveNoAcceptedParents(t, suite.Client, suite.TimeoutConfig, route2NN) + }) + + t.Run("Exact hostname Gateway must have 0 Routes attached", func(t *testing.T) { + kubernetes.GatewayMustHaveZeroRoutes(t, suite.Client, suite.TimeoutConfig, exactGwNN) + }) + + t.Run("Wildcard hostname Gateway must have 0 Routes attached", func(t *testing.T) { + kubernetes.GatewayMustHaveZeroRoutes(t, suite.Client, suite.TimeoutConfig, wildcardGwNN) + }) + }, +} diff --git a/conformance/tests/tlsroute-invalid-no-matching-listener-hostname.yaml b/conformance/tests/tlsroute-invalid-no-matching-listener-hostname.yaml new file mode 100644 index 0000000000..82f3844251 --- /dev/null +++ b/conformance/tests/tlsroute-invalid-no-matching-listener-hostname.yaml @@ -0,0 +1,72 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-tls-exact-hostname + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: tls + port: 443 + protocol: TLS + hostname: "www.example.com" + allowedRoutes: + namespaces: + from: Same + kinds: + - kind: TLSRoute + tls: + mode: Passthrough +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-tls-wildcard-hostname + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: tls + port: 443 + protocol: TLS + hostname: "*.example.com" + allowedRoutes: + namespaces: + from: Same + kinds: + - kind: TLSRoute + tls: + mode: Passthrough +--- +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TLSRoute +metadata: + name: tlsroute-hostname-mismatch-1 + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: gateway-tls-exact-hostname + hostnames: + - "www.nomatch.com" + - "*.nomatch.com" + rules: + - backendRefs: + - name: tls-backend + port: 443 +--- +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TLSRoute +metadata: + name: tlsroute-hostname-mismatch-2 + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: gateway-tls-wildcard-hostname + hostnames: + - "www.different.com" + - "*.different.com" + rules: + - backendRefs: + - name: tls-backend + port: 443 diff --git a/conformance/tests/tlsroute-invalid-no-matching-listener.go b/conformance/tests/tlsroute-invalid-no-matching-listener.go new file mode 100644 index 0000000000..04e475b84f --- /dev/null +++ b/conformance/tests/tlsroute-invalid-no-matching-listener.go @@ -0,0 +1,97 @@ +/* +Copyright 2026 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" + + v1 "sigs.k8s.io/gateway-api/apis/v1" + "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, TLSRouteInvalidNoMatchingListener) +} + +var TLSRouteInvalidNoMatchingListener = suite.ConformanceTest{ + ShortName: "TLSRouteInvalidNoMatchingListener", + Description: "A TLSRoute should set Accepted=False when attaching to a Gateway with no compatible TLS listener", + Features: []features.FeatureName{ + features.SupportGateway, + features.SupportTLSRoute, + }, + Manifests: []string{"tests/tlsroute-invalid-no-matching-listener.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + routeNoMatchingPortNN := types.NamespacedName{Name: "tlsroute-no-matching-listener", Namespace: "gateway-conformance-infra"} + routeNotAllowedKindNN := types.NamespacedName{Name: "tlsroute-not-allowed-kind", Namespace: "gateway-conformance-infra"} + routeWrongProtocolNN := types.NamespacedName{Name: "tlsroute-wrong-protocol", Namespace: "gateway-conformance-infra"} + routeWrongSectionNN := types.NamespacedName{Name: "tlsroute-wrong-section-name", Namespace: "gateway-conformance-infra"} + + gwHTTPOnlyNN := types.NamespacedName{Name: "gateway-http-only", Namespace: "gateway-conformance-infra"} + gwTLSHTTPRouteOnlyNN := types.NamespacedName{Name: "gateway-tls-httproute-only", Namespace: "gateway-conformance-infra"} + gwHTTPSOnlyNN := types.NamespacedName{Name: "gateway-https-only", Namespace: "gateway-conformance-infra"} + + acceptedCondNoMatchingParent := metav1.Condition{ + Type: string(v1.RouteConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(v1.RouteReasonNoMatchingParent), + } + acceptedCondNotAllowed := metav1.Condition{ + Type: string(v1.RouteConditionAccepted), + Status: metav1.ConditionFalse, + Reason: string(v1.RouteReasonNotAllowedByListeners), + } + t.Run("TLSRoute rejected when listener protocol is HTTP", func(t *testing.T) { + kubernetes.TLSRouteMustHaveCondition(t, suite.Client, suite.TimeoutConfig, routeNoMatchingPortNN, gwHTTPOnlyNN, acceptedCondNotAllowed) + }) + t.Run("TLSRoute rejected when kind not allowed", func(t *testing.T) { + kubernetes.TLSRouteMustHaveCondition(t, suite.Client, suite.TimeoutConfig, routeNotAllowedKindNN, gwTLSHTTPRouteOnlyNN, acceptedCondNotAllowed) + }) + t.Run("TLSRoute rejected when listener protocol is HTTPS", func(t *testing.T) { + kubernetes.TLSRouteMustHaveCondition(t, suite.Client, suite.TimeoutConfig, routeWrongProtocolNN, gwHTTPSOnlyNN, acceptedCondNotAllowed) + }) + t.Run("TLSRoute rejected when sectionName not found", func(t *testing.T) { + kubernetes.TLSRouteMustHaveCondition(t, suite.Client, suite.TimeoutConfig, routeWrongSectionNN, gwHTTPOnlyNN, acceptedCondNoMatchingParent) + }) + t.Run("TLSRoute (HTTP listener) should not have Parents accepted", func(t *testing.T) { + kubernetes.TLSRouteMustHaveNoAcceptedParents(t, suite.Client, suite.TimeoutConfig, routeNoMatchingPortNN) + }) + t.Run("TLSRoute (kind not allowed) should not have Parents accepted", func(t *testing.T) { + kubernetes.TLSRouteMustHaveNoAcceptedParents(t, suite.Client, suite.TimeoutConfig, routeNotAllowedKindNN) + }) + t.Run("TLSRoute (HTTPS listener) should not have Parents accepted", func(t *testing.T) { + kubernetes.TLSRouteMustHaveNoAcceptedParents(t, suite.Client, suite.TimeoutConfig, routeWrongProtocolNN) + }) + t.Run("TLSRoute (wrong sectionName) should not have Parents accepted", func(t *testing.T) { + kubernetes.TLSRouteMustHaveNoAcceptedParents(t, suite.Client, suite.TimeoutConfig, routeWrongSectionNN) + }) + t.Run("Gateway HTTP-only should have 0 Routes attached", func(t *testing.T) { + kubernetes.GatewayMustHaveZeroRoutes(t, suite.Client, suite.TimeoutConfig, gwHTTPOnlyNN) + }) + t.Run("Gateway TLS-HTTPRoute-only should have 0 Routes attached", func(t *testing.T) { + kubernetes.GatewayMustHaveZeroRoutes(t, suite.Client, suite.TimeoutConfig, gwTLSHTTPRouteOnlyNN) + }) + t.Run("Gateway HTTPS-only should have 0 Routes attached", func(t *testing.T) { + kubernetes.GatewayMustHaveZeroRoutes(t, suite.Client, suite.TimeoutConfig, gwHTTPSOnlyNN) + }) + }, +} diff --git a/conformance/tests/tlsroute-invalid-no-matching-listener.yaml b/conformance/tests/tlsroute-invalid-no-matching-listener.yaml new file mode 100644 index 0000000000..e58e6562a1 --- /dev/null +++ b/conformance/tests/tlsroute-invalid-no-matching-listener.yaml @@ -0,0 +1,117 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-http-only + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: http + port: 80 + protocol: HTTP + allowedRoutes: + namespaces: + from: Same + kinds: + - kind: TLSRoute +--- +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TLSRoute +metadata: + name: tlsroute-no-matching-listener + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: gateway-http-only + hostnames: + - tls.example.com + rules: + - backendRefs: + - name: tls-backend + port: 443 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-tls-httproute-only + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: http + port: 80 + protocol: HTTP + allowedRoutes: + namespaces: + from: Same + kinds: + - kind: HTTPRoute +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: gateway-https-only + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: https + port: 443 + protocol: HTTPS + allowedRoutes: + namespaces: + from: Same + kinds: + - kind: TLSRoute + tls: + mode: Terminate + certificateRefs: + - name: tls-validity-checks-certificate + namespace: gateway-conformance-infra +--- +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TLSRoute +metadata: + name: tlsroute-not-allowed-kind + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: gateway-tls-httproute-only + hostnames: + - tls.example.com + rules: + - backendRefs: + - name: tls-backend + port: 443 +--- +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TLSRoute +metadata: + name: tlsroute-wrong-protocol + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: gateway-https-only + hostnames: + - tls.example.com + rules: + - backendRefs: + - name: tls-backend + port: 443 +--- +apiVersion: gateway.networking.k8s.io/v1alpha2 +kind: TLSRoute +metadata: + name: tlsroute-wrong-section-name + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: gateway-http-only + sectionName: nonexistent-listener + hostnames: + - tls.example.com + rules: + - backendRefs: + - name: tls-backend + port: 443 diff --git a/conformance/utils/kubernetes/helpers.go b/conformance/utils/kubernetes/helpers.go index 6bc0b179c5..4256f11658 100644 --- a/conformance/utils/kubernetes/helpers.go +++ b/conformance/utils/kubernetes/helpers.go @@ -680,6 +680,51 @@ func HTTPRouteMustHaveNoAcceptedParents(t *testing.T, client client.Client, time require.NoErrorf(t, waitErr, "error waiting for HTTPRoute to have no accepted parents") } +// TLSRouteMustHaveNoAcceptedParents waits for the specified TLSRoute to have either no parents +// or a single parent that is not accepted. This is used to validate TLSRoute errors. +func TLSRouteMustHaveNoAcceptedParents(t *testing.T, client client.Client, timeoutConfig config.TimeoutConfig, routeName types.NamespacedName) { + t.Helper() + + var actual []v1alpha2.RouteParentStatus + emptyChecked := false + waitErr := wait.PollUntilContextTimeout(context.Background(), 1*time.Second, timeoutConfig.HTTPRouteMustNotHaveParents, true, func(ctx context.Context) (bool, error) { + route := &v1alpha2.TLSRoute{} + err := client.Get(ctx, routeName, route) + if err != nil { + return false, fmt.Errorf("error fetching TLSRoute: %w", err) + } + + actual = route.Status.Parents + + if len(actual) == 0 { + // For empty status, we need to distinguish between "correctly did not set" and "hasn't set yet" + // Ensure we iterate at least two times (taking advantage of the 1s poll delay) to give it some time. + if !emptyChecked { + emptyChecked = true + return false, nil + } + return true, nil + } + if len(actual) > 1 { + // Only expect one parent + return false, nil + } + + for _, parent := range actual { + if err := ConditionsHaveLatestObservedGeneration(route, parent.Conditions); err != nil { + tlog.Logf(t, "TLSRoute %s (controller=%v,ref=%#v) %v", routeName, parent.ControllerName, parent, err) + return false, nil + } + } + + return conditionsMatch(t, []metav1.Condition{{ + Type: string(gatewayv1.RouteConditionAccepted), + Status: "False", + }}, actual[0].Conditions), nil + }) + require.NoErrorf(t, waitErr, "error waiting for TLSRoute to have no accepted parents") +} + // RouteTypeMustHaveParentsField ensures the provided routeType has a // routeType.Status.Parents field of type []v1alpha2.RouteParentStatus. func RouteTypeMustHaveParentsField(t *testing.T, routeType any) string {