This is an automated email from the ASF dual-hosted git repository.
AlinsRan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/apisix-ingress-controller.git
The following commit(s) were added to refs/heads/master by this push:
new c69d50cf fix: retract route configuration when no parent accepts the
route any more (#2858)
c69d50cf is described below
commit c69d50cf6e69ec7bf138f446277110ba4e8fbea7
Author: AlinsRan <[email protected]>
AuthorDate: Fri Sep 11 14:18:09 2026 +0800
fix: retract route configuration when no parent accepts the route any more
(#2858)
---
internal/controller/grpcroute_controller.go | 15 ++
internal/controller/httproute_controller.go | 15 ++
.../httproute_controller_retract_test.go | 177 +++++++++++++++++++++
internal/controller/tcproute_controller.go | 15 ++
internal/controller/tlsroute_controller.go | 15 ++
internal/controller/udproute_controller.go | 15 ++
internal/controller/utils.go | 15 +-
test/e2e/gatewayapi/httproute.go | 122 ++++++++++++++
8 files changed, 387 insertions(+), 2 deletions(-)
diff --git a/internal/controller/grpcroute_controller.go
b/internal/controller/grpcroute_controller.go
index df50f135..b2e0997c 100644
--- a/internal/controller/grpcroute_controller.go
+++ b/internal/controller/grpcroute_controller.go
@@ -291,6 +291,21 @@ func (r *GRPCRouteReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (
if err := r.Provider.Update(ctx, tctx, routeToUpdate); err !=
nil {
return ctrl.Result{}, err
}
+ return ctrl.Result{}, nil
+ }
+
+ // The route still resolves to one of our Gateways but no parent
accepts it any
+ // more, so retract what an earlier reconcile published. The store is
what every
+ // sync pushes, so leaving the entry keeps the data plane serving the
route.
+ // Provider.Delete derives the resource labels from the object Kind,
which is not
+ // set on every object read through the client.
+ gr.TypeMeta = metav1.TypeMeta{
+ Kind: KindGRPCRoute,
+ APIVersion: gatewayv1.GroupVersion.String(),
+ }
+ if err := r.Provider.Delete(ctx, gr); err != nil {
+ r.Log.Error(err, "failed to delete grpcroute", "grpcroute",
utils.NamespacedName(gr))
+ return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
diff --git a/internal/controller/httproute_controller.go
b/internal/controller/httproute_controller.go
index 285899b8..3c76ab92 100644
--- a/internal/controller/httproute_controller.go
+++ b/internal/controller/httproute_controller.go
@@ -293,6 +293,21 @@ func (r *HTTPRouteReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (
if err := r.Provider.Update(ctx, tctx, routeToUpdate); err !=
nil {
return ctrl.Result{}, err
}
+ return ctrl.Result{}, nil
+ }
+
+ // The route still resolves to one of our Gateways but no parent
accepts it any
+ // more, so retract what an earlier reconcile published. The store is
what every
+ // sync pushes, so leaving the entry keeps the data plane serving the
route.
+ // Provider.Delete derives the resource labels from the object Kind,
which is not
+ // set on every object read through the client.
+ hr.TypeMeta = metav1.TypeMeta{
+ Kind: KindHTTPRoute,
+ APIVersion: gatewayv1.GroupVersion.String(),
+ }
+ if err := r.Provider.Delete(ctx, hr); err != nil {
+ r.Log.Error(err, "failed to delete httproute", "httproute",
utils.NamespacedName(hr))
+ return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
diff --git a/internal/controller/httproute_controller_retract_test.go
b/internal/controller/httproute_controller_retract_test.go
new file mode 100644
index 00000000..857c2fa5
--- /dev/null
+++ b/internal/controller/httproute_controller_retract_test.go
@@ -0,0 +1,177 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you 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 controller
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ k8stypes "k8s.io/apimachinery/pkg/types"
+ clientgoscheme "k8s.io/client-go/kubernetes/scheme"
+ ctrl "sigs.k8s.io/controller-runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+
+ "github.com/apache/apisix-ingress-controller/api/v1alpha1"
+ "github.com/apache/apisix-ingress-controller/internal/controller/config"
+ "github.com/apache/apisix-ingress-controller/internal/manager/readiness"
+)
+
+const (
+ retractGatewayNamespace = "infra"
+ retractRouteNamespace = "tenant"
+ retractRouteName = "route"
+)
+
+// newHTTPRouteRetractFixture builds a Gateway of our class in
retractGatewayNamespace
+// and an HTTPRoute in retractRouteNamespace that names it as its parent. from
+// controls the listener's allowedRoutes, which is what revoking
cross-namespace
+// access changes.
+func newHTTPRouteRetractFixture(
+ t *testing.T,
+ from gatewayv1.FromNamespaces,
+) (*HTTPRouteReconciler, *recordingProvider, *recordingUpdater) {
+ t.Helper()
+
+ scheme := runtime.NewScheme()
+ require.NoError(t, clientgoscheme.AddToScheme(scheme))
+ require.NoError(t, gatewayv1.Install(scheme))
+ require.NoError(t, v1alpha1.AddToScheme(scheme))
+
+ gatewayClass := &gatewayv1.GatewayClass{
+ ObjectMeta: metav1.ObjectMeta{Name: "apisix"},
+ Spec: gatewayv1.GatewayClassSpec{
+ ControllerName:
gatewayv1.GatewayController(config.ControllerConfig.ControllerName),
+ },
+ }
+ gateway := &gatewayv1.Gateway{
+ ObjectMeta: metav1.ObjectMeta{Namespace:
retractGatewayNamespace, Name: "gw"},
+ Spec: gatewayv1.GatewaySpec{
+ GatewayClassName: "apisix",
+ Listeners: []gatewayv1.Listener{{
+ Name: "http",
+ Protocol: gatewayv1.HTTPProtocolType,
+ Port: 80,
+ AllowedRoutes: &gatewayv1.AllowedRoutes{
+ Namespaces:
&gatewayv1.RouteNamespaces{From: &from},
+ },
+ }},
+ },
+ }
+ route := &gatewayv1.HTTPRoute{
+ ObjectMeta: metav1.ObjectMeta{Namespace: retractRouteNamespace,
Name: retractRouteName},
+ Spec: gatewayv1.HTTPRouteSpec{
+ // A route with hostnames of its own is what makes
filterHostnames fail
+ // once no listener matches, which is how a route-wide
reason used to
+ // overwrite the parent's own.
+ Hostnames: []gatewayv1.Hostname{"tenant.example"},
+ CommonRouteSpec: gatewayv1.CommonRouteSpec{
+ ParentRefs: []gatewayv1.ParentReference{{
+ Name:
gatewayv1.ObjectName(gateway.Name),
+ Namespace:
(*gatewayv1.Namespace)(&gateway.Namespace),
+ }},
+ },
+ },
+ }
+
+ cli := fake.NewClientBuilder().WithScheme(scheme).
+ WithObjects([]client.Object{gatewayClass, gateway, route}...).
+ WithStatusSubresource(route).
+ Build()
+
+ readier := readiness.NewReadinessManager(cli, logr.Discard())
+ require.NoError(t, readier.Start(context.Background()))
+
+ prov := &recordingProvider{}
+ updater := &recordingUpdater{}
+ return &HTTPRouteReconciler{
+ Client: cli,
+ Scheme: scheme,
+ Log: logr.Discard(),
+ Provider: prov,
+ Updater: updater,
+ Readier: readier,
+ }, prov, updater
+}
+
+func reconcileRetractHTTPRoute(t *testing.T, r *HTTPRouteReconciler)
(ctrl.Result, error) {
+ t.Helper()
+ return r.Reconcile(context.Background(), ctrl.Request{
+ NamespacedName: k8stypes.NamespacedName{Namespace:
retractRouteNamespace, Name: retractRouteName},
+ })
+}
+
+var retractRouteKey = k8stypes.NamespacedName{Namespace:
retractRouteNamespace, Name: retractRouteName}
+
+// Narrowing a listener's allowedRoutes leaves the HTTPRoute in place but
stops it
+// being accepted. The configuration an earlier reconcile published must be
+// retracted, otherwise the data plane keeps serving a route the Gateway no
longer
+// admits and only deleting the HTTPRoute clears it.
+func TestHTTPRouteReconcile_RetractsWhenListenerStopsAllowingRoute(t
*testing.T) {
+ r, prov, updater := newHTTPRouteRetractFixture(t,
gatewayv1.NamespacesFromSame)
+
+ result, err := reconcileRetractHTTPRoute(t, r)
+
+ require.NoError(t, err)
+ assert.Equal(t, ctrl.Result{}, result)
+ assert.Equal(t, []k8stypes.NamespacedName{retractRouteKey},
prov.deleted)
+ assert.Zero(t, prov.updated, "a route that is not accepted must not be
published")
+
+ // The reason must say why this parent rejected the route. The
route-wide
+ // status derived from filterHostnames would otherwise report
+ // NoMatchingListenerHostname, which is a symptom of nothing having
matched
+ // rather than the cause.
+ require.Len(t, updater.updates, 1)
+ mutated, ok :=
updater.updates[0].Mutator.Mutate(&gatewayv1.HTTPRoute{}).(*gatewayv1.HTTPRoute)
+ require.True(t, ok)
+ require.Len(t, mutated.Status.Parents, 1)
+ accepted :=
meta.FindStatusCondition(mutated.Status.Parents[0].Conditions,
string(gatewayv1.RouteConditionAccepted))
+ require.NotNil(t, accepted)
+ assert.Equal(t, metav1.ConditionFalse, accepted.Status)
+ assert.Equal(t, string(gatewayv1.RouteReasonNotAllowedByListeners),
accepted.Reason)
+}
+
+// An accepted route must still be published and must not be retracted.
+func TestHTTPRouteReconcile_PublishesAcceptedRoute(t *testing.T) {
+ r, prov, _ := newHTTPRouteRetractFixture(t, gatewayv1.NamespacesFromAll)
+
+ _, err := reconcileRetractHTTPRoute(t, r)
+
+ require.NoError(t, err)
+ assert.Empty(t, prov.deleted, "an accepted route must not be retracted")
+ assert.Equal(t, 1, prov.updated)
+}
+
+// A provider failure while retracting must surface so the reconcile is
retried.
+func TestHTTPRouteReconcile_RetractErrorIsReturned(t *testing.T) {
+ r, prov, _ := newHTTPRouteRetractFixture(t,
gatewayv1.NamespacesFromSame)
+ prov.deleteErr = errors.New("provider unavailable")
+
+ _, err := reconcileRetractHTTPRoute(t, r)
+
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "provider unavailable")
+}
diff --git a/internal/controller/tcproute_controller.go
b/internal/controller/tcproute_controller.go
index 4df650a3..b8d6f97e 100644
--- a/internal/controller/tcproute_controller.go
+++ b/internal/controller/tcproute_controller.go
@@ -381,6 +381,21 @@ func (r *TCPRouteReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (c
if err := r.Provider.Update(ctx, tctx, routeToUpdate); err !=
nil {
return ctrl.Result{}, err
}
+ return ctrl.Result{}, nil
+ }
+
+ // The route still resolves to one of our Gateways but no parent
accepts it any
+ // more, so retract what an earlier reconcile published. The store is
what every
+ // sync pushes, so leaving the entry keeps the data plane serving the
route.
+ // Provider.Delete derives the resource labels from the object Kind,
which is not
+ // set on every object read through the client.
+ tr.TypeMeta = metav1.TypeMeta{
+ Kind: KindTCPRoute,
+ APIVersion: gatewayv1.GroupVersion.String(),
+ }
+ if err := r.Provider.Delete(ctx, tr); err != nil {
+ r.Log.Error(err, "failed to delete tcproute", "tcproute",
utils.NamespacedName(tr))
+ return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
diff --git a/internal/controller/tlsroute_controller.go
b/internal/controller/tlsroute_controller.go
index 3b23f000..5bbd5a59 100644
--- a/internal/controller/tlsroute_controller.go
+++ b/internal/controller/tlsroute_controller.go
@@ -373,6 +373,21 @@ func (r *TLSRouteReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (c
if err := r.Provider.Update(ctx, tctx, routeToUpdate); err !=
nil {
return ctrl.Result{}, err
}
+ return ctrl.Result{}, nil
+ }
+
+ // The route still resolves to one of our Gateways but no parent
accepts it any
+ // more, so retract what an earlier reconcile published. The store is
what every
+ // sync pushes, so leaving the entry keeps the data plane serving the
route.
+ // Provider.Delete derives the resource labels from the object Kind,
which is not
+ // set on every object read through the client.
+ tr.TypeMeta = metav1.TypeMeta{
+ Kind: types.KindTLSRoute,
+ APIVersion: gatewayv1.GroupVersion.String(),
+ }
+ if err := r.Provider.Delete(ctx, tr); err != nil {
+ r.Log.Error(err, "failed to delete tlsroute", "tlsroute",
utils.NamespacedName(tr))
+ return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
diff --git a/internal/controller/udproute_controller.go
b/internal/controller/udproute_controller.go
index 31a3ab2a..3ca88907 100644
--- a/internal/controller/udproute_controller.go
+++ b/internal/controller/udproute_controller.go
@@ -381,6 +381,21 @@ func (r *UDPRouteReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (c
if err := r.Provider.Update(ctx, tctx, routeToUpdate); err !=
nil {
return ctrl.Result{}, err
}
+ return ctrl.Result{}, nil
+ }
+
+ // The route still resolves to one of our Gateways but no parent
accepts it any
+ // more, so retract what an earlier reconcile published. The store is
what every
+ // sync pushes, so leaving the entry keeps the data plane serving the
route.
+ // Provider.Delete derives the resource labels from the object Kind,
which is not
+ // set on every object read through the client.
+ tr.TypeMeta = metav1.TypeMeta{
+ Kind: KindUDPRoute,
+ APIVersion: gatewayv1.GroupVersion.String(),
+ }
+ if err := r.Provider.Delete(ctx, tr); err != nil {
+ r.Log.Error(err, "failed to delete udproute", "udproute",
utils.NamespacedName(tr))
+ return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
diff --git a/internal/controller/utils.go b/internal/controller/utils.go
index deceecdd..14bda2bb 100644
--- a/internal/controller/utils.go
+++ b/internal/controller/utils.go
@@ -268,9 +268,20 @@ func SetRouteConditionAccepted(routeParentStatus
*gatewayv1.RouteParentStatus, g
condition.Reason =
string(gatewayv1.RouteReasonNoMatchingListenerHostname)
}
- if !IsConditionPresentAndEqual(routeParentStatus.Conditions, condition)
&& !slices.ContainsFunc(routeParentStatus.Conditions, func(item
metav1.Condition) bool {
- return item.Type == condition.Type && item.Status ==
metav1.ConditionFalse && condition.Status == metav1.ConditionTrue
+ // ParseRouteParentRefs already recorded why this particular parent
rejected the
+ // route: NotAllowedByListeners, NoMatchingParent,
NoMatchingListenerHostname.
+ // status and message here are route-wide, derived from whatever failed
first
+ // across every parent, so they must not overwrite that. Leaving them
to do so
+ // reports a route rejected by allowedRoutes as
NoMatchingListenerHostname,
+ // because filterHostnames finds no listener to intersect against once
nothing
+ // matched, and its generic error lands here.
+ if slices.ContainsFunc(routeParentStatus.Conditions, func(item
metav1.Condition) bool {
+ return item.Type == condition.Type && item.Status ==
metav1.ConditionFalse
}) {
+ return
+ }
+
+ if !IsConditionPresentAndEqual(routeParentStatus.Conditions, condition)
{
routeParentStatus.Conditions =
MergeCondition(routeParentStatus.Conditions, condition)
}
}
diff --git a/test/e2e/gatewayapi/httproute.go b/test/e2e/gatewayapi/httproute.go
index 48cc9e47..9635d335 100644
--- a/test/e2e/gatewayapi/httproute.go
+++ b/test/e2e/gatewayapi/httproute.go
@@ -220,6 +220,128 @@ spec:
})
})
+ Context("HTTPRoute revoked by its listener", func() {
+ // The listener starts out admitting HTTPRoute and is then
narrowed to
+ // GRPCRoute only. The route object is untouched throughout,
which is the
+ // point: revoking a route's access must not require editing
the route.
+ var gatewayAllowingKinds = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+ name: %s
+spec:
+ gatewayClassName: %s
+ listeners:
+ - name: http1
+ protocol: HTTP
+ port: 80
+ allowedRoutes:
+ kinds:
+ - group: gateway.networking.k8s.io
+ kind: %s
+ infrastructure:
+ parametersRef:
+ group: apisix.apache.org
+ kind: GatewayProxy
+ name: apisix-proxy-config
+`
+
+ var route = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+ name: httpbin
+spec:
+ parentRefs:
+ - name: %s
+ hostnames:
+ - httpbin.example
+ rules:
+ - matches:
+ - path:
+ type: Exact
+ value: /get
+ backendRefs:
+ - name: httpbin-service-e2e-test
+ port: 80
+`
+
+ // allowKind rewrites the listener to admit only the given
route kind.
+ var allowKind = func(kind string) {
+ Expect(s.CreateResourceFromString(
+ fmt.Sprintf(gatewayAllowingKinds,
s.Namespace(), s.Namespace(), kind),
+ )).NotTo(HaveOccurred(), "applying Gateway allowing
"+kind)
+ }
+
+ BeforeEach(func() {
+ By("create GatewayProxy")
+
Expect(s.CreateResourceFromString(s.GetGatewayProxySpec())).NotTo(HaveOccurred(),
"creating GatewayProxy")
+
+ By("create GatewayClass")
+
Expect(s.CreateResourceFromString(s.GetGatewayClassYaml())).NotTo(HaveOccurred(),
"creating GatewayClass")
+ s.RetryAssertion(func() string {
+ gcyaml, _ := s.GetResourceYaml("GatewayClass",
s.Namespace())
+ return gcyaml
+ }).Should(ContainSubstring("message: the gatewayclass
has been accepted by the apisix-ingress-controller"),
+ "check GatewayClass condition")
+
+ By("create Gateway admitting HTTPRoute")
+ allowKind("HTTPRoute")
+ s.RetryAssertion(func() string {
+ gwyaml, _ := s.GetResourceYaml("Gateway",
s.Namespace())
+ return gwyaml
+ }).Should(ContainSubstring("message: the gateway has
been accepted by the apisix-ingress-controller"),
+ "check Gateway condition status")
+ })
+
+ It("stops serving the route and resumes when the listener
admits it again", func() {
+ By("create HTTPRoute")
+ s.ResourceApplied("HTTPRoute", "httpbin",
fmt.Sprintf(route, s.Namespace()), 1)
+
+ s.RequestAssert(&scaffold.RequestAssert{
+ Method: "GET",
+ Path: "/get",
+ Host: "httpbin.example",
+ Check:
scaffold.WithExpectedStatus(http.StatusOK),
+ Timeout: time.Second * 30,
+ Interval: time.Second * 2,
+ })
+
+ By("narrow the listener to GRPCRoute, leaving the
HTTPRoute untouched")
+ allowKind("GRPCRoute")
+
+ By("the route reports that no listener accepts it")
+ s.RetryAssertion(func() string {
+ routeYaml, _ := s.GetResourceYaml("HTTPRoute",
"httpbin")
+ return routeYaml
+ }).Should(ContainSubstring("reason:
NotAllowedByListeners"), "check HTTPRoute condition")
+
+ By("and the data plane stops serving it")
+ // Without the retraction the previously published
route keeps
+ // forwarding, so the status and the data plane
disagree until the
+ // HTTPRoute itself is deleted.
+ s.RequestAssert(&scaffold.RequestAssert{
+ Method: "GET",
+ Path: "/get",
+ Host: "httpbin.example",
+ Check:
scaffold.WithExpectedStatus(http.StatusNotFound),
+ Timeout: time.Second * 30,
+ Interval: time.Second * 2,
+ })
+
+ By("restore the listener and the route is served again")
+ allowKind("HTTPRoute")
+ s.RequestAssert(&scaffold.RequestAssert{
+ Method: "GET",
+ Path: "/get",
+ Host: "httpbin.example",
+ Check:
scaffold.WithExpectedStatus(http.StatusOK),
+ Timeout: time.Second * 30,
+ Interval: time.Second * 2,
+ })
+ })
+ })
+
Context("HTTPRoute with Multiple Gateway", Serial, func() {
var additionalGatewayGroupID string
var additionalSvc *corev1.Service