Skip to content

Commit 2d3d18f

Browse files
weng271190436Wei Weng
andauthored
feat: webhook readiness wait for informer cache sync (#367)
* wait for informer cache to be ready Signed-off-by: Wei Weng <[email protected]> Co-authored-by: Wei Weng <[email protected]>
1 parent 1e1765e commit 2d3d18f

File tree

6 files changed

+543
-1
lines changed

6 files changed

+543
-1
lines changed

cmd/hubagent/main.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ 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+
readiness "github.com/kubefleet-dev/kubefleet/pkg/utils/informer/readiness"
50+
"github.com/kubefleet-dev/kubefleet/pkg/utils/validator"
4951
"github.com/kubefleet-dev/kubefleet/pkg/webhook"
5052
// +kubebuilder:scaffold:imports
5153
)
@@ -165,7 +167,17 @@ func main() {
165167

166168
ctx := ctrl.SetupSignalHandler()
167169
if err := workload.SetupControllers(ctx, &wg, mgr, config, opts); err != nil {
168-
klog.ErrorS(err, "unable to set up ready check")
170+
klog.ErrorS(err, "unable to set up controllers")
171+
exitWithErrorFunc()
172+
}
173+
174+
// Add readiness check for dynamic informer cache AFTER controllers are set up.
175+
// This ensures the discovery cache is populated before the hub agent is marked ready,
176+
// which is critical for all controllers that rely on dynamic resource discovery.
177+
// AddReadyzCheck adds additional readiness check instead of replacing the one registered earlier provided the name is different.
178+
// Both registered checks need to pass for the manager to be considered ready.
179+
if err := mgr.AddReadyzCheck("informer-cache", readiness.InformerReadinessChecker(validator.ResourceInformer)); err != nil {
180+
klog.ErrorS(err, "unable to set up informer cache readiness check")
169181
exitWithErrorFunc()
170182
}
171183

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: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
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+
22+
"k8s.io/apimachinery/pkg/runtime/schema"
23+
"k8s.io/client-go/dynamic/fake"
24+
"k8s.io/client-go/kubernetes/scheme"
25+
)
26+
27+
func TestGetAllResources(t *testing.T) {
28+
tests := []struct {
29+
name string
30+
namespaceScopedResources []APIResourceMeta
31+
clusterScopedResources []APIResourceMeta
32+
staticResources []APIResourceMeta
33+
expectedResourceCount int
34+
expectedNamespacedCount int
35+
}{
36+
{
37+
name: "mixed cluster and namespace scoped resources",
38+
namespaceScopedResources: []APIResourceMeta{
39+
{
40+
GroupVersionKind: schema.GroupVersionKind{
41+
Group: "",
42+
Version: "v1",
43+
Kind: "ConfigMap",
44+
},
45+
GroupVersionResource: schema.GroupVersionResource{
46+
Group: "",
47+
Version: "v1",
48+
Resource: "configmaps",
49+
},
50+
IsClusterScoped: false,
51+
},
52+
{
53+
GroupVersionKind: schema.GroupVersionKind{
54+
Group: "",
55+
Version: "v1",
56+
Kind: "Secret",
57+
},
58+
GroupVersionResource: schema.GroupVersionResource{
59+
Group: "",
60+
Version: "v1",
61+
Resource: "secrets",
62+
},
63+
IsClusterScoped: false,
64+
},
65+
},
66+
clusterScopedResources: []APIResourceMeta{
67+
{
68+
GroupVersionKind: schema.GroupVersionKind{
69+
Group: "",
70+
Version: "v1",
71+
Kind: "Namespace",
72+
},
73+
GroupVersionResource: schema.GroupVersionResource{
74+
Group: "",
75+
Version: "v1",
76+
Resource: "namespaces",
77+
},
78+
IsClusterScoped: true,
79+
},
80+
},
81+
staticResources: []APIResourceMeta{
82+
{
83+
GroupVersionKind: schema.GroupVersionKind{
84+
Group: "",
85+
Version: "v1",
86+
Kind: "Node",
87+
},
88+
GroupVersionResource: schema.GroupVersionResource{
89+
Group: "",
90+
Version: "v1",
91+
Resource: "nodes",
92+
},
93+
IsClusterScoped: true,
94+
isStaticResource: true,
95+
},
96+
},
97+
expectedResourceCount: 4, // All resources including static
98+
expectedNamespacedCount: 2, // Only namespace-scoped, excluding static
99+
},
100+
{
101+
name: "no resources",
102+
expectedResourceCount: 0,
103+
expectedNamespacedCount: 0,
104+
},
105+
{
106+
name: "only namespace scoped resources",
107+
namespaceScopedResources: []APIResourceMeta{
108+
{
109+
GroupVersionKind: schema.GroupVersionKind{
110+
Group: "apps",
111+
Version: "v1",
112+
Kind: "Deployment",
113+
},
114+
GroupVersionResource: schema.GroupVersionResource{
115+
Group: "apps",
116+
Version: "v1",
117+
Resource: "deployments",
118+
},
119+
IsClusterScoped: false,
120+
},
121+
},
122+
expectedResourceCount: 1,
123+
expectedNamespacedCount: 1,
124+
},
125+
{
126+
name: "only cluster scoped resources",
127+
clusterScopedResources: []APIResourceMeta{
128+
{
129+
GroupVersionKind: schema.GroupVersionKind{
130+
Group: "rbac.authorization.k8s.io",
131+
Version: "v1",
132+
Kind: "ClusterRole",
133+
},
134+
GroupVersionResource: schema.GroupVersionResource{
135+
Group: "rbac.authorization.k8s.io",
136+
Version: "v1",
137+
Resource: "clusterroles",
138+
},
139+
IsClusterScoped: true,
140+
},
141+
},
142+
expectedResourceCount: 1,
143+
expectedNamespacedCount: 0,
144+
},
145+
}
146+
147+
for _, tt := range tests {
148+
t.Run(tt.name, func(t *testing.T) {
149+
// Create a fake dynamic client
150+
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
151+
stopCh := make(chan struct{})
152+
defer close(stopCh)
153+
154+
mgr := NewInformerManager(fakeClient, 0, stopCh)
155+
implMgr := mgr.(*informerManagerImpl)
156+
157+
// Add namespace-scoped resources
158+
for _, res := range tt.namespaceScopedResources {
159+
res.isPresent = true
160+
implMgr.apiResources[res.GroupVersionKind] = &res
161+
}
162+
163+
// Add cluster-scoped resources
164+
for _, res := range tt.clusterScopedResources {
165+
res.isPresent = true
166+
implMgr.apiResources[res.GroupVersionKind] = &res
167+
}
168+
169+
// Add static resources
170+
for _, res := range tt.staticResources {
171+
res.isPresent = true
172+
implMgr.apiResources[res.GroupVersionKind] = &res
173+
}
174+
175+
// Test GetAllResources
176+
allResources := mgr.GetAllResources()
177+
if got := len(allResources); got != tt.expectedResourceCount {
178+
t.Errorf("GetAllResources() returned %d resources, want %d", got, tt.expectedResourceCount)
179+
}
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+
if !resourceMap[res.GroupVersionResource] {
189+
t.Errorf("namespace-scoped resource %v should be in GetAllResources", res.GroupVersionResource)
190+
}
191+
}
192+
193+
for _, res := range tt.clusterScopedResources {
194+
if !resourceMap[res.GroupVersionResource] {
195+
t.Errorf("cluster-scoped resource %v should be in GetAllResources", res.GroupVersionResource)
196+
}
197+
}
198+
199+
for _, res := range tt.staticResources {
200+
if !resourceMap[res.GroupVersionResource] {
201+
t.Errorf("static resource %v should be in GetAllResources", res.GroupVersionResource)
202+
}
203+
}
204+
205+
// Test GetNameSpaceScopedResources
206+
namespacedResources := mgr.GetNameSpaceScopedResources()
207+
if got := len(namespacedResources); got != tt.expectedNamespacedCount {
208+
t.Errorf("GetNameSpaceScopedResources() returned %d resources, want %d", got, tt.expectedNamespacedCount)
209+
}
210+
211+
// Verify only namespace-scoped, non-static resources are present
212+
namespacedMap := make(map[schema.GroupVersionResource]bool)
213+
for _, gvr := range namespacedResources {
214+
namespacedMap[gvr] = true
215+
}
216+
217+
for _, res := range tt.namespaceScopedResources {
218+
if !namespacedMap[res.GroupVersionResource] {
219+
t.Errorf("namespace-scoped resource %v should be in GetNameSpaceScopedResources", res.GroupVersionResource)
220+
}
221+
}
222+
223+
// Verify cluster-scoped and static resources are NOT in namespace-scoped list
224+
for _, res := range tt.clusterScopedResources {
225+
if namespacedMap[res.GroupVersionResource] {
226+
t.Errorf("cluster-scoped resource %v should NOT be in GetNameSpaceScopedResources", res.GroupVersionResource)
227+
}
228+
}
229+
230+
for _, res := range tt.staticResources {
231+
if namespacedMap[res.GroupVersionResource] {
232+
t.Errorf("static resource %v should NOT be in GetNameSpaceScopedResources", res.GroupVersionResource)
233+
}
234+
}
235+
})
236+
}
237+
}
238+
239+
func TestGetAllResources_NotPresent(t *testing.T) {
240+
// Test that resources marked as not present are excluded
241+
fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme)
242+
stopCh := make(chan struct{})
243+
defer close(stopCh)
244+
245+
mgr := NewInformerManager(fakeClient, 0, stopCh)
246+
implMgr := mgr.(*informerManagerImpl)
247+
248+
// Add a resource that is present
249+
presentRes := APIResourceMeta{
250+
GroupVersionKind: schema.GroupVersionKind{
251+
Group: "",
252+
Version: "v1",
253+
Kind: "ConfigMap",
254+
},
255+
GroupVersionResource: schema.GroupVersionResource{
256+
Group: "",
257+
Version: "v1",
258+
Resource: "configmaps",
259+
},
260+
IsClusterScoped: false,
261+
isPresent: true,
262+
}
263+
implMgr.apiResources[presentRes.GroupVersionKind] = &presentRes
264+
265+
// Add a resource that is NOT present (deleted)
266+
notPresentRes := APIResourceMeta{
267+
GroupVersionKind: schema.GroupVersionKind{
268+
Group: "",
269+
Version: "v1",
270+
Kind: "Secret",
271+
},
272+
GroupVersionResource: schema.GroupVersionResource{
273+
Group: "",
274+
Version: "v1",
275+
Resource: "secrets",
276+
},
277+
IsClusterScoped: false,
278+
isPresent: false,
279+
}
280+
implMgr.apiResources[notPresentRes.GroupVersionKind] = &notPresentRes
281+
282+
allResources := mgr.GetAllResources()
283+
if got := len(allResources); got != 1 {
284+
t.Fatalf("GetAllResources() returned %d resources, want 1 (should only return present resources)", got)
285+
}
286+
if got := allResources[0]; got != presentRes.GroupVersionResource {
287+
t.Errorf("GetAllResources()[0] = %v, want %v", got, presentRes.GroupVersionResource)
288+
}
289+
}

0 commit comments

Comments
 (0)