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 bdeb96f5 fix: delete data plane config when a route leaves a managed 
Gateway (#2834)
bdeb96f5 is described below

commit bdeb96f571af161d9ede0924044f7247ac64b671
Author: Johannes Engler <[email protected]>
AuthorDate: Wed Sep 9 09:37:30 2026 +0200

    fix: delete data plane config when a route leaves a managed Gateway (#2834)
---
 internal/adc/client/client.go                    |   7 +-
 internal/controller/grpcroute_controller.go      |  18 ++-
 internal/controller/httproute_controller.go      |  23 ++-
 internal/controller/httproute_controller_test.go | 111 +++++++++++++++
 internal/controller/tcproute_controller.go       |  18 ++-
 internal/controller/tlsroute_controller.go       |  18 ++-
 internal/controller/udproute_controller.go       |  18 ++-
 internal/controller/utils.go                     |  17 ++-
 internal/controller/utils_parentref_test.go      |  84 ++++++++++-
 internal/provider/apisix/provider.go             |  10 +-
 internal/provider/apisix/provider_test.go        |  67 +++++++++
 test/e2e/gatewayapi/httproute.go                 | 170 +++++++++++++++++++++++
 12 files changed, 541 insertions(+), 20 deletions(-)

diff --git a/internal/adc/client/client.go b/internal/adc/client/client.go
index b3db60ca..e80d1b27 100644
--- a/internal/adc/client/client.go
+++ b/internal/adc/client/client.go
@@ -240,9 +240,10 @@ func (c *Client) Delete(ctx context.Context, args Task) 
error {
        return c.applySync(ctx, args, delta)
 }
 
-func (c *Client) DeleteConfig(ctx context.Context, args Task) error {
-       _, err := c.applyStoreChanges(args, true)
-       return err
+// DeleteConfig removes the stored configuration for args.Key and reports what
+// it removed, so callers can skip a data plane sync when the key held nothing.
+func (c *Client) DeleteConfig(ctx context.Context, args Task) (StoreDelta, 
error) {
+       return c.applyStoreChanges(args, true)
 }
 
 func (c *Client) Validate(ctx context.Context, task Task) error {
diff --git a/internal/controller/grpcroute_controller.go 
b/internal/controller/grpcroute_controller.go
index dc3d0e03..bbc45265 100644
--- a/internal/controller/grpcroute_controller.go
+++ b/internal/controller/grpcroute_controller.go
@@ -181,12 +181,28 @@ func (r *GRPCRouteReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (
                msg:    "Route is accepted",
        }
 
-       gateways, err := ParseRouteParentRefs(ctx, r.Client, r.Log, gr, 
gr.Spec.ParentRefs)
+       gateways, unresolvedParents, err := ParseRouteParentRefs(ctx, r.Client, 
r.Log, gr, gr.Spec.ParentRefs)
        if err != nil {
                return ctrl.Result{}, err
        }
 
        if len(gateways) == 0 {
+               if unresolvedParents {
+                       // See the HTTPRoute reconciler: an unresolvable 
parentRef leaves ownership
+                       // unknown rather than disproven, so the data plane 
must be left alone.
+                       return ctrl.Result{}, nil
+               }
+               // See the HTTPRoute reconciler: a route that no longer 
references a
+               // Gateway managed by this controller must have its previously 
pushed
+               // configuration removed, or the data plane keeps serving it.
+               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", gr)
+                       return ctrl.Result{}, err
+               }
                return ctrl.Result{}, nil
        }
 
diff --git a/internal/controller/httproute_controller.go 
b/internal/controller/httproute_controller.go
index f721ebd2..26b94683 100644
--- a/internal/controller/httproute_controller.go
+++ b/internal/controller/httproute_controller.go
@@ -163,12 +163,33 @@ func (r *HTTPRouteReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (
                msg:    "Route is accepted",
        }
 
-       gateways, err := ParseRouteParentRefs(ctx, r.Client, r.Log, hr, 
hr.Spec.ParentRefs)
+       gateways, unresolvedParents, err := ParseRouteParentRefs(ctx, r.Client, 
r.Log, hr, hr.Spec.ParentRefs)
        if err != nil {
                return ctrl.Result{}, err
        }
 
        if len(gateways) == 0 {
+               if unresolvedParents {
+                       // A missing Gateway or GatewayClass leaves ownership 
unknown rather than
+                       // disproven. GatewayClass is cluster-scoped, so while 
one is absent every
+                       // route under it resolves empty and deleting would 
drain the data plane.
+                       return ctrl.Result{}, nil
+               }
+               // The route does not reference any Gateway managed by this 
controller.
+               // It may have referenced one before, e.g. when its parentRefs 
are
+               // repointed at a Gateway belonging to another GatewayClass, so 
the
+               // configuration a previous reconcile pushed has to be removed. 
Without
+               // this the data plane keeps serving the route indefinitely.
+               // Provider.Delete derives the resource labels from the object 
Kind, which
+               // is empty on objects 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", hr)
+                       return ctrl.Result{}, err
+               }
                return ctrl.Result{}, nil
        }
 
diff --git a/internal/controller/httproute_controller_test.go 
b/internal/controller/httproute_controller_test.go
new file mode 100644
index 00000000..00876c02
--- /dev/null
+++ b/internal/controller/httproute_controller_test.go
@@ -0,0 +1,111 @@
+// 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"
+       "testing"
+       "time"
+
+       "github.com/go-logr/logr"
+       "github.com/stretchr/testify/assert"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       k8stypes "k8s.io/apimachinery/pkg/types"
+       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/internal/manager/readiness"
+)
+
+type noopReadier struct {
+       readiness.ReadinessManager
+}
+
+func (noopReadier) Done(client.Object, k8stypes.NamespacedName) {}
+
+// TestHTTPRouteReconcile_EmptyGateways covers the two ways a route resolves 
to no
+// Gateway of this controller. Only a resolved parent naming another controller
+// proves the route is not ours; an unresolvable parent must leave the data 
plane
+// alone, since a missing cluster-scoped GatewayClass empties the list for 
every
+// route under it at once.
+func TestHTTPRouteReconcile_EmptyGateways(t *testing.T) {
+       scheme := parentRefTestScheme(t)
+
+       route := &gatewayv1.HTTPRoute{
+               ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "r"},
+               Spec: gatewayv1.HTTPRouteSpec{
+                       CommonRouteSpec: gatewayv1.CommonRouteSpec{
+                               ParentRefs: []gatewayv1.ParentReference{{Name: 
"gw"}},
+                       },
+               },
+       }
+       gw := func(class string) *gatewayv1.Gateway {
+               return &gatewayv1.Gateway{
+                       ObjectMeta: metav1.ObjectMeta{Namespace: "default", 
Name: "gw"},
+                       Spec: gatewayv1.GatewaySpec{
+                               GatewayClassName: gatewayv1.ObjectName(class),
+                               Listeners: []gatewayv1.Listener{
+                                       {Name: "http", Port: 80, Protocol: 
gatewayv1.HTTPProtocolType},
+                               },
+                       },
+               }
+       }
+
+       for _, tc := range []struct {
+               name       string
+               objects    []client.Object
+               wantDelete bool
+       }{
+               {
+                       name: "gatewayclass of another controller",
+                       objects: []client.Object{route, gw("other"), 
&gatewayv1.GatewayClass{
+                               ObjectMeta: metav1.ObjectMeta{Name: "other"},
+                               Spec:       
gatewayv1.GatewayClassSpec{ControllerName: "example.com/other-controller"},
+                       }},
+                       wantDelete: true,
+               },
+               {
+                       name:       "gatewayclass missing",
+                       objects:    []client.Object{route, gw("gone")},
+                       wantDelete: false,
+               },
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       cli := 
fake.NewClientBuilder().WithScheme(scheme).WithObjects(tc.objects...).Build()
+                       prov := &recordingProvider{}
+                       r := &HTTPRouteReconciler{
+                               Client:   cli,
+                               Scheme:   scheme,
+                               Log:      logr.Discard(),
+                               Provider: prov,
+                               Readier:  noopReadier{},
+                       }
+
+                       ctx, cancel := 
context.WithTimeout(context.Background(), 10*time.Second)
+                       defer cancel()
+                       _, err := r.Reconcile(ctx, ctrl.Request{
+                               NamespacedName: 
k8stypes.NamespacedName{Namespace: "default", Name: "r"},
+                       })
+                       assert.NoError(t, err)
+                       assert.Equal(t, tc.wantDelete, len(prov.deleted) == 1,
+                               "unexpected data plane delete, deletes=%d", 
len(prov.deleted))
+               })
+       }
+}
diff --git a/internal/controller/tcproute_controller.go 
b/internal/controller/tcproute_controller.go
index 927244cb..4cf16ebe 100644
--- a/internal/controller/tcproute_controller.go
+++ b/internal/controller/tcproute_controller.go
@@ -273,12 +273,28 @@ func (r *TCPRouteReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (c
                msg:    "Route is accepted",
        }
 
-       gateways, err := ParseRouteParentRefs(ctx, r.Client, r.Log, tr, 
tr.Spec.ParentRefs)
+       gateways, unresolvedParents, err := ParseRouteParentRefs(ctx, r.Client, 
r.Log, tr, tr.Spec.ParentRefs)
        if err != nil {
                return ctrl.Result{}, err
        }
 
        if len(gateways) == 0 {
+               if unresolvedParents {
+                       // See the HTTPRoute reconciler: an unresolvable 
parentRef leaves ownership
+                       // unknown rather than disproven, so the data plane 
must be left alone.
+                       return ctrl.Result{}, nil
+               }
+               // See the HTTPRoute reconciler: a route that no longer 
references a
+               // Gateway managed by this controller must have its previously 
pushed
+               // configuration removed, or the data plane keeps serving it.
+               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", tr)
+                       return ctrl.Result{}, err
+               }
                return ctrl.Result{}, nil
        }
 
