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")))
+}

Reply via email to