From c5c3eff7a3dc5fd7829ca9e111dffc75d3b86611 Mon Sep 17 00:00:00 2001 From: Wei Weng Date: Thu, 11 Dec 2025 01:37:48 +0000 Subject: [PATCH 1/3] wait for informer cache to be ready Signed-off-by: Wei Weng --- cmd/hubagent/main.go | 13 +- pkg/utils/informer/informermanager.go | 16 ++ pkg/utils/informer/informermanager_test.go | 270 +++++++++++++++++++++ pkg/webhook/readiness.go | 61 +++++ pkg/webhook/readiness_test.go | 140 +++++++++++ test/utils/informer/manager.go | 15 ++ 6 files changed, 514 insertions(+), 1 deletion(-) create mode 100644 pkg/utils/informer/informermanager_test.go create mode 100644 pkg/webhook/readiness.go create mode 100644 pkg/webhook/readiness_test.go diff --git a/cmd/hubagent/main.go b/cmd/hubagent/main.go index 42409c0ea..cbba40f47 100644 --- a/cmd/hubagent/main.go +++ b/cmd/hubagent/main.go @@ -46,6 +46,7 @@ import ( "github.com/kubefleet-dev/kubefleet/cmd/hubagent/options" "github.com/kubefleet-dev/kubefleet/cmd/hubagent/workload" mcv1beta1 "github.com/kubefleet-dev/kubefleet/pkg/controllers/membercluster/v1beta1" + "github.com/kubefleet-dev/kubefleet/pkg/utils/validator" "github.com/kubefleet-dev/kubefleet/pkg/webhook" // +kubebuilder:scaffold:imports ) @@ -165,7 +166,17 @@ func main() { ctx := ctrl.SetupSignalHandler() if err := workload.SetupControllers(ctx, &wg, mgr, config, opts); err != nil { - klog.ErrorS(err, "unable to set up ready check") + klog.ErrorS(err, "unable to set up controllers") + exitWithErrorFunc() + } + + // Add readiness check for dynamic informer cache AFTER controllers are set up. + // This ensures the discovery cache is populated before the hub agent is marked ready, + // which is critical for all controllers that rely on dynamic resource discovery. + // AddReadyzCheck adds additional readiness check instead of replacing the one registered earlier provided the name is different. + // Both registered checks need to pass for the manager to be considered ready. + if err := mgr.AddReadyzCheck("informer-cache", webhook.ResourceInformerReadinessChecker(validator.ResourceInformer)); err != nil { + klog.ErrorS(err, "unable to set up informer cache readiness check") exitWithErrorFunc() } diff --git a/pkg/utils/informer/informermanager.go b/pkg/utils/informer/informermanager.go index 4ea1a5143..53da3343a 100644 --- a/pkg/utils/informer/informermanager.go +++ b/pkg/utils/informer/informermanager.go @@ -61,6 +61,9 @@ type Manager interface { // GetNameSpaceScopedResources returns the list of namespace scoped resources we are watching. GetNameSpaceScopedResources() []schema.GroupVersionResource + // GetAllResources returns the list of all resources (both cluster-scoped and namespace-scoped) we are watching. + GetAllResources() []schema.GroupVersionResource + // IsClusterScopedResources returns if a resource is cluster scoped. IsClusterScopedResources(resource schema.GroupVersionKind) bool @@ -224,6 +227,19 @@ func (s *informerManagerImpl) GetNameSpaceScopedResources() []schema.GroupVersio return res } +func (s *informerManagerImpl) GetAllResources() []schema.GroupVersionResource { + s.resourcesLock.RLock() + defer s.resourcesLock.RUnlock() + + res := make([]schema.GroupVersionResource, 0, len(s.apiResources)) + for _, resource := range s.apiResources { + if resource.isPresent { + res = append(res, resource.GroupVersionResource) + } + } + return res +} + func (s *informerManagerImpl) IsClusterScopedResources(gvk schema.GroupVersionKind) bool { s.resourcesLock.RLock() defer s.resourcesLock.RUnlock() diff --git a/pkg/utils/informer/informermanager_test.go b/pkg/utils/informer/informermanager_test.go new file mode 100644 index 000000000..5d0984c87 --- /dev/null +++ b/pkg/utils/informer/informermanager_test.go @@ -0,0 +1,270 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed 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 informer + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes/scheme" +) + +func TestGetAllResources(t *testing.T) { + tests := []struct { + name string + namespaceScopedResources []APIResourceMeta + clusterScopedResources []APIResourceMeta + staticResources []APIResourceMeta + expectedResourceCount int + expectedNamespacedCount int + }{ + { + name: "mixed cluster and namespace scoped resources", + namespaceScopedResources: []APIResourceMeta{ + { + GroupVersionKind: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + GroupVersionResource: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "configmaps", + }, + IsClusterScoped: false, + }, + { + GroupVersionKind: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Secret", + }, + GroupVersionResource: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "secrets", + }, + IsClusterScoped: false, + }, + }, + clusterScopedResources: []APIResourceMeta{ + { + GroupVersionKind: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Namespace", + }, + GroupVersionResource: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "namespaces", + }, + IsClusterScoped: true, + }, + }, + staticResources: []APIResourceMeta{ + { + GroupVersionKind: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Node", + }, + GroupVersionResource: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "nodes", + }, + IsClusterScoped: true, + isStaticResource: true, + }, + }, + expectedResourceCount: 4, // All resources including static + expectedNamespacedCount: 2, // Only namespace-scoped, excluding static + }, + { + name: "no resources", + expectedResourceCount: 0, + expectedNamespacedCount: 0, + }, + { + name: "only namespace scoped resources", + namespaceScopedResources: []APIResourceMeta{ + { + GroupVersionKind: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + GroupVersionResource: schema.GroupVersionResource{ + Group: "apps", + Version: "v1", + Resource: "deployments", + }, + IsClusterScoped: false, + }, + }, + expectedResourceCount: 1, + expectedNamespacedCount: 1, + }, + { + name: "only cluster scoped resources", + clusterScopedResources: []APIResourceMeta{ + { + GroupVersionKind: schema.GroupVersionKind{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Kind: "ClusterRole", + }, + GroupVersionResource: schema.GroupVersionResource{ + Group: "rbac.authorization.k8s.io", + Version: "v1", + Resource: "clusterroles", + }, + IsClusterScoped: true, + }, + }, + expectedResourceCount: 1, + expectedNamespacedCount: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a fake dynamic client + fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme) + stopCh := make(chan struct{}) + defer close(stopCh) + + mgr := NewInformerManager(fakeClient, 0, stopCh) + implMgr := mgr.(*informerManagerImpl) + + // Add namespace-scoped resources + for _, res := range tt.namespaceScopedResources { + res.isPresent = true + implMgr.apiResources[res.GroupVersionKind] = &res + } + + // Add cluster-scoped resources + for _, res := range tt.clusterScopedResources { + res.isPresent = true + implMgr.apiResources[res.GroupVersionKind] = &res + } + + // Add static resources + for _, res := range tt.staticResources { + res.isPresent = true + implMgr.apiResources[res.GroupVersionKind] = &res + } + + // Test GetAllResources + allResources := mgr.GetAllResources() + assert.Equal(t, tt.expectedResourceCount, len(allResources), "GetAllResources should return correct count") + + // Verify all expected resources are present + resourceMap := make(map[schema.GroupVersionResource]bool) + for _, gvr := range allResources { + resourceMap[gvr] = true + } + + for _, res := range tt.namespaceScopedResources { + assert.True(t, resourceMap[res.GroupVersionResource], "namespace-scoped resource %v should be in GetAllResources", res.GroupVersionResource) + } + + for _, res := range tt.clusterScopedResources { + assert.True(t, resourceMap[res.GroupVersionResource], "cluster-scoped resource %v should be in GetAllResources", res.GroupVersionResource) + } + + for _, res := range tt.staticResources { + assert.True(t, resourceMap[res.GroupVersionResource], "static resource %v should be in GetAllResources", res.GroupVersionResource) + } + + // Test GetNameSpaceScopedResources + namespacedResources := mgr.GetNameSpaceScopedResources() + assert.Equal(t, tt.expectedNamespacedCount, len(namespacedResources), "GetNameSpaceScopedResources should return correct count") + + // Verify only namespace-scoped, non-static resources are present + namespacedMap := make(map[schema.GroupVersionResource]bool) + for _, gvr := range namespacedResources { + namespacedMap[gvr] = true + } + + for _, res := range tt.namespaceScopedResources { + assert.True(t, namespacedMap[res.GroupVersionResource], "namespace-scoped resource %v should be in GetNameSpaceScopedResources", res.GroupVersionResource) + } + + // Verify cluster-scoped and static resources are NOT in namespace-scoped list + for _, res := range tt.clusterScopedResources { + assert.False(t, namespacedMap[res.GroupVersionResource], "cluster-scoped resource %v should NOT be in GetNameSpaceScopedResources", res.GroupVersionResource) + } + + for _, res := range tt.staticResources { + assert.False(t, namespacedMap[res.GroupVersionResource], "static resource %v should NOT be in GetNameSpaceScopedResources", res.GroupVersionResource) + } + }) + } +} + +func TestGetAllResources_NotPresent(t *testing.T) { + // Test that resources marked as not present are excluded + fakeClient := fake.NewSimpleDynamicClient(scheme.Scheme) + stopCh := make(chan struct{}) + defer close(stopCh) + + mgr := NewInformerManager(fakeClient, 0, stopCh) + implMgr := mgr.(*informerManagerImpl) + + // Add a resource that is present + presentRes := APIResourceMeta{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + GroupVersionResource: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "configmaps", + }, + IsClusterScoped: false, + isPresent: true, + } + implMgr.apiResources[presentRes.GroupVersionKind] = &presentRes + + // Add a resource that is NOT present (deleted) + notPresentRes := APIResourceMeta{ + GroupVersionKind: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Secret", + }, + GroupVersionResource: schema.GroupVersionResource{ + Group: "", + Version: "v1", + Resource: "secrets", + }, + IsClusterScoped: false, + isPresent: false, + } + implMgr.apiResources[notPresentRes.GroupVersionKind] = ¬PresentRes + + allResources := mgr.GetAllResources() + assert.Equal(t, 1, len(allResources), "should only return present resources") + assert.Equal(t, presentRes.GroupVersionResource, allResources[0], "should return the present resource") +} diff --git a/pkg/webhook/readiness.go b/pkg/webhook/readiness.go new file mode 100644 index 000000000..889841828 --- /dev/null +++ b/pkg/webhook/readiness.go @@ -0,0 +1,61 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed 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 webhook + +import ( + "fmt" + "net/http" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/klog/v2" + + "github.com/kubefleet-dev/kubefleet/pkg/utils/informer" +) + +// ResourceInformerReadinessChecker creates a readiness check function that verifies +// all resource informer caches are synced before marking the pod as ready. +// This prevents the webhook from accepting requests before the discovery cache is populated. +func ResourceInformerReadinessChecker(resourceInformer informer.Manager) func(*http.Request) error { + return func(_ *http.Request) error { + if resourceInformer == nil { + return fmt.Errorf("resource informer not initialized") + } + + // Require ALL informer caches to be synced before marking ready + allResources := resourceInformer.GetAllResources() + if len(allResources) == 0 { + // This can happen during startup when the ResourceInformer is created but the ChangeDetector + // hasn't discovered and registered any resources yet via AddDynamicResources(). + return fmt.Errorf("resource informer not ready: no resources registered") + } + + // Check that ALL informers have synced + unsyncedResources := []schema.GroupVersionResource{} + for _, gvr := range allResources { + if !resourceInformer.IsInformerSynced(gvr) { + unsyncedResources = append(unsyncedResources, gvr) + } + } + + if len(unsyncedResources) > 0 { + return fmt.Errorf("resource informer not ready: %d/%d informers not synced yet", len(unsyncedResources), len(allResources)) + } + + klog.V(5).InfoS("All resource informers synced", "totalInformers", len(allResources)) + return nil + } +} diff --git a/pkg/webhook/readiness_test.go b/pkg/webhook/readiness_test.go new file mode 100644 index 000000000..d93852f05 --- /dev/null +++ b/pkg/webhook/readiness_test.go @@ -0,0 +1,140 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed 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 webhook + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" + + "github.com/kubefleet-dev/kubefleet/pkg/utils/informer" + testinformer "github.com/kubefleet-dev/kubefleet/test/utils/informer" +) + +func TestResourceInformerReadinessChecker(t *testing.T) { + tests := []struct { + name string + resourceInformer informer.Manager + expectError bool + errorContains string + }{ + { + name: "nil informer", + resourceInformer: nil, + expectError: true, + errorContains: "resource informer not initialized", + }, + { + name: "no resources registered", + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{}, + }, + expectError: true, + errorContains: "no resources registered", + }, + { + name: "all informers synced", + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{ + {Group: "", Version: "v1", Kind: "ConfigMap"}: false, // namespace-scoped + {Group: "", Version: "v1", Kind: "Secret"}: false, // namespace-scoped + {Group: "", Version: "v1", Kind: "Namespace"}: true, // cluster-scoped + }, + IsClusterScopedResource: true, // true = map stores cluster-scoped resources + InformerSynced: ptr.To(true), + }, + expectError: false, + }, + { + name: "some informers not synced", + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{ + {Group: "", Version: "v1", Kind: "ConfigMap"}: false, // namespace-scoped + {Group: "", Version: "v1", Kind: "Secret"}: false, // namespace-scoped + {Group: "", Version: "v1", Kind: "Namespace"}: true, // cluster-scoped + }, + IsClusterScopedResource: true, + InformerSynced: ptr.To(false), + }, + expectError: true, + errorContains: "informers not synced yet", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checker := ResourceInformerReadinessChecker(tt.resourceInformer) + err := checker(nil) + + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestResourceInformerReadinessChecker_PartialSync(t *testing.T) { + // Test the case where we have multiple resources but only some are synced + fakeManager := &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{ + {Group: "", Version: "v1", Kind: "ConfigMap"}: false, // namespace-scoped + {Group: "", Version: "v1", Kind: "Secret"}: false, // namespace-scoped + {Group: "apps", Version: "v1", Kind: "Deployment"}: false, // namespace-scoped + {Group: "", Version: "v1", Kind: "Namespace"}: true, // cluster-scoped + }, + IsClusterScopedResource: true, + InformerSynced: ptr.To(false), + // This will make IsInformerSynced return false for all resources + } + + checker := ResourceInformerReadinessChecker(fakeManager) + err := checker(nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "informers not synced yet") + // Should report 4 unsynced (3 namespace-scoped + 1 cluster-scoped) + assert.Contains(t, err.Error(), "4/4") +} + +func TestResourceInformerReadinessChecker_AllSyncedMultipleResources(t *testing.T) { + // Test with many resources all synced + fakeManager := &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{ + {Group: "", Version: "v1", Kind: "ConfigMap"}: false, // namespace-scoped + {Group: "", Version: "v1", Kind: "Secret"}: false, // namespace-scoped + {Group: "", Version: "v1", Kind: "Service"}: false, // namespace-scoped + {Group: "apps", Version: "v1", Kind: "Deployment"}: false, // namespace-scoped + {Group: "apps", Version: "v1", Kind: "StatefulSet"}: false, // namespace-scoped + {Group: "", Version: "v1", Kind: "Namespace"}: true, // cluster-scoped + {Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "ClusterRole"}: true, // cluster-scoped + }, + IsClusterScopedResource: true, + InformerSynced: ptr.To(true), + } + + checker := ResourceInformerReadinessChecker(fakeManager) + err := checker(nil) + + assert.NoError(t, err, "Should be ready when all informers are synced") +} diff --git a/test/utils/informer/manager.go b/test/utils/informer/manager.go index b67c8fc6a..fde757292 100644 --- a/test/utils/informer/manager.go +++ b/test/utils/informer/manager.go @@ -160,6 +160,21 @@ func (m *FakeManager) GetNameSpaceScopedResources() []schema.GroupVersionResourc return m.NamespaceScopedResources } +func (m *FakeManager) GetAllResources() []schema.GroupVersionResource { + allResources := make([]schema.GroupVersionResource, 0, len(m.APIResources)) + for gvk := range m.APIResources { + // Return a GVR with the same Group/Version and Kind as Resource + // The actual resource name doesn't matter since IsInformerSynced ignores the GVR parameter + gvr := schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: gvk.Kind, + } + allResources = append(allResources, gvr) + } + return allResources +} + func (m *FakeManager) IsClusterScopedResources(gvk schema.GroupVersionKind) bool { return m.APIResources[gvk] == m.IsClusterScopedResource } From 385311f8b3980faff0d9e0abfc8e641114f9ae16 Mon Sep 17 00:00:00 2001 From: Wei Weng Date: Thu, 11 Dec 2025 01:37:48 +0000 Subject: [PATCH 2/3] move readiness check function to informer manager package Signed-off-by: Wei Weng --- cmd/hubagent/main.go | 3 +- pkg/{webhook => utils/informer}/readiness.go | 10 +- pkg/utils/informer/readiness_test.go | 186 +++++++++++++++++++ pkg/webhook/readiness_test.go | 140 -------------- 4 files changed, 192 insertions(+), 147 deletions(-) rename pkg/{webhook => utils/informer}/readiness.go (84%) create mode 100644 pkg/utils/informer/readiness_test.go delete mode 100644 pkg/webhook/readiness_test.go diff --git a/cmd/hubagent/main.go b/cmd/hubagent/main.go index cbba40f47..6dcbe0e15 100644 --- a/cmd/hubagent/main.go +++ b/cmd/hubagent/main.go @@ -46,6 +46,7 @@ import ( "github.com/kubefleet-dev/kubefleet/cmd/hubagent/options" "github.com/kubefleet-dev/kubefleet/cmd/hubagent/workload" mcv1beta1 "github.com/kubefleet-dev/kubefleet/pkg/controllers/membercluster/v1beta1" + "github.com/kubefleet-dev/kubefleet/pkg/utils/informer" "github.com/kubefleet-dev/kubefleet/pkg/utils/validator" "github.com/kubefleet-dev/kubefleet/pkg/webhook" // +kubebuilder:scaffold:imports @@ -175,7 +176,7 @@ func main() { // which is critical for all controllers that rely on dynamic resource discovery. // AddReadyzCheck adds additional readiness check instead of replacing the one registered earlier provided the name is different. // Both registered checks need to pass for the manager to be considered ready. - if err := mgr.AddReadyzCheck("informer-cache", webhook.ResourceInformerReadinessChecker(validator.ResourceInformer)); err != nil { + if err := mgr.AddReadyzCheck("informer-cache", informer.ReadinessChecker(validator.ResourceInformer)); err != nil { klog.ErrorS(err, "unable to set up informer cache readiness check") exitWithErrorFunc() } diff --git a/pkg/webhook/readiness.go b/pkg/utils/informer/readiness.go similarity index 84% rename from pkg/webhook/readiness.go rename to pkg/utils/informer/readiness.go index 889841828..cca6f4935 100644 --- a/pkg/webhook/readiness.go +++ b/pkg/utils/informer/readiness.go @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package webhook +package informer import ( "fmt" @@ -22,14 +22,12 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/klog/v2" - - "github.com/kubefleet-dev/kubefleet/pkg/utils/informer" ) -// ResourceInformerReadinessChecker creates a readiness check function that verifies +// ReadinessChecker creates a readiness check function that verifies // all resource informer caches are synced before marking the pod as ready. -// This prevents the webhook from accepting requests before the discovery cache is populated. -func ResourceInformerReadinessChecker(resourceInformer informer.Manager) func(*http.Request) error { +// This prevents components from processing requests before the discovery cache is populated. +func ReadinessChecker(resourceInformer Manager) func(*http.Request) error { return func(_ *http.Request) error { if resourceInformer == nil { return fmt.Errorf("resource informer not initialized") diff --git a/pkg/utils/informer/readiness_test.go b/pkg/utils/informer/readiness_test.go new file mode 100644 index 000000000..a0b20b071 --- /dev/null +++ b/pkg/utils/informer/readiness_test.go @@ -0,0 +1,186 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed 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 informer + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/tools/cache" +) + +// mockInformerManager is a simple mock for testing +type mockInformerManager struct { + allResources []schema.GroupVersionResource + syncedMap map[schema.GroupVersionResource]bool +} + +func (m *mockInformerManager) AddDynamicResources(resources []APIResourceMeta, handler cache.ResourceEventHandler, listComplete bool) { +} +func (m *mockInformerManager) AddStaticResource(resource APIResourceMeta, handler cache.ResourceEventHandler) { +} +func (m *mockInformerManager) IsInformerSynced(resource schema.GroupVersionResource) bool { + if m.syncedMap == nil { + return false + } + synced, exists := m.syncedMap[resource] + return exists && synced +} +func (m *mockInformerManager) Start() {} +func (m *mockInformerManager) Stop() {} +func (m *mockInformerManager) Lister(resource schema.GroupVersionResource) cache.GenericLister { + return nil +} +func (m *mockInformerManager) GetNameSpaceScopedResources() []schema.GroupVersionResource { return nil } +func (m *mockInformerManager) GetAllResources() []schema.GroupVersionResource { + return m.allResources +} +func (m *mockInformerManager) IsClusterScopedResources(resource schema.GroupVersionKind) bool { + return false +} +func (m *mockInformerManager) WaitForCacheSync() {} +func (m *mockInformerManager) GetClient() dynamic.Interface { return nil } + +func TestReadinessChecker(t *testing.T) { + tests := []struct { + name string + resourceInformer Manager + expectError bool + errorContains string + }{ + { + name: "nil informer", + resourceInformer: nil, + expectError: true, + errorContains: "resource informer not initialized", + }, + { + name: "no resources registered", + resourceInformer: &mockInformerManager{ + allResources: []schema.GroupVersionResource{}, + }, + expectError: true, + errorContains: "no resources registered", + }, + { + name: "all informers synced", + resourceInformer: &mockInformerManager{ + allResources: []schema.GroupVersionResource{ + {Group: "", Version: "v1", Resource: "configmaps"}, + {Group: "", Version: "v1", Resource: "secrets"}, + {Group: "", Version: "v1", Resource: "namespaces"}, + }, + syncedMap: map[schema.GroupVersionResource]bool{ + {Group: "", Version: "v1", Resource: "configmaps"}: true, + {Group: "", Version: "v1", Resource: "secrets"}: true, + {Group: "", Version: "v1", Resource: "namespaces"}: true, + }, + }, + expectError: false, + }, + { + name: "some informers not synced", + resourceInformer: &mockInformerManager{ + allResources: []schema.GroupVersionResource{ + {Group: "", Version: "v1", Resource: "configmaps"}, + {Group: "", Version: "v1", Resource: "secrets"}, + {Group: "", Version: "v1", Resource: "namespaces"}, + }, + syncedMap: map[schema.GroupVersionResource]bool{ + {Group: "", Version: "v1", Resource: "configmaps"}: true, + {Group: "", Version: "v1", Resource: "secrets"}: false, + {Group: "", Version: "v1", Resource: "namespaces"}: true, + }, + }, + expectError: true, + errorContains: "informers not synced yet", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checker := ReadinessChecker(tt.resourceInformer) + err := checker(nil) + + if tt.expectError { + assert.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestReadinessChecker_PartialSync(t *testing.T) { + // Test the case where we have multiple resources but only some are synced + mockManager := &mockInformerManager{ + allResources: []schema.GroupVersionResource{ + {Group: "", Version: "v1", Resource: "configmaps"}, + {Group: "", Version: "v1", Resource: "secrets"}, + {Group: "apps", Version: "v1", Resource: "deployments"}, + {Group: "", Version: "v1", Resource: "namespaces"}, + }, + syncedMap: map[schema.GroupVersionResource]bool{ + {Group: "", Version: "v1", Resource: "configmaps"}: false, + {Group: "", Version: "v1", Resource: "secrets"}: false, + {Group: "apps", Version: "v1", Resource: "deployments"}: false, + {Group: "", Version: "v1", Resource: "namespaces"}: false, + }, + } + + checker := ReadinessChecker(mockManager) + err := checker(nil) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "informers not synced yet") + // Should report 4 unsynced + assert.Contains(t, err.Error(), "4/4") +} + +func TestReadinessChecker_AllSyncedMultipleResources(t *testing.T) { + // Test with many resources all synced + mockManager := &mockInformerManager{ + allResources: []schema.GroupVersionResource{ + {Group: "", Version: "v1", Resource: "configmaps"}, + {Group: "", Version: "v1", Resource: "secrets"}, + {Group: "", Version: "v1", Resource: "services"}, + {Group: "apps", Version: "v1", Resource: "deployments"}, + {Group: "apps", Version: "v1", Resource: "statefulsets"}, + {Group: "", Version: "v1", Resource: "namespaces"}, + {Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "clusterroles"}, + }, + syncedMap: map[schema.GroupVersionResource]bool{ + {Group: "", Version: "v1", Resource: "configmaps"}: true, + {Group: "", Version: "v1", Resource: "secrets"}: true, + {Group: "", Version: "v1", Resource: "services"}: true, + {Group: "apps", Version: "v1", Resource: "deployments"}: true, + {Group: "apps", Version: "v1", Resource: "statefulsets"}: true, + {Group: "", Version: "v1", Resource: "namespaces"}: true, + {Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "clusterroles"}: true, + }, + } + + checker := ReadinessChecker(mockManager) + err := checker(nil) + + assert.NoError(t, err, "Should be ready when all informers are synced") +} diff --git a/pkg/webhook/readiness_test.go b/pkg/webhook/readiness_test.go deleted file mode 100644 index d93852f05..000000000 --- a/pkg/webhook/readiness_test.go +++ /dev/null @@ -1,140 +0,0 @@ -/* -Copyright 2025 The KubeFleet Authors. - -Licensed 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 webhook - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/utils/ptr" - - "github.com/kubefleet-dev/kubefleet/pkg/utils/informer" - testinformer "github.com/kubefleet-dev/kubefleet/test/utils/informer" -) - -func TestResourceInformerReadinessChecker(t *testing.T) { - tests := []struct { - name string - resourceInformer informer.Manager - expectError bool - errorContains string - }{ - { - name: "nil informer", - resourceInformer: nil, - expectError: true, - errorContains: "resource informer not initialized", - }, - { - name: "no resources registered", - resourceInformer: &testinformer.FakeManager{ - APIResources: map[schema.GroupVersionKind]bool{}, - }, - expectError: true, - errorContains: "no resources registered", - }, - { - name: "all informers synced", - resourceInformer: &testinformer.FakeManager{ - APIResources: map[schema.GroupVersionKind]bool{ - {Group: "", Version: "v1", Kind: "ConfigMap"}: false, // namespace-scoped - {Group: "", Version: "v1", Kind: "Secret"}: false, // namespace-scoped - {Group: "", Version: "v1", Kind: "Namespace"}: true, // cluster-scoped - }, - IsClusterScopedResource: true, // true = map stores cluster-scoped resources - InformerSynced: ptr.To(true), - }, - expectError: false, - }, - { - name: "some informers not synced", - resourceInformer: &testinformer.FakeManager{ - APIResources: map[schema.GroupVersionKind]bool{ - {Group: "", Version: "v1", Kind: "ConfigMap"}: false, // namespace-scoped - {Group: "", Version: "v1", Kind: "Secret"}: false, // namespace-scoped - {Group: "", Version: "v1", Kind: "Namespace"}: true, // cluster-scoped - }, - IsClusterScopedResource: true, - InformerSynced: ptr.To(false), - }, - expectError: true, - errorContains: "informers not synced yet", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checker := ResourceInformerReadinessChecker(tt.resourceInformer) - err := checker(nil) - - if tt.expectError { - assert.Error(t, err) - if tt.errorContains != "" { - assert.Contains(t, err.Error(), tt.errorContains) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestResourceInformerReadinessChecker_PartialSync(t *testing.T) { - // Test the case where we have multiple resources but only some are synced - fakeManager := &testinformer.FakeManager{ - APIResources: map[schema.GroupVersionKind]bool{ - {Group: "", Version: "v1", Kind: "ConfigMap"}: false, // namespace-scoped - {Group: "", Version: "v1", Kind: "Secret"}: false, // namespace-scoped - {Group: "apps", Version: "v1", Kind: "Deployment"}: false, // namespace-scoped - {Group: "", Version: "v1", Kind: "Namespace"}: true, // cluster-scoped - }, - IsClusterScopedResource: true, - InformerSynced: ptr.To(false), - // This will make IsInformerSynced return false for all resources - } - - checker := ResourceInformerReadinessChecker(fakeManager) - err := checker(nil) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "informers not synced yet") - // Should report 4 unsynced (3 namespace-scoped + 1 cluster-scoped) - assert.Contains(t, err.Error(), "4/4") -} - -func TestResourceInformerReadinessChecker_AllSyncedMultipleResources(t *testing.T) { - // Test with many resources all synced - fakeManager := &testinformer.FakeManager{ - APIResources: map[schema.GroupVersionKind]bool{ - {Group: "", Version: "v1", Kind: "ConfigMap"}: false, // namespace-scoped - {Group: "", Version: "v1", Kind: "Secret"}: false, // namespace-scoped - {Group: "", Version: "v1", Kind: "Service"}: false, // namespace-scoped - {Group: "apps", Version: "v1", Kind: "Deployment"}: false, // namespace-scoped - {Group: "apps", Version: "v1", Kind: "StatefulSet"}: false, // namespace-scoped - {Group: "", Version: "v1", Kind: "Namespace"}: true, // cluster-scoped - {Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "ClusterRole"}: true, // cluster-scoped - }, - IsClusterScopedResource: true, - InformerSynced: ptr.To(true), - } - - checker := ResourceInformerReadinessChecker(fakeManager) - err := checker(nil) - - assert.NoError(t, err, "Should be ready when all informers are synced") -} From 27ebb6bf2c3ce71b36fe6bcc350b8714810acdd9 Mon Sep 17 00:00:00 2001 From: Wei Weng Date: Thu, 11 Dec 2025 01:37:48 +0000 Subject: [PATCH 3/3] fix tests Signed-off-by: Wei Weng --- cmd/hubagent/main.go | 4 +- pkg/utils/informer/informermanager_test.go | 41 ++-- .../informer/{ => readiness}/readiness.go | 7 +- .../informer/readiness/readiness_test.go | 150 ++++++++++++++ pkg/utils/informer/readiness_test.go | 186 ------------------ 5 files changed, 186 insertions(+), 202 deletions(-) rename pkg/utils/informer/{ => readiness}/readiness.go (88%) create mode 100644 pkg/utils/informer/readiness/readiness_test.go delete mode 100644 pkg/utils/informer/readiness_test.go diff --git a/cmd/hubagent/main.go b/cmd/hubagent/main.go index 6dcbe0e15..d69d1d867 100644 --- a/cmd/hubagent/main.go +++ b/cmd/hubagent/main.go @@ -46,7 +46,7 @@ import ( "github.com/kubefleet-dev/kubefleet/cmd/hubagent/options" "github.com/kubefleet-dev/kubefleet/cmd/hubagent/workload" mcv1beta1 "github.com/kubefleet-dev/kubefleet/pkg/controllers/membercluster/v1beta1" - "github.com/kubefleet-dev/kubefleet/pkg/utils/informer" + readiness "github.com/kubefleet-dev/kubefleet/pkg/utils/informer/readiness" "github.com/kubefleet-dev/kubefleet/pkg/utils/validator" "github.com/kubefleet-dev/kubefleet/pkg/webhook" // +kubebuilder:scaffold:imports @@ -176,7 +176,7 @@ func main() { // which is critical for all controllers that rely on dynamic resource discovery. // AddReadyzCheck adds additional readiness check instead of replacing the one registered earlier provided the name is different. // Both registered checks need to pass for the manager to be considered ready. - if err := mgr.AddReadyzCheck("informer-cache", informer.ReadinessChecker(validator.ResourceInformer)); err != nil { + if err := mgr.AddReadyzCheck("informer-cache", readiness.InformerReadinessChecker(validator.ResourceInformer)); err != nil { klog.ErrorS(err, "unable to set up informer cache readiness check") exitWithErrorFunc() } diff --git a/pkg/utils/informer/informermanager_test.go b/pkg/utils/informer/informermanager_test.go index 5d0984c87..53f2ce74a 100644 --- a/pkg/utils/informer/informermanager_test.go +++ b/pkg/utils/informer/informermanager_test.go @@ -19,7 +19,6 @@ package informer import ( "testing" - "github.com/stretchr/testify/assert" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/kubernetes/scheme" @@ -175,7 +174,9 @@ func TestGetAllResources(t *testing.T) { // Test GetAllResources allResources := mgr.GetAllResources() - assert.Equal(t, tt.expectedResourceCount, len(allResources), "GetAllResources should return correct count") + if got := len(allResources); got != tt.expectedResourceCount { + t.Errorf("GetAllResources() returned %d resources, want %d", got, tt.expectedResourceCount) + } // Verify all expected resources are present resourceMap := make(map[schema.GroupVersionResource]bool) @@ -184,20 +185,28 @@ func TestGetAllResources(t *testing.T) { } for _, res := range tt.namespaceScopedResources { - assert.True(t, resourceMap[res.GroupVersionResource], "namespace-scoped resource %v should be in GetAllResources", res.GroupVersionResource) + if !resourceMap[res.GroupVersionResource] { + t.Errorf("namespace-scoped resource %v should be in GetAllResources", res.GroupVersionResource) + } } for _, res := range tt.clusterScopedResources { - assert.True(t, resourceMap[res.GroupVersionResource], "cluster-scoped resource %v should be in GetAllResources", res.GroupVersionResource) + if !resourceMap[res.GroupVersionResource] { + t.Errorf("cluster-scoped resource %v should be in GetAllResources", res.GroupVersionResource) + } } for _, res := range tt.staticResources { - assert.True(t, resourceMap[res.GroupVersionResource], "static resource %v should be in GetAllResources", res.GroupVersionResource) + if !resourceMap[res.GroupVersionResource] { + t.Errorf("static resource %v should be in GetAllResources", res.GroupVersionResource) + } } // Test GetNameSpaceScopedResources namespacedResources := mgr.GetNameSpaceScopedResources() - assert.Equal(t, tt.expectedNamespacedCount, len(namespacedResources), "GetNameSpaceScopedResources should return correct count") + if got := len(namespacedResources); got != tt.expectedNamespacedCount { + t.Errorf("GetNameSpaceScopedResources() returned %d resources, want %d", got, tt.expectedNamespacedCount) + } // Verify only namespace-scoped, non-static resources are present namespacedMap := make(map[schema.GroupVersionResource]bool) @@ -206,16 +215,22 @@ func TestGetAllResources(t *testing.T) { } for _, res := range tt.namespaceScopedResources { - assert.True(t, namespacedMap[res.GroupVersionResource], "namespace-scoped resource %v should be in GetNameSpaceScopedResources", res.GroupVersionResource) + if !namespacedMap[res.GroupVersionResource] { + t.Errorf("namespace-scoped resource %v should be in GetNameSpaceScopedResources", res.GroupVersionResource) + } } // Verify cluster-scoped and static resources are NOT in namespace-scoped list for _, res := range tt.clusterScopedResources { - assert.False(t, namespacedMap[res.GroupVersionResource], "cluster-scoped resource %v should NOT be in GetNameSpaceScopedResources", res.GroupVersionResource) + if namespacedMap[res.GroupVersionResource] { + t.Errorf("cluster-scoped resource %v should NOT be in GetNameSpaceScopedResources", res.GroupVersionResource) + } } for _, res := range tt.staticResources { - assert.False(t, namespacedMap[res.GroupVersionResource], "static resource %v should NOT be in GetNameSpaceScopedResources", res.GroupVersionResource) + if namespacedMap[res.GroupVersionResource] { + t.Errorf("static resource %v should NOT be in GetNameSpaceScopedResources", res.GroupVersionResource) + } } }) } @@ -265,6 +280,10 @@ func TestGetAllResources_NotPresent(t *testing.T) { implMgr.apiResources[notPresentRes.GroupVersionKind] = ¬PresentRes allResources := mgr.GetAllResources() - assert.Equal(t, 1, len(allResources), "should only return present resources") - assert.Equal(t, presentRes.GroupVersionResource, allResources[0], "should return the present resource") + if got := len(allResources); got != 1 { + t.Fatalf("GetAllResources() returned %d resources, want 1 (should only return present resources)", got) + } + if got := allResources[0]; got != presentRes.GroupVersionResource { + t.Errorf("GetAllResources()[0] = %v, want %v", got, presentRes.GroupVersionResource) + } } diff --git a/pkg/utils/informer/readiness.go b/pkg/utils/informer/readiness/readiness.go similarity index 88% rename from pkg/utils/informer/readiness.go rename to pkg/utils/informer/readiness/readiness.go index cca6f4935..23e12cf86 100644 --- a/pkg/utils/informer/readiness.go +++ b/pkg/utils/informer/readiness/readiness.go @@ -14,20 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -package informer +package readiness import ( "fmt" "net/http" + "github.com/kubefleet-dev/kubefleet/pkg/utils/informer" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/klog/v2" ) -// ReadinessChecker creates a readiness check function that verifies +// InformerReadinessChecker creates a readiness check function that verifies // all resource informer caches are synced before marking the pod as ready. // This prevents components from processing requests before the discovery cache is populated. -func ReadinessChecker(resourceInformer Manager) func(*http.Request) error { +func InformerReadinessChecker(resourceInformer informer.Manager) func(*http.Request) error { return func(_ *http.Request) error { if resourceInformer == nil { return fmt.Errorf("resource informer not initialized") diff --git a/pkg/utils/informer/readiness/readiness_test.go b/pkg/utils/informer/readiness/readiness_test.go new file mode 100644 index 000000000..973324fdc --- /dev/null +++ b/pkg/utils/informer/readiness/readiness_test.go @@ -0,0 +1,150 @@ +/* +Copyright 2025 The KubeFleet Authors. + +Licensed 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 readiness + +import ( + "strings" + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" + + "github.com/kubefleet-dev/kubefleet/pkg/utils/informer" + testinformer "github.com/kubefleet-dev/kubefleet/test/utils/informer" +) + +func TestReadinessChecker(t *testing.T) { + tests := []struct { + name string + resourceInformer informer.Manager + expectError bool + errorContains string + }{ + { + name: "nil informer", + resourceInformer: nil, + expectError: true, + errorContains: "resource informer not initialized", + }, + { + name: "no resources registered", + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{}, + }, + expectError: true, + errorContains: "no resources registered", + }, + { + name: "all informers synced", + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{ + {Group: "", Version: "v1", Kind: "ConfigMap"}: true, // this boolean is ignored + {Group: "", Version: "v1", Kind: "Secret"}: true, + {Group: "", Version: "v1", Kind: "Namespace"}: true, + }, + InformerSynced: ptr.To(true), // this makes all informers synced + }, + expectError: false, + }, + { + name: "some informers not synced", + resourceInformer: &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{ + {Group: "", Version: "v1", Kind: "ConfigMap"}: false, // this boolean is ignored + {Group: "", Version: "v1", Kind: "Secret"}: false, + {Group: "", Version: "v1", Kind: "Namespace"}: false, + }, + IsClusterScopedResource: true, + InformerSynced: ptr.To(false), // this makes all informers not synced + }, + expectError: true, + errorContains: "informers not synced yet", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checker := InformerReadinessChecker(tt.resourceInformer) + err := checker(nil) + + if tt.expectError { + if err == nil { + t.Errorf("ReadinessChecker() expected error, got nil") + } + if tt.errorContains != "" && err != nil { + if got := err.Error(); !strings.Contains(got, tt.errorContains) { + t.Errorf("error message should contain %q, got: %s", tt.errorContains, got) + } + } + } else { + if err != nil { + t.Errorf("ReadinessChecker() unexpected error: %v", err) + } + } + }) + } +} + +func TestReadinessChecker_NoneSync(t *testing.T) { + // Test the case where we have multiple resources but none are synced + mockManager := &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{ + {Group: "", Version: "v1", Kind: "ConfigMap"}: false, // this boolean is ignored + {Group: "", Version: "v1", Kind: "Secret"}: false, + {Group: "apps", Version: "v1", Kind: "Deployment"}: false, + {Group: "", Version: "v1", Kind: "Namespace"}: false, + }, + InformerSynced: ptr.To(false), // this makes all informers not synced + } + + checker := InformerReadinessChecker(mockManager) + err := checker(nil) + + if err == nil { + t.Fatal("ReadinessChecker() should return error when no informers are synced") + } + if got := err.Error(); !strings.Contains(got, "informers not synced yet") { + t.Errorf("error message should contain 'informers not synced yet', got: %s", got) + } + // Should report 4 unsynced + if got := err.Error(); !strings.Contains(got, "4/4") { + t.Errorf("error message should contain '4/4', got: %s", got) + } +} + +func TestReadinessChecker_AllSyncedMultipleResources(t *testing.T) { + // Test with many resources all synced + mockManager := &testinformer.FakeManager{ + APIResources: map[schema.GroupVersionKind]bool{ + {Group: "", Version: "v1", Kind: "ConfigMap"}: true, // this boolean is ignored + {Group: "", Version: "v1", Kind: "Secret"}: true, + {Group: "", Version: "v1", Kind: "Service"}: true, + {Group: "apps", Version: "v1", Kind: "Deployment"}: true, + {Group: "apps", Version: "v1", Kind: "StatefulSet"}: true, + {Group: "", Version: "v1", Kind: "Namespace"}: true, + {Group: "rbac.authorization.k8s.io", Version: "v1", Kind: "ClusterRole"}: true, + }, + InformerSynced: ptr.To(true), // this makes all informers synced + } + + checker := InformerReadinessChecker(mockManager) + err := checker(nil) + + if err != nil { + t.Errorf("ReadinessChecker() unexpected error when all informers are synced: %v", err) + } +} diff --git a/pkg/utils/informer/readiness_test.go b/pkg/utils/informer/readiness_test.go deleted file mode 100644 index a0b20b071..000000000 --- a/pkg/utils/informer/readiness_test.go +++ /dev/null @@ -1,186 +0,0 @@ -/* -Copyright 2025 The KubeFleet Authors. - -Licensed 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 informer - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/tools/cache" -) - -// mockInformerManager is a simple mock for testing -type mockInformerManager struct { - allResources []schema.GroupVersionResource - syncedMap map[schema.GroupVersionResource]bool -} - -func (m *mockInformerManager) AddDynamicResources(resources []APIResourceMeta, handler cache.ResourceEventHandler, listComplete bool) { -} -func (m *mockInformerManager) AddStaticResource(resource APIResourceMeta, handler cache.ResourceEventHandler) { -} -func (m *mockInformerManager) IsInformerSynced(resource schema.GroupVersionResource) bool { - if m.syncedMap == nil { - return false - } - synced, exists := m.syncedMap[resource] - return exists && synced -} -func (m *mockInformerManager) Start() {} -func (m *mockInformerManager) Stop() {} -func (m *mockInformerManager) Lister(resource schema.GroupVersionResource) cache.GenericLister { - return nil -} -func (m *mockInformerManager) GetNameSpaceScopedResources() []schema.GroupVersionResource { return nil } -func (m *mockInformerManager) GetAllResources() []schema.GroupVersionResource { - return m.allResources -} -func (m *mockInformerManager) IsClusterScopedResources(resource schema.GroupVersionKind) bool { - return false -} -func (m *mockInformerManager) WaitForCacheSync() {} -func (m *mockInformerManager) GetClient() dynamic.Interface { return nil } - -func TestReadinessChecker(t *testing.T) { - tests := []struct { - name string - resourceInformer Manager - expectError bool - errorContains string - }{ - { - name: "nil informer", - resourceInformer: nil, - expectError: true, - errorContains: "resource informer not initialized", - }, - { - name: "no resources registered", - resourceInformer: &mockInformerManager{ - allResources: []schema.GroupVersionResource{}, - }, - expectError: true, - errorContains: "no resources registered", - }, - { - name: "all informers synced", - resourceInformer: &mockInformerManager{ - allResources: []schema.GroupVersionResource{ - {Group: "", Version: "v1", Resource: "configmaps"}, - {Group: "", Version: "v1", Resource: "secrets"}, - {Group: "", Version: "v1", Resource: "namespaces"}, - }, - syncedMap: map[schema.GroupVersionResource]bool{ - {Group: "", Version: "v1", Resource: "configmaps"}: true, - {Group: "", Version: "v1", Resource: "secrets"}: true, - {Group: "", Version: "v1", Resource: "namespaces"}: true, - }, - }, - expectError: false, - }, - { - name: "some informers not synced", - resourceInformer: &mockInformerManager{ - allResources: []schema.GroupVersionResource{ - {Group: "", Version: "v1", Resource: "configmaps"}, - {Group: "", Version: "v1", Resource: "secrets"}, - {Group: "", Version: "v1", Resource: "namespaces"}, - }, - syncedMap: map[schema.GroupVersionResource]bool{ - {Group: "", Version: "v1", Resource: "configmaps"}: true, - {Group: "", Version: "v1", Resource: "secrets"}: false, - {Group: "", Version: "v1", Resource: "namespaces"}: true, - }, - }, - expectError: true, - errorContains: "informers not synced yet", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - checker := ReadinessChecker(tt.resourceInformer) - err := checker(nil) - - if tt.expectError { - assert.Error(t, err) - if tt.errorContains != "" { - assert.Contains(t, err.Error(), tt.errorContains) - } - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestReadinessChecker_PartialSync(t *testing.T) { - // Test the case where we have multiple resources but only some are synced - mockManager := &mockInformerManager{ - allResources: []schema.GroupVersionResource{ - {Group: "", Version: "v1", Resource: "configmaps"}, - {Group: "", Version: "v1", Resource: "secrets"}, - {Group: "apps", Version: "v1", Resource: "deployments"}, - {Group: "", Version: "v1", Resource: "namespaces"}, - }, - syncedMap: map[schema.GroupVersionResource]bool{ - {Group: "", Version: "v1", Resource: "configmaps"}: false, - {Group: "", Version: "v1", Resource: "secrets"}: false, - {Group: "apps", Version: "v1", Resource: "deployments"}: false, - {Group: "", Version: "v1", Resource: "namespaces"}: false, - }, - } - - checker := ReadinessChecker(mockManager) - err := checker(nil) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "informers not synced yet") - // Should report 4 unsynced - assert.Contains(t, err.Error(), "4/4") -} - -func TestReadinessChecker_AllSyncedMultipleResources(t *testing.T) { - // Test with many resources all synced - mockManager := &mockInformerManager{ - allResources: []schema.GroupVersionResource{ - {Group: "", Version: "v1", Resource: "configmaps"}, - {Group: "", Version: "v1", Resource: "secrets"}, - {Group: "", Version: "v1", Resource: "services"}, - {Group: "apps", Version: "v1", Resource: "deployments"}, - {Group: "apps", Version: "v1", Resource: "statefulsets"}, - {Group: "", Version: "v1", Resource: "namespaces"}, - {Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "clusterroles"}, - }, - syncedMap: map[schema.GroupVersionResource]bool{ - {Group: "", Version: "v1", Resource: "configmaps"}: true, - {Group: "", Version: "v1", Resource: "secrets"}: true, - {Group: "", Version: "v1", Resource: "services"}: true, - {Group: "apps", Version: "v1", Resource: "deployments"}: true, - {Group: "apps", Version: "v1", Resource: "statefulsets"}: true, - {Group: "", Version: "v1", Resource: "namespaces"}: true, - {Group: "rbac.authorization.k8s.io", Version: "v1", Resource: "clusterroles"}: true, - }, - } - - checker := ReadinessChecker(mockManager) - err := checker(nil) - - assert.NoError(t, err, "Should be ready when all informers are synced") -}