diff --git a/pkg/serviceprovider/apireconciler.go b/pkg/serviceprovider/apireconciler.go index 3da7185..2f7c28f 100644 --- a/pkg/serviceprovider/apireconciler.go +++ b/pkg/serviceprovider/apireconciler.go @@ -43,6 +43,8 @@ type APIReconciler[T API, C Config] struct { withWorkloadCluster bool // secretNamespace is the namespace to watch secrets in on the platform cluster. Used only if the ServiceProviderReconciler also implements SecretWatcher. secretNamespace string + // configMapNamespace is the namespace to watch ConfigMaps in on the platform cluster. Used only if the ServiceProviderReconciler also implements ConfigMapWatcher. + configMapNamespace string // emptyObj creates an empty object of the api type emptyObj func() T } @@ -123,6 +125,13 @@ func (b *APIReconcilerBuilder[T, C]) SecretNamespace(ns string) *APIReconcilerBu return b } +// ConfigMapNamespace enables ConfigMap watching in the given namespace on the platform cluster. +// Only used if the ServiceProviderReconciler also implements ConfigMapWatcher. +func (b *APIReconcilerBuilder[T, C]) ConfigMapNamespace(ns string) *APIReconcilerBuilder[T, C] { + b.apiReconciler.configMapNamespace = ns + return b +} + // ProviderConfig sets the provider config. func (b *APIReconcilerBuilder[T, C]) ProviderConfig(config C) *APIReconcilerBuilder[T, C] { b.apiReconciler.providerConfig.Store(&config) @@ -360,6 +369,22 @@ func (r *APIReconciler[T, C]) SetupWithManager(mgr ctrl.Manager, name string, pr ) } + // Optional: watch ConfigMaps on the platform cluster if the reconciler implements ConfigMapWatcher + if cw, ok := r.reconciler.(ConfigMapWatcher[C]); ok && r.configMapNamespace != "" { + controller = controller.WatchesRawSource( + source.Kind( + r.platformCluster.Cluster().GetCache(), + &corev1.ConfigMap{}, + handler.TypedEnqueueRequestsFromMapFunc(r.mapConfigMapToRequests(cw)), + controllerutil2.ToTypedPredicate[*corev1.ConfigMap]( + predicate.NewPredicateFuncs(func(obj client.Object) bool { + return obj.GetNamespace() == r.configMapNamespace + }), + ), + ), + ) + } + return controller.Named(name).Complete(r) } @@ -378,6 +403,21 @@ func (r *APIReconciler[T, C]) mapSecretToRequests(sw SecretWatcher[C]) func(ctx } } +// mapConfigMapToRequests returns a typed map function that checks whether a changed ConfigMap +// is referenced by the service provider and, if so, enqueues all ServiceProviderAPI objects. +func (r *APIReconciler[T, C]) mapConfigMapToRequests(cw ConfigMapWatcher[C]) func(ctx context.Context, configMap *corev1.ConfigMap) []reconcile.Request { + return func(ctx context.Context, configMap *corev1.ConfigMap) []reconcile.Request { + var pcVal C + if pc := r.providerConfig.Load(); pc != nil { + pcVal = *pc + } + if !cw.IsReferencedConfigMap(ctx, configMap, pcVal) { + return nil + } + return r.enqueueAllObjects(ctx) + } +} + // enqueueAllObjects lists all ServiceProviderAPI objects and returns a reconcile request for each. func (r *APIReconciler[T, C]) enqueueAllObjects(ctx context.Context) []reconcile.Request { var list unstructured.UnstructuredList diff --git a/pkg/serviceprovider/apireconciler_test.go b/pkg/serviceprovider/apireconciler_test.go index b263edf..7653819 100644 --- a/pkg/serviceprovider/apireconciler_test.go +++ b/pkg/serviceprovider/apireconciler_test.go @@ -497,6 +497,18 @@ func (m *MockSecretWatchingReconciler) IsReferencedSecret(_ context.Context, sec return m.referencedSecrets[secret.Name] } +// MockConfigMapWatchingReconciler satisfies both ServiceProviderReconciler and ConfigMapWatcher. +type MockConfigMapWatchingReconciler struct { + MockServiceProviderReconciler + referencedConfigMaps map[string]bool +} + +var _ ConfigMapWatcher[*fakeProviderConfigImpl] = &MockConfigMapWatchingReconciler{} + +func (m *MockConfigMapWatchingReconciler) IsReferencedConfigMap(_ context.Context, configMap *corev1.ConfigMap, _ *fakeProviderConfigImpl) bool { + return m.referencedConfigMaps[configMap.Name] +} + // createFakeClusterWithUnstructuredList creates a fake cluster whose client supports // listing unstructured objects by intercepting List calls and populating the result // from the given objects. @@ -544,7 +556,7 @@ func TestMapSecretToRequests(t *testing.T) { referenced: map[string]bool{secretName: true}, providerConfig: &fakeProviderConfigImpl{FakePollInterval: time.Hour}, existingObjs: []client.Object{ - &fakeApiImpl{ObjectMeta: metav1.ObjectMeta{Name: "obj-1", Namespace: testNamespaceName}}, + &fakeApiImpl{ObjectMeta: metav1.ObjectMeta{Name: "obj-1", Namespace: testNamespaceName}}, //nolint:goconst &fakeApiImpl{ObjectMeta: metav1.ObjectMeta{Name: "obj-2", Namespace: testNamespaceName}}, }, wantRequests: 2, @@ -659,3 +671,92 @@ func TestAPIReconciler_enqueueAllObjects(t *testing.T) { }) } } + +func TestMapConfigMapToRequests(t *testing.T) { + const configMapName = "my-configmap" + tests := []struct { + name string + configMap *corev1.ConfigMap + referenced map[string]bool + providerConfig *fakeProviderConfigImpl + existingObjs []client.Object + wantRequests int + }{ + { + name: "referenced ConfigMap with existing objects triggers reconciliation", + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: configMapName, Namespace: testNamespaceName}, + }, + referenced: map[string]bool{configMapName: true}, + providerConfig: &fakeProviderConfigImpl{FakePollInterval: time.Hour}, + existingObjs: []client.Object{ + &fakeApiImpl{ObjectMeta: metav1.ObjectMeta{Name: "obj-1", Namespace: testNamespaceName}}, + &fakeApiImpl{ObjectMeta: metav1.ObjectMeta{Name: "obj-2", Namespace: testNamespaceName}}, + }, + wantRequests: 2, + }, + { + name: "unreferenced ConfigMap does not trigger reconciliation", + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: "other-configmap", Namespace: testNamespaceName}, + }, + referenced: map[string]bool{configMapName: true}, + providerConfig: &fakeProviderConfigImpl{FakePollInterval: time.Hour}, + existingObjs: []client.Object{ + &fakeApiImpl{ObjectMeta: metav1.ObjectMeta{Name: "obj-1", Namespace: testNamespaceName}}, + }, + wantRequests: 0, + }, + { + name: "referenced ConfigMap with no existing objects returns empty", + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: configMapName, Namespace: testNamespaceName}, + }, + referenced: map[string]bool{configMapName: true}, + providerConfig: &fakeProviderConfigImpl{FakePollInterval: time.Hour}, + existingObjs: nil, + wantRequests: 0, + }, + { + name: "nil provider config does not panic", + configMap: &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{Name: configMapName, Namespace: testNamespaceName}, + }, + referenced: map[string]bool{configMapName: true}, + providerConfig: nil, + existingObjs: nil, + wantRequests: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + onboardingCluster := createFakeClusterWithUnstructuredList(t, "onboarding", tt.existingObjs) + + mockCW := &MockConfigMapWatchingReconciler{ + referencedConfigMaps: tt.referenced, + } + + r := &APIReconciler[*fakeApiImpl, *fakeProviderConfigImpl]{ + emptyObj: func() *fakeApiImpl { return &fakeApiImpl{} }, + onboardingCluster: onboardingCluster, + reconciler: mockCW, + } + r.providerConfig.Store(&tt.providerConfig) + + mapFn := r.mapConfigMapToRequests(mockCW) + reqs := mapFn(context.Background(), tt.configMap) + assert.Equal(t, tt.wantRequests, len(reqs)) + + if tt.wantRequests > 0 { + names := make(map[string]bool) + for _, req := range reqs { + names[req.Name] = true + } + for _, obj := range tt.existingObjs { + assert.True(t, names[obj.GetName()], "expected request for object %s", obj.GetName()) + } + } + }) + } +} diff --git a/pkg/serviceprovider/configmapwatcher.go b/pkg/serviceprovider/configmapwatcher.go new file mode 100644 index 0000000..217cdb0 --- /dev/null +++ b/pkg/serviceprovider/configmapwatcher.go @@ -0,0 +1,18 @@ +package serviceprovider + +import ( + "context" + + corev1 "k8s.io/api/core/v1" +) + +// ConfigMapWatcher can optionally be implemented by a Reconciler to trigger reconciliation +// of all API objects when a referenced ConfigMap in the provider namespace changes. +// The watch is set up on the platform cluster and filtered to the namespace configured via ConfigMapNamespace. +type ConfigMapWatcher[PC Config] interface { + // IsReferencedConfigMap returns true if the given ConfigMap should trigger + // reconciliation. pc is the current provider config — it will be the + // zero value (nil for pointer types) if not yet loaded; implementations + // must guard against this. + IsReferencedConfigMap(ctx context.Context, configMap *corev1.ConfigMap, pc PC) bool +} diff --git a/pkg/serviceprovider/secretwatcher.go b/pkg/serviceprovider/secretwatcher.go index bf7bef8..925ef90 100644 --- a/pkg/serviceprovider/secretwatcher.go +++ b/pkg/serviceprovider/secretwatcher.go @@ -8,7 +8,7 @@ import ( // SecretWatcher can optionally be implemented by a Reconciler to trigger reconciliation // of all API objects when a referenced secret in the provider namespace changes. -// The watch is set up on the platform cluster and filtered to the namespace configured via WithSecretNamespace. +// The watch is set up on the platform cluster and filtered to the namespace configured via SecretNamespace. type SecretWatcher[PC Config] interface { // IsReferencedSecret returns true if the given secret should trigger // reconciliation. pc is the current provider config — it will be the