This is an automated email from the ASF dual-hosted git repository.
bzp2010 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 62db2a1e feat(cache): track which Kubernetes resource owns each
service, ssl and consumer (#2883)
62db2a1e is described below
commit 62db2a1ecd653d31f59f3df919474d437ddddfe8
Author: Zeping Bai <[email protected]>
AuthorDate: Fri Sep 18 15:01:20 2026 +0800
feat(cache): track which Kubernetes resource owns each service, ssl and
consumer (#2883)
---
internal/adc/cache/store.go | 108 +++++++++++++++++++++++++
internal/adc/cache/store_test.go | 165 +++++++++++++++++++++++++++++++++++++++
2 files changed, 273 insertions(+)
diff --git a/internal/adc/cache/store.go b/internal/adc/cache/store.go
index 55c76f97..17a8a6d6 100644
--- a/internal/adc/cache/store.go
+++ b/internal/adc/cache/store.go
@@ -18,6 +18,7 @@
package cache
import (
+ "cmp"
"fmt"
"sync"
@@ -26,6 +27,7 @@ import (
adctypes "github.com/apache/apisix-ingress-controller/api/adc"
"github.com/apache/apisix-ingress-controller/internal/controller/label"
+ "github.com/apache/apisix-ingress-controller/internal/types"
)
type Store struct {
@@ -36,6 +38,20 @@ type Store struct {
log logr.Logger
}
+// Entity is a top-level ADC resource a cacheKey holds: a service, ssl or
consumer.
+type Entity struct {
+ Type string
+ ID string
+ // Name identifies the entity in a status message: a service's name, a
consumer's
+ // username, or the id for the other types.
+ Name string
+ Owner types.NamespacedNameKind
+ // Children are the routes and stream routes a service holds. A service
whose
+ // children are all dropped serves nothing, even though the service
itself was never
+ // rejected.
+ Children []Entity
+}
+
func NewStore(log logr.Logger) *Store {
return &Store{
cacheMap: make(map[string]Cache),
@@ -44,6 +60,25 @@ func NewStore(log logr.Logger) *Store {
}
}
+func ownerFromLabels(labels map[string]string) types.NamespacedNameKind {
+ return types.NamespacedNameKind{
+ Kind: labels[label.LabelKind],
+ Namespace: labels[label.LabelNamespace],
+ Name: labels[label.LabelName],
+ }
+}
+
+func childrenOf(service *adctypes.Service, owner types.NamespacedNameKind)
[]Entity {
+ children := make([]Entity, 0,
len(service.Routes)+len(service.StreamRoutes))
+ for _, route := range service.Routes {
+ children = append(children, Entity{Type: adctypes.TypeRoute,
ID: route.ID, Name: cmp.Or(route.Name, route.ID), Owner: owner})
+ }
+ for _, streamRoute := range service.StreamRoutes {
+ children = append(children, Entity{Type:
adctypes.TypeStreamRoute, ID: streamRoute.ID, Name: cmp.Or(streamRoute.Name,
streamRoute.ID), Owner: owner})
+ }
+ return children
+}
+
func (s *Store) Insert(name string, resourceTypes []string, resources
*adctypes.Resources, Labels map[string]string) error {
s.Lock()
defer s.Unlock()
@@ -317,3 +352,76 @@ func (s *Store) GetResourceLabel(name, resourceType
string, id string) (map[stri
}
return nil, nil
}
+
+// Lookup finds the top-level entity of resourceType and id the cacheKey name
holds,
+// along with the Kubernetes resource that produced it, read straight from the
entity's
+// own stored labels (the same ones KindLabelSelector matches Insert/Delete
against, so
+// there is exactly one place this can ever disagree with what a selector
would find). It
+// covers service, ssl and consumer; global_rule and plugin_metadata still
only expose
+// GetResourceLabel, until their own storage grows the same per-entity owner
this does.
+func (s *Store) Lookup(name, resourceType, id string) (Entity, bool) {
+ s.Lock()
+ defer s.Unlock()
+ targetCache, ok := s.cacheMap[name]
+ if !ok {
+ return Entity{}, false
+ }
+ switch resourceType {
+ case adctypes.TypeService:
+ service, err := targetCache.GetService(id)
+ if err != nil {
+ return Entity{}, false
+ }
+ return Entity{Type: resourceType, ID: id, Name:
cmp.Or(service.Name, id), Owner: ownerFromLabels(service.GetLabels())}, true
+ case adctypes.TypeSSL:
+ ssl, err := targetCache.GetSSL(id)
+ if err != nil {
+ return Entity{}, false
+ }
+ return Entity{Type: resourceType, ID: id, Name: id, Owner:
ownerFromLabels(ssl.GetLabels())}, true
+ case adctypes.TypeConsumer:
+ consumer, err := targetCache.GetConsumer(id)
+ if err != nil {
+ return Entity{}, false
+ }
+ return Entity{Type: resourceType, ID: id, Name: id, Owner:
ownerFromLabels(consumer.GetLabels())}, true
+ }
+ return Entity{}, false
+}
+
+// OwnedEntities lists every service, ssl and consumer owner produced in the
cacheKey
+// name, via the same KindLabelSelector index Insert and Delete already use to
find an
+// owner's resources. See Lookup for why global_rule and plugin_metadata are
absent.
+func (s *Store) OwnedEntities(name string, owner types.NamespacedNameKind)
[]Entity {
+ s.Lock()
+ defer s.Unlock()
+ targetCache, ok := s.cacheMap[name]
+ if !ok {
+ return nil
+ }
+ selector := &KindLabelSelector{Kind: owner.Kind, Namespace:
owner.Namespace, Name: owner.Name}
+
+ var entities []Entity
+ if services, err := targetCache.ListServices(selector); err == nil {
+ for _, service := range services {
+ entities = append(entities, Entity{
+ Type: adctypes.TypeService,
+ ID: service.ID,
+ Name: cmp.Or(service.Name, service.ID),
+ Owner: owner,
+ Children: childrenOf(service, owner),
+ })
+ }
+ }
+ if ssls, err := targetCache.ListSSL(selector); err == nil {
+ for _, ssl := range ssls {
+ entities = append(entities, Entity{Type:
adctypes.TypeSSL, ID: ssl.ID, Name: ssl.ID, Owner: owner})
+ }
+ }
+ if consumers, err := targetCache.ListConsumers(selector); err == nil {
+ for _, consumer := range consumers {
+ entities = append(entities, Entity{Type:
adctypes.TypeConsumer, ID: consumer.Username, Name: consumer.Username, Owner:
owner})
+ }
+ }
+ return entities
+}
diff --git a/internal/adc/cache/store_test.go b/internal/adc/cache/store_test.go
new file mode 100644
index 00000000..288c6d48
--- /dev/null
+++ b/internal/adc/cache/store_test.go
@@ -0,0 +1,165 @@
+// 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 cache
+
+import (
+ "testing"
+
+ "github.com/go-logr/logr"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ adctypes "github.com/apache/apisix-ingress-controller/api/adc"
+ "github.com/apache/apisix-ingress-controller/internal/controller/label"
+ "github.com/apache/apisix-ingress-controller/internal/types"
+)
+
+const configName = "GatewayProxy/ns/gp"
+
+func ownerNamed(kind, name string) types.NamespacedNameKind {
+ return types.NamespacedNameKind{Kind: kind, Namespace: "ns", Name: name}
+}
+
+func labelsOf(owner types.NamespacedNameKind) map[string]string {
+ return map[string]string{label.LabelKind: owner.Kind,
label.LabelNamespace: owner.Namespace, label.LabelName: owner.Name}
+}
+
+func service(id string, owner types.NamespacedNameKind) *adctypes.Service {
+ return &adctypes.Service{Metadata: adctypes.Metadata{ID: id, Name:
"name-" + id, Labels: labelsOf(owner)}}
+}
+
+func ssl(id string, owner types.NamespacedNameKind) *adctypes.SSL {
+ return &adctypes.SSL{Metadata: adctypes.Metadata{ID: id, Labels:
labelsOf(owner)}}
+}
+
+func consumer(username string, owner types.NamespacedNameKind)
*adctypes.Consumer {
+ return &adctypes.Consumer{Username: username, Metadata:
adctypes.Metadata{Labels: labelsOf(owner)}}
+}
+
+func TestLookupFindsTheOwnerOfEveryTopLevelType(t *testing.T) {
+ route := ownerNamed(types.KindApisixRoute, "route")
+ tls := ownerNamed(types.KindApisixTls, "tls")
+ consumerOwner := ownerNamed(types.KindConsumer, "consumer")
+
+ s := NewStore(logr.Discard())
+ require.NoError(t, s.Insert(configName, []string{adctypes.TypeService},
&adctypes.Resources{Services: []*adctypes.Service{service("svc", route)}},
labelsOf(route)))
+ require.NoError(t, s.Insert(configName, []string{adctypes.TypeSSL},
&adctypes.Resources{SSLs: []*adctypes.SSL{ssl("ssl", tls)}}, labelsOf(tls)))
+ require.NoError(t, s.Insert(configName,
[]string{adctypes.TypeConsumer}, &adctypes.Resources{Consumers:
[]*adctypes.Consumer{consumer("alice", consumerOwner)}},
labelsOf(consumerOwner)))
+
+ cases := []struct {
+ resourceType, id, name string
+ owner types.NamespacedNameKind
+ }{
+ {adctypes.TypeService, "svc", "name-svc", route},
+ {adctypes.TypeSSL, "ssl", "ssl", tls},
+ {adctypes.TypeConsumer, "alice", "alice", consumerOwner},
+ }
+ for _, tc := range cases {
+ t.Run(tc.resourceType, func(t *testing.T) {
+ entity, ok := s.Lookup(configName, tc.resourceType,
tc.id)
+ require.True(t, ok)
+ assert.Equal(t, tc.owner, entity.Owner)
+ assert.Equal(t, tc.name, entity.Name)
+ })
+ }
+
+ _, ok := s.Lookup(configName, adctypes.TypeService, "missing")
+ assert.False(t, ok)
+ _, ok = s.Lookup("GatewayProxy/ns/other", adctypes.TypeService, "svc")
+ assert.False(t, ok)
+}
+
+// TestLookupHasNoOpinionOnGlobalRuleOrPluginMetadataYet documents the current
boundary:
+// their storage carries no owner yet, so Lookup can't answer for them.
+func TestLookupHasNoOpinionOnGlobalRuleOrPluginMetadataYet(t *testing.T) {
+ s := NewStore(logr.Discard())
+ require.NoError(t, s.Insert(configName,
[]string{adctypes.TypeGlobalRule}, &adctypes.Resources{GlobalRules:
adctypes.GlobalRule{"prometheus": map[string]any{}}},
labelsOf(ownerNamed(types.KindApisixGlobalRule, "global"))))
+
+ _, ok := s.Lookup(configName, adctypes.TypeGlobalRule, "prometheus")
+ assert.False(t, ok)
+}
+
+// TestLookupReadsTheEntitysOwnLabelsNotInsertsArgument covers why Lookup
can't source
+// the owner from anywhere but the entity's own stored labels: those are also
what
+// KindLabelSelector matches Delete and a future Insert against, so this is
the only
+// choice that can never disagree with which owner a selector-based lookup
would find.
+// Insert's Labels argument is deliberately wrong here to prove it plays no
part.
+func TestLookupReadsTheEntitysOwnLabelsNotInsertsArgument(t *testing.T) {
+ owner := ownerNamed(types.KindApisixRoute, "owner")
+ wrong := ownerNamed(types.KindApisixRoute, "wrong")
+ s := NewStore(logr.Discard())
+
+ require.NoError(t, s.Insert(configName, []string{adctypes.TypeSSL},
&adctypes.Resources{SSLs: []*adctypes.SSL{ssl("ssl", owner)}}, labelsOf(wrong)))
+
+ entity, ok := s.Lookup(configName, adctypes.TypeSSL, "ssl")
+ require.True(t, ok)
+ assert.Equal(t, owner, entity.Owner, "the ssl's own labels, not
whatever Insert was called with")
+}
+
+func TestInsertForgetsTheOwnerOfReplacedResources(t *testing.T) {
+ route := ownerNamed(types.KindApisixRoute, "route")
+ s := NewStore(logr.Discard())
+ require.NoError(t, s.Insert(configName, []string{adctypes.TypeService},
&adctypes.Resources{Services: []*adctypes.Service{service("old", route)}},
labelsOf(route)))
+ require.NoError(t, s.Insert(configName, []string{adctypes.TypeService},
&adctypes.Resources{Services: []*adctypes.Service{service("new", route)}},
labelsOf(route)))
+
+ _, ok := s.Lookup(configName, adctypes.TypeService, "old")
+ assert.False(t, ok)
+ _, ok = s.Lookup(configName, adctypes.TypeService, "new")
+ assert.True(t, ok)
+
+ require.NoError(t, s.Delete(configName, []string{adctypes.TypeService},
labelsOf(route)))
+ _, ok = s.Lookup(configName, adctypes.TypeService, "new")
+ assert.False(t, ok)
+}
+
+func TestDeleteWithoutResourceTypesForgetsEveryOwnerToo(t *testing.T) {
+ route := ownerNamed(types.KindApisixRoute, "route")
+ s := NewStore(logr.Discard())
+ require.NoError(t, s.Insert(configName, []string{adctypes.TypeService},
&adctypes.Resources{Services: []*adctypes.Service{service("svc", route)}},
labelsOf(route)))
+
+ require.NoError(t, s.Delete(configName, nil, nil))
+ _, ok := s.Lookup(configName, adctypes.TypeService, "svc")
+ assert.False(t, ok)
+}
+
+func TestOwnedEntities(t *testing.T) {
+ route := ownerNamed(types.KindApisixRoute, "route")
+ other := ownerNamed(types.KindApisixRoute, "other")
+ s := NewStore(logr.Discard())
+
+ withChildren := service("svc", route)
+ withChildren.Routes = []*adctypes.Route{{Metadata:
adctypes.Metadata{ID: "r1", Name: "r1"}}}
+ withChildren.StreamRoutes = []*adctypes.StreamRoute{{Metadata:
adctypes.Metadata{ID: "sr1"}}}
+ require.NoError(t, s.Insert(configName, []string{adctypes.TypeService},
&adctypes.Resources{Services: []*adctypes.Service{withChildren}},
labelsOf(route)))
+ require.NoError(t, s.Insert(configName, []string{adctypes.TypeSSL},
&adctypes.Resources{SSLs: []*adctypes.SSL{ssl("ssl", route)}}, labelsOf(route)))
+ require.NoError(t, s.Insert(configName,
[]string{adctypes.TypeConsumer}, &adctypes.Resources{Consumers:
[]*adctypes.Consumer{consumer("alice", other)}}, labelsOf(other)))
+
+ got := s.OwnedEntities(configName, route)
+ assert.Len(t, got, 2, "only what route itself owns, not other's
consumer")
+
+ var svcEntity Entity
+ for _, e := range got {
+ if e.Type == adctypes.TypeService {
+ svcEntity = e
+ }
+ }
+ require.Equal(t, "svc", svcEntity.ID)
+ assert.Len(t, svcEntity.Children, 2, "the service's route and stream
route")
+
+ assert.Empty(t, s.OwnedEntities(configName,
ownerNamed(types.KindApisixRoute, "nobody")))
+}