diff --git a/internal/gatewayapi/helpers.go b/internal/gatewayapi/helpers.go index 03736100c3..b90b00c495 100644 --- a/internal/gatewayapi/helpers.go +++ b/internal/gatewayapi/helpers.go @@ -203,6 +203,15 @@ func isRefToListenerSet(parentRef gwapiv1.ParentReference) bool { return false } +func HasReadyListener(listeners []*ListenerContext) bool { + for _, listener := range listeners { + if listener.IsReady() { + return true + } + } + return false +} + // ValidateHTTPRouteFilter validates the provided filter within HTTPRoute. func ValidateHTTPRouteFilter(filter *gwapiv1.HTTPRouteFilter, extGKs ...schema.GroupKind) error { switch { diff --git a/internal/gatewayapi/route.go b/internal/gatewayapi/route.go index 978f9e0eaa..2dac879ac8 100644 --- a/internal/gatewayapi/route.go +++ b/internal/gatewayapi/route.go @@ -110,6 +110,16 @@ func (t *Translator) ProcessGRPCRoutes(grpcRoutes []*gwapiv1.GRPCRoute, gateways return relevantGRPCRoutes } +// gatewayAcceptedMsg returns the message embedding the Gateway's generation +// so that a gateway-only update changes the message text and breaks the watchable DeepEqual gate +// observedGeneration stays as the route's own generation to remain spec-compliant. +func (t *Translator) gatewayMsg(gw *GatewayContext, base string) string { + if gw != nil && gw.GetGeneration() > 0 { + return fmt.Sprintf("%s (gateway generation: %d)", base, gw.GetGeneration()) + } + return base +} + func (t *Translator) processHTTPRouteParentRefs(httpRoute *HTTPRouteContext, resources *resource.Resources, xdsIR resource.XdsIRMap) { for _, parentRef := range httpRoute.ParentRefs { // Need to compute Route rules within the parentRef loop because @@ -190,7 +200,7 @@ func (t *Translator) processHTTPRouteParentRefs(httpRoute *HTTPRouteContext, res gwapiv1.RouteConditionAccepted, metav1.ConditionFalse, gwapiv1.RouteReasonNoMatchingListenerHostname, - "There were no hostname intersections between the HTTPRoute and this parent ref's Listener(s).", + t.gatewayMsg(parentRef.GetGateway(), "There were no hostname intersections between the HTTPRoute and this parent ref's Listener(s)."), ) } @@ -209,7 +219,7 @@ func (t *Translator) processHTTPRouteParentRefs(httpRoute *HTTPRouteContext, res gwapiv1.RouteConditionAccepted, metav1.ConditionTrue, gwapiv1.RouteReasonAccepted, - "Route is accepted", + t.gatewayMsg(parentRef.GetGateway(), "Route is accepted"), ) } } @@ -928,7 +938,7 @@ func (t *Translator) processGRPCRouteParentRefs(grpcRoute *GRPCRouteContext, res gwapiv1.RouteConditionAccepted, metav1.ConditionFalse, gwapiv1.RouteReasonNoMatchingListenerHostname, - "There were no hostname intersections between the GRPCRoute and this parent ref's Listener(s).", + t.gatewayMsg(parentRef.GetGateway(), "There were no hostname intersections between the GRPCRoute and this parent ref's Listener(s)."), ) } @@ -942,7 +952,7 @@ func (t *Translator) processGRPCRouteParentRefs(grpcRoute *GRPCRouteContext, res gwapiv1.RouteConditionAccepted, metav1.ConditionTrue, gwapiv1.RouteReasonAccepted, - "Route is accepted", + t.gatewayMsg(parentRef.GetGateway(), "Route is accepted"), ) } @@ -1503,7 +1513,7 @@ func (t *Translator) processTLSRouteParentRefs(tlsRoute *TLSRouteContext, resour gwapiv1.RouteConditionAccepted, metav1.ConditionFalse, gwapiv1.RouteReasonNoMatchingListenerHostname, - "There were no hostname intersections between the TLSRoute and this parent ref's Listener(s).", + t.gatewayMsg(parentRef.GetGateway(), "There were no hostname intersections between the TLSRoute and this parent ref's Listener(s)."), ) } @@ -1522,7 +1532,7 @@ func (t *Translator) processTLSRouteParentRefs(tlsRoute *TLSRouteContext, resour gwapiv1.RouteConditionAccepted, metav1.ConditionTrue, gwapiv1.RouteReasonAccepted, - "Route is accepted", + t.gatewayMsg(parentRef.GetGateway(), "Route is accepted"), ) } } @@ -1663,7 +1673,7 @@ func (t *Translator) processUDPRouteParentRefs(udpRoute *UDPRouteContext, resour gwapiv1.RouteConditionAccepted, metav1.ConditionTrue, gwapiv1.RouteReasonAccepted, - "Route is accepted", + t.gatewayMsg(parentRef.GetGateway(), "Route is accepted"), ) } @@ -1675,7 +1685,7 @@ func (t *Translator) processUDPRouteParentRefs(udpRoute *UDPRouteContext, resour gwapiv1.RouteConditionAccepted, metav1.ConditionFalse, gwapiv1.RouteReasonUnsupportedValue, - "Multiple routes on the same UDP listener", + t.gatewayMsg(parentRef.GetGateway(), "Multiple routes on the same UDP listener"), ) } } @@ -1825,7 +1835,7 @@ func (t *Translator) processTCPRouteParentRefs(tcpRoute *TCPRouteContext, resour gwapiv1.RouteConditionAccepted, metav1.ConditionTrue, gwapiv1.RouteReasonAccepted, - "Route is accepted", + t.gatewayMsg(parentRef.GetGateway(), "Route is accepted"), ) } if !accepted { @@ -1836,7 +1846,7 @@ func (t *Translator) processTCPRouteParentRefs(tcpRoute *TCPRouteContext, resour gwapiv1.RouteConditionAccepted, metav1.ConditionFalse, gwapiv1.RouteReasonUnsupportedValue, - "Multiple routes on the same TCP listener", + t.gatewayMsg(parentRef.GetGateway(), "Multiple routes on the same TCP listener"), ) } @@ -2292,7 +2302,7 @@ func (t *Translator) processAllowedListenersForParentRefs( gwapiv1.RouteConditionAccepted, metav1.ConditionFalse, gwapiv1.RouteReasonNoMatchingParent, - "No listeners match this parent ref", + t.gatewayMsg(parentRefCtx.GetGateway(), "No listeners match this parent ref"), ) continue } @@ -2306,6 +2316,11 @@ func (t *Translator) processAllowedListenersForParentRefs( } } + var selectedGateway *GatewayContext + if len(selectedListeners) > 0 { + selectedGateway = selectedListeners[0].gateway + } + if len(allowedListeners) == 0 { routeStatus := GetRouteStatus(routeContext) status.SetRouteStatusCondition(routeStatus, @@ -2314,7 +2329,7 @@ func (t *Translator) processAllowedListenersForParentRefs( gwapiv1.RouteConditionAccepted, metav1.ConditionFalse, gwapiv1.RouteReasonNotAllowedByListeners, - "No listeners included by this parent ref allowed this attachment.", + t.gatewayMsg(selectedGateway, "No listeners included by this parent ref allowed this attachment."), ) continue } @@ -2327,7 +2342,7 @@ func (t *Translator) processAllowedListenersForParentRefs( gwapiv1.RouteConditionAccepted, metav1.ConditionTrue, gwapiv1.RouteReasonAccepted, - "Route is accepted", + t.gatewayMsg(parentRefCtx.GetGateway(), "Route is accepted"), ) } return relevantRoute diff --git a/internal/gatewayapi/runner/runner_test.go b/internal/gatewayapi/runner/runner_test.go index b85eb102a3..041a601e7f 100644 --- a/internal/gatewayapi/runner/runner_test.go +++ b/internal/gatewayapi/runner/runner_test.go @@ -11,11 +11,16 @@ import ( "maps" "os" "path/filepath" + "strings" "testing" "time" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + discoveryv1 "k8s.io/api/discovery/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/intstr" gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" gwapiv1a2 "sigs.k8s.io/gateway-api/apis/v1alpha2" @@ -23,6 +28,7 @@ import ( "github.com/envoyproxy/gateway/internal/crypto" "github.com/envoyproxy/gateway/internal/envoygateway/config" "github.com/envoyproxy/gateway/internal/extension/registry" + "github.com/envoyproxy/gateway/internal/gatewayapi/resource" "github.com/envoyproxy/gateway/internal/ir" "github.com/envoyproxy/gateway/internal/message" pb "github.com/envoyproxy/gateway/proto/extension" @@ -522,3 +528,421 @@ func TestLoadTLSConfig_HostMode(t *testing.T) { require.Equal(t, tls.RequireAndVerifyClientCert, tlsConfig.ClientAuth) require.Equal(t, uint16(tls.VersionTLS13), tlsConfig.MinVersion) } + +func TestHTTPRouteStatusStaleAfterListenerHostnameChange(t *testing.T) { + pResources := new(message.ProviderResources) + xdsIR := new(message.XdsIR) + infraIR := new(message.InfraIR) + cfg, err := config.New(os.Stdout, os.Stderr) + require.NoError(t, err) + extMgr, closeFunc, err := registry.NewInMemoryManager(&egv1a1.ExtensionManager{}, &pb.UnimplementedEnvoyGatewayExtensionServer{}) + require.NoError(t, err) + defer closeFunc() + + r := New(&Config{ + Server: *cfg, + ProviderResources: pResources, + RunnerErrors: new(message.RunnerErrors), + XdsIR: xdsIR, + InfraIR: infraIR, + ExtensionManager: extMgr, + }) + err = r.Start(t.Context()) + require.NoError(t, err) + + routeKey := types.NamespacedName{Namespace: "default", Name: "httproute-1"} + + // buildResources constructs a minimal ControllerResources snapshot. + // The HTTPRoute's generation is fixed at 1 throughout, only the Gateway changes. + buildResources := func(listenerHostname string, gatewayGeneration int64) *resource.ControllerResources { + listenerHostnameTyped := gwapiv1.Hostname(listenerHostname) + res := resource.NewResources() + res.GatewayClass = &gwapiv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "envoy-gateway-class"}, + Spec: gwapiv1.GatewayClassSpec{ + ControllerName: egv1a1.GatewayControllerName, + }, + } + res.Gateways = []*gwapiv1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "envoy-gateway", + Name: "gateway-1", + Generation: gatewayGeneration, + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "envoy-gateway-class", + Listeners: []gwapiv1.Listener{ + { + Name: "http", + Protocol: gwapiv1.HTTPProtocolType, + Port: 80, + Hostname: &listenerHostnameTyped, + AllowedRoutes: &gwapiv1.AllowedRoutes{ + Namespaces: &gwapiv1.RouteNamespaces{ + From: func() *gwapiv1.FromNamespaces { + f := gwapiv1.NamespacesFromAll + return &f + }(), + }, + }, + }, + }, + }, + }, + } + routeHostname := gwapiv1.Hostname("app.example.com") + res.HTTPRoutes = []*gwapiv1.HTTPRoute{ + { + TypeMeta: metav1.TypeMeta{ + APIVersion: "gateway.networking.k8s.io/v1", + Kind: "HTTPRoute", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-1", + Generation: 1, // intentionally fixed — route is never edited + }, + Spec: gwapiv1.HTTPRouteSpec{ + CommonRouteSpec: gwapiv1.CommonRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Namespace: func() *gwapiv1.Namespace { + ns := gwapiv1.Namespace("envoy-gateway") + return &ns + }(), + Name: "gateway-1", + }, + }, + }, + Hostnames: []gwapiv1.Hostname{routeHostname}, + Rules: []gwapiv1.HTTPRouteRule{ + { + BackendRefs: []gwapiv1.HTTPBackendRef{ + { + BackendRef: gwapiv1.BackendRef{ + BackendObjectReference: gwapiv1.BackendObjectReference{ + Name: "service-1", + Port: func() *gwapiv1.PortNumber { + p := gwapiv1.PortNumber(8080) + return &p + }(), + }, + }, + }, + }, + }, + }, + }, + }, + } + res.Namespaces = []*corev1.Namespace{ + {ObjectMeta: metav1.ObjectMeta{Name: "envoy-gateway"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "default"}}, + } + res.Services = []*corev1.Service{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "service-1"}, + Spec: corev1.ServiceSpec{ + ClusterIP: "1.1.1.1", + Ports: []corev1.ServicePort{ + {Name: "http", Port: 8080, TargetPort: intstr.FromInt32(8080), Protocol: corev1.ProtocolTCP}, + }, + }, + }, + } + res.EndpointSlices = []*discoveryv1.EndpointSlice{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "endpointslice-1", + Labels: map[string]string{discoveryv1.LabelServiceName: "service-1"}, + }, + AddressType: discoveryv1.AddressTypeIPv4, + Ports: []discoveryv1.EndpointPort{ + {Name: func() *string { s := "http"; return &s }(), Port: func() *int32 { p := int32(8080); return &p }(), Protocol: func() *corev1.Protocol { p := corev1.ProtocolTCP; return &p }()}, + }, + Endpoints: []discoveryv1.Endpoint{ + {Addresses: []string{"7.7.7.7"}, Conditions: discoveryv1.EndpointConditions{Ready: func() *bool { b := true; return &b }()}}, + }, + }, + } + cr := resource.ControllerResources{res} + return &cr + } + + // Cycle 1: Gateway gen=1, listener hostname does NOT match the route hostname + // Route: "app.example.com", Listener: "other.example.com", Accepted=False/NoMatchingListenerHostname + pResources.GatewayAPIResources.Store(egv1a1.GatewayControllerName, &resource.ControllerResourcesContext{ + Resources: buildResources("other.example.com", 1), + }) + + require.Eventually(t, func() bool { + status, ok := pResources.HTTPRouteStatuses.Load(routeKey) + if !ok || status == nil || len(status.Parents) == 0 { + return false + } + for _, cond := range status.Parents[0].Conditions { + if cond.Type == string(gwapiv1.RouteConditionAccepted) { + return cond.Status == metav1.ConditionFalse && + cond.Reason == string(gwapiv1.RouteReasonNoMatchingListenerHostname) && + cond.ObservedGeneration == 1 + } + } + return false + }, 5*time.Second, 50*time.Millisecond, + "cycle 1: expected Accepted=False/NoMatchingListenerHostname with ObservedGeneration=1") + + // Cycle 2: Gateway gen=2, still mismatching hostname + pResources.GatewayAPIResources.Store(egv1a1.GatewayControllerName, &resource.ControllerResourcesContext{ + Resources: buildResources("other.example.com", 2), + }) + + require.Eventually(t, func() bool { + status, ok := pResources.HTTPRouteStatuses.Load(routeKey) + if !ok || status == nil || len(status.Parents) == 0 { + return false + } + for _, cond := range status.Parents[0].Conditions { + if cond.Type == string(gwapiv1.RouteConditionAccepted) { + return cond.Status == metav1.ConditionFalse && + cond.Reason == string(gwapiv1.RouteReasonNoMatchingListenerHostname) && + cond.ObservedGeneration == 1 && + strings.Contains(cond.Message, "gateway generation: 2") + } + } + return false + }, 5*time.Second, 50*time.Millisecond, + "cycle 2: expected message to contain 'gateway generation: 2' when Gateway generation increased, "+ + "even though the condition outcome (Accepted=False/NoMatchingListenerHostname) is unchanged. "+ + "Without the fix, the message is identical to cycle 1 and the watchable DeepEqual gate suppresses the update.") + + // Cycle 3: Gateway gen=3, hostname is fixed to match the route + pResources.GatewayAPIResources.Store(egv1a1.GatewayControllerName, &resource.ControllerResourcesContext{ + Resources: buildResources("app.example.com", 3), + }) + + require.Eventually(t, func() bool { + status, ok := pResources.HTTPRouteStatuses.Load(routeKey) + if !ok || status == nil || len(status.Parents) == 0 { + return false + } + for _, cond := range status.Parents[0].Conditions { + if cond.Type == string(gwapiv1.RouteConditionAccepted) { + return cond.Status == metav1.ConditionTrue + } + } + return false + }, 5*time.Second, 50*time.Millisecond, + "cycle 3: expected Accepted=True after Gateway listener hostname was fixed to match the route") +} + +func TestHTTPRouteStatusStaleAfterAllowedRoutesChange(t *testing.T) { + pResources := new(message.ProviderResources) + xdsIR := new(message.XdsIR) + infraIR := new(message.InfraIR) + cfg, err := config.New(os.Stdout, os.Stderr) + require.NoError(t, err) + extMgr, closeFunc, err := registry.NewInMemoryManager(&egv1a1.ExtensionManager{}, &pb.UnimplementedEnvoyGatewayExtensionServer{}) + require.NoError(t, err) + defer closeFunc() + + r := New(&Config{ + Server: *cfg, + ProviderResources: pResources, + RunnerErrors: new(message.RunnerErrors), + XdsIR: xdsIR, + InfraIR: infraIR, + ExtensionManager: extMgr, + }) + err = r.Start(t.Context()) + require.NoError(t, err) + + routeKey := types.NamespacedName{Namespace: "default", Name: "httproute-2"} + + // buildResources constructs a snapshot where the listener's AllowedRoutes is controlled + // The HTTPRoute's generation is fixed at 1 throughout. + buildResources := func(allowAll bool, gatewayGeneration int64) *resource.ControllerResources { + from := gwapiv1.NamespacesFromSame + if allowAll { + from = gwapiv1.NamespacesFromAll + } + res := resource.NewResources() + res.GatewayClass = &gwapiv1.GatewayClass{ + ObjectMeta: metav1.ObjectMeta{Name: "envoy-gateway-class"}, + Spec: gwapiv1.GatewayClassSpec{ + ControllerName: egv1a1.GatewayControllerName, + }, + } + hostname := gwapiv1.Hostname("app.example.com") + res.Gateways = []*gwapiv1.Gateway{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "envoy-gateway", + Name: "gateway-2", + Generation: gatewayGeneration, + }, + Spec: gwapiv1.GatewaySpec{ + GatewayClassName: "envoy-gateway-class", + Listeners: []gwapiv1.Listener{ + { + Name: "http", + Protocol: gwapiv1.HTTPProtocolType, + Port: 80, + Hostname: &hostname, + AllowedRoutes: &gwapiv1.AllowedRoutes{ + Namespaces: &gwapiv1.RouteNamespaces{ + From: &from, + }, + }, + }, + }, + }, + }, + } + routeHostname := gwapiv1.Hostname("app.example.com") + res.HTTPRoutes = []*gwapiv1.HTTPRoute{ + { + TypeMeta: metav1.TypeMeta{ + APIVersion: "gateway.networking.k8s.io/v1", + Kind: "HTTPRoute", + }, + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "httproute-2", + Generation: 1, + }, + Spec: gwapiv1.HTTPRouteSpec{ + CommonRouteSpec: gwapiv1.CommonRouteSpec{ + ParentRefs: []gwapiv1.ParentReference{ + { + Namespace: func() *gwapiv1.Namespace { + ns := gwapiv1.Namespace("envoy-gateway") + return &ns + }(), + Name: "gateway-2", + }, + }, + }, + Hostnames: []gwapiv1.Hostname{routeHostname}, + Rules: []gwapiv1.HTTPRouteRule{ + { + BackendRefs: []gwapiv1.HTTPBackendRef{ + { + BackendRef: gwapiv1.BackendRef{ + BackendObjectReference: gwapiv1.BackendObjectReference{ + Name: "service-2", + Port: func() *gwapiv1.PortNumber { + p := gwapiv1.PortNumber(8080) + return &p + }(), + }, + }, + }, + }, + }, + }, + }, + }, + } + res.Namespaces = []*corev1.Namespace{ + {ObjectMeta: metav1.ObjectMeta{Name: "envoy-gateway"}}, + {ObjectMeta: metav1.ObjectMeta{Name: "default"}}, + } + res.Services = []*corev1.Service{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "service-2"}, + Spec: corev1.ServiceSpec{ + ClusterIP: "2.2.2.2", + Ports: []corev1.ServicePort{ + {Name: "http", Port: 8080, TargetPort: intstr.FromInt32(8080), Protocol: corev1.ProtocolTCP}, + }, + }, + }, + } + res.EndpointSlices = []*discoveryv1.EndpointSlice{ + { + ObjectMeta: metav1.ObjectMeta{ + Namespace: "default", + Name: "endpointslice-2", + Labels: map[string]string{discoveryv1.LabelServiceName: "service-2"}, + }, + AddressType: discoveryv1.AddressTypeIPv4, + Ports: []discoveryv1.EndpointPort{ + {Name: func() *string { s := "http"; return &s }(), Port: func() *int32 { p := int32(8080); return &p }(), Protocol: func() *corev1.Protocol { p := corev1.ProtocolTCP; return &p }()}, + }, + Endpoints: []discoveryv1.Endpoint{ + {Addresses: []string{"8.8.8.8"}, Conditions: discoveryv1.EndpointConditions{Ready: func() *bool { b := true; return &b }()}}, + }, + }, + } + cr := resource.ControllerResources{res} + return &cr + } + + // Cycle 1: Gateway gen=1, AllowedRoutes.From=Same, route in "default" is not allowed + pResources.GatewayAPIResources.Store(egv1a1.GatewayControllerName, &resource.ControllerResourcesContext{ + Resources: buildResources(false, 1), + }) + + require.Eventually(t, func() bool { + status, ok := pResources.HTTPRouteStatuses.Load(routeKey) + if !ok || status == nil || len(status.Parents) == 0 { + return false + } + for _, cond := range status.Parents[0].Conditions { + if cond.Type == string(gwapiv1.RouteConditionAccepted) { + return cond.Status == metav1.ConditionFalse && + cond.Reason == string(gwapiv1.RouteReasonNotAllowedByListeners) && + cond.ObservedGeneration == 1 + } + } + return false + }, 5*time.Second, 50*time.Millisecond, + "cycle 1: expected Accepted=False/NotAllowedByListeners with ObservedGeneration=1") + + // Cycle 2: Gateway gen=2, still From=Same restriction, unrelated Gateway change + pResources.GatewayAPIResources.Store(egv1a1.GatewayControllerName, &resource.ControllerResourcesContext{ + Resources: buildResources(false, 2), + }) + + require.Eventually(t, func() bool { + status, ok := pResources.HTTPRouteStatuses.Load(routeKey) + if !ok || status == nil || len(status.Parents) == 0 { + return false + } + for _, cond := range status.Parents[0].Conditions { + if cond.Type == string(gwapiv1.RouteConditionAccepted) { + return cond.Status == metav1.ConditionFalse && + cond.Reason == string(gwapiv1.RouteReasonNotAllowedByListeners) && + cond.ObservedGeneration == 1 && + strings.Contains(cond.Message, "gateway generation: 2") + } + } + return false + }, 5*time.Second, 50*time.Millisecond, + "cycle 2: expected message to contain 'gateway generation: 2' when Gateway generation increased, "+ + "even though the condition outcome (Accepted=False/NotAllowedByListeners) is unchanged. "+ + "Without the fix in processAllowedListenersForParentRefs, the message is identical to cycle 1 "+ + "and the watchable DeepEqual gate suppresses the update.") + + // Cycle 3: Gateway gen=3, AllowedRoutes.From=All, route is now allowed + pResources.GatewayAPIResources.Store(egv1a1.GatewayControllerName, &resource.ControllerResourcesContext{ + Resources: buildResources(true, 3), + }) + + require.Eventually(t, func() bool { + status, ok := pResources.HTTPRouteStatuses.Load(routeKey) + if !ok || status == nil || len(status.Parents) == 0 { + return false + } + for _, cond := range status.Parents[0].Conditions { + if cond.Type == string(gwapiv1.RouteConditionAccepted) { + return cond.Status == metav1.ConditionTrue && + strings.Contains(cond.Message, "gateway generation: 3") + } + } + return false + }, 5*time.Second, 50*time.Millisecond, + "cycle 3: expected Accepted=True after Gateway AllowedRoutes was updated to allow all namespaces") +} diff --git a/test/e2e/testdata/httproute-stale-status.yaml b/test/e2e/testdata/httproute-stale-status.yaml new file mode 100644 index 0000000000..08fab0d6f4 --- /dev/null +++ b/test/e2e/testdata/httproute-stale-status.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: stale-status-gw + namespace: gateway-conformance-infra +spec: + gatewayClassName: "{GATEWAY_CLASS_NAME}" + listeners: + - name: http-first + port: 8888 + protocol: HTTP + hostname: "other.example.com" + allowedRoutes: + namespaces: + from: Same +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: stale-status-route + namespace: gateway-conformance-infra +spec: + parentRefs: + - name: stale-status-gw + hostnames: + - "stale.example.com" + rules: + - backendRefs: + - name: infra-backend-v1 + port: 8080 diff --git a/test/e2e/tests/httproute_stale_status.go b/test/e2e/tests/httproute_stale_status.go new file mode 100644 index 0000000000..0557df907f --- /dev/null +++ b/test/e2e/tests/httproute_stale_status.go @@ -0,0 +1,143 @@ +// Copyright Envoy Gateway Authors +// SPDX-License-Identifier: Apache-2.0 +// The full text of the Apache license is available in the LICENSE file at +// the root of the repo. + +//go:build e2e + +package tests + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + gwapiv1 "sigs.k8s.io/gateway-api/apis/v1" + "sigs.k8s.io/gateway-api/conformance/utils/suite" + "sigs.k8s.io/gateway-api/conformance/utils/tlog" +) + +func init() { + ConformanceTests = append(ConformanceTests, HTTPRouteStaleStatusTest) +} + +var HTTPRouteStaleStatusTest = suite.ConformanceTest{ + ShortName: "HTTPRouteStaleStatus", + Description: "Stale Accepted=False/NoMatchingListenerHostname condition must be cleared after a matching listener is added to the Gateway", + Manifests: []string{"testdata/httproute-stale-status.yaml"}, + Test: func(t *testing.T, suite *suite.ConformanceTestSuite) { + ns := "gateway-conformance-infra" + gwNN := types.NamespacedName{Name: "stale-status-gw", Namespace: ns} + routeNN := types.NamespacedName{Name: "stale-status-route", Namespace: ns} + + // Phase 1: Gateway listener hostname "other.example.com" does not match the + // route's hostname "stale.example.com". + // Expect Accepted=False / NoMatchingListenerHostname. + t.Run("route rejected with NoMatchingListenerHostname when hostnames differ", func(t *testing.T) { + tlog.Logf(t, "Waiting for HTTPRoute %s to be Accepted=False/NoMatchingListenerHostname", routeNN) + require.NoError(t, wait.PollUntilContextTimeout( + t.Context(), time.Second, suite.TimeoutConfig.MaxTimeToConsistency, true, + func(ctx context.Context) (bool, error) { + route := &gwapiv1.HTTPRoute{} + if err := suite.Client.Get(ctx, routeNN, route); err != nil { + tlog.Logf(t, "failed to get HTTPRoute: %v", err) + return false, nil + } + for _, parent := range route.Status.Parents { + for _, cond := range parent.Conditions { + if cond.Type == string(gwapiv1.RouteConditionAccepted) { + if cond.Status == metav1.ConditionFalse && + cond.Reason == string(gwapiv1.RouteReasonNoMatchingListenerHostname) { + tlog.Logf(t, "HTTPRoute correctly Accepted=False/NoMatchingListenerHostname: %s", cond.Message) + return true, nil + } + tlog.Logf(t, "Accepted condition not yet expected (status=%s reason=%s)", cond.Status, cond.Reason) + } + } + } + return false, nil + }, + )) + }) + + // Phase 2: Add a second listener whose hostname matches the route's hostname. + // The original non-matching listener is kept. + t.Run("matching listener added to gateway", func(t *testing.T) { + tlog.Logf(t, "Adding matching listener (stale.example.com) to gateway %s", gwNN) + require.NoError(t, wait.PollUntilContextTimeout( + t.Context(), time.Second, suite.TimeoutConfig.MaxTimeToConsistency, true, + func(ctx context.Context) (bool, error) { + gw := &gwapiv1.Gateway{} + if err := suite.Client.Get(ctx, gwNN, gw); err != nil { + tlog.Logf(t, "failed to get Gateway: %v", err) + return false, nil + } + gw.Spec.Listeners = []gwapiv1.Listener{ + { + Name: "http-first", + Port: 8888, + Protocol: gwapiv1.HTTPProtocolType, + Hostname: ptrTo(gwapiv1.Hostname("other.example.com")), + AllowedRoutes: &gwapiv1.AllowedRoutes{ + Namespaces: &gwapiv1.RouteNamespaces{From: ptrTo(gwapiv1.NamespacesFromSame)}, + }, + }, + { + Name: "http-match", + Port: 8889, + Protocol: gwapiv1.HTTPProtocolType, + Hostname: ptrTo(gwapiv1.Hostname("stale.example.com")), + AllowedRoutes: &gwapiv1.AllowedRoutes{ + Namespaces: &gwapiv1.RouteNamespaces{From: ptrTo(gwapiv1.NamespacesFromSame)}, + }, + }, + } + if err := suite.Client.Update(ctx, gw, &client.UpdateOptions{}); err != nil { + tlog.Logf(t, "failed to update Gateway: %v", err) + return false, nil + } + tlog.Logf(t, "Gateway updated, new generation: %d", gw.Generation) + return true, nil + }, + )) + }) + + // Phase 3: The NoMatchingListenerHostname condition must now be gone. + // The route must be Accepted=True / Accepted. + t.Run("NoMatchingListenerHostname cleared and route accepted after matching listener added", func(t *testing.T) { + tlog.Logf(t, "Waiting for HTTPRoute %s to be Accepted=True/Accepted", routeNN) + require.NoError(t, wait.PollUntilContextTimeout( + t.Context(), time.Second, suite.TimeoutConfig.MaxTimeToConsistency, true, + func(ctx context.Context) (bool, error) { + route := &gwapiv1.HTTPRoute{} + if err := suite.Client.Get(ctx, routeNN, route); err != nil { + tlog.Logf(t, "failed to get HTTPRoute: %v", err) + return false, nil + } + for _, parent := range route.Status.Parents { + for _, cond := range parent.Conditions { + if cond.Type == string(gwapiv1.RouteConditionAccepted) { + if cond.Status == metav1.ConditionTrue && + cond.Reason == string(gwapiv1.RouteReasonAccepted) { + tlog.Logf(t, "NoMatchingListenerHostname cleared, route is Accepted=True/Accepted: %s", cond.Message) + return true, nil + } + tlog.Logf(t, "Condition not yet updated (status=%s reason=%s msg=%s)", cond.Status, cond.Reason, cond.Message) + } + } + } + return false, nil + }, + )) + }) + }, +} + +func ptrTo[T any](v T) *T { + return &v +}