diff --git a/internal/controller/tlsroute_controller.go 
b/internal/controller/tlsroute_controller.go
index e2d62453..c9df0e62 100644
--- a/internal/controller/tlsroute_controller.go
+++ b/internal/controller/tlsroute_controller.go
@@ -273,12 +273,28 @@ func (r *TLSRouteReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (c
                msg:    "Route is accepted",
        }
 
-       gateways, err := ParseRouteParentRefs(ctx, r.Client, r.Log, tr, 
tr.Spec.ParentRefs)
+       gateways, unresolvedParents, err := ParseRouteParentRefs(ctx, r.Client, 
r.Log, tr, tr.Spec.ParentRefs)
        if err != nil {
                return ctrl.Result{}, err
        }
 
        if len(gateways) == 0 {
+               if unresolvedParents {
+                       // See the HTTPRoute reconciler: an unresolvable 
parentRef leaves ownership
+                       // unknown rather than disproven, so the data plane 
must be left alone.
+                       return ctrl.Result{}, nil
+               }
+               // See the HTTPRoute reconciler: a route that no longer 
references a
+               // Gateway managed by this controller must have its previously 
pushed
+               // configuration removed, or the data plane keeps serving it.
+               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", tr)
+                       return ctrl.Result{}, err
+               }
                return ctrl.Result{}, nil
        }
 
