Skip to content

Commit 7a302ea

Browse files
committed
Provide a truly lazy restmapper
This commit adds a rest mapper that will lazily query the provided client for discovery information to do REST mappings.
1 parent 3c4deba commit 7a302ea

File tree

2 files changed

+372
-0
lines changed

2 files changed

+372
-0
lines changed
Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
/*
2+
Copyright 2023 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package lazyrestmapper
18+
19+
import (
20+
"sync"
21+
22+
"k8s.io/apimachinery/pkg/api/meta"
23+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
24+
"k8s.io/apimachinery/pkg/runtime/schema"
25+
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
26+
"k8s.io/client-go/discovery"
27+
"k8s.io/client-go/rest"
28+
"k8s.io/client-go/restmapper"
29+
)
30+
31+
// LazyRESTMapper is a RESTMapper that will lazily query the provided
32+
// client for discovery information to do REST mappings.
33+
type LazyRESTMapper struct {
34+
mapper meta.RESTMapper
35+
client *discovery.DiscoveryClient
36+
knownGroups map[string]*restmapper.APIGroupResources
37+
38+
// mutex to provide thread-safe mapper reloading
39+
mu sync.Mutex
40+
}
41+
42+
// NewLazyRESTMapper initializes a LazyRESTMapper
43+
func NewLazyRESTMapper(c *rest.Config) (meta.RESTMapper, error) {
44+
dc, err := discovery.NewDiscoveryClientForConfig(c)
45+
if err != nil {
46+
return nil, err
47+
}
48+
49+
return &LazyRESTMapper{
50+
mapper: restmapper.NewDiscoveryRESTMapper([]*restmapper.APIGroupResources{}),
51+
client: dc,
52+
knownGroups: map[string]*restmapper.APIGroupResources{},
53+
}, nil
54+
}
55+
56+
func (m *LazyRESTMapper) addKnownGroupAndReload(groupName string) error {
57+
m.mu.Lock()
58+
defer m.mu.Unlock()
59+
60+
groupResources, err := getFilteredAPIGroupResources(m.client, groupName)
61+
if err != nil || groupResources == nil {
62+
return err
63+
}
64+
65+
m.knownGroups[groupName] = groupResources
66+
67+
updatedGroupResources := make([]*restmapper.APIGroupResources, 0, len(m.knownGroups))
68+
for _, v := range m.knownGroups {
69+
updatedGroupResources = append(updatedGroupResources, v)
70+
}
71+
72+
m.mapper = restmapper.NewDiscoveryRESTMapper(updatedGroupResources)
73+
74+
return nil
75+
}
76+
77+
// KindFor implements Mapper.KindFor
78+
func (m *LazyRESTMapper) KindFor(resource schema.GroupVersionResource) (schema.GroupVersionKind, error) {
79+
res, err := m.mapper.KindFor(resource)
80+
if meta.IsNoMatchError(err) {
81+
if err = m.addKnownGroupAndReload(resource.Group); err != nil {
82+
return res, err
83+
}
84+
res, err = m.mapper.KindFor(resource)
85+
}
86+
return res, err
87+
}
88+
89+
// KindsFor implements Mapper.KindsFor
90+
func (m *LazyRESTMapper) KindsFor(resource schema.GroupVersionResource) ([]schema.GroupVersionKind, error) {
91+
res, err := m.mapper.KindsFor(resource)
92+
if meta.IsNoMatchError(err) {
93+
if err = m.addKnownGroupAndReload(resource.Group); err != nil {
94+
return res, err
95+
}
96+
res, err = m.mapper.KindsFor(resource)
97+
}
98+
return res, err
99+
}
100+
101+
// ResourceFor implements Mapper.ResourceFor
102+
func (m *LazyRESTMapper) ResourceFor(input schema.GroupVersionResource) (schema.GroupVersionResource, error) {
103+
res, err := m.mapper.ResourceFor(input)
104+
if meta.IsNoMatchError(err) {
105+
if err = m.addKnownGroupAndReload(input.Group); err != nil {
106+
return res, err
107+
}
108+
res, err = m.mapper.ResourceFor(input)
109+
}
110+
return res, err
111+
}
112+
113+
// ResourcesFor implements Mapper.ResourcesFor
114+
func (m *LazyRESTMapper) ResourcesFor(input schema.GroupVersionResource) ([]schema.GroupVersionResource, error) {
115+
res, err := m.mapper.ResourcesFor(input)
116+
if meta.IsNoMatchError(err) {
117+
if err = m.addKnownGroupAndReload(input.Group); err != nil {
118+
return res, err
119+
}
120+
res, err = m.mapper.ResourcesFor(input)
121+
}
122+
return res, err
123+
}
124+
125+
// RESTMapping implements Mapper.RESTMapping
126+
func (m *LazyRESTMapper) RESTMapping(gk schema.GroupKind, versions ...string) (*meta.RESTMapping, error) {
127+
res, err := m.mapper.RESTMapping(gk, versions...)
128+
if meta.IsNoMatchError(err) {
129+
if err = m.addKnownGroupAndReload(gk.Group); err != nil {
130+
return res, err
131+
}
132+
res, err = m.mapper.RESTMapping(gk, versions...)
133+
}
134+
return res, err
135+
}
136+
137+
// RESTMappings implements Mapper.RESTMappings
138+
func (m *LazyRESTMapper) RESTMappings(gk schema.GroupKind, versions ...string) ([]*meta.RESTMapping, error) {
139+
res, err := m.mapper.RESTMappings(gk, versions...)
140+
if meta.IsNoMatchError(err) {
141+
if err = m.addKnownGroupAndReload(gk.Group); err != nil {
142+
return res, err
143+
}
144+
res, err = m.mapper.RESTMappings(gk, versions...)
145+
}
146+
return res, err
147+
}
148+
149+
// ResourceSingularizer implements Mapper.ResourceSingularizer
150+
func (m *LazyRESTMapper) ResourceSingularizer(resource string) (string, error) {
151+
return m.mapper.ResourceSingularizer(resource)
152+
}
153+
154+
// fetchGroupVersionResources uses the discovery client to fetch the resources for the specified group in parallel.
155+
// Mainly replicates the same named function from the client-go internals aside from the changed `apiGroups` argument type (uses slice instead of a single group).
156+
// ref: https://github.com/kubernetes/kubernetes/blob/a84d877310ba5cf9237c8e8e3218229c202d3a1e/staging/src/k8s.io/client-go/discovery/discovery_client.go#L506
157+
func fetchGroupVersionResources(d discovery.DiscoveryInterface, apiGroup metav1.APIGroup) (map[schema.GroupVersion]*metav1.APIResourceList, map[schema.GroupVersion]error) {
158+
groupVersionResources := make(map[schema.GroupVersion]*metav1.APIResourceList)
159+
failedGroups := make(map[schema.GroupVersion]error)
160+
161+
wg := &sync.WaitGroup{}
162+
resultLock := &sync.Mutex{}
163+
for _, version := range apiGroup.Versions {
164+
groupVersion := schema.GroupVersion{Group: apiGroup.Name, Version: version.Version}
165+
wg.Add(1)
166+
go func() {
167+
defer wg.Done()
168+
defer utilruntime.HandleCrash()
169+
170+
apiResourceList, err := d.ServerResourcesForGroupVersion(groupVersion.String())
171+
172+
// lock to record results
173+
resultLock.Lock()
174+
defer resultLock.Unlock()
175+
176+
if err != nil {
177+
// TODO: maybe restrict this to NotFound errors
178+
failedGroups[groupVersion] = err
179+
}
180+
if apiResourceList != nil {
181+
// even in case of error, some fallback might have been returned
182+
groupVersionResources[groupVersion] = apiResourceList
183+
}
184+
}()
185+
}
186+
wg.Wait()
187+
188+
return groupVersionResources, failedGroups
189+
}
190+
191+
// filteredServerGroupsAndResources returns the supported resources for groups filtered by passed predicate and versions.
192+
// Mainly replicate ServerGroupsAndResources function from the client-go. The difference is that this function takes
193+
// a group name as an argument for filtering out unwanted groups.
194+
// ref: https://github.com/kubernetes/kubernetes/blob/a84d877310ba5cf9237c8e8e3218229c202d3a1e/staging/src/k8s.io/client-go/discovery/discovery_client.go#L383
195+
func filteredServerGroupsAndResources(d discovery.DiscoveryInterface, groupName string) (*metav1.APIGroup, []*metav1.APIResourceList, error) {
196+
sgs, err := d.ServerGroups()
197+
if sgs == nil {
198+
return nil, nil, err
199+
}
200+
201+
apiGroup := metav1.APIGroup{}
202+
for i := range sgs.Groups {
203+
if groupName == (&sgs.Groups[i]).Name {
204+
apiGroup = sgs.Groups[i]
205+
}
206+
}
207+
208+
groupVersionResources, failedGroups := fetchGroupVersionResources(d, apiGroup)
209+
210+
// order results by group/version discovery order
211+
result := []*metav1.APIResourceList{}
212+
for _, version := range apiGroup.Versions {
213+
gv := schema.GroupVersion{Group: apiGroup.Name, Version: version.Version}
214+
if resources, ok := groupVersionResources[gv]; ok {
215+
result = append(result, resources)
216+
}
217+
}
218+
219+
if len(failedGroups) == 0 {
220+
return &apiGroup, result, nil
221+
}
222+
223+
return &apiGroup, result, &discovery.ErrGroupDiscoveryFailed{Groups: failedGroups}
224+
}
225+
226+
// getFilteredAPIGroupResources uses the provided discovery client to gather
227+
// discovery information and populate a slice of APIGroupResources.
228+
func getFilteredAPIGroupResources(cl discovery.DiscoveryInterface, groupName string) (*restmapper.APIGroupResources, error) {
229+
group, rs, err := filteredServerGroupsAndResources(cl, groupName)
230+
if rs == nil || group == nil {
231+
return nil, err
232+
// TODO track the errors and update callers to handle partial errors.
233+
}
234+
rsm := map[string]*metav1.APIResourceList{}
235+
for _, r := range rs {
236+
rsm[r.GroupVersion] = r
237+
}
238+
239+
groupResources := &restmapper.APIGroupResources{
240+
Group: *group,
241+
VersionedResources: make(map[string][]metav1.APIResource),
242+
}
243+
for _, version := range group.Versions {
244+
resources, ok := rsm[version.GroupVersion]
245+
if !ok {
246+
continue
247+
}
248+
groupResources.VersionedResources[version.Version] = resources.APIResources
249+
}
250+
251+
return groupResources, nil
252+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
Copyright 2023 The Kubernetes Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package lazyrestmapper
18+
19+
import (
20+
"testing"
21+
22+
_ "github.com/onsi/ginkgo/v2"
23+
gmg "github.com/onsi/gomega"
24+
25+
"k8s.io/apimachinery/pkg/runtime/schema"
26+
"k8s.io/client-go/discovery"
27+
"k8s.io/client-go/rest"
28+
"sigs.k8s.io/controller-runtime/pkg/envtest"
29+
)
30+
31+
func setupEnvtest(t *testing.T) (*rest.Config, func(t *testing.T)) {
32+
t.Log("Setup envtest")
33+
g := gmg.NewWithT(t)
34+
testEnv := &envtest.Environment{}
35+
cfg, err := testEnv.Start()
36+
g.Expect(err).NotTo(gmg.HaveOccurred())
37+
g.Expect(cfg).NotTo(gmg.BeNil())
38+
39+
teardownFunc := func(t *testing.T) {
40+
t.Log("Stop envtest")
41+
g.Expect(testEnv.Stop()).To(gmg.Succeed())
42+
}
43+
return cfg, teardownFunc
44+
}
45+
46+
func TestLazyRestMapperProvider(t *testing.T) {
47+
restCfg, tearDownFn := setupEnvtest(t)
48+
defer tearDownFn(t)
49+
50+
t.Run("getFilteredAPIGroupResources should return APIGroupResources based on passed arguments", func(t *testing.T) {
51+
g := gmg.NewWithT(t)
52+
53+
discoveryClient, err := discovery.NewDiscoveryClientForConfig(restCfg)
54+
g.Expect(err).NotTo(gmg.HaveOccurred())
55+
56+
// Get GroupResources for kubernetes core and kubernetes apps groups
57+
filteredAPIGroupResources, err := getFilteredAPIGroupResources(discoveryClient, "apps")
58+
g.Expect(err).NotTo(gmg.HaveOccurred())
59+
g.Expect(filteredAPIGroupResources.Group.Name).To(gmg.Equal("apps"))
60+
})
61+
62+
t.Run("LazyRESTMapper should fetch data based on the request", func(t *testing.T) {
63+
g := gmg.NewWithT(t)
64+
65+
lazyRestMapper, err := NewLazyRESTMapper(restCfg)
66+
g.Expect(err).NotTo(gmg.HaveOccurred())
67+
68+
mapping, err := lazyRestMapper.RESTMapping(schema.GroupKind{Group: "apps", Kind: "deployment"})
69+
g.Expect(err).NotTo(gmg.HaveOccurred())
70+
g.Expect(mapping.GroupVersionKind.Kind).To(gmg.Equal("deployment"))
71+
72+
mappings, err := lazyRestMapper.RESTMappings(schema.GroupKind{Group: "", Kind: "pod"})
73+
g.Expect(err).NotTo(gmg.HaveOccurred())
74+
g.Expect(len(mappings)).To(gmg.Equal(1))
75+
g.Expect(mappings[0].GroupVersionKind.Kind).To(gmg.Equal("pod"))
76+
77+
kind, err := lazyRestMapper.KindFor(schema.GroupVersionResource{Group: "networking.k8s.io", Version: "v1", Resource: "ingresses"})
78+
g.Expect(err).NotTo(gmg.HaveOccurred())
79+
g.Expect(kind.Kind).To(gmg.Equal("Ingress"))
80+
81+
kinds, err := lazyRestMapper.KindsFor(schema.GroupVersionResource{Group: "policy", Version: "v1", Resource: "poddisruptionbudgets"})
82+
g.Expect(err).NotTo(gmg.HaveOccurred())
83+
g.Expect(len(kinds)).To(gmg.Equal(1))
84+
g.Expect(kinds[0].Kind).To(gmg.Equal("PodDisruptionBudget"))
85+
86+
resource, err := lazyRestMapper.ResourceFor(schema.GroupVersionResource{Group: "discovery.k8s.io", Version: "v1", Resource: "endpointslices"})
87+
g.Expect(err).NotTo(gmg.HaveOccurred())
88+
g.Expect(resource.Resource).To(gmg.Equal("endpointslices"))
89+
90+
resources, err := lazyRestMapper.ResourcesFor(schema.GroupVersionResource{Group: "events.k8s.io", Version: "v1", Resource: "events"})
91+
g.Expect(err).NotTo(gmg.HaveOccurred())
92+
g.Expect(len(resources)).To(gmg.Equal(1))
93+
g.Expect(resources[0].Resource).To(gmg.Equal("events"))
94+
})
95+
96+
t.Run("LazyRESTMapper should return an error if the resource doesn't exist", func(t *testing.T) {
97+
g := gmg.NewWithT(t)
98+
99+
lazyRestMapper, err := NewLazyRESTMapper(restCfg)
100+
g.Expect(err).NotTo(gmg.HaveOccurred())
101+
102+
_, err = lazyRestMapper.RESTMapping(schema.GroupKind{Group: "INVALID"})
103+
g.Expect(err).To(gmg.HaveOccurred())
104+
105+
_, err = lazyRestMapper.RESTMappings(schema.GroupKind{Group: "INVALID"})
106+
g.Expect(err).To(gmg.HaveOccurred())
107+
108+
_, err = lazyRestMapper.KindFor(schema.GroupVersionResource{Group: "INVALID"})
109+
g.Expect(err).To(gmg.HaveOccurred())
110+
111+
_, err = lazyRestMapper.KindsFor(schema.GroupVersionResource{Group: "INVALID"})
112+
g.Expect(err).To(gmg.HaveOccurred())
113+
114+
_, err = lazyRestMapper.ResourceFor(schema.GroupVersionResource{Group: "INVALID"})
115+
g.Expect(err).To(gmg.HaveOccurred())
116+
117+
_, err = lazyRestMapper.ResourcesFor(schema.GroupVersionResource{Group: "INVALID"})
118+
g.Expect(err).To(gmg.HaveOccurred())
119+
})
120+
}

0 commit comments

Comments
 (0)