From 82dbcdf1d5785780162ded8eac66acedeec5e043 Mon Sep 17 00:00:00 2001 From: Lucas Wittchen Date: Thu, 4 Jun 2026 09:44:50 +0200 Subject: [PATCH] feat: trigger reconciliation when k8s secret password is rotated Signed-off-by: Lucas Wittchen --- .../cluster/postgresql/role/reconciler.go | 33 ++++++++- .../postgresql/role/reconciler_test.go | 64 +++++++++++++++++ .../namespaced/postgresql/role/reconciler.go | 37 +++++++++- .../postgresql/role/reconciler_test.go | 68 +++++++++++++++++++ 4 files changed, 196 insertions(+), 6 deletions(-) diff --git a/pkg/controller/cluster/postgresql/role/reconciler.go b/pkg/controller/cluster/postgresql/role/reconciler.go index ae88ba5d..507fe1eb 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler.go +++ b/pkg/controller/cluster/postgresql/role/reconciler.go @@ -32,6 +32,8 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" xpcontroller "github.com/crossplane/crossplane-runtime/v2/pkg/controller" @@ -65,6 +67,20 @@ const ( maxConcurrency = 5 ) +func clusterSecretMapper(kube client.Client, s *corev1.Secret) []reconcile.Request { + var rl v1alpha1.RoleList + if err := kube.List(context.Background(), &rl); err != nil { + return nil + } + reqs := make([]reconcile.Request, 0) + for _, role := range rl.Items { + if role.Spec.ForProvider.PasswordSecretRef != nil && role.Spec.ForProvider.PasswordSecretRef.Name == s.Name && role.Spec.ForProvider.PasswordSecretRef.Namespace == s.Namespace { + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: role.Namespace, Name: role.Name}}) + } + } + return reqs +} + // Setup adds a controller that reconciles Role managed resources. func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { name := managed.ControllerName(v1alpha1.RoleGroupKind) @@ -89,13 +105,24 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { )); err != nil { return err } - return ctrl.NewControllerManagedBy(mgr). + builder := ctrl.NewControllerManagedBy(mgr). Named(name). For(&v1alpha1.Role{}). WithOptions(controller.Options{ MaxConcurrentReconciles: maxConcurrency, - }). - Complete(r) + }) + + // Watch Secrets and enqueue cluster Roles that reference the secret as + // their PasswordSecretRef so password Secret changes trigger reconciliation. + builder = builder.Watches(&corev1.Secret{}, handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + s, ok := obj.(*corev1.Secret) + if !ok { + return nil + } + return clusterSecretMapper(mgr.GetClient(), s) + })) + + return builder.Complete(r) } type connector struct { diff --git a/pkg/controller/cluster/postgresql/role/reconciler_test.go b/pkg/controller/cluster/postgresql/role/reconciler_test.go index 1f7a511d..9d10ba03 100644 --- a/pkg/controller/cluster/postgresql/role/reconciler_test.go +++ b/pkg/controller/cluster/postgresql/role/reconciler_test.go @@ -80,6 +80,70 @@ func (m mockDB) GetServerVersion(ctx context.Context) (int, error) { return m.MockGetServerVersion(ctx) } +func Test_clusterSecretMapper(t *testing.T) { + secret := &corev1.Secret{} + secret.Name = "pwsecret" + secret.Namespace = "ns-a" + + mock := &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + rs := &v1alpha1.RoleList{ + Items: []v1alpha1.Role{ + {ObjectMeta: v1.ObjectMeta{Namespace: "ns-a", Name: "r1"}, Spec: v1alpha1.RoleSpec{ForProvider: v1alpha1.RoleParameters{PasswordSecretRef: &xpv1.SecretKeySelector{SecretReference: xpv1.SecretReference{Name: "pwsecret", Namespace: "ns-a"}}}}}, + {ObjectMeta: v1.ObjectMeta{Namespace: "ns-b", Name: "r2"}, Spec: v1alpha1.RoleSpec{ForProvider: v1alpha1.RoleParameters{PasswordSecretRef: &xpv1.SecretKeySelector{SecretReference: xpv1.SecretReference{Name: "other", Namespace: "ns-b"}}}}}, + }, + } + *list.(*v1alpha1.RoleList) = *rs + return nil + }, + } + + reqs := clusterSecretMapper(mock, secret) + if len(reqs) != 1 { + t.Fatalf("expected 1 request, got %d", len(reqs)) + } + if reqs[0].Name != "r1" { + t.Fatalf("unexpected reconcile name: %v", reqs[0]) + } +} + +func Test_clusterSecretMapper_SecretDataChange(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: v1.ObjectMeta{Name: "pwsecret", Namespace: "ns-a"}, + Data: map[string][]byte{"password": []byte("old")}, + } + + mock := &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + rs := &v1alpha1.RoleList{ + Items: []v1alpha1.Role{ + {ObjectMeta: v1.ObjectMeta{Namespace: "ns-a", Name: "r1"}, Spec: v1alpha1.RoleSpec{ForProvider: v1alpha1.RoleParameters{PasswordSecretRef: &xpv1.SecretKeySelector{SecretReference: xpv1.SecretReference{Name: "pwsecret", Namespace: "ns-a"}}}}}, + {ObjectMeta: v1.ObjectMeta{Namespace: "ns-b", Name: "r2"}, Spec: v1alpha1.RoleSpec{ForProvider: v1alpha1.RoleParameters{PasswordSecretRef: &xpv1.SecretKeySelector{SecretReference: xpv1.SecretReference{Name: "other", Namespace: "ns-b"}}}}}, + }, + } + *list.(*v1alpha1.RoleList) = *rs + return nil + }, + } + + reqs := clusterSecretMapper(mock, secret) + if len(reqs) != 1 { + t.Fatalf("expected 1 request, got %d", len(reqs)) + } + if reqs[0].Name != "r1" { + t.Fatalf("unexpected reconcile name: %v", reqs[0]) + } + + secret.Data["password"] = []byte("new") + reqs = clusterSecretMapper(mock, secret) + if len(reqs) != 1 { + t.Fatalf("expected 1 request after password change, got %d", len(reqs)) + } + if reqs[0].Name != "r1" { + t.Fatalf("unexpected reconcile name after password change: %v", reqs[0]) + } +} + func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.LegacyManaged) error { return nil } diff --git a/pkg/controller/namespaced/postgresql/role/reconciler.go b/pkg/controller/namespaced/postgresql/role/reconciler.go index 95430342..a680adbb 100644 --- a/pkg/controller/namespaced/postgresql/role/reconciler.go +++ b/pkg/controller/namespaced/postgresql/role/reconciler.go @@ -30,6 +30,11 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" xpcontroller "github.com/crossplane/crossplane-runtime/v2/pkg/controller" @@ -61,6 +66,20 @@ const ( maxConcurrency = 5 ) +func namespacedSecretMapper(kube client.Client, s *corev1.Secret) []reconcile.Request { + var rl namespacedv1alpha1.RoleList + if err := kube.List(context.Background(), &rl, client.InNamespace(s.Namespace)); err != nil { + return nil + } + reqs := make([]reconcile.Request, 0) + for _, role := range rl.Items { + if role.Spec.ForProvider.PasswordSecretRef != nil && role.Spec.ForProvider.PasswordSecretRef.Name == s.Name { + reqs = append(reqs, reconcile.Request{NamespacedName: types.NamespacedName{Namespace: role.Namespace, Name: role.Name}}) + } + } + return reqs +} + // Setup adds a controller that reconciles Database managed resources. func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { name := managed.ControllerName(namespacedv1alpha1.RoleGroupKind) @@ -86,13 +105,25 @@ func Setup(mgr ctrl.Manager, o xpcontroller.Options) error { )); err != nil { return err } - return ctrl.NewControllerManagedBy(mgr). + builder := ctrl.NewControllerManagedBy(mgr). Named(name). For(&namespacedv1alpha1.Role{}). WithOptions(controller.Options{ MaxConcurrentReconciles: maxConcurrency, - }). - Complete(r) + }) + + // Watch Secrets and enqueue Roles that reference the secret as their + // PasswordSecretRef so that changes to the password Secret trigger + // reconciliation of the Role that depends on it. + builder = builder.Watches(&corev1.Secret{}, handler.TypedEnqueueRequestsFromMapFunc(func(ctx context.Context, obj client.Object) []reconcile.Request { + s, ok := obj.(*corev1.Secret) + if !ok { + return nil + } + return namespacedSecretMapper(mgr.GetClient(), s) + })) + + return builder.Complete(r) } type connector struct { diff --git a/pkg/controller/namespaced/postgresql/role/reconciler_test.go b/pkg/controller/namespaced/postgresql/role/reconciler_test.go index c7f86387..0e0e9a10 100644 --- a/pkg/controller/namespaced/postgresql/role/reconciler_test.go +++ b/pkg/controller/namespaced/postgresql/role/reconciler_test.go @@ -83,6 +83,74 @@ func (m mockDB) GetServerVersion(ctx context.Context) (int, error) { return m.MockGetServerVersion(ctx) } +func Test_namespacedSecretMapper(t *testing.T) { + secret := &corev1.Secret{} + secret.Name = "pwsecret" + secret.Namespace = "myns" + + mock := &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + rs := &v1alpha1.RoleList{ + Items: []v1alpha1.Role{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "myns", Name: "r1"}, + Spec: v1alpha1.RoleSpec{ForProvider: v1alpha1.RoleParameters{PasswordSecretRef: &common.LocalSecretKeySelector{LocalSecretReference: common.LocalSecretReference{Name: "pwsecret"}}}}, + }, + }, + } + *list.(*v1alpha1.RoleList) = *rs + return nil + }, + } + + reqs := namespacedSecretMapper(mock, secret) + if len(reqs) != 1 { + t.Fatalf("expected 1 request, got %d", len(reqs)) + } + if reqs[0].Name != "r1" { + t.Fatalf("unexpected reconcile name: %v", reqs[0]) + } +} + +func Test_namespacedSecretMapper_SecretDataChange(t *testing.T) { + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "pwsecret", Namespace: "myns"}, + Data: map[string][]byte{"password": []byte("old")}, + } + + mock := &test.MockClient{ + MockList: func(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + rs := &v1alpha1.RoleList{ + Items: []v1alpha1.Role{ + { + ObjectMeta: metav1.ObjectMeta{Namespace: "myns", Name: "r1"}, + Spec: v1alpha1.RoleSpec{ForProvider: v1alpha1.RoleParameters{PasswordSecretRef: &common.LocalSecretKeySelector{LocalSecretReference: common.LocalSecretReference{Name: "pwsecret"}}}}, + }, + }, + } + *list.(*v1alpha1.RoleList) = *rs + return nil + }, + } + + reqs := namespacedSecretMapper(mock, secret) + if len(reqs) != 1 { + t.Fatalf("expected 1 request, got %d", len(reqs)) + } + if reqs[0].Name != "r1" { + t.Fatalf("unexpected reconcile name: %v", reqs[0]) + } + + secret.Data["password"] = []byte("new") + reqs = namespacedSecretMapper(mock, secret) + if len(reqs) != 1 { + t.Fatalf("expected 1 request after password change, got %d", len(reqs)) + } + if reqs[0].Name != "r1" { + t.Fatalf("unexpected reconcile name after password change: %v", reqs[0]) + } +} + func TestConnect(t *testing.T) { errBoom := errors.New("boom") nopUsage := func(ctx context.Context, mg resource.ModernManaged) error { return nil }