diff --git a/internal/controller/udproute_controller.go 
b/internal/controller/udproute_controller.go
index ffb43644..2c8a910b 100644
--- a/internal/controller/udproute_controller.go
+++ b/internal/controller/udproute_controller.go
@@ -273,12 +273,28 @@ func (r *UDPRouteReconciler) Reconcile(ctx 
context.Context, req ctrl.Request) (c
                msg:    "Route is accepted",
        }
 
-       gateways, err := ParseRouteParentRefs(ctx, r.Client, r.Log, tr, 
tr.Spec.ParentRefs)
+       gateways, unresolvedParents, err := ParseRouteParentRefs(ctx, r.Client, 
r.Log, tr, tr.Spec.ParentRefs)
        if err != nil {
                return ctrl.Result{}, err
        }
 
        if len(gateways) == 0 {
+               if unresolvedParents {
+                       // See the HTTPRoute reconciler: an unresolvable 
parentRef leaves ownership
+                       // unknown rather than disproven, so the data plane 
must be left alone.
+                       return ctrl.Result{}, nil
+               }
+               // See the HTTPRoute reconciler: a route that no longer 
references a
+               // Gateway managed by this controller must have its previously 
pushed
+               // configuration removed, or the data plane keeps serving it.
+               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", tr)
+                       return ctrl.Result{}, err
+               }
                return ctrl.Result{}, nil
        }
 
diff --git a/internal/controller/utils.go b/internal/controller/utils.go
index db6894f4..cf5167b4 100644
--- a/internal/controller/utils.go
+++ b/internal/controller/utils.go
@@ -324,14 +324,21 @@ func parentRefTargetsListenerExplicitly(parentRef 
gatewayv1.ParentReference) boo
        return parentRef.Port != nil
 }
 
+// ParseRouteParentRefs resolves the parentRefs of a route to the Gateways this
+// controller manages. The second return value reports that at least one
+// parentRef could not be resolved, because its Gateway or that Gateway's
+// GatewayClass does not exist. An empty gateway list then means "ownership
+// unknown", not "owned by another controller", and callers must not act on the
+// route's data plane configuration.
 func ParseRouteParentRefs(
        ctx context.Context,
        mgrc client.Client,
        log logr.Logger,
        route client.Object,
        parentRefs []gatewayv1.ParentReference,
-) ([]RouteParentRefContext, error) {
+) ([]RouteParentRefContext, bool, error) {
        gateways := make([]RouteParentRefContext, 0)
+       unresolved := false
        for _, parentRef := range parentRefs {
                namespace := route.GetNamespace()
                if parentRef.Namespace != nil {
@@ -349,9 +356,10 @@ func ParseRouteParentRefs(
                        Name:      name,
                }, &gateway); err != nil {
                        if client.IgnoreNotFound(err) == nil {
+                               unresolved = true
                                continue
                        }
-                       return nil, fmt.Errorf("failed to retrieve gateway for 
route: %w", err)
+                       return nil, false, fmt.Errorf("failed to retrieve 
gateway for route: %w", err)
                }
 
                gatewayClass := gatewayv1.GatewayClass{}
@@ -359,9 +367,10 @@ func ParseRouteParentRefs(
                        Name: string(gateway.Spec.GatewayClassName),
                }, &gatewayClass); err != nil {
                        if client.IgnoreNotFound(err) == nil {
+                               unresolved = true
                                continue
                        }
-                       return nil, fmt.Errorf("failed to retrieve gatewayclass 
for gateway: %w", err)
+                       return nil, false, fmt.Errorf("failed to retrieve 
gatewayclass for gateway: %w", err)
                }
 
                if string(gatewayClass.Spec.ControllerName) != 
config.ControllerConfig.ControllerName {
@@ -499,7 +508,7 @@ func ParseRouteParentRefs(
                }
        }
 
-       return gateways, nil
+       return gateways, unresolved, nil
 }
 
 // reuseUnchangedListenerStatus keeps the previously published status when
diff --git a/internal/controller/utils_parentref_test.go 
b/internal/controller/utils_parentref_test.go
index 1e74f0ce..4a6bf3b3 100644
--- a/internal/controller/utils_parentref_test.go
+++ b/internal/controller/utils_parentref_test.go
@@ -27,6 +27,7 @@ import (
        metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
        "k8s.io/apimachinery/pkg/runtime"
        "k8s.io/utils/ptr"
+       "sigs.k8s.io/controller-runtime/pkg/client"
        "sigs.k8s.io/controller-runtime/pkg/client/fake"
        gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
 
@@ -94,7 +95,7 @@ func TestParseRouteParentRefs_ReasonOrderIndependent(t 
*testing.T) {
                        cli := fake.NewClientBuilder().WithScheme(scheme).
                                WithObjects(newParentRefGatewayClass(), 
gw).Build()
 
-                       got, err := ParseRouteParentRefs(context.Background(), 
cli, logr.Discard(), route,
+                       got, _, err := 
ParseRouteParentRefs(context.Background(), cli, logr.Discard(), route,
                                []gatewayv1.ParentReference{{Name: "gw"}})
                        require.NoError(t, err)
                        require.Len(t, got, 1)
@@ -145,7 +146,7 @@ func TestParseRouteParentRefs_ExplicitListenerMatch(t 
*testing.T) {
        port8080 := gatewayv1.PortNumber(8080)
 
        t.Run("invalid explicit ref is not satisfied by another gateway's 
listener", func(t *testing.T) {
-               got, err := ParseRouteParentRefs(context.Background(), cli, 
logr.Discard(), route,
+               got, _, err := ParseRouteParentRefs(context.Background(), cli, 
logr.Discard(), route,
                        []gatewayv1.ParentReference{
                                // Explicit sectionName "web" on gw-a, which 
has no such listener.
                                {Name: "gw-a", SectionName: &webSection},
@@ -171,7 +172,7 @@ func TestParseRouteParentRefs_ExplicitListenerMatch(t 
*testing.T) {
        })
 
        t.Run("explicit sectionName on the owning gateway is an explicit 
match", func(t *testing.T) {
-               got, err := ParseRouteParentRefs(context.Background(), cli, 
logr.Discard(), route,
+               got, _, err := ParseRouteParentRefs(context.Background(), cli, 
logr.Discard(), route,
                        []gatewayv1.ParentReference{{Name: "gw-b", SectionName: 
&webSection}})
                require.NoError(t, err)
                require.Len(t, got, 1)
@@ -180,7 +181,7 @@ func TestParseRouteParentRefs_ExplicitListenerMatch(t 
*testing.T) {
        })
 
        t.Run("explicit port on the owning gateway is an explicit match", 
func(t *testing.T) {
-               got, err := ParseRouteParentRefs(context.Background(), cli, 
logr.Discard(), route,
+               got, _, err := ParseRouteParentRefs(context.Background(), cli, 
logr.Discard(), route,
                        []gatewayv1.ParentReference{{Name: "gw-b", Port: 
&port8080}})
                require.NoError(t, err)
                require.Len(t, got, 1)
@@ -189,7 +190,7 @@ func TestParseRouteParentRefs_ExplicitListenerMatch(t 
*testing.T) {
        })
 
        t.Run("implicit ref is not an explicit match", func(t *testing.T) {
-               got, err := ParseRouteParentRefs(context.Background(), cli, 
logr.Discard(), route,
+               got, _, err := ParseRouteParentRefs(context.Background(), cli, 
logr.Discard(), route,
                        []gatewayv1.ParentReference{{Name: "gw-b"}})
                require.NoError(t, err)
                require.Len(t, got, 1)
@@ -222,7 +223,7 @@ func TestParseRouteParentRefs_ConflictingTLSModePort(t 
*testing.T) {
        cli := fake.NewClientBuilder().WithScheme(scheme).
                WithObjects(newParentRefGatewayClass(), gw).Build()
 
-       got, err := ParseRouteParentRefs(context.Background(), cli, 
logr.Discard(), route,
+       got, _, err := ParseRouteParentRefs(context.Background(), cli, 
logr.Discard(), route,
                []gatewayv1.ParentReference{{Name: "gw"}})
        require.NoError(t, err)
        require.Len(t, got, 1)
@@ -231,3 +232,74 @@ func TestParseRouteParentRefs_ConflictingTLSModePort(t 
*testing.T) {
                "route must not attach to a conflicting-tls-mode port")
        assert.Equal(t, string(gatewayv1.RouteReasonNotAllowedByListeners), 
cond.Reason)
 }
+
+// TestParseRouteParentRefs_UnresolvedParents verifies the second return value
+// distinguishes "no parentRef named this controller" from "a parentRef could 
not
+// be resolved". Callers delete data plane configuration on an empty gateway 
list,
+// which is only correct in the first case: a GatewayClass is cluster-scoped, 
so
+// while one is missing every route under it resolves to an empty list at once.
+func TestParseRouteParentRefs_UnresolvedParents(t *testing.T) {
+       scheme := parentRefTestScheme(t)
+
+       route := &gatewayv1.HTTPRoute{
+               ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "r"},
+       }
+       listener := gatewayv1.Listener{Name: "http", Port: 80, Protocol: 
gatewayv1.HTTPProtocolType}
+       gw := func(name, class string) *gatewayv1.Gateway {
+               return &gatewayv1.Gateway{
+                       ObjectMeta: metav1.ObjectMeta{Namespace: "default", 
Name: name},
+                       Spec: gatewayv1.GatewaySpec{
+                               GatewayClassName: gatewayv1.ObjectName(class),
+                               Listeners:        
[]gatewayv1.Listener{listener},
+                       },
+               }
+       }
+       foreignClass := &gatewayv1.GatewayClass{
+               ObjectMeta: metav1.ObjectMeta{Name: "other"},
+               Spec:       gatewayv1.GatewayClassSpec{ControllerName: 
"example.com/other-controller"},
+       }
+
+       for _, tc := range []struct {
+               name           string
+               objects        []client.Object
+               parentRef      string
+               wantGateways   int
+               wantUnresolved bool
+       }{
+               {
+                       name:           "gateway missing",
+                       objects:        
[]client.Object{newParentRefGatewayClass()},
+                       parentRef:      "gw",
+                       wantUnresolved: true,
+               },
+               {
+                       name:           "gatewayclass missing",
+                       objects:        []client.Object{gw("gw", "apisix")},
+                       parentRef:      "gw",
+                       wantUnresolved: true,
+               },
+               {
+                       name:           "gateway of another controller",
+                       objects:        []client.Object{foreignClass, gw("gw", 
"other")},
+                       parentRef:      "gw",
+                       wantUnresolved: false,
+               },
+               {
+                       name:           "gateway of this controller",
+                       objects:        
[]client.Object{newParentRefGatewayClass(), gw("gw", "apisix")},
+                       parentRef:      "gw",
+                       wantGateways:   1,
+                       wantUnresolved: false,
+               },
+       } {
+               t.Run(tc.name, func(t *testing.T) {
+                       cli := 
fake.NewClientBuilder().WithScheme(scheme).WithObjects(tc.objects...).Build()
+
+                       got, unresolved, err := 
ParseRouteParentRefs(context.Background(), cli, logr.Discard(), route,
+                               []gatewayv1.ParentReference{{Name: 
gatewayv1.ObjectName(tc.parentRef)}})
+                       require.NoError(t, err)
+                       assert.Len(t, got, tc.wantGateways)
+                       assert.Equal(t, tc.wantUnresolved, unresolved)
+               })
+       }
+}
diff --git a/internal/provider/apisix/provider.go 
b/internal/provider/apisix/provider.go
index 86d7f532..039e3d75 100644
--- a/internal/provider/apisix/provider.go
+++ b/internal/provider/apisix/provider.go
@@ -228,13 +228,19 @@ func (d *apisixProvider) Delete(ctx context.Context, obj 
client.Object) error {
                        Labels: labels,
                })
        }
-       defer d.syncNotify()
-       return d.client.DeleteConfig(ctx, adcclient.Task{
+       delta, err := d.client.DeleteConfig(ctx, adcclient.Task{
                Key:           nnk,
                Name:          nnk.String(),
                Labels:        labels,
                ResourceTypes: resourceTypes,
        })
+       // Syncing pushes the whole store to every data plane. Objects this 
controller
+       // never configured delete nothing, and reconciles for them are 
frequent, so
+       // notify only when the store actually changed.
+       if len(delta.Deleted) > 0 {
+               d.syncNotify()
+       }
+       return err
 }
 
 func (d *apisixProvider) buildConfig(tctx *provider.TranslateContext, nnk 
types.NamespacedNameKind) (map[types.NamespacedNameKind]adctypes.Config, error) 
{
diff --git a/internal/provider/apisix/provider_test.go 
b/internal/provider/apisix/provider_test.go
new file mode 100644
index 00000000..e3be9b13
--- /dev/null
+++ b/internal/provider/apisix/provider_test.go
@@ -0,0 +1,67 @@
+// 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 apisix
+
+import (
+       "context"
+       "testing"
+       "time"
+
+       "github.com/go-logr/logr"
+       "github.com/stretchr/testify/require"
+       metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+       gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+
+       adctypes "github.com/apache/apisix-ingress-controller/api/adc"
+       adcclient 
"github.com/apache/apisix-ingress-controller/internal/adc/client"
+       "github.com/apache/apisix-ingress-controller/internal/types"
+       "github.com/apache/apisix-ingress-controller/internal/utils"
+)
+
+// TestDeleteNotifiesSyncOnlyWhenConfigWasRemoved covers the cost side of route
+// ownership: a sync pushes the whole store to every data plane, and reconciles
+// for routes this controller never configured are frequent (any EndpointSlice
+// event on a shared backend enqueues them), so those must not notify.
+func TestDeleteNotifiesSyncOnlyWhenConfigWasRemoved(t *testing.T) {
+       cli, err := adcclient.New(logr.Discard(), ProviderTypeAPISIX, 
time.Second)
+       require.NoError(t, err)
+
+       d := &apisixProvider{
+               client: cli,
+               syncCh: make(chan struct{}, 1),
+               log:    logr.Discard(),
+       }
+
+       route := &gatewayv1.HTTPRoute{
+               TypeMeta: metav1.TypeMeta{
+                       Kind:       "HTTPRoute",
+                       APIVersion: gatewayv1.GroupVersion.String(),
+               },
+               ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: 
"route"},
+       }
+
+       require.NoError(t, d.Delete(context.Background(), route))
+       require.Empty(t, d.syncCh, "a route this controller never configured 
must not trigger a sync")
+
+       cli.ConfigManager.Update(utils.NamespacedNameKind(route), 
map[types.NamespacedNameKind]adctypes.Config{
+               {Namespace: "default", Name: "proxy", Kind: "GatewayProxy"}: 
{Name: "proxy"},
+       })
+
+       require.NoError(t, d.Delete(context.Background(), route))
+       require.Len(t, d.syncCh, 1, "removing configuration this controller 
pushed must trigger a sync")
+}
diff --git a/test/e2e/gatewayapi/httproute.go b/test/e2e/gatewayapi/httproute.go
index d21760b5..f8b70b8f 100644
--- a/test/e2e/gatewayapi/httproute.go
+++ b/test/e2e/gatewayapi/httproute.go
@@ -251,6 +251,82 @@ spec:
       name: additional-proxy-config
 `
 
+               // GatewayClass owned by a different controller, plus a Gateway 
using it.
+               // Moving a route onto this Gateway takes it out of the scope 
of the
+               // controller under test without deleting the route itself.
+               var foreignGatewayClassYaml = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: GatewayClass
+metadata:
+  name: %s
+spec:
+  controllerName: "apisix.apache.org/not-exist"
+`
+
+               var foreignGateway = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+  name: foreign-gateway
+spec:
+  gatewayClassName: %s
+  listeners:
+    - name: http-foreign
+      protocol: HTTP
+      port: 80
+      allowedRoutes:
+        namespaces:
+          from: All
+`
+
+               // HTTPRoute with a single parent, whose name is filled in by 
the test.
+               var singleParentHTTPRoute = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: moving-route
+spec:
+  parentRefs:
+  - name: %s
+    namespace: %s
+  hostnames:
+  - httpbin-additional.example
+  rules:
+  - matches:
+    - path:
+        type: Exact
+        value: /get
+    backendRefs:
+    - name: httpbin-service-e2e-test
+      port: 80
+`
+
+               // The same route with an extra match, so re-applying it bumps 
the
+               // generation and forces a reconcile while the GatewayClass is 
missing.
+               var singleParentHTTPRouteExtraMatch = `
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: moving-route
+spec:
+  parentRefs:
+  - name: %s
+    namespace: %s
+  hostnames:
+  - httpbin-additional.example
+  rules:
+  - matches:
+    - path:
+        type: Exact
+        value: /get
+    - path:
+        type: Exact
+        value: /headers
+    backendRefs:
+    - name: httpbin-service-e2e-test
+      port: 80
+`
+
                // HTTPRoute that references both gateways
                var multiGatewayHTTPRoute = `
 apiVersion: gateway.networking.k8s.io/v1
@@ -372,6 +448,100 @@ spec:
                                Check:  
scaffold.WithExpectedStatus(http.StatusNotFound),
                        })
                })
+
+               It("HTTPRoute should stop being served after moving to another 
controller's Gateway", func() {
+                       By("Create HTTPRoute on the additional gateway")
+                       s.ResourceApplied("HTTPRoute", "moving-route",
+                               fmt.Sprintf(singleParentHTTPRoute, 
"additional-gateway", additionalSvc.Namespace), 1)
+
+                       client, err := 
s.NewAPISIXClientForGateway(additionalGatewayGroupID)
+                       Expect(err).NotTo(HaveOccurred(), "creating client for 
additional gateway")
+
+                       By("HTTPRoute should be accessible through the 
additional gateway")
+                       s.RequestAssert(&scaffold.RequestAssert{
+                               Client:   client,
+                               Method:   "GET",
+                               Path:     "/get",
+                               Host:     "httpbin-additional.example",
+                               Check:    
scaffold.WithExpectedStatus(http.StatusOK),
+                               Timeout:  time.Second * 30,
+                               Interval: time.Second * 2,
+                       })
+
+                       By("Create a Gateway owned by another controller")
+                       foreignGatewayClassName := 
fmt.Sprintf("foreign-gatewayclass-%d", time.Now().Nanosecond())
+                       err = s.CreateResourceFromStringWithNamespace(
+                               fmt.Sprintf(foreignGatewayClassYaml, 
foreignGatewayClassName), "")
+                       Expect(err).NotTo(HaveOccurred(), "creating foreign 
GatewayClass")
+
+                       err = s.CreateResourceFromStringWithNamespace(
+                               fmt.Sprintf(foreignGateway, 
foreignGatewayClassName), additionalSvc.Namespace)
+                       Expect(err).NotTo(HaveOccurred(), "creating foreign 
Gateway")
+
+                       By("Move the HTTPRoute's parentRefs to that Gateway")
+                       err = s.CreateResourceFromString(
+                               fmt.Sprintf(singleParentHTTPRoute, 
"foreign-gateway", additionalSvc.Namespace))
+                       Expect(err).NotTo(HaveOccurred(), "moving HTTPRoute 
parentRefs")
+
+                       By("HTTPRoute should no longer be accessible through 
the additional gateway")
+                       s.RequestAssert(&scaffold.RequestAssert{
+                               Client:   client,
+                               Method:   "GET",
+                               Path:     "/get",
+                               Host:     "httpbin-additional.example",
+                               Check:    
scaffold.WithExpectedStatus(http.StatusNotFound),
+                               Timeout:  time.Second * 30,
+                               Interval: time.Second * 2,
+                       })
+               })
+
+               It("HTTPRoute should keep being served when its GatewayClass 
disappears", func() {
+                       By("Create HTTPRoute on the additional gateway")
+                       s.ResourceApplied("HTTPRoute", "moving-route",
+                               fmt.Sprintf(singleParentHTTPRoute, 
"additional-gateway", additionalSvc.Namespace), 1)
+
+                       client, err := 
s.NewAPISIXClientForGateway(additionalGatewayGroupID)
+                       Expect(err).NotTo(HaveOccurred(), "creating client for 
additional gateway")
+
+                       By("HTTPRoute should be accessible through the 
additional gateway")
+                       s.RequestAssert(&scaffold.RequestAssert{
+                               Client:   client,
+                               Method:   "GET",
+                               Path:     "/get",
+                               Host:     "httpbin-additional.example",
+                               Check:    
scaffold.WithExpectedStatus(http.StatusOK),
+                               Timeout:  time.Second * 30,
+                               Interval: time.Second * 2,
+                       })
+
+                       By("Delete the GatewayClass the additional Gateway 
belongs to")
+                       // gc-protection keeps the GatewayClass alive for as 
long as a Gateway
+                       // references it, so a plain delete only marks it 
Terminating and the
+                       // lookup still resolves. Dropping the finalizer 
produces the state a
+                       // CRD upgrade or a restore leaves behind: class gone, 
Gateway alive.
+                       _, err = s.RunKubectlAndGetOutput("delete", 
"gatewayclass", additionalGatewayClassName, "--wait=false")
+                       Expect(err).NotTo(HaveOccurred(), "deleting additional 
GatewayClass")
+                       _, err = s.RunKubectlAndGetOutput("patch", 
"gatewayclass", additionalGatewayClassName,
+                               "--type=merge", "-p", 
`{"metadata":{"finalizers":[]}}`)
+                       Expect(err).NotTo(HaveOccurred(), "removing the 
GatewayClass finalizer")
+
+                       s.RetryAssertion(func() error {
+                               _, err := s.RunKubectlAndGetOutput("get", 
"gatewayclass", additionalGatewayClassName)
+                               return err
+                       }).Should(HaveOccurred(), "GatewayClass should be gone")
+
+                       By("Update the HTTPRoute so it reconciles while the 
GatewayClass is gone")
+                       err = s.CreateResourceFromString(
+                               fmt.Sprintf(singleParentHTTPRouteExtraMatch, 
"additional-gateway", additionalSvc.Namespace))
+                       Expect(err).NotTo(HaveOccurred(), "updating HTTPRoute")
+
+                       By("HTTPRoute should still be served: ownership is 
unknown, not disproven")
+                       request := func() int {
+                               return 
client.GET("/get").WithHost("httpbin-additional.example").Expect().Raw().StatusCode
+                       }
+                       Consistently(request).WithTimeout(time.Second * 
30).ProbeEvery(time.Second * 2).
+                               Should(Equal(http.StatusOK))
+               })
        })
 
        Context("HTTPRoute Base", func() {

Reply via email to