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 d779fa2c fix: preserve shared GatewayProxy resources when deleting
Gateways (#2905)
d779fa2c is described below
commit d779fa2c14dc75c852ec479e42926055cad1acea
Author: AlinsRan <[email protected]>
AuthorDate: Tue Oct 27 08:51:37 2026 +0800
fix: preserve shared GatewayProxy resources when deleting Gateways (#2905)
---
internal/controller/grpcroute_controller.go | 12 +-
internal/controller/httproute_controller.go | 13 +-
internal/controller/httproute_controller_test.go | 10 +-
internal/controller/route_parent_resolver.go | 101 ++++++++++++
internal/controller/route_parent_resolver_test.go | 192 ++++++++++++++++++++++
internal/controller/tcproute_controller.go | 12 +-
internal/controller/tlsroute_controller.go | 12 +-
internal/controller/udproute_controller.go | 12 +-
internal/controller/utils.go | 32 ++--
internal/controller/utils_parentref_test.go | 60 +++----
internal/provider/apisix/gateway_delete_test.go | 85 ++++++++++
internal/provider/apisix/provider.go | 71 ++++----
internal/provider/apisix/provider_test.go | 107 ++++++++++++
internal/provider/common/configmanager.go | 13 ++
test/conformance/README.md | 20 +++
test/conformance/conformance_test.go | 16 +-
test/e2e/crds/v2/tls.go | 16 +-
test/e2e/ingress/annotations.go | 31 ++--
test/e2e/scaffold/apisix_deployer.go | 6 +
19 files changed, 660 insertions(+), 161 deletions(-)
diff --git a/internal/controller/grpcroute_controller.go
b/internal/controller/grpcroute_controller.go
index 1218afcb..8bc90458 100644
--- a/internal/controller/grpcroute_controller.go
+++ b/internal/controller/grpcroute_controller.go
@@ -57,6 +57,8 @@ type GRPCRouteReconciler struct { //nolint:revive
Provider provider.Provider
+ parentResolver routeParentResolver
+
genericEvent chan event.GenericEvent
Updater status.Updater
@@ -94,7 +96,7 @@ func (r *GRPCRouteReconciler) SetupWithManager(mgr
ctrl.Manager) error {
return false
},
DeleteFunc: func(e event.DeleteEvent)
bool {
- return false
+ return true
},
CreateFunc: func(e event.CreateEvent)
bool {
return true
@@ -164,6 +166,7 @@ func (r *GRPCRouteReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (
gr := new(gatewayv1.GRPCRoute)
if err := r.Get(ctx, req.NamespacedName, gr); err != nil {
if client.IgnoreNotFound(err) == nil {
+ r.parentResolver.Forget(req.NamespacedName)
gr.Namespace = req.Namespace
gr.Name = req.Name
@@ -192,17 +195,12 @@ func (r *GRPCRouteReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (
msg: "Route is accepted",
}
- gateways, unresolvedParents, err := ParseRouteParentRefs(ctx, r.Client,
r.Log, gr, gr.Spec.ParentRefs)
+ gateways, err := r.parentResolver.Resolve(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.
diff --git a/internal/controller/httproute_controller.go
b/internal/controller/httproute_controller.go
index 5050cea9..d3b8a670 100644
--- a/internal/controller/httproute_controller.go
+++ b/internal/controller/httproute_controller.go
@@ -60,6 +60,8 @@ type HTTPRouteReconciler struct { //nolint:revive
Provider provider.Provider
+ parentResolver routeParentResolver
+
genericEvent chan event.GenericEvent
Updater status.Updater
@@ -97,7 +99,7 @@ func (r *HTTPRouteReconciler) SetupWithManager(mgr
ctrl.Manager) error {
return false
},
DeleteFunc: func(e event.DeleteEvent)
bool {
- return false
+ return true
},
CreateFunc: func(e event.CreateEvent)
bool {
return true
@@ -143,6 +145,7 @@ func (r *HTTPRouteReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (
hr := new(gatewayv1.HTTPRoute)
if err := r.Get(ctx, req.NamespacedName, hr); err != nil {
if client.IgnoreNotFound(err) == nil {
+ r.parentResolver.Forget(req.NamespacedName)
if err := r.updateHTTPRoutePolicyStatusOnDeleting(ctx,
req.NamespacedName); err != nil {
return ctrl.Result{}, err
}
@@ -174,18 +177,12 @@ func (r *HTTPRouteReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (
msg: "Route is accepted",
}
- gateways, unresolvedParents, err := ParseRouteParentRefs(ctx, r.Client,
r.Log, hr, hr.Spec.ParentRefs)
+ gateways, err := r.parentResolver.Resolve(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
diff --git a/internal/controller/httproute_controller_test.go
b/internal/controller/httproute_controller_test.go
index 00876c02..38b77234 100644
--- a/internal/controller/httproute_controller_test.go
+++ b/internal/controller/httproute_controller_test.go
@@ -40,11 +40,7 @@ type noopReadier struct {
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.
+// Routes with no known managed parents retract any stale configuration.
func TestHTTPRouteReconcile_EmptyGateways(t *testing.T) {
scheme := parentRefTestScheme(t)
@@ -82,9 +78,9 @@ func TestHTTPRouteReconcile_EmptyGateways(t *testing.T) {
wantDelete: true,
},
{
- name: "gatewayclass missing",
+ name: "gatewayclass never resolved",
objects: []client.Object{route, gw("gone")},
- wantDelete: false,
+ wantDelete: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
diff --git a/internal/controller/route_parent_resolver.go
b/internal/controller/route_parent_resolver.go
new file mode 100644
index 00000000..89cab1c8
--- /dev/null
+++ b/internal/controller/route_parent_resolver.go
@@ -0,0 +1,101 @@
+// 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"
+ "sync"
+
+ "github.com/go-logr/logr"
+ "k8s.io/apimachinery/pkg/types"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+
+ "github.com/apache/apisix-ingress-controller/internal/controller/config"
+)
+
+type resolvedParentClass struct {
+ gatewayUID types.UID
+ class *gatewayv1.GatewayClass
+}
+
+type routeParentOwnership struct {
+ uid types.UID
+ parents map[client.ObjectKey]resolvedParentClass
+}
+
+// routeParentResolver remembers ownership only for parents previously resolved
+// for this route. A never-resolved sibling must not prevent deletion cleanup.
+// Like the provider's desired configuration, this state is rebuilt on startup.
+type routeParentResolver struct {
+ mu sync.Mutex
+ parents map[client.ObjectKey]routeParentOwnership
+}
+
+func (r *routeParentResolver) Forget(key client.ObjectKey) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ delete(r.parents, key)
+}
+
+func (r *routeParentResolver) Resolve(ctx context.Context, cli client.Client,
log logr.Logger, route client.Object, refs []gatewayv1.ParentReference)
([]RouteParentRefContext, error) {
+ // controller-runtime never reconciles one route concurrently, so the
lock only
+ // guards the map, not the parent lookups between the read and the
write.
+ routeKey := client.ObjectKeyFromObject(route)
+ r.mu.Lock()
+ previous := r.parents[routeKey].parents
+ if r.parents[routeKey].uid != route.GetUID() {
+ previous = nil
+ }
+ r.mu.Unlock()
+ next := make(map[client.ObjectKey]resolvedParentClass)
+ gateways, err := parseRouteParentRefs(ctx, cli, log, route, refs,
func(gw *gatewayv1.Gateway) (*gatewayv1.GatewayClass, error) {
+ key := client.ObjectKeyFromObject(gw)
+ gc := &gatewayv1.GatewayClass{}
+ if err := cli.Get(ctx, client.ObjectKey{Name:
string(gw.Spec.GatewayClassName)}, gc); err != nil {
+ if client.IgnoreNotFound(err) != nil {
+ return nil, err
+ }
+ known, ok := previous[key]
+ if !ok || known.gatewayUID != gw.UID ||
known.class.Name != string(gw.Spec.GatewayClassName) {
+ return nil, nil
+ }
+ // Keep resolving the surviving Gateway's current
listeners without
+ // retaining configuration belonging to a different,
deleted parent.
+ gc = known.class
+ }
+ if string(gc.Spec.ControllerName) ==
config.ControllerConfig.ControllerName {
+ next[key] = resolvedParentClass{gatewayUID: gw.UID,
class: gc}
+ }
+ return gc, nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if len(next) == 0 {
+ delete(r.parents, routeKey)
+ } else {
+ if r.parents == nil {
+ r.parents =
make(map[client.ObjectKey]routeParentOwnership)
+ }
+ r.parents[routeKey] = routeParentOwnership{uid: route.GetUID(),
parents: next}
+ }
+ return gateways, nil
+}
diff --git a/internal/controller/route_parent_resolver_test.go
b/internal/controller/route_parent_resolver_test.go
new file mode 100644
index 00000000..4ab3f505
--- /dev/null
+++ b/internal/controller/route_parent_resolver_test.go
@@ -0,0 +1,192 @@
+// 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/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/utils/ptr"
+ "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/client/fake"
+ "sigs.k8s.io/controller-runtime/pkg/client/interceptor"
+ gatewayv1 "sigs.k8s.io/gateway-api/apis/v1"
+)
+
+func TestRouteParentResolverGatewayDeletion(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ route client.Object
+ protocol gatewayv1.ProtocolType
+ }{
+ {"HTTPRoute", &gatewayv1.HTTPRoute{},
gatewayv1.HTTPProtocolType},
+ {"GRPCRoute", &gatewayv1.GRPCRoute{},
gatewayv1.HTTPProtocolType},
+ {"TCPRoute", &gatewayv1.TCPRoute{}, gatewayv1.TCPProtocolType},
+ {"UDPRoute", &gatewayv1.UDPRoute{}, gatewayv1.UDPProtocolType},
+ {"TLSRoute", &gatewayv1.TLSRoute{}, gatewayv1.TLSProtocolType},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, knownSibling := range []bool{false, true} {
+ name := "never-resolved sibling"
+ if knownSibling {
+ name = "previously-resolved sibling"
+ }
+ t.Run(name, func(t *testing.T) {
+ ctx := context.Background()
+ route :=
tc.route.DeepCopyObject().(client.Object)
+ route.SetNamespace("default")
+ route.SetName("route")
+ route.SetUID("route-uid")
+ classA := newParentRefGatewayClass()
+ classB := classA.DeepCopy()
+ classB.Name = "sibling-class"
+ listener := gatewayv1.Listener{Name:
"listener", Port: 9000, Protocol: tc.protocol}
+ if tc.protocol ==
gatewayv1.TLSProtocolType {
+ listener.TLS =
&gatewayv1.ListenerTLSConfig{Mode: ptr.To(gatewayv1.TLSModePassthrough)}
+ }
+ a := &gatewayv1.Gateway{
+ ObjectMeta:
metav1.ObjectMeta{Namespace: "default", Name: "a", UID: "a-uid"},
+ Spec:
gatewayv1.GatewaySpec{GatewayClassName: gatewayv1.ObjectName(classA.Name),
Listeners: []gatewayv1.Listener{listener}},
+ }
+ b := a.DeepCopy()
+ b.Name, b.UID = "b", "b-uid"
+ b.Spec.GatewayClassName =
gatewayv1.ObjectName(classB.Name)
+ objects := []client.Object{classA, a, b}
+ if knownSibling {
+ objects = append(objects,
classB)
+ }
+ cli :=
fake.NewClientBuilder().WithScheme(parentRefTestScheme(t)).WithObjects(objects...).Build()
+ refs :=
[]gatewayv1.ParentReference{{Name: "a"}, {Name: "b"}}
+ resolver := &routeParentResolver{}
+ parents, err := resolver.Resolve(ctx,
cli, logr.Discard(), route, refs)
+ require.NoError(t, err)
+ expected := 1
+ if knownSibling {
+ expected = 2
+ }
+ require.Len(t, parents, expected)
+ require.True(t,
isRouteAccepted(parents))
+ if knownSibling {
+ require.NoError(t,
cli.Delete(ctx, classB))
+ }
+ require.NoError(t, cli.Delete(ctx, a))
+ parents, err = resolver.Resolve(ctx,
cli, logr.Discard(), route, refs)
+ require.NoError(t, err)
+ if knownSibling {
+ require.Len(t, parents, 1)
+ require.Equal(t, "b",
parents[0].Gateway.Name)
+ require.True(t,
isRouteAccepted(parents))
+ // Listener changes still apply
while its class is absent.
+
b.Spec.Listeners[0].AllowedRoutes = &gatewayv1.AllowedRoutes{
+ Kinds:
[]gatewayv1.RouteGroupKind{{Kind: "UnsupportedRoute"}},
+ }
+ require.NoError(t,
cli.Update(ctx, b))
+ parents, err =
resolver.Resolve(ctx, cli, logr.Discard(), route, refs)
+ require.NoError(t, err)
+ require.False(t,
isRouteAccepted(parents))
+ require.NoError(t,
cli.Delete(ctx, b))
+ parents, err =
resolver.Resolve(ctx, cli, logr.Discard(), route, refs)
+ require.NoError(t, err)
+ }
+ require.Empty(t, parents)
+ require.Empty(t, resolver.parents)
+ })
+ }
+ })
+ }
+}
+
+func TestRouteParentResolverDoesNotReuseOwnership(t *testing.T) {
+ for _, change := range []string{"gateway recreated", "route recreated",
"class changed", "route deleted", "parent removed", "class reassigned"} {
+ t.Run(change, func(t *testing.T) {
+ ctx := context.Background()
+ gc := newParentRefGatewayClass()
+ gw := &gatewayv1.Gateway{
+ ObjectMeta: metav1.ObjectMeta{Namespace:
"default", Name: "gw", UID: "gateway-uid"},
+ Spec:
gatewayv1.GatewaySpec{GatewayClassName: gatewayv1.ObjectName(gc.Name),
Listeners: []gatewayv1.Listener{{Name: "http", Port: 80, Protocol:
gatewayv1.HTTPProtocolType}}},
+ }
+ route := &gatewayv1.HTTPRoute{ObjectMeta:
metav1.ObjectMeta{Namespace: "default", Name: "route", UID: "route-uid"}}
+ cli :=
fake.NewClientBuilder().WithScheme(parentRefTestScheme(t)).WithObjects(gc,
gw).Build()
+ resolver := &routeParentResolver{}
+ refs := []gatewayv1.ParentReference{{Name: "gw"}}
+ parents, err := resolver.Resolve(ctx, cli,
logr.Discard(), route, refs)
+ require.NoError(t, err)
+ require.Len(t, parents, 1)
+ require.NoError(t, cli.Delete(ctx, gc))
+ switch change {
+ case "gateway recreated":
+ require.NoError(t, cli.Delete(ctx, gw))
+ gw.ResourceVersion = ""
+ gw.UID = "new-gateway-uid"
+ require.NoError(t, cli.Create(ctx, gw))
+ case "route recreated":
+ route.UID = "new-route-uid"
+ case "class changed":
+ gw.Spec.GatewayClassName = "different-class"
+ require.NoError(t, cli.Update(ctx, gw))
+ case "route deleted":
+
resolver.Forget(client.ObjectKeyFromObject(route))
+ case "parent removed":
+ _, err = resolver.Resolve(ctx, cli,
logr.Discard(), route, nil)
+ require.NoError(t, err)
+ case "class reassigned":
+ gc.ResourceVersion = ""
+ gc.Spec.ControllerName =
"other.example/controller"
+ require.NoError(t, cli.Create(ctx, gc))
+ }
+ parents, err = resolver.Resolve(ctx, cli,
logr.Discard(), route, refs)
+ require.NoError(t, err)
+ require.Empty(t, parents)
+ })
+ }
+}
+
+func TestRouteParentResolverPreservesOwnershipOnReadError(t *testing.T) {
+ ctx := context.Background()
+ gc := newParentRefGatewayClass()
+ gw := &gatewayv1.Gateway{
+ ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "gw",
UID: "gateway-uid"},
+ Spec: gatewayv1.GatewaySpec{GatewayClassName:
gatewayv1.ObjectName(gc.Name), Listeners: []gatewayv1.Listener{{Name: "http",
Port: 80, Protocol: gatewayv1.HTTPProtocolType}}},
+ }
+ route := &gatewayv1.HTTPRoute{ObjectMeta: metav1.ObjectMeta{Namespace:
"default", Name: "route"}}
+ fail := false
+ readErr := errors.New("temporary API failure")
+ cli :=
fake.NewClientBuilder().WithScheme(parentRefTestScheme(t)).WithObjects(gc, gw).
+ WithInterceptorFuncs(interceptor.Funcs{Get: func(ctx
context.Context, cli client.WithWatch, key client.ObjectKey, obj client.Object,
opts ...client.GetOption) error {
+ if _, ok := obj.(*gatewayv1.GatewayClass); ok && fail {
+ return readErr
+ }
+ return cli.Get(ctx, key, obj, opts...)
+ }}).Build()
+ resolver := &routeParentResolver{}
+ refs := []gatewayv1.ParentReference{{Name: "gw"}}
+ _, err := resolver.Resolve(ctx, cli, logr.Discard(), route, refs)
+ require.NoError(t, err)
+ fail = true
+ _, err = resolver.Resolve(ctx, cli, logr.Discard(), route, refs)
+ require.ErrorIs(t, err, readErr)
+ fail = false
+ require.NoError(t, cli.Delete(ctx, gc))
+ parents, err := resolver.Resolve(ctx, cli, logr.Discard(), route, refs)
+ require.NoError(t, err)
+ require.Len(t, parents, 1)
+}
diff --git a/internal/controller/tcproute_controller.go
b/internal/controller/tcproute_controller.go
index 8428a55e..8116f482 100644
--- a/internal/controller/tcproute_controller.go
+++ b/internal/controller/tcproute_controller.go
@@ -55,6 +55,8 @@ type TCPRouteReconciler struct { //nolint:revive
Provider provider.Provider
+ parentResolver routeParentResolver
+
Updater status.Updater
Readier readiness.ReadinessManager
@@ -86,7 +88,7 @@ func (r *TCPRouteReconciler) SetupWithManager(mgr
ctrl.Manager) error {
return false
},
DeleteFunc: func(e event.DeleteEvent)
bool {
- return false
+ return true
},
CreateFunc: func(e event.CreateEvent)
bool {
return true
@@ -253,6 +255,7 @@ func (r *TCPRouteReconciler) Reconcile(ctx context.Context,
req ctrl.Request) (c
tr := new(gatewayv1.TCPRoute)
if err := r.Get(ctx, req.NamespacedName, tr); err != nil {
if client.IgnoreNotFound(err) == nil {
+ r.parentResolver.Forget(req.NamespacedName)
tr.Namespace = req.Namespace
tr.Name = req.Name
@@ -283,17 +286,12 @@ func (r *TCPRouteReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (c
msg: "Route is accepted",
}
- gateways, unresolvedParents, err := ParseRouteParentRefs(ctx, r.Client,
r.Log, tr, tr.Spec.ParentRefs)
+ gateways, err := r.parentResolver.Resolve(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.
diff --git a/internal/controller/tlsroute_controller.go
b/internal/controller/tlsroute_controller.go
index 1346c3ef..1ba9f4f5 100644
--- a/internal/controller/tlsroute_controller.go
+++ b/internal/controller/tlsroute_controller.go
@@ -55,6 +55,8 @@ type TLSRouteReconciler struct { //nolint:revive
Provider provider.Provider
+ parentResolver routeParentResolver
+
Updater status.Updater
Readier readiness.ReadinessManager
@@ -86,7 +88,7 @@ func (r *TLSRouteReconciler) SetupWithManager(mgr
ctrl.Manager) error {
return false
},
DeleteFunc: func(e event.DeleteEvent)
bool {
- return false
+ return true
},
CreateFunc: func(e event.CreateEvent)
bool {
return true
@@ -253,6 +255,7 @@ func (r *TLSRouteReconciler) Reconcile(ctx context.Context,
req ctrl.Request) (c
tr := new(gatewayv1.TLSRoute)
if err := r.Get(ctx, req.NamespacedName, tr); err != nil {
if client.IgnoreNotFound(err) == nil {
+ r.parentResolver.Forget(req.NamespacedName)
tr.Namespace = req.Namespace
tr.Name = req.Name
@@ -283,17 +286,12 @@ func (r *TLSRouteReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (c
msg: "Route is accepted",
}
- gateways, unresolvedParents, err := ParseRouteParentRefs(ctx, r.Client,
r.Log, tr, tr.Spec.ParentRefs)
+ gateways, err := r.parentResolver.Resolve(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.
diff --git a/internal/controller/udproute_controller.go
b/internal/controller/udproute_controller.go
index a9475941..3f3255d4 100644
--- a/internal/controller/udproute_controller.go
+++ b/internal/controller/udproute_controller.go
@@ -55,6 +55,8 @@ type UDPRouteReconciler struct { //nolint:revive
Provider provider.Provider
+ parentResolver routeParentResolver
+
Updater status.Updater
Readier readiness.ReadinessManager
@@ -86,7 +88,7 @@ func (r *UDPRouteReconciler) SetupWithManager(mgr
ctrl.Manager) error {
return false
},
DeleteFunc: func(e event.DeleteEvent)
bool {
- return false
+ return true
},
CreateFunc: func(e event.CreateEvent)
bool {
return true
@@ -253,6 +255,7 @@ func (r *UDPRouteReconciler) Reconcile(ctx context.Context,
req ctrl.Request) (c
tr := new(gatewayv1.UDPRoute)
if err := r.Get(ctx, req.NamespacedName, tr); err != nil {
if client.IgnoreNotFound(err) == nil {
+ r.parentResolver.Forget(req.NamespacedName)
tr.Namespace = req.Namespace
tr.Name = req.Name
@@ -283,17 +286,12 @@ func (r *UDPRouteReconciler) Reconcile(ctx
context.Context, req ctrl.Request) (c
msg: "Route is accepted",
}
- gateways, unresolvedParents, err := ParseRouteParentRefs(ctx, r.Client,
r.Log, tr, tr.Spec.ParentRefs)
+ gateways, err := r.parentResolver.Resolve(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.
diff --git a/internal/controller/utils.go b/internal/controller/utils.go
index bc328f4c..e5814c9b 100644
--- a/internal/controller/utils.go
+++ b/internal/controller/utils.go
@@ -338,21 +338,15 @@ 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(
+func parseRouteParentRefs(
ctx context.Context,
mgrc client.Client,
log logr.Logger,
route client.Object,
parentRefs []gatewayv1.ParentReference,
-) ([]RouteParentRefContext, bool, error) {
+ resolveClass func(*gatewayv1.Gateway) (*gatewayv1.GatewayClass, error),
+) ([]RouteParentRefContext, error) {
gateways := make([]RouteParentRefContext, 0)
- unresolved := false
for _, parentRef := range parentRefs {
namespace := route.GetNamespace()
if parentRef.Namespace != nil {
@@ -370,24 +364,18 @@ func ParseRouteParentRefs(
Name: name,
}, &gateway); err != nil {
if client.IgnoreNotFound(err) == nil {
- unresolved = true
+ // A deleted Gateway no longer authorizes its
routes to serve traffic.
continue
}
- return nil, false, fmt.Errorf("failed to retrieve
gateway for route: %w", err)
+ return nil, fmt.Errorf("failed to retrieve gateway for
route: %w", err)
}
- gatewayClass := gatewayv1.GatewayClass{}
- if err := mgrc.Get(ctx, client.ObjectKey{
- Name: string(gateway.Spec.GatewayClassName),
- }, &gatewayClass); err != nil {
- if client.IgnoreNotFound(err) == nil {
- unresolved = true
- continue
- }
- return nil, false, fmt.Errorf("failed to retrieve
gatewayclass for gateway: %w", err)
+ gatewayClass, err := resolveClass(&gateway)
+ if err != nil {
+ return nil, fmt.Errorf("failed to retrieve gatewayclass
for gateway: %w", err)
}
- if string(gatewayClass.Spec.ControllerName) !=
config.ControllerConfig.ControllerName {
+ if gatewayClass == nil ||
string(gatewayClass.Spec.ControllerName) !=
config.ControllerConfig.ControllerName {
continue
}
@@ -522,7 +510,7 @@ func ParseRouteParentRefs(
}
}
- return gateways, unresolved, nil
+ return gateways, 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 4a6bf3b3..3b60f028 100644
--- a/internal/controller/utils_parentref_test.go
+++ b/internal/controller/utils_parentref_test.go
@@ -95,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 :=
(&routeParentResolver{}).Resolve(context.Background(), cli, logr.Discard(),
route,
[]gatewayv1.ParentReference{{Name: "gw"}})
require.NoError(t, err)
require.Len(t, got, 1)
@@ -146,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 :=
(&routeParentResolver{}).Resolve(context.Background(), cli, logr.Discard(),
route,
[]gatewayv1.ParentReference{
// Explicit sectionName "web" on gw-a, which
has no such listener.
{Name: "gw-a", SectionName: &webSection},
@@ -172,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 :=
(&routeParentResolver{}).Resolve(context.Background(), cli, logr.Discard(),
route,
[]gatewayv1.ParentReference{{Name: "gw-b", SectionName:
&webSection}})
require.NoError(t, err)
require.Len(t, got, 1)
@@ -181,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 :=
(&routeParentResolver{}).Resolve(context.Background(), cli, logr.Discard(),
route,
[]gatewayv1.ParentReference{{Name: "gw-b", Port:
&port8080}})
require.NoError(t, err)
require.Len(t, got, 1)
@@ -190,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 :=
(&routeParentResolver{}).Resolve(context.Background(), cli, logr.Discard(),
route,
[]gatewayv1.ParentReference{{Name: "gw-b"}})
require.NoError(t, err)
require.Len(t, got, 1)
@@ -223,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 := (&routeParentResolver{}).Resolve(context.Background(), cli,
logr.Discard(), route,
[]gatewayv1.ParentReference{{Name: "gw"}})
require.NoError(t, err)
require.Len(t, got, 1)
@@ -233,11 +233,7 @@ func TestParseRouteParentRefs_ConflictingTLSModePort(t
*testing.T) {
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.
+// Unknown parents must not prevent cleanup of previously managed
configuration.
func TestParseRouteParentRefs_UnresolvedParents(t *testing.T) {
scheme := parentRefTestScheme(t)
@@ -260,46 +256,40 @@ func TestParseRouteParentRefs_UnresolvedParents(t
*testing.T) {
}
for _, tc := range []struct {
- name string
- objects []client.Object
- parentRef string
- wantGateways int
- wantUnresolved bool
+ name string
+ objects []client.Object
+ parentRef string
+ wantGateways int
}{
{
- name: "gateway missing",
- objects:
[]client.Object{newParentRefGatewayClass()},
- parentRef: "gw",
- wantUnresolved: true,
+ name: "gateway missing retracts stale routes",
+ objects: []client.Object{newParentRefGatewayClass()},
+ parentRef: "gw",
},
{
- name: "gatewayclass missing",
- objects: []client.Object{gw("gw", "apisix")},
- parentRef: "gw",
- wantUnresolved: true,
+ name: "gatewayclass missing",
+ objects: []client.Object{gw("gw", "apisix")},
+ parentRef: "gw",
},
{
- name: "gateway of another controller",
- objects: []client.Object{foreignClass, gw("gw",
"other")},
- parentRef: "gw",
- wantUnresolved: false,
+ name: "gateway of another controller",
+ objects: []client.Object{foreignClass, gw("gw",
"other")},
+ parentRef: "gw",
},
{
- name: "gateway of this controller",
- objects:
[]client.Object{newParentRefGatewayClass(), gw("gw", "apisix")},
- parentRef: "gw",
- wantGateways: 1,
- wantUnresolved: false,
+ name: "gateway of this controller",
+ objects:
[]client.Object{newParentRefGatewayClass(), gw("gw", "apisix")},
+ parentRef: "gw",
+ wantGateways: 1,
},
} {
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,
+ got, err :=
(&routeParentResolver{}).Resolve(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/gateway_delete_test.go
b/internal/provider/apisix/gateway_delete_test.go
new file mode 100644
index 00000000..3ba0cdc8
--- /dev/null
+++ b/internal/provider/apisix/gateway_delete_test.go
@@ -0,0 +1,85 @@
+// 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"
+ "encoding/json"
+ "net/http"
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/require"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ 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"
+
+ adctypes "github.com/apache/apisix-ingress-controller/api/adc"
+ "github.com/apache/apisix-ingress-controller/internal/controller"
+ "github.com/apache/apisix-ingress-controller/internal/manager/readiness"
+ "github.com/apache/apisix-ingress-controller/internal/types"
+ "github.com/apache/apisix-ingress-controller/internal/utils"
+)
+
+type gatewayDeleteReadier struct{ readiness.ReadinessManager }
+
+func (gatewayDeleteReadier) Done(client.Object, k8stypes.NamespacedName) {}
+
+func TestGatewayDeletionRetractsRouteWithUnknownSibling(t *testing.T) {
+ withMockADCServer(t, func(w http.ResponseWriter, r *http.Request) {
+ _ = json.NewEncoder(w).Encode(adctypes.SyncResult{Status:
adctypes.StatusSuccess})
+ })
+ d := newTestProvider(t)
+ gateway := &gatewayv1.Gateway{
+ TypeMeta: metav1.TypeMeta{Kind: "Gateway", APIVersion:
gatewayv1.GroupVersion.String()},
+ ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name:
"deleted"},
+ }
+ route := &gatewayv1.HTTPRoute{
+ TypeMeta: metav1.TypeMeta{Kind: "HTTPRoute", APIVersion:
gatewayv1.GroupVersion.String()},
+ ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name:
"route"},
+ Spec: gatewayv1.HTTPRouteSpec{CommonRouteSpec:
gatewayv1.CommonRouteSpec{
+ ParentRefs: []gatewayv1.ParentReference{{Name:
"deleted"}, {Name: "unresolved"}},
+ }},
+ }
+ sibling := &gatewayv1.Gateway{
+ ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name:
"unresolved"},
+ Spec: gatewayv1.GatewaySpec{GatewayClassName:
"missing-class"},
+ }
+ proxy := types.NamespacedNameKind{Kind: "GatewayProxy", Namespace:
"default", Name: "proxy"}
+ configs := map[types.NamespacedNameKind]adctypes.Config{proxy: {Name:
proxy.String(), BackendType: "apisix", ServerAddrs:
[]string{"http://apisix:9180"}}}
+ owner := utils.NamespacedNameKind(gateway)
+ routeOwner := utils.NamespacedNameKind(route)
+ require.NoError(t, d.applyResourceState(owner, configs,
[]string{adctypes.TypeSSL}, &adctypes.Resources{}, labelsOf(owner),
pluginsNone))
+ require.NoError(t, d.applyResourceState(routeOwner, configs,
[]string{adctypes.TypeService}, &adctypes.Resources{
+ Services: []*adctypes.Service{{Metadata: adctypes.Metadata{ID:
"stale-route", Labels: labelsOf(routeOwner)}}},
+ }, labelsOf(routeOwner), pluginsNone))
+ require.NoError(t, d.Delete(context.Background(), gateway))
+ scheme := runtime.NewScheme()
+ require.NoError(t, gatewayv1.Install(scheme))
+ cli := fake.NewClientBuilder().WithScheme(scheme).WithObjects(route,
sibling).Build()
+ r := &controller.HTTPRouteReconciler{Client: cli, Scheme: scheme, Log:
logr.Discard(), Provider: d, Readier: gatewayDeleteReadier{}}
+ _, err := r.Reconcile(context.Background(),
ctrl.Request{NamespacedName: client.ObjectKeyFromObject(route)})
+ require.NoError(t, err)
+ resources, err := d.store.GetResources(proxy.String())
+ require.NoError(t, err)
+ require.Empty(t, resources.Services, "deleted Gateway's route survives
reconciliation with an unresolved sibling")
+}
diff --git a/internal/provider/apisix/provider.go
b/internal/provider/apisix/provider.go
index 403d1528..29f67d2e 100644
--- a/internal/provider/apisix/provider.go
+++ b/internal/provider/apisix/provider.go
@@ -236,16 +236,22 @@ func (d *apisixProvider) Delete(ctx context.Context, obj
client.Object) error {
resourceTypes []string
labels map[string]string
plugins pluginSource
- // wholeConfig is set for a Gateway or IngressClass, whose
deletion wipes every
- // config it referenced.
- wholeConfig bool
+ // IngressClass deletion clears its config; Gateways can share
a config.
+ wholeConfig bool
+ immediateSync bool
)
switch obj.(type) {
case *gatewayv1.HTTPRoute, *apiv2.ApisixRoute, *gatewayv1.GRPCRoute,
*gatewayv1.TCPRoute, *gatewayv1.UDPRoute, *gatewayv1.TLSRoute:
resourceTypes = append(resourceTypes, adctypes.TypeService)
labels = label.GenLabel(obj)
- case *gatewayv1.Gateway, *networkingv1.IngressClass:
+ case *gatewayv1.Gateway:
+ resourceTypes = append(resourceTypes, adctypes.TypeSSL)
+ labels = label.GenLabel(obj)
+ plugins = pluginsFromGatewayProxy
+ immediateSync = true
+ case *networkingv1.IngressClass:
wholeConfig = true
+ immediateSync = true
case *networkingv1.Ingress:
resourceTypes = append(resourceTypes, adctypes.TypeService,
adctypes.TypeSSL)
labels = label.GenLabel(obj)
@@ -268,11 +274,10 @@ func (d *apisixProvider) Delete(ctx context.Context, obj
client.Object) error {
if err != nil {
return err
}
- // Full synchronization is performed on a gateway by gateway basis
- // and it is not possible to perform scheduled synchronization
- // on deleted gateway level resources
- if wholeConfig {
- d.syncEvictedConfigsNow(ctx, removed, resourceTypes, labels)
+ // Push deletions immediately even when the deleted resource was the
last
+ // source of a config and no scheduled sync will pick it up.
+ if immediateSync {
+ d.syncEvictedConfigsNow(ctx, removed)
return nil
}
// Syncing pushes the whole store to every data plane. Objects this
controller never
@@ -357,7 +362,8 @@ func (d *apisixProvider) removeResourceState(
// evictFromStore deletes rk's contribution from each of the given configs'
cached
// snapshots. global_rules and plugin_metadata sourced from a GatewayProxy
belong to that
-// GatewayProxy's own config and are left in place. Callers must already hold
d.Lock.
+// GatewayProxy's own config and are left in place until no Gateway or
IngressClass
+// references it any more. Callers must already hold d.Lock.
func (d *apisixProvider) evictFromStore(
rk types.NamespacedNameKind,
configs map[types.NamespacedNameKind]adctypes.Config,
@@ -365,19 +371,31 @@ func (d *apisixProvider) evictFromStore(
labels map[string]string,
plugins pluginSource,
) error {
- for _, cfg := range configs {
+ for gatewayProxy, cfg := range configs {
if err := d.store.Delete(cfg.Name, resourceTypes, labels); err
!= nil {
return fmt.Errorf("store delete failed for config %s:
%w", cfg.Name, err)
}
- if plugins == pluginsFromResource {
- if err := d.store.SetGlobalRules(cfg.Name, rk, nil);
err != nil {
- return fmt.Errorf("store global rules failed
for config %s: %w", cfg.Name, err)
+ owner := rk
+ switch {
+ case plugins == pluginsFromGatewayProxy &&
!d.configManager.HasResource(gatewayProxy, isGatewayProxyReferrer):
+ owner = gatewayProxy
+ if err := d.store.SetPluginMetadata(cfg.Name, nil); err
!= nil {
+ return fmt.Errorf("store plugin metadata failed
for config %s: %w", cfg.Name, err)
}
+ case plugins != pluginsFromResource:
+ continue
+ }
+ if err := d.store.SetGlobalRules(cfg.Name, owner, nil); err !=
nil {
+ return fmt.Errorf("store global rules failed for config
%s: %w", cfg.Name, err)
}
}
return nil
}
+func isGatewayProxyReferrer(rk types.NamespacedNameKind) bool {
+ return rk.Kind == types.KindGateway || rk.Kind == types.KindIngressClass
+}
+
// syncConfigNow reads name's current data (via build, called only once this
cacheKey's
// lock is actually held) and pushes it, one atomic read-then-push step per
cacheKey, so
// whichever caller is granted the lock decides what to push only once it
holds it: nothing
@@ -465,28 +483,23 @@ func toADCExecutionError(name string, err error)
types.ADCExecutionError {
return types.ADCExecutionError{Name: name, FailedErrors:
[]types.ADCExecutionServerAddrError{{Err: err.Error()}}}
}
-// syncEvictedConfigsNow pushes an empty resource set for each of the given
configs
-// immediately, instead of waiting for the next scheduled sync round, through
the same
-// per-cacheKey lock the periodic sync uses, so it can never race a periodic
round for the
-// same GatewayProxy. Used only when the deleted resource is a Gateway or
IngressClass,
-// where resourceTypes is empty, so the preceding removeResourceState call
already
-// reset each config's whole cached snapshot via Store.Delete, and that reset
should reach
-// the data plane promptly. Failures are logged, not surfaced as a status
update, matching
-// the deferred path, which only reports through the next scheduled sync round.
+// syncEvictedConfigsNow pushes the remaining snapshot immediately after
deleting
+// a Gateway or IngressClass. Read it under the sync lock so a concurrent
update
+// cannot be overwritten by a stale snapshot or an unconditional empty push.
func (d *apisixProvider) syncEvictedConfigsNow(
ctx context.Context,
configs map[types.NamespacedNameKind]adctypes.Config,
- resourceTypes []string,
- labels map[string]string,
) {
for _, cfg := range configs {
_, err := d.syncConfigNow(ctx, cfg.Name, func()
(adcclient.SyncInput, error) {
+ resources, err := d.store.GetResources(cfg.Name)
+ if err != nil {
+ return adcclient.SyncInput{}, err
+ }
return adcclient.SyncInput{
- Name: cfg.Name,
- Config: cfg,
- Resources: &adctypes.Resources{},
- ResourceTypes: resourceTypes,
- Labels: labels,
+ Name: cfg.Name,
+ Config: cfg,
+ Resources: resources,
}, nil
})
if err != nil {
diff --git a/internal/provider/apisix/provider_test.go
b/internal/provider/apisix/provider_test.go
index 1bd8b6b9..55eba787 100644
--- a/internal/provider/apisix/provider_test.go
+++ b/internal/provider/apisix/provider_test.go
@@ -347,3 +347,110 @@ func
TestRemoveResourceStateRemovesAnApisixGlobalRulesPlugins(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, adctypes.GlobalRule{"cors": map[string]any{}},
resources.GlobalRules, "only the deleted resource's own plugins go away")
}
+
+func TestDeleteGatewayPreservesSharedProxyResources(t *testing.T) {
+ var mu sync.Mutex
+ var received adcclient.ADCServerRequest
+ withMockADCServer(t, func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ defer mu.Unlock()
+ require.NoError(t, json.NewDecoder(r.Body).Decode(&received))
+ w.WriteHeader(http.StatusOK)
+ _ = json.NewEncoder(w).Encode(adctypes.SyncResult{Status:
adctypes.StatusSuccess})
+ })
+ d := newTestProvider(t)
+ gateway := &gatewayv1.Gateway{
+ TypeMeta: metav1.TypeMeta{Kind: "Gateway", APIVersion:
gatewayv1.GroupVersion.String()},
+ ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name:
"deleted"},
+ }
+ owner := utils.NamespacedNameKind(gateway)
+ other := types.NamespacedNameKind{Kind: "Gateway", Namespace:
"default", Name: "remaining"}
+ route := types.NamespacedNameKind{Kind: "HTTPRoute", Namespace:
"default", Name: "route"}
+ proxy := types.NamespacedNameKind{Kind: "GatewayProxy", Namespace:
"default", Name: "proxy"}
+ configs := map[types.NamespacedNameKind]adctypes.Config{proxy: {
+ Name: proxy.String(), BackendType: "apisix", ServerAddrs:
[]string{"http://apisix:9180"},
+ }}
+ for _, key := range []types.NamespacedNameKind{owner, other} {
+ resources := &adctypes.Resources{
+ SSLs: []*adctypes.SSL{{Metadata:
adctypes.Metadata{ID: key.Name, Labels: labelsOf(key)}}},
+ GlobalRules: adctypes.GlobalRule{"cors":
map[string]any{}},
+ PluginMetadata: adctypes.PluginMetadata{"http-logger":
map[string]any{}},
+ }
+ require.NoError(t, d.applyResourceState(key, configs,
[]string{adctypes.TypeSSL}, resources, labelsOf(key), pluginsFromGatewayProxy))
+ }
+ require.NoError(t, d.applyResourceState(route, configs,
[]string{adctypes.TypeService}, &adctypes.Resources{
+ Services: []*adctypes.Service{{Metadata: adctypes.Metadata{ID:
"route", Labels: labelsOf(route)}}},
+ }, labelsOf(route), pluginsNone))
+
+ require.NoError(t, d.Delete(context.Background(), gateway))
+ resources, err := d.store.GetResources(proxy.String())
+ require.NoError(t, err)
+ require.Len(t, resources.SSLs, 1)
+ assert.Equal(t, "remaining", resources.SSLs[0].ID)
+ require.Len(t, resources.Services, 1)
+ assert.Equal(t, "route", resources.Services[0].ID)
+ assert.Contains(t, resources.GlobalRules, "cors")
+ assert.Contains(t, resources.PluginMetadata, "http-logger")
+ mu.Lock()
+ defer mu.Unlock()
+ assert.Equal(t, resources.Services, received.Task.Config.Services)
+ assert.Equal(t, resources.SSLs, received.Task.Config.SSLs)
+ assert.Equal(t, resources.GlobalRules, received.Task.Config.GlobalRules)
+ assert.Equal(t, resources.PluginMetadata,
received.Task.Config.PluginMetadata)
+ assert.Empty(t, d.configManager.Get(owner))
+ assert.NotEmpty(t, d.configManager.Get(other))
+ assert.NotEmpty(t, d.configManager.Get(route))
+}
+
+func TestDeleteLastGatewayClearsProxyPlugins(t *testing.T) {
+ var mu sync.Mutex
+ var received adcclient.ADCServerRequest
+ withMockADCServer(t, func(w http.ResponseWriter, r *http.Request) {
+ mu.Lock()
+ defer mu.Unlock()
+ received = adcclient.ADCServerRequest{}
+ assert.NoError(t, json.NewDecoder(r.Body).Decode(&received))
+ _ = json.NewEncoder(w).Encode(adctypes.SyncResult{Status:
adctypes.StatusSuccess})
+ })
+ proxy := types.NamespacedNameKind{Kind: "GatewayProxy", Namespace:
"default", Name: "proxy"}
+ configs := map[types.NamespacedNameKind]adctypes.Config{proxy: {
+ Name: proxy.String(), BackendType: "apisix", ServerAddrs:
[]string{"http://apisix:9180"},
+ }}
+ plugins := &adctypes.Resources{
+ GlobalRules: adctypes.GlobalRule{"cors": map[string]any{}},
+ PluginMetadata: adctypes.PluginMetadata{"http-logger":
map[string]any{}},
+ }
+ for name, referrers := range map[string][]types.NamespacedNameKind{
+ "last referrer": nil,
+ "IngressClass remains": {{Kind: "IngressClass", Name:
"apisix"}},
+ "another Gateway remains": {{Kind: "Gateway", Namespace:
"default", Name: "remaining"}},
+ } {
+ t.Run(name, func(t *testing.T) {
+ d := newTestProvider(t)
+ gateway := &gatewayv1.Gateway{
+ TypeMeta: metav1.TypeMeta{Kind: "Gateway",
APIVersion: gatewayv1.GroupVersion.String()},
+ ObjectMeta: metav1.ObjectMeta{Namespace:
"default", Name: "deleted"},
+ }
+ route := types.NamespacedNameKind{Kind: "HTTPRoute",
Namespace: "default", Name: "route"}
+ for _, key := range append(referrers,
utils.NamespacedNameKind(gateway)) {
+ require.NoError(t, d.applyResourceState(key,
configs, []string{adctypes.TypeSSL}, plugins, labelsOf(key),
pluginsFromGatewayProxy))
+ }
+ require.NoError(t, d.applyResourceState(route, configs,
[]string{adctypes.TypeService}, &adctypes.Resources{}, labelsOf(route),
pluginsNone))
+
+ require.NoError(t, d.Delete(context.Background(),
gateway))
+ resources, err := d.store.GetResources(proxy.String())
+ require.NoError(t, err)
+ if len(referrers) == 0 {
+ assert.Empty(t, resources.GlobalRules, "an
attached route must not keep the GatewayProxy's plugins alive")
+ assert.Empty(t, resources.PluginMetadata)
+ } else {
+ assert.Equal(t, plugins.GlobalRules,
resources.GlobalRules)
+ assert.Equal(t, plugins.PluginMetadata,
resources.PluginMetadata)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ assert.Equal(t, resources.GlobalRules,
received.Task.Config.GlobalRules, "the deletion push must carry the remaining
global rules")
+ assert.Equal(t, resources.PluginMetadata,
received.Task.Config.PluginMetadata)
+ })
+ }
+}
diff --git a/internal/provider/common/configmanager.go
b/internal/provider/common/configmanager.go
index 387537b0..e8735839 100644
--- a/internal/provider/common/configmanager.go
+++ b/internal/provider/common/configmanager.go
@@ -18,6 +18,7 @@
package common
import (
+ "slices"
"sync"
)
@@ -148,6 +149,18 @@ func (s *ConfigManager[K, T]) Update(
return discard
}
+// HasResource reports whether a resource accepted by match still references
config.
+func (s *ConfigManager[K, T]) HasResource(config K, match func(K) bool) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for resource, configKeys := range s.resourceConfigKeys {
+ if match(resource) && slices.Contains(configKeys, config) {
+ return true
+ }
+ }
+ return false
+}
+
func (s *ConfigManager[K, T]) Set(key K, cfg T) {
s.mu.Lock()
defer s.mu.Unlock()
diff --git a/test/conformance/README.md b/test/conformance/README.md
index c4ffbdcf..c97643a5 100644
--- a/test/conformance/README.md
+++ b/test/conformance/README.md
@@ -114,3 +114,23 @@ that capability.
runs in parallel and those tests report after the report is written, so a
report can read `Failed: 0` while `make conformance-test` exited non-zero;
the exit status is what to trust.
+
+## Coverage and remaining skips
+
+The default profiles are `GATEWAY-HTTP`, `GATEWAY-GRPC`, and `GATEWAY-TLS`.
+Both Admin API and standalone providers run the same tests. Tests requiring
+undeclared Extended features or other profiles are also skipped by the upstream
+suite; those skips are separate from the explicit exclusions below.
+
+| Test | Level | Remaining constraint |
+| --- | --- | --- |
+| `HTTPRouteHTTPSListener` | Core | The suite generates a certificate with a
bare `*` SAN. The translator excludes that SAN, and port 443 in this deployment
is assigned to stream TLS passthrough rather than HTTP TLS termination. |
+| `HTTPRouteMultipleGateways` | Core | Both Gateways share an address and
listener, but the test requires their dedicated `/` routes to reach different
backends independently. Separate data plane listeners or addresses are needed. |
+| `TLSRouteHostnameIntersection` | Core | Gateways share the same stream
listener and SNI routing table. A wildcard accepted by one Gateway can also
answer traffic sent to another Gateway's identical address. |
+| `HTTPRouteRedirectPortAndScheme` | Extended | Uses HTTP TLS termination,
with the same certificate and port constraints as `HTTPRouteHTTPSListener`. |
+| `TLSRouteTerminateSimpleSameNamespace` | Extended, provisional | Requires a
terminating stream listener on the selected port, while this deployment exposes
passthrough on port 443. |
+
+The invalid-backend Core tests for unknown kinds, missing Services, and denied
+cross-namespace references are enabled. They check both `ResolvedRefs=False`
+and HTTP 500 responses; these responses must remain synchronizable through both
+providers even when the translated upstream has no nodes.
diff --git a/test/conformance/conformance_test.go
b/test/conformance/conformance_test.go
index c583d253..55188ac7 100644
--- a/test/conformance/conformance_test.go
+++ b/test/conformance/conformance_test.go
@@ -30,17 +30,9 @@ var skippedTestsForSSL = []string{
tests.HTTPRouteRedirectPortAndScheme.ShortName,
}
-// Known gaps tracked for follow-up. These are genuine feature gaps rather than
-// architectural limits, so they are expected to shrink over time.
-var skippedTestsForKnownGaps = []string{
- // An unresolvable or unknown-kind backendRef must respond 500. The
- // translator already injects fault-injection for these, but they still
trip
- // against the APISIX admin API: UnknownKind fails consistently (passes
in
- // standalone), while the cross-namespace and nonexistent cases are
flaky
- // across runs. Kept skipped until the empty-upstream sync is sorted
out.
- tests.HTTPRouteInvalidBackendRefUnknownKind.ShortName,
- tests.HTTPRouteInvalidCrossNamespaceBackendRef.ShortName,
- tests.HTTPRouteInvalidNonExistentBackendRef.ShortName,
+// These tests require listener or data plane isolation that the shared
+// conformance deployment does not provide.
+var skippedTestsForSharedDataplane = []string{
// Terminate mode itself is covered by
TLSRouteListenerTerminateSupportedKinds
// and by the e2e TLSRoute suite. This provisional test additionally
requires a
// standalone Gateway with no GatewayProxy attached to reach
Accepted=True, and
@@ -71,7 +63,7 @@ func TestGatewayAPIConformance(t *testing.T) {
opts.CleanupBaseResources = true
opts.GatewayClassName = gatewayClassName
opts.SkipTests = append(opts.SkipTests, skippedTestsForSSL...)
- opts.SkipTests = append(opts.SkipTests, skippedTestsForKnownGaps...)
+ opts.SkipTests = append(opts.SkipTests,
skippedTestsForSharedDataplane...)
// Implementation is left to the flags DefaultOptions already applied.
// Assigning it here would override them and pin the report to a stale
version.
diff --git a/test/e2e/crds/v2/tls.go b/test/e2e/crds/v2/tls.go
index 8a6bc32a..d45af78f 100644
--- a/test/e2e/crds/v2/tls.go
+++ b/test/e2e/crds/v2/tls.go
@@ -162,13 +162,15 @@ spec:
assert.ElementsMatch(GinkgoT(), []string{host},
tls[0].Snis)
By("test HTTPS request to dataplane")
- Eventually(func() int {
- return s.NewAPISIXHttpsClient("api6.com").
- GET("/get").
- WithHost("api6.com").
- Expect().
- Raw().StatusCode
- }).WithTimeout(30 * time.Second).ProbeEvery(1 *
time.Second).Should(Equal(http.StatusOK))
+ s.RequestAssert(&scaffold.RequestAssert{
+ Client: s.NewAPISIXHttpsClient("api6.com"),
+ Method: "GET",
+ Path: "/get",
+ Host: "api6.com",
+ Check:
scaffold.WithExpectedStatus(http.StatusOK),
+ Timeout: 30 * time.Second,
+ Interval: time.Second,
+ })
s.NewAPISIXHttpsClient("api6.com").
GET("/get").
diff --git a/test/e2e/ingress/annotations.go b/test/e2e/ingress/annotations.go
index 622d0cb8..2f813c88 100644
--- a/test/e2e/ingress/annotations.go
+++ b/test/e2e/ingress/annotations.go
@@ -623,19 +623,24 @@ spec:
Check:
scaffold.WithExpectedStatus(http.StatusPermanentRedirect),
})
- _ = s.NewAPISIXClient().
- GET("/get").
- WithHost("httpbin.example").
- Expect().
- Status(http.StatusMovedPermanently).
-
Header("Location").IsEqual("https://httpbin.example:9443/get")
-
- _ = s.NewAPISIXClient().
- GET("/ip").
- WithHost("httpbin.example").
- Expect().
- Status(http.StatusPermanentRedirect).
- Header("Location").IsEqual("/anything/ip")
+ s.RequestAssert(&scaffold.RequestAssert{
+ Method: "GET",
+ Path: "/get",
+ Host: "httpbin.example",
+ Checks: []scaffold.ResponseCheckFunc{
+
scaffold.WithExpectedStatus(http.StatusMovedPermanently),
+ scaffold.WithExpectedHeader("Location",
"https://httpbin.example:9443/get"),
+ },
+ })
+ s.RequestAssert(&scaffold.RequestAssert{
+ Method: "GET",
+ Path: "/ip",
+ Host: "httpbin.example",
+ Checks: []scaffold.ResponseCheckFunc{
+
scaffold.WithExpectedStatus(http.StatusPermanentRedirect),
+ scaffold.WithExpectedHeader("Location",
"/anything/ip"),
+ },
+ })
})
It("csrf", func() {
diff --git a/test/e2e/scaffold/apisix_deployer.go
b/test/e2e/scaffold/apisix_deployer.go
index 3d5b3b5c..6e722d4b 100644
--- a/test/e2e/scaffold/apisix_deployer.go
+++ b/test/e2e/scaffold/apisix_deployer.go
@@ -285,6 +285,12 @@ func (s *APISIXDeployer) deployDataplane(opts
*APISIXDeployOptions) *corev1.Serv
LabelSelector: "app.kubernetes.io/name=apisix",
})
Expect(err).ToNot(HaveOccurred(), "waiting for gateway pod
ready")
+ } else {
+ // A terminating pod can still be Ready and selected by a new
port-forward.
+ err = k8s.WaitUntilNumPodsCreatedE(s.GinkgoT, kubectlOpts,
metav1.ListOptions{
+ LabelSelector: "app.kubernetes.io/name=apisix",
+ }, 0, 60, time.Second)
+ Expect(err).ToNot(HaveOccurred(), "waiting for gateway pods to
terminate")
}
Eventually(func() bool {