Skip to content

Commit f8acd6e

Browse files
authored
Merge pull request #2677 from g-gaston/avoid-extra-calls-for-not-found-resource-0.16
[release-0.16] 🐛 Avoid extra calls for not found resource
2 parents 67d355d + 91a3132 commit f8acd6e

File tree

2 files changed

+240
-35
lines changed

2 files changed

+240
-35
lines changed

pkg/client/apiutil/restmapper.go

Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"net/http"
2222
"sync"
2323

24+
apierrors "k8s.io/apimachinery/pkg/api/errors"
2425
"k8s.io/apimachinery/pkg/api/meta"
2526
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2627
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -179,23 +180,28 @@ func (m *mapper) addKnownGroupAndReload(groupName string, versions ...string) er
179180
Group: metav1.APIGroup{Name: groupName},
180181
VersionedResources: make(map[string][]metav1.APIResource),
181182
}
182-
if _, ok := m.knownGroups[groupName]; ok {
183-
groupResources = m.knownGroups[groupName]
184-
}
185183

186184
// Update information for group resources about versioned resources.
187185
// The number of API calls is equal to the number of versions: /apis/<group>/<version>.
188-
groupVersionResources, err := m.fetchGroupVersionResources(groupName, versions...)
186+
// If we encounter a missing API version (NotFound error), we will remove the group from
187+
// the m.apiGroups and m.knownGroups caches.
188+
// If this happens, in the next call the group will be added back to apiGroups
189+
// and only the existing versions will be loaded in knownGroups.
190+
groupVersionResources, err := m.fetchGroupVersionResourcesLocked(groupName, versions...)
189191
if err != nil {
190192
return fmt.Errorf("failed to get API group resources: %w", err)
191193
}
192-
for version, resources := range groupVersionResources {
193-
groupResources.VersionedResources[version.Version] = resources.APIResources
194+
195+
if _, ok := m.knownGroups[groupName]; ok {
196+
groupResources = m.knownGroups[groupName]
194197
}
195198

196199
// Update information for group resources about the API group by adding new versions.
197200
// Ignore the versions that are already registered.
198-
for _, version := range versions {
201+
for groupVersion, resources := range groupVersionResources {
202+
version := groupVersion.Version
203+
204+
groupResources.VersionedResources[version] = resources.APIResources
199205
found := false
200206
for _, v := range groupResources.Group.Versions {
201207
if v.Version == version {
@@ -216,12 +222,7 @@ func (m *mapper) addKnownGroupAndReload(groupName string, versions ...string) er
216222
m.knownGroups[groupName] = groupResources
217223

218224
// Finally, update the group with received information and regenerate the mapper.
219-
updatedGroupResources := make([]*restmapper.APIGroupResources, 0, len(m.knownGroups))
220-
for _, agr := range m.knownGroups {
221-
updatedGroupResources = append(updatedGroupResources, agr)
222-
}
223-
224-
m.mapper = restmapper.NewDiscoveryRESTMapper(updatedGroupResources)
225+
m.refreshMapper()
225226
return nil
226227
}
227228

@@ -267,18 +268,31 @@ func (m *mapper) findAPIGroupByName(groupName string) (*metav1.APIGroup, error)
267268
return nil, fmt.Errorf("failed to find API group %q", groupName)
268269
}
269270

270-
// fetchGroupVersionResources fetches the resources for the specified group and its versions.
271-
func (m *mapper) fetchGroupVersionResources(groupName string, versions ...string) (map[schema.GroupVersion]*metav1.APIResourceList, error) {
271+
// fetchGroupVersionResourcesLocked fetches the resources for the specified group and its versions.
272+
// This method might modify the cache so it needs to be called under the lock.
273+
func (m *mapper) fetchGroupVersionResourcesLocked(groupName string, versions ...string) (map[schema.GroupVersion]*metav1.APIResourceList, error) {
272274
groupVersionResources := make(map[schema.GroupVersion]*metav1.APIResourceList)
273275
failedGroups := make(map[schema.GroupVersion]error)
274276

275277
for _, version := range versions {
276278
groupVersion := schema.GroupVersion{Group: groupName, Version: version}
277279

278280
apiResourceList, err := m.client.ServerResourcesForGroupVersion(groupVersion.String())
281+
if apierrors.IsNotFound(err) && m.isGroupVersionCached(groupVersion) {
282+
// If the version is not found, we remove the group from the cache
283+
// so it gets refreshed on the next call.
284+
delete(m.apiGroups, groupName)
285+
delete(m.knownGroups, groupName)
286+
// It's important to refresh the mapper after invalidating the cache, since returning an error
287+
// aborts the call and leaves the underlying mapper unchanged. If not refreshed, the next call
288+
// will still return a match for the NotFound version.
289+
m.refreshMapper()
290+
}
291+
279292
if err != nil {
280293
failedGroups[groupVersion] = err
281294
}
295+
282296
if apiResourceList != nil {
283297
// even in case of error, some fallback might have been returned.
284298
groupVersionResources[groupVersion] = apiResourceList
@@ -292,3 +306,21 @@ func (m *mapper) fetchGroupVersionResources(groupName string, versions ...string
292306

293307
return groupVersionResources, nil
294308
}
309+
310+
// isGroupVersionCached checks if a version for a group is cached in the known groups cache.
311+
func (m *mapper) isGroupVersionCached(gv schema.GroupVersion) bool {
312+
if cachedGroup, ok := m.knownGroups[gv.Group]; ok {
313+
_, cached := cachedGroup.VersionedResources[gv.Version]
314+
return cached
315+
}
316+
317+
return false
318+
}
319+
320+
func (m *mapper) refreshMapper() {
321+
updatedGroupResources := make([]*restmapper.APIGroupResources, 0, len(m.knownGroups))
322+
for _, agr := range m.knownGroups {
323+
updatedGroupResources = append(updatedGroupResources, agr)
324+
}
325+
m.mapper = restmapper.NewDiscoveryRESTMapper(updatedGroupResources)
326+
}

pkg/client/apiutil/restmapper_test.go

Lines changed: 193 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,20 @@ package apiutil_test
1818

1919
import (
2020
"context"
21+
"fmt"
2122
"net/http"
2223
"testing"
2324

24-
"k8s.io/apimachinery/pkg/api/meta"
25-
2625
_ "github.com/onsi/ginkgo/v2"
2726
gmg "github.com/onsi/gomega"
28-
27+
"github.com/onsi/gomega/format"
28+
gomegatypes "github.com/onsi/gomega/types"
2929
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
30+
apierrors "k8s.io/apimachinery/pkg/api/errors"
31+
"k8s.io/apimachinery/pkg/api/meta"
3032
"k8s.io/apimachinery/pkg/runtime/schema"
3133
"k8s.io/apimachinery/pkg/types"
34+
"k8s.io/client-go/discovery"
3235
"k8s.io/client-go/kubernetes/scheme"
3336
"k8s.io/client-go/rest"
3437
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -493,23 +496,7 @@ func TestLazyRestMapperProvider(t *testing.T) {
493496
g.Expect(err).NotTo(gmg.HaveOccurred())
494497

495498
// Register another CRD in runtime - "riders.crew.example.com".
496-
497-
crd := &apiextensionsv1.CustomResourceDefinition{}
498-
err = c.Get(context.TODO(), types.NamespacedName{Name: "drivers.crew.example.com"}, crd)
499-
g.Expect(err).NotTo(gmg.HaveOccurred())
500-
g.Expect(crd.Spec.Names.Kind).To(gmg.Equal("Driver"))
501-
502-
newCRD := &apiextensionsv1.CustomResourceDefinition{}
503-
crd.DeepCopyInto(newCRD)
504-
newCRD.Name = "riders.crew.example.com"
505-
newCRD.Spec.Names = apiextensionsv1.CustomResourceDefinitionNames{
506-
Kind: "Rider",
507-
Plural: "riders",
508-
}
509-
newCRD.ResourceVersion = ""
510-
511-
// Create the new CRD.
512-
g.Expect(c.Create(context.TODO(), newCRD)).To(gmg.Succeed())
499+
createNewCRD(context.TODO(), g, c, "crew.example.com", "Rider", "riders")
513500

514501
// Wait a bit until the CRD is registered.
515502
g.Eventually(func() error {
@@ -528,4 +515,190 @@ func TestLazyRestMapperProvider(t *testing.T) {
528515
g.Expect(err).NotTo(gmg.HaveOccurred())
529516
g.Expect(mapping.GroupVersionKind.Kind).To(gmg.Equal("rider"))
530517
})
518+
519+
t.Run("LazyRESTMapper should invalidate the group cache if a version is not found", func(t *testing.T) {
520+
g := gmg.NewWithT(t)
521+
ctx := context.Background()
522+
523+
httpClient, err := rest.HTTPClientFor(restCfg)
524+
g.Expect(err).NotTo(gmg.HaveOccurred())
525+
526+
crt := newCountingRoundTripper(httpClient.Transport)
527+
httpClient.Transport = crt
528+
529+
lazyRestMapper, err := apiutil.NewDynamicRESTMapper(restCfg, httpClient)
530+
g.Expect(err).NotTo(gmg.HaveOccurred())
531+
532+
s := scheme.Scheme
533+
err = apiextensionsv1.AddToScheme(s)
534+
g.Expect(err).NotTo(gmg.HaveOccurred())
535+
536+
c, err := client.New(restCfg, client.Options{Scheme: s})
537+
g.Expect(err).NotTo(gmg.HaveOccurred())
538+
539+
// Register a new CRD in a new group to avoid collisions when deleting versions - "taxis.inventory.example.com".
540+
group := "inventory.example.com"
541+
kind := "Taxi"
542+
plural := "taxis"
543+
crdName := plural + "." + group
544+
// Create a CRD with two versions: v1alpha1 and v1 where both are served and
545+
// v1 is the storage version so we can easily remove v1alpha1 later.
546+
crd := newCRD(ctx, g, c, group, kind, plural)
547+
v1alpha1 := crd.Spec.Versions[0]
548+
v1alpha1.Name = "v1alpha1"
549+
v1alpha1.Storage = false
550+
v1alpha1.Served = true
551+
v1 := crd.Spec.Versions[0]
552+
v1.Name = "v1"
553+
v1.Storage = true
554+
v1.Served = true
555+
crd.Spec.Versions = []apiextensionsv1.CustomResourceDefinitionVersion{v1alpha1, v1}
556+
g.Expect(c.Create(ctx, crd)).To(gmg.Succeed())
557+
t.Cleanup(func() {
558+
g.Expect(c.Delete(ctx, crd)).To(gmg.Succeed())
559+
})
560+
561+
// Wait until the CRD is registered.
562+
discHTTP, err := rest.HTTPClientFor(restCfg)
563+
g.Expect(err).NotTo(gmg.HaveOccurred())
564+
discClient, err := discovery.NewDiscoveryClientForConfigAndClient(restCfg, discHTTP)
565+
g.Expect(err).NotTo(gmg.HaveOccurred())
566+
g.Eventually(func(g gmg.Gomega) {
567+
_, err = discClient.ServerResourcesForGroupVersion(group + "/v1")
568+
g.Expect(err).NotTo(gmg.HaveOccurred())
569+
}).Should(gmg.Succeed(), "v1 should be available")
570+
571+
// There are no requests before any call
572+
g.Expect(crt.GetRequestCount()).To(gmg.Equal(0))
573+
574+
// Since we don't specify what version we expect, restmapper will fetch them all and search there.
575+
// To fetch a list of available versions
576+
// #1: GET https://host/api
577+
// #2: GET https://host/apis
578+
// Then, for all available versions:
579+
// #3: GET https://host/apis/inventory.example.com/v1alpha1
580+
// #4: GET https://host/apis/inventory.example.com/v1
581+
// This should fill the cache for apiGroups and versions.
582+
mapping, err := lazyRestMapper.RESTMapping(schema.GroupKind{Group: group, Kind: kind})
583+
g.Expect(err).NotTo(gmg.HaveOccurred())
584+
g.Expect(mapping.GroupVersionKind.Kind).To(gmg.Equal(kind))
585+
g.Expect(crt.GetRequestCount()).To(gmg.Equal(4))
586+
crt.Reset() // We reset the counter to check how many additional requests are made later.
587+
588+
// At this point v1alpha1 should be cached
589+
_, err = lazyRestMapper.RESTMapping(schema.GroupKind{Group: group, Kind: kind}, "v1alpha1")
590+
g.Expect(err).NotTo(gmg.HaveOccurred())
591+
g.Expect(crt.GetRequestCount()).To(gmg.Equal(0))
592+
593+
// We update the CRD to only have v1 version.
594+
g.Expect(c.Get(ctx, types.NamespacedName{Name: crdName}, crd)).To(gmg.Succeed())
595+
for _, version := range crd.Spec.Versions {
596+
if version.Name == "v1" {
597+
v1 = version
598+
break
599+
}
600+
}
601+
crd.Spec.Versions = []apiextensionsv1.CustomResourceDefinitionVersion{v1}
602+
g.Expect(c.Update(ctx, crd)).To(gmg.Succeed())
603+
604+
// We wait until v1alpha1 is not available anymore.
605+
g.Eventually(func(g gmg.Gomega) {
606+
_, err = discClient.ServerResourcesForGroupVersion(group + "/v1alpha1")
607+
g.Expect(apierrors.IsNotFound(err)).To(gmg.BeTrue(), "v1alpha1 should not be available anymore")
608+
}).Should(gmg.Succeed())
609+
610+
// Although v1alpha1 is not available anymore, the cache is not invalidated yet so it should return a mapping.
611+
_, err = lazyRestMapper.RESTMapping(schema.GroupKind{Group: group, Kind: kind}, "v1alpha1")
612+
g.Expect(err).NotTo(gmg.HaveOccurred())
613+
g.Expect(crt.GetRequestCount()).To(gmg.Equal(0))
614+
615+
// We request Limo, which is not in the mapper because it doesn't exist.
616+
// This will trigger a reload of the lazy mapper cache.
617+
// Reloading the cache will read v2 again and since it's not available anymore, it should invalidate the cache.
618+
// #1: GET https://host/apis/inventory.example.com/v1alpha1
619+
// #2: GET https://host/apis/inventory.example.com/v1
620+
_, err = lazyRestMapper.RESTMapping(schema.GroupKind{Group: group, Kind: "Limo"})
621+
g.Expect(err).To(beNoMatchError())
622+
g.Expect(crt.GetRequestCount()).To(gmg.Equal(2))
623+
crt.Reset()
624+
625+
// Now we request v1alpha1 again and it should return an error since the cache was invalidated.
626+
// #1: GET https://host/apis/inventory.example.com/v1alpha1
627+
_, err = lazyRestMapper.RESTMapping(schema.GroupKind{Group: group, Kind: kind}, "v1alpha1")
628+
g.Expect(err).To(beNoMatchError())
629+
g.Expect(crt.GetRequestCount()).To(gmg.Equal(1))
630+
crt.Reset()
631+
632+
// Since we don't specify what version we expect, restmapper will fetch them all and search there.
633+
// To fetch a list of available versions
634+
// #1: GET https://host/api
635+
// #2: GET https://host/apis
636+
// Then, for all available versions:
637+
// #3: GET https://host/apis/inventory.example.com/v1
638+
mapping, err = lazyRestMapper.RESTMapping(schema.GroupKind{Group: group, Kind: kind})
639+
g.Expect(err).NotTo(gmg.HaveOccurred())
640+
g.Expect(mapping.GroupVersionKind.Kind).To(gmg.Equal(kind))
641+
g.Expect(crt.GetRequestCount()).To(gmg.Equal(3))
642+
})
643+
}
644+
645+
// createNewCRD creates a new CRD with the given group, kind, and plural and returns it.
646+
func createNewCRD(ctx context.Context, g gmg.Gomega, c client.Client, group, kind, plural string) *apiextensionsv1.CustomResourceDefinition {
647+
newCRD := newCRD(ctx, g, c, group, kind, plural)
648+
g.Expect(c.Create(ctx, newCRD)).To(gmg.Succeed())
649+
650+
return newCRD
651+
}
652+
653+
// newCRD returns a new CRD with the given group, kind, and plural.
654+
func newCRD(ctx context.Context, g gmg.Gomega, c client.Client, group, kind, plural string) *apiextensionsv1.CustomResourceDefinition {
655+
crd := &apiextensionsv1.CustomResourceDefinition{}
656+
err := c.Get(ctx, types.NamespacedName{Name: "drivers.crew.example.com"}, crd)
657+
g.Expect(err).NotTo(gmg.HaveOccurred())
658+
g.Expect(crd.Spec.Names.Kind).To(gmg.Equal("Driver"))
659+
660+
newCRD := &apiextensionsv1.CustomResourceDefinition{}
661+
crd.DeepCopyInto(newCRD)
662+
newCRD.Spec.Group = group
663+
newCRD.Name = plural + "." + group
664+
newCRD.Spec.Names = apiextensionsv1.CustomResourceDefinitionNames{
665+
Kind: kind,
666+
Plural: plural,
667+
}
668+
newCRD.ResourceVersion = ""
669+
670+
return newCRD
671+
}
672+
673+
func beNoMatchError() gomegatypes.GomegaMatcher {
674+
return &errorMatcher{
675+
checkFunc: meta.IsNoMatchError,
676+
message: "NoMatch",
677+
}
678+
}
679+
680+
type errorMatcher struct {
681+
checkFunc func(error) bool
682+
message string
683+
}
684+
685+
func (e *errorMatcher) Match(actual interface{}) (success bool, err error) {
686+
if actual == nil {
687+
return false, nil
688+
}
689+
690+
actualErr, actualOk := actual.(error)
691+
if !actualOk {
692+
return false, fmt.Errorf("expected an error-type. got:\n%s", format.Object(actual, 1))
693+
}
694+
695+
return e.checkFunc(actualErr), nil
696+
}
697+
698+
func (e *errorMatcher) FailureMessage(actual interface{}) (message string) {
699+
return format.Message(actual, fmt.Sprintf("to be %s error", e.message))
700+
}
701+
702+
func (e *errorMatcher) NegatedFailureMessage(actual interface{}) (message string) {
703+
return format.Message(actual, fmt.Sprintf("not to be %s error", e.message))
531704
}

0 commit comments

Comments
 (0)