Skip to content

Commit 5527613

Browse files
Wei WengWei Weng
authored andcommitted
webhook readiness wait for informer cache sync
Signed-off-by: Wei Weng <Wei.Weng@microsoft.com>
1 parent 3820bb0 commit 5527613

File tree

6 files changed

+571
-1
lines changed

6 files changed

+571
-1
lines changed

cmd/hubagent/main.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import (
4646
"github.com/kubefleet-dev/kubefleet/cmd/hubagent/options"
4747
"github.com/kubefleet-dev/kubefleet/cmd/hubagent/workload"
4848
mcv1beta1 "github.com/kubefleet-dev/kubefleet/pkg/controllers/membercluster/v1beta1"
49+
"github.com/kubefleet-dev/kubefleet/pkg/utils/validator"
4950
"github.com/kubefleet-dev/kubefleet/pkg/webhook"
5051
// +kubebuilder:scaffold:imports
5152
)
@@ -164,10 +165,19 @@ func main() {
164165

165166
ctx := ctrl.SetupSignalHandler()
166167
if err := workload.SetupControllers(ctx, &wg, mgr, config, opts); err != nil {
167-
klog.ErrorS(err, "unable to set up ready check")
168+
klog.ErrorS(err, "unable to set up controllers")
168169
exitWithErrorFunc()
169170
}
170171

172+
// Add webhook readiness check AFTER controllers are set up (when ResourceInformer is initialized)
173+
// This prevents webhook from accepting requests before discovery cache is populated
174+
if opts.EnableWebhook {
175+
if err := mgr.AddReadyzCheck("webhook-cache", webhook.ResourceInformerReadinessChecker(validator.ResourceInformer)); err != nil {
176+
klog.ErrorS(err, "unable to set up webhook readiness check")
177+
exitWithErrorFunc()
178+
}
179+
}
180+
171181
// +kubebuilder:scaffold:builder
172182

173183
wg.Add(1)

pkg/utils/informer/informermanager.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ type Manager interface {
6161
// GetNameSpaceScopedResources returns the list of namespace scoped resources we are watching.
6262
GetNameSpaceScopedResources() []schema.GroupVersionResource
6363

64+
// GetAllResources returns the list of all resources (both cluster-scoped and namespace-scoped) we are watching.
65+
GetAllResources() []schema.GroupVersionResource
66+
6467
// IsClusterScopedResources returns if a resource is cluster scoped.
6568
IsClusterScopedResources(resource schema.GroupVersionKind) bool
6669

@@ -224,6 +227,19 @@ func (s *informerManagerImpl) GetNameSpaceScopedResources() []schema.GroupVersio
224227
return res
225228
}
226229

230+
func (s *informerManagerImpl) GetAllResources() []schema.GroupVersionResource {
231+
s.resourcesLock.RLock()
232+
defer s.resourcesLock.RUnlock()
233+
234+
res := make([]schema.GroupVersionResource, 0, len(s.apiResources))
235+
for _, resource := range s.apiResources {
236+
if resource.isPresent {
237+
res = append(res, resource.GroupVersionResource)
238+
}
239+
}
240+
return res
241+
}
242+
227243
func (s *informerManagerImpl) IsClusterScopedResources(gvk schema.GroupVersionKind) bool {
228244
s.resourcesLock.RLock()
229245
defer s.resourcesLock.RUnlock()
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
/*
2+
Copyright 2025 The KubeFleet 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 informer
18+
19+
import (
20+
"testing"
21+
"time"
22+
23+
"github.com/stretchr/testify/assert"
24+
"k8s.io/apimachinery/pkg/runtime/schema"
25+
"k8s.io/client-go/dynamic/fake"
26+
"k8s.io/client-go/kubernetes/scheme"
27+
)
28+
29+
func TestGetAllResources(t *testing.T) {
30+
tests := []struct {
31+
name string
32+
namespaceScopedResources []APIResourceMeta
33+
clusterScopedResources []APIResourceMeta
34+
staticResources []APIResourceMeta
35+
expectedResourceCount int
36+
expectedNamespacedCount int
37+
}{
38+
{
39+
name: "mixed cluster and namespace scoped resources",
40+
namespaceScopedResources: []APIResourceMeta{
41+
{
42+
GroupVersionKind: schema.GroupVersionKind{
43+
Group: "",
44+
Version: "v1",
45+
Kind: "ConfigMap",
46+
},
47+
GroupVersionResource: schema.GroupVersionResource{
48+
Group: "",
49+
Version: "v1",
50+
Resource: "configmaps",
51+
},
52+
IsClusterScoped: false,
53+
},
54+
{
55+
GroupVersionKind: schema.GroupVersionKind{
56+
Group: "",
57+
Version: "v1",
58+
Kind: "Secret",
59+
},
60+
GroupVersionResource: schema.GroupVersionResource{
61+
Group: "",
62+
Version: "v1",
63+
Resource: "secrets",
64+
},
65+
IsClusterScoped: false,
66+
},
67+
},
68+
clusterScopedResources: []APIResourceMeta{
69+
{
70+
GroupVersionKind: schema.GroupVersionKind{
71+
Group: "",
72+
Version: "v1",
73+
Kind: "Namespace",
74+
},
75+
GroupVersionResource: schema.GroupVersionResource{
76+
Group: "",
77+
Version: "v1",
78+
Resource: "namespaces",
79+
},
80+
IsClusterScoped: true,
81+
},
82+
},
83+
staticResources: []APIResourceMeta{
84+
{
85+
GroupVersionKind: schema.GroupVersionKind{
86+
Group: "",
87+
Version: "v1",
88+
Kind: "Node",
89+
},
90+
GroupVersionResource: schema.GroupVersionResource{
91+
Group: "",
92+
Version: "v1",
93+
Resource: "nodes",
94+
},
95+
IsClusterScoped: true,
96+
isStaticResource: true,
97+
},
98+
},
99+
expectedResourceCount: 4, // All resources including static
100+
expectedNamespacedCount: 2, // Only namespace-scoped, excluding static
101+
},
102+
{
103+
name: "no resources",
104+
expectedResourceCount: 0,
105+
expectedNamespacedCount: 0,
106+
},
107+
{
108+
name: "only namespace scoped resources",
109+
namespaceScopedResources: []APIResourceMeta{
110+
{
111+
GroupVersionKind: schema.GroupVersionKind{
112+
Group: "apps",
113+
Version: "v1",
114+
Kind: "Deployment",
115+
},
116+
GroupVersionResource: schema.GroupVersionResource{
117+
Group: "apps",
118+
Version: "v1",
119+
Resource: "deployments",
120+
},
121+
IsClusterScoped: false,
122+
},
123+
},
124+
expectedResourceCount: 1,
125+
expectedNamespacedCount: 1,
126+
},
127+
{
128+
name: "only cluster scoped resources",
129+
clusterScopedResources: []APIResourceMeta{
130+
{
131+
GroupVersionKind: schema.GroupVersionKind{
132+
Group: "rbac.authorization.k8s.io",
133+
Version: "v1",
134+
Kind: "ClusterRole",
135+
},
136+
GroupVersionResource: schema.GroupVersionResource{
137+
Group: "rbac.authorization.k8s.io",
138+
Version: "v1",
139+
Resource: "clusterroles",
140+
},
141+
IsClusterScoped: true,
142+
},
143+
},
144+
expectedResourceCount: 1,
145+
expectedNamespacedCount: 0,
146+
},
147+
}
148+
149+
for _, tt := range tests {
150+
t.Run(tt.name, func(t *testing.T) {
151+
// Create a fake dynamic client
152+
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
153+
stopCh := make(chan struct{})
154+
defer close(stopCh)
155+
156+
mgr := NewInformerManager(fakeClient, 0, stopCh)
157+
implMgr := mgr.(*informerManagerImpl)
158+
159+
// Add namespace-scoped resources
160+
for _, res := range tt.namespaceScopedResources {
161+
res.isPresent = true
162+
implMgr.apiResources[res.GroupVersionKind] = &res
163+
}
164+
165+
// Add cluster-scoped resources
166+
for _, res := range tt.clusterScopedResources {
167+
res.isPresent = true
168+
implMgr.apiResources[res.GroupVersionKind] = &res
169+
}
170+
171+
// Add static resources
172+
for _, res := range tt.staticResources {
173+
res.isPresent = true
174+
implMgr.apiResources[res.GroupVersionKind] = &res
175+
}
176+
177+
// Test GetAllResources
178+
allResources := mgr.GetAllResources()
179+
assert.Equal(t, tt.expectedResourceCount, len(allResources), "GetAllResources should return correct count")
180+
181+
// Verify all expected resources are present
182+
resourceMap := make(map[schema.GroupVersionResource]bool)
183+
for _, gvr := range allResources {
184+
resourceMap[gvr] = true
185+
}
186+
187+
for _, res := range tt.namespaceScopedResources {
188+
assert.True(t, resourceMap[res.GroupVersionResource], "namespace-scoped resource %v should be in GetAllResources", res.GroupVersionResource)
189+
}
190+
191+
for _, res := range tt.clusterScopedResources {
192+
assert.True(t, resourceMap[res.GroupVersionResource], "cluster-scoped resource %v should be in GetAllResources", res.GroupVersionResource)
193+
}
194+
195+
for _, res := range tt.staticResources {
196+
assert.True(t, resourceMap[res.GroupVersionResource], "static resource %v should be in GetAllResources", res.GroupVersionResource)
197+
}
198+
199+
// Test GetNameSpaceScopedResources
200+
namespacedResources := mgr.GetNameSpaceScopedResources()
201+
assert.Equal(t, tt.expectedNamespacedCount, len(namespacedResources), "GetNameSpaceScopedResources should return correct count")
202+
203+
// Verify only namespace-scoped, non-static resources are present
204+
namespacedMap := make(map[schema.GroupVersionResource]bool)
205+
for _, gvr := range namespacedResources {
206+
namespacedMap[gvr] = true
207+
}
208+
209+
for _, res := range tt.namespaceScopedResources {
210+
assert.True(t, namespacedMap[res.GroupVersionResource], "namespace-scoped resource %v should be in GetNameSpaceScopedResources", res.GroupVersionResource)
211+
}
212+
213+
// Verify cluster-scoped and static resources are NOT in namespace-scoped list
214+
for _, res := range tt.clusterScopedResources {
215+
assert.False(t, namespacedMap[res.GroupVersionResource], "cluster-scoped resource %v should NOT be in GetNameSpaceScopedResources", res.GroupVersionResource)
216+
}
217+
218+
for _, res := range tt.staticResources {
219+
assert.False(t, namespacedMap[res.GroupVersionResource], "static resource %v should NOT be in GetNameSpaceScopedResources", res.GroupVersionResource)
220+
}
221+
})
222+
}
223+
}
224+
225+
func TestGetAllResources_NotPresent(t *testing.T) {
226+
// Test that resources marked as not present are excluded
227+
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
228+
stopCh := make(chan struct{})
229+
defer close(stopCh)
230+
231+
mgr := NewInformerManager(fakeClient, 0, stopCh)
232+
implMgr := mgr.(*informerManagerImpl)
233+
234+
// Add a resource that is present
235+
presentRes := APIResourceMeta{
236+
GroupVersionKind: schema.GroupVersionKind{
237+
Group: "",
238+
Version: "v1",
239+
Kind: "ConfigMap",
240+
},
241+
GroupVersionResource: schema.GroupVersionResource{
242+
Group: "",
243+
Version: "v1",
244+
Resource: "configmaps",
245+
},
246+
IsClusterScoped: false,
247+
isPresent: true,
248+
}
249+
implMgr.apiResources[presentRes.GroupVersionKind] = &presentRes
250+
251+
// Add a resource that is NOT present (deleted)
252+
notPresentRes := APIResourceMeta{
253+
GroupVersionKind: schema.GroupVersionKind{
254+
Group: "",
255+
Version: "v1",
256+
Kind: "Secret",
257+
},
258+
GroupVersionResource: schema.GroupVersionResource{
259+
Group: "",
260+
Version: "v1",
261+
Resource: "secrets",
262+
},
263+
IsClusterScoped: false,
264+
isPresent: false,
265+
}
266+
implMgr.apiResources[notPresentRes.GroupVersionKind] = &notPresentRes
267+
268+
allResources := mgr.GetAllResources()
269+
assert.Equal(t, 1, len(allResources), "should only return present resources")
270+
assert.Equal(t, presentRes.GroupVersionResource, allResources[0], "should return the present resource")
271+
}
272+
273+
func TestInformerManagerConcurrency(t *testing.T) {
274+
// Test that GetAllResources and GetNameSpaceScopedResources are safe for concurrent access
275+
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
276+
stopCh := make(chan struct{})
277+
defer close(stopCh)
278+
279+
mgr := NewInformerManager(fakeClient, 0, stopCh)
280+
implMgr := mgr.(*informerManagerImpl)
281+
282+
// Add some resources
283+
for i := 0; i < 10; i++ {
284+
res := APIResourceMeta{
285+
GroupVersionKind: schema.GroupVersionKind{
286+
Group: "",
287+
Version: "v1",
288+
Kind: "Resource" + string(rune(i)),
289+
},
290+
GroupVersionResource: schema.GroupVersionResource{
291+
Group: "",
292+
Version: "v1",
293+
Resource: "resource" + string(rune(i)) + "s",
294+
},
295+
IsClusterScoped: i%2 == 0, // Alternate between cluster and namespace scoped
296+
isPresent: true,
297+
}
298+
implMgr.apiResources[res.GroupVersionKind] = &res
299+
}
300+
301+
// Run concurrent reads
302+
done := make(chan bool, 100)
303+
for i := 0; i < 50; i++ {
304+
go func() {
305+
mgr.GetAllResources()
306+
done <- true
307+
}()
308+
go func() {
309+
mgr.GetNameSpaceScopedResources()
310+
done <- true
311+
}()
312+
}
313+
314+
// Wait for all goroutines with timeout
315+
timeout := time.After(5 * time.Second)
316+
for i := 0; i < 100; i++ {
317+
select {
318+
case <-done:
319+
// Success
320+
case <-timeout:
321+
t.Fatal("Timeout waiting for concurrent reads to complete")
322+
}
323+
}
324+
}

0 commit comments

Comments
 (0)