diff --git a/config/crd/bases/atlas.mongodb.com_atlasdatabaseusers.yaml b/config/crd/bases/atlas.mongodb.com_atlasdatabaseusers.yaml index 5674baff37..52b75b72d6 100644 --- a/config/crd/bases/atlas.mongodb.com_atlasdatabaseusers.yaml +++ b/config/crd/bases/atlas.mongodb.com_atlasdatabaseusers.yaml @@ -66,6 +66,18 @@ spec: - USER - ROLE type: string + connectionSecret: + description: LocalObjectReference is a reference to an object in the + same namespace as the referent + properties: + name: + description: |- + Name of the resource being referred to + More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + type: string + required: + - name + type: object databaseName: default: admin description: DatabaseName is a Database against which Atlas authenticates diff --git a/internal/translation/dbuser/conversion.go b/internal/translation/dbuser/conversion.go index b79608e701..4df4a1591e 100644 --- a/internal/translation/dbuser/conversion.go +++ b/internal/translation/dbuser/conversion.go @@ -68,6 +68,7 @@ func (u *User) clearedSpecClone() *akov2.AtlasDatabaseUserSpec { clone.Project.Name = "" clone.Project.Namespace = "" clone.PasswordSecret = nil + clone.ConnectionSecret = nil return &clone } diff --git a/internal/translation/dbuser/conversion_test.go b/internal/translation/dbuser/conversion_test.go index 9012a60796..40eee7ad70 100644 --- a/internal/translation/dbuser/conversion_test.go +++ b/internal/translation/dbuser/conversion_test.go @@ -9,6 +9,7 @@ import ( "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/timeutil" "github.com/mongodb/mongodb-atlas-kubernetes/v2/internal/translation/dbuser" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api" akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1" "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1/common" ) @@ -393,6 +394,7 @@ func TestDiffSpecs(t *testing.T) { spec.Project.Name = "some-project" spec.Project.Namespace = "some-namespace" spec.PasswordSecret = &common.ResourceRef{Name: "some-secret-ref"} + spec.ConnectionSecret = &api.LocalObjectReference{Name: "some-local-secret-ref"} return spec }(), }, @@ -408,6 +410,7 @@ func TestDiffSpecs(t *testing.T) { spec.Project.Name = "another-project" spec.Project.Namespace = "another-namespace" spec.PasswordSecret = &common.ResourceRef{Name: "another-secret-ref"} + spec.ConnectionSecret = &api.LocalObjectReference{Name: "another-local-secret-ref"} return spec }(), }, diff --git a/pkg/api/credentials.go b/pkg/api/credentials.go new file mode 100644 index 0000000000..763d98c62a --- /dev/null +++ b/pkg/api/credentials.go @@ -0,0 +1,28 @@ +package api + +type LocalRef string + +// +k8s:deepcopy-gen=false + +// CredentialsProvider gives access to custom local credentials +type CredentialsProvider interface { + Credentials() *LocalObjectReference +} + +// +k8s:deepcopy-gen=false + +// ResourceWithCredentials is to be implemented by all CRDs using custom local credentials +type ResourceWithCredentials interface { + CredentialsProvider + GetName() string + GetNamespace() string +} + +// LocalCredentialHolder is to be embedded by Specs of CRDs using custom local credentials +type LocalCredentialHolder struct { + ConnectionSecret *LocalObjectReference `json:"connectionSecret,omitempty"` +} + +func (ch *LocalCredentialHolder) Credentials() *LocalObjectReference { + return ch.ConnectionSecret +} diff --git a/pkg/api/localref.go b/pkg/api/localref.go new file mode 100644 index 0000000000..69af4ffd19 --- /dev/null +++ b/pkg/api/localref.go @@ -0,0 +1,8 @@ +package api + +// LocalObjectReference is a reference to an object in the same namespace as the referent +type LocalObjectReference struct { + // Name of the resource being referred to + // More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names + Name string `json:"name"` +} diff --git a/pkg/api/v1/atlasdatabaseuser_types.go b/pkg/api/v1/atlasdatabaseuser_types.go index ac763bfe31..bbbdcdf58e 100644 --- a/pkg/api/v1/atlasdatabaseuser_types.go +++ b/pkg/api/v1/atlasdatabaseuser_types.go @@ -52,6 +52,8 @@ const ( // AtlasDatabaseUserSpec defines the desired state of Database User in Atlas type AtlasDatabaseUserSpec struct { + api.LocalCredentialHolder `json:",inline"` + // Project is a reference to AtlasProject resource the user belongs to Project common.ResourceRefNamespaced `json:"projectRef"` @@ -296,6 +298,10 @@ func (p *AtlasDatabaseUser) WithDeleteAfterDate(date string) *AtlasDatabaseUser return p } +func (p AtlasDatabaseUser) Credentials() *api.LocalObjectReference { + return p.Spec.Credentials() +} + func DefaultDBUser(namespace, username, projectName string) *AtlasDatabaseUser { return NewDBUser(namespace, username, username, projectName).WithRole("clusterMonitor", "admin", "") } diff --git a/pkg/api/v1/zz_generated.deepcopy.go b/pkg/api/v1/zz_generated.deepcopy.go index 0b916fe3f6..fdd4c48b46 100644 --- a/pkg/api/v1/zz_generated.deepcopy.go +++ b/pkg/api/v1/zz_generated.deepcopy.go @@ -677,6 +677,7 @@ func (in *AtlasDatabaseUserList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *AtlasDatabaseUserSpec) DeepCopyInto(out *AtlasDatabaseUserSpec) { *out = *in + in.LocalCredentialHolder.DeepCopyInto(&out.LocalCredentialHolder) out.Project = in.Project if in.Labels != nil { in, out := &in.Labels, &out.Labels diff --git a/pkg/api/zz_generated.deepcopy.go b/pkg/api/zz_generated.deepcopy.go index 6311a7c2ef..3e2216c836 100644 --- a/pkg/api/zz_generated.deepcopy.go +++ b/pkg/api/zz_generated.deepcopy.go @@ -49,3 +49,38 @@ func (in *Condition) DeepCopy() *Condition { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalCredentialHolder) DeepCopyInto(out *LocalCredentialHolder) { + *out = *in + if in.ConnectionSecret != nil { + in, out := &in.ConnectionSecret, &out.ConnectionSecret + *out = new(LocalObjectReference) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalCredentialHolder. +func (in *LocalCredentialHolder) DeepCopy() *LocalCredentialHolder { + if in == nil { + return nil + } + out := new(LocalCredentialHolder) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *LocalObjectReference) DeepCopyInto(out *LocalObjectReference) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LocalObjectReference. +func (in *LocalObjectReference) DeepCopy() *LocalObjectReference { + if in == nil { + return nil + } + out := new(LocalObjectReference) + in.DeepCopyInto(out) + return out +} diff --git a/pkg/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go b/pkg/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go index 23c15f8670..8c431cbfba 100644 --- a/pkg/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go +++ b/pkg/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go @@ -214,6 +214,17 @@ func (r *AtlasDatabaseUserReconciler) SetupWithManager(mgr ctrl.Manager, skipNam handler.EnqueueRequestsFromMapFunc(r.findAtlasDatabaseUserForSecret), builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), ). + Watches( + &corev1.Secret{}, + handler.EnqueueRequestsFromMapFunc(indexer.CredentialsIndexMapperFunc( + indexer.AtlasDatabaseUserCredentialsIndex, + &akov2.AtlasDatabaseUserList{}, + indexer.DatabaseUserRequests, + r.Client, + r.Log, + )), + builder.WithPredicates(predicate.ResourceVersionChangedPredicate{}), + ). WithOptions(controller.TypedOptions[reconcile.Request]{SkipNameValidation: pointer.MakePtr(skipNameValidation)}). Complete(r) } diff --git a/pkg/controller/atlasdatabaseuser/databaseuser.go b/pkg/controller/atlasdatabaseuser/databaseuser.go index 7c46436931..0da3ced362 100644 --- a/pkg/controller/atlasdatabaseuser/databaseuser.go +++ b/pkg/controller/atlasdatabaseuser/databaseuser.go @@ -48,7 +48,11 @@ func (r *AtlasDatabaseUserReconciler) handleDatabaseUser(ctx *workflow.Context, return r.terminate(ctx, atlasDatabaseUser, api.DatabaseUserReadyType, workflow.Internal, true, err) } - sdkClient, _, err := r.AtlasProvider.SdkClient(ctx.Context, atlasProject.ConnectionSecretObjectKey(), r.Log) + credentialsSecret, err := customresource.ComputeSecret(atlasProject, atlasDatabaseUser) + if err != nil { + return r.terminate(ctx, atlasDatabaseUser, api.DatabaseUserReadyType, workflow.AtlasAPIAccessNotConfigured, true, err) + } + sdkClient, _, err := r.AtlasProvider.SdkClient(ctx.Context, credentialsSecret, r.Log) if err != nil { return r.terminate(ctx, atlasDatabaseUser, api.DatabaseUserReadyType, workflow.AtlasAPIAccessNotConfigured, true, err) } diff --git a/pkg/controller/customresource/customresource.go b/pkg/controller/customresource/customresource.go index cd6f9a5842..c3253464de 100644 --- a/pkg/controller/customresource/customresource.go +++ b/pkg/controller/customresource/customresource.go @@ -4,13 +4,12 @@ import ( "context" "fmt" + "github.com/Masterminds/semver" "go.uber.org/zap" apierrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/Masterminds/semver" - "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api" akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1" "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/controller/workflow" @@ -139,3 +138,20 @@ func SetAnnotation(resource api.AtlasCustomResource, key, value string) { annot[key] = value resource.SetAnnotations(annot) } + +func ComputeSecret(project *akov2.AtlasProject, resource api.ResourceWithCredentials) (*client.ObjectKey, error) { + if resource == nil { + return nil, fmt.Errorf("resource cannot be nil") + } + creds := resource.Credentials() + if creds != nil && creds.Name != "" { + return &client.ObjectKey{ + Namespace: resource.GetNamespace(), + Name: creds.Name, + }, nil + } + if project == nil { + return nil, fmt.Errorf("project cannot be nil") + } + return project.ConnectionSecretObjectKey(), nil +} diff --git a/pkg/controller/customresource/customresource_test.go b/pkg/controller/customresource/customresource_test.go index 2d3140ec7f..c5ec3ea8e0 100644 --- a/pkg/controller/customresource/customresource_test.go +++ b/pkg/controller/customresource/customresource_test.go @@ -6,11 +6,14 @@ import ( "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api" akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1/common" "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1/status" "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/version" "github.com/stretchr/testify/assert" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" ) func TestResourceShouldBeLeftInAtlas(t *testing.T) { @@ -227,3 +230,102 @@ func TestResourceVersionIsValid(t *testing.T) { }) } } + +func TestComputeSecret(t *testing.T) { + for _, tt := range []struct { + name string + project *akov2.AtlasProject + resource api.ResourceWithCredentials + wantRef *types.NamespacedName + wantErrorMsg string + }{ + { + name: "nil inputs fails with resource cannot be nil", + wantErrorMsg: "resource cannot be nil", + }, + + { + name: "nil project ignored if resource is set", + resource: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Namespace: "local"}, + Spec: akov2.AtlasDatabaseUserSpec{ + LocalCredentialHolder: api.LocalCredentialHolder{ + ConnectionSecret: &api.LocalObjectReference{Name: "local-secret"}, + }, + }, + }, + wantRef: &client.ObjectKey{ + Name: "local-secret", + Namespace: "local", + }, + }, + + { + name: "nil resource and empty project fails", + project: &akov2.AtlasProject{}, + wantErrorMsg: "resource cannot be nil", + }, + + { + name: "when both are set empty it renders nil", + project: &akov2.AtlasProject{}, + resource: &akov2.AtlasDatabaseUser{}, + }, + + { + name: "empty resource and proper project get creds from project", + project: &akov2.AtlasProject{ + Spec: akov2.AtlasProjectSpec{ + Name: "", + RegionUsageRestrictions: "", + ConnectionSecret: &common.ResourceRefNamespaced{ + Name: "project-secret", + Namespace: "some-namespace", + }, + }, + }, + resource: &akov2.AtlasDatabaseUser{}, + wantRef: &client.ObjectKey{ + Name: "project-secret", + Namespace: "some-namespace", + }, + }, + + { + name: "when both are properly set the resource wins", + project: &akov2.AtlasProject{ + Spec: akov2.AtlasProjectSpec{ + Name: "", + RegionUsageRestrictions: "", + ConnectionSecret: &common.ResourceRefNamespaced{ + Name: "project-secret", + Namespace: "some-namespace", + }, + }, + }, + resource: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{Namespace: "local"}, + Spec: akov2.AtlasDatabaseUserSpec{ + LocalCredentialHolder: api.LocalCredentialHolder{ + ConnectionSecret: &api.LocalObjectReference{Name: "local-secret"}, + }, + }, + }, + wantRef: &client.ObjectKey{ + Name: "local-secret", + Namespace: "local", + }, + }, + } { + t.Run(tt.name, func(t *testing.T) { + result, err := ComputeSecret(tt.project, tt.resource) + if tt.wantErrorMsg != "" { + assert.Nil(t, result, nil) + assert.ErrorContains(t, err, tt.wantErrorMsg) + } else { + assert.Equal(t, result, tt.wantRef) + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/indexer/atlasdatabaseusercredentials.go b/pkg/indexer/atlasdatabaseusercredentials.go new file mode 100644 index 0000000000..1507cac909 --- /dev/null +++ b/pkg/indexer/atlasdatabaseusercredentials.go @@ -0,0 +1,24 @@ +package indexer + +import ( + "go.uber.org/zap" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1" +) + +const ( + AtlasDatabaseUserCredentialsIndex = "atlasdatabaseuser.credentials" +) + +func NewAtlasDatabaseUserByCredentialIndexer(logger *zap.Logger) *LocalCredentialIndexer { + return NewLocalCredentialsIndexer(AtlasDatabaseUserCredentialsIndex, &akov2.AtlasDatabaseUser{}, logger) +} + +func DatabaseUserRequests(list *akov2.AtlasDatabaseUserList) []reconcile.Request { + requests := make([]reconcile.Request, 0, len(list.Items)) + for _, item := range list.Items { + requests = append(requests, toRequest(&item)) + } + return requests +} diff --git a/pkg/indexer/indexer.go b/pkg/indexer/indexer.go index 482bd3b45a..dd0d5612f8 100644 --- a/pkg/indexer/indexer.go +++ b/pkg/indexer/indexer.go @@ -32,6 +32,7 @@ func RegisterAll(ctx context.Context, mgr manager.Manager, logger *zap.Logger) e NewAtlasProjectByTeamIndexer(logger), NewAtlasFederatedAuthBySecretsIndexer(logger), NewAtlasDatabaseUserBySecretsIndexer(logger), + NewAtlasDatabaseUserByCredentialIndexer(logger), ) } diff --git a/pkg/indexer/localcredentials.go b/pkg/indexer/localcredentials.go new file mode 100644 index 0000000000..f91bc1d5e0 --- /dev/null +++ b/pkg/indexer/localcredentials.go @@ -0,0 +1,92 @@ +package indexer + +import ( + "context" + "reflect" + + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api" +) + +type LocalCredentialIndexer struct { + obj client.Object + name string + logger *zap.SugaredLogger +} + +func NewLocalCredentialsIndexer(name string, obj client.Object, logger *zap.Logger) *LocalCredentialIndexer { + return &LocalCredentialIndexer{ + obj: obj, + name: name, + logger: logger.Named(name).Sugar(), + } +} + +func (lc *LocalCredentialIndexer) Object() client.Object { + return lc.obj +} + +func (lc *LocalCredentialIndexer) Name() string { + return lc.name +} + +func (lc *LocalCredentialIndexer) Keys(object client.Object) []string { + if reflect.TypeOf(object) != reflect.TypeOf(lc.obj) { + lc.logger.Errorf("expected %T but got %T", lc.obj, object) + return nil + } + + credentialProvider, ok := (object).(api.CredentialsProvider) + if !ok { + lc.logger.Errorf("expected %T to implement api.CredentialProvider", object) + return nil + } + + if localRef := credentialProvider.Credentials(); localRef != nil && localRef.Name != "" { + return []string{types.NamespacedName{Namespace: object.GetNamespace(), Name: localRef.Name}.String()} + } + return []string{} +} + +type requestsFunc[L client.ObjectList] func(L) []reconcile.Request + +func CredentialsIndexMapperFunc[L client.ObjectList](indexerName string, list L, reqsFn requestsFunc[L], kubeClient client.Client, logger *zap.SugaredLogger) handler.MapFunc { + return func(ctx context.Context, obj client.Object) []reconcile.Request { + secret, ok := obj.(*corev1.Secret) + if !ok { + logger.Warnf("watching Secret but got %T", obj) + return nil + } + + listOpts := &client.ListOptions{ + FieldSelector: fields.OneTermEqualSelector( + indexerName, + client.ObjectKeyFromObject(secret).String(), + ), + } + err := kubeClient.List(ctx, list, listOpts) + if err != nil { + logger.Errorf("failed to list from indexer %s: %v", indexerName, err) + return nil + } + return reqsFn(list) + } +} + +// ToRequest is a helper to turns CRD objects into reconcile requests. +// Most Reconciliable implementations may leverage it. +func toRequest(obj client.Object) reconcile.Request { + return reconcile.Request{ + NamespacedName: types.NamespacedName{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + }, + } +} diff --git a/pkg/indexer/localcredentials_test.go b/pkg/indexer/localcredentials_test.go new file mode 100644 index 0000000000..7f1e95fd87 --- /dev/null +++ b/pkg/indexer/localcredentials_test.go @@ -0,0 +1,172 @@ +package indexer + +import ( + "context" + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + "go.uber.org/zap/zaptest" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/handler" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api" + akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1" +) + +func TestAtlasDatabaseUserLocalCredentialsIndexer(t *testing.T) { + for _, tc := range []struct { + name string + object client.Object + wantKeys []string + }{ + { + name: "should return nil on wrong type", + object: &akov2.AtlasBackupPolicy{}, + wantKeys: nil, + }, + { + name: "should return no keys when there are no references", + object: &akov2.AtlasDatabaseUser{}, + wantKeys: []string{}, + }, + { + name: "should return no keys when there is an empty reference", + object: &akov2.AtlasDatabaseUser{ + Spec: akov2.AtlasDatabaseUserSpec{ + LocalCredentialHolder: api.LocalCredentialHolder{ + ConnectionSecret: &api.LocalObjectReference{}, + }, + }, + }, + wantKeys: []string{}, + }, + { + name: "should return keys when there is a reference", + object: &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "user", + Namespace: "ns", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + LocalCredentialHolder: api.LocalCredentialHolder{ + ConnectionSecret: &api.LocalObjectReference{Name: "secret-ref"}, + }, + }, + }, + wantKeys: []string{"ns/secret-ref"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + indexer := NewLocalCredentialsIndexer( + AtlasDatabaseUserCredentialsIndex, + &akov2.AtlasDatabaseUser{}, + zaptest.NewLogger(t), + ) + keys := indexer.Keys(tc.object) + sort.Strings(keys) + assert.Equal(t, tc.wantKeys, keys) + assert.Equal(t, AtlasDatabaseUserCredentialsIndex, indexer.Name()) + assert.Equal(t, &akov2.AtlasDatabaseUser{}, indexer.Object()) + }) + } +} + +func TestCredentialsIndexMapperFunc(t *testing.T) { + for _, tc := range []struct { + name string + mapperFn func(kubeClient client.Client, logger *zap.SugaredLogger) handler.MapFunc + objects []client.Object + input client.Object + want []reconcile.Request + }{ + { + name: "nil input & list renders nil", + mapperFn: dbUserMapperFunc, + }, + { + name: "nil list renders empty list", + mapperFn: dbUserMapperFunc, + input: &corev1.Secret{}, + want: []reconcile.Request{}, + }, + { + name: "empty input with proper empty list type renders empty list", + mapperFn: dbUserMapperFunc, + input: &corev1.Secret{}, + want: []reconcile.Request{}, + }, + { + name: "matching input credentials renders matching user", + mapperFn: dbUserMapperFunc, + input: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret-ref", + Namespace: "ns", + }, + }, + objects: []client.Object{ + &akov2.AtlasDatabaseUser{ + ObjectMeta: metav1.ObjectMeta{ + Name: "matching-user", + Namespace: "ns", + }, + Spec: akov2.AtlasDatabaseUserSpec{ + LocalCredentialHolder: api.LocalCredentialHolder{ + ConnectionSecret: &api.LocalObjectReference{ + Name: "secret-ref", + }, + }, + }, + }, + }, + want: []reconcile.Request{ + {NamespacedName: types.NamespacedName{ + Name: "matching-user", + Namespace: "ns", + }}, + }, + }, + } { + scheme := runtime.NewScheme() + assert.NoError(t, corev1.AddToScheme(scheme)) + assert.NoError(t, akov2.AddToScheme(scheme)) + indexer := NewLocalCredentialsIndexer( + AtlasDatabaseUserCredentialsIndex, + &akov2.AtlasDatabaseUser{}, + zaptest.NewLogger(t), + ) + t.Run(tc.name, func(t *testing.T) { + fakeClient := fake.NewClientBuilder(). + WithScheme(scheme). + WithObjects(tc.objects...). + WithIndex( + &akov2.AtlasDatabaseUser{}, + AtlasDatabaseUserCredentialsIndex, + func(obj client.Object) []string { + return indexer.Keys(obj) + }). + Build() + fn := tc.mapperFn(fakeClient, zaptest.NewLogger(t).Sugar()) + result := fn(context.Background(), tc.input) + assert.Equal(t, tc.want, result) + }) + } +} + +func dbUserMapperFunc(kubeClient client.Client, logger *zap.SugaredLogger) handler.MapFunc { + return CredentialsIndexMapperFunc( + AtlasDatabaseUserCredentialsIndex, + &akov2.AtlasDatabaseUserList{}, + DatabaseUserRequests, + kubeClient, + logger, + ) +} diff --git a/pkg/operator/builder.go b/pkg/operator/builder.go index e74d2a1375..6cdf9f18bc 100644 --- a/pkg/operator/builder.go +++ b/pkg/operator/builder.go @@ -262,14 +262,14 @@ func (b *Builder) Build(ctx context.Context) (manager.Manager, error) { return nil, fmt.Errorf("unable to create controller AtlasFederatedAuth: %w", err) } - streamsInstanceReconiler := atlasstream.NewAtlasStreamsInstanceReconciler( + streamsInstanceReconciler := atlasstream.NewAtlasStreamsInstanceReconciler( mgr, b.predicates, b.atlasProvider, b.deletionProtection, b.logger, ) - if err = streamsInstanceReconiler.SetupWithManager(mgr, b.skipNameValidation); err != nil { + if err = streamsInstanceReconciler.SetupWithManager(mgr, b.skipNameValidation); err != nil { return nil, fmt.Errorf("unable to create controller AtlasStreamsInstance: %w", err) } diff --git a/test/e2e/db_users_test.go b/test/e2e/db_users_test.go index a1af3b056d..12819029ce 100644 --- a/test/e2e/db_users_test.go +++ b/test/e2e/db_users_test.go @@ -2,6 +2,7 @@ package e2e import ( "context" + "fmt" "strings" "time" @@ -23,10 +24,15 @@ import ( "github.com/mongodb/mongodb-atlas-kubernetes/v2/test/helper/e2e/data" "github.com/mongodb/mongodb-atlas-kubernetes/v2/test/helper/e2e/k8s" "github.com/mongodb/mongodb-atlas-kubernetes/v2/test/helper/e2e/model" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/test/helper/e2e/utils" "github.com/mongodb/mongodb-atlas-kubernetes/v2/test/helper/resources" ) -var _ = Describe("Operator watch all namespace should create connection secrets for database users in any namespace", Label("users"), func() { +const ( + localSecretName = "local-secret" +) + +var _ = Describe("Operator watch all namespace should create connection secrets for database users in any namespace", Label("users", "users-ns"), func() { var testData *model.TestDataProvider secondNamespace := "second-namespace" @@ -76,14 +82,17 @@ var _ = Describe("Operator watch all namespace should create connection secrets data.WithSecretRef("dbuser-secret-u2"), data.WithReadWriteRole(), data.WithNamespace(secondNamespace), + // user 2 access Atlas a local secret + data.WithCredentials(localSecretName), ), ) testData.Resources.Namespace = config.DefaultOperatorNS }) By("Running operator watching global namespace", func() { - k8s.CreateNamespace(testData.Context, testData.K8SClient, config.DefaultOperatorNS) + Expect(k8s.CreateNamespace(testData.Context, testData.K8SClient, config.DefaultOperatorNS)).To(Succeed()) k8s.CreateDefaultSecret(testData.Context, testData.K8SClient, config.DefaultOperatorGlobalKey, config.DefaultOperatorNS) - k8s.CreateNamespace(testData.Context, testData.K8SClient, secondNamespace) + Expect(k8s.CreateNamespace(testData.Context, testData.K8SClient, secondNamespace)).To(Succeed()) + k8s.CreateDefaultSecret(testData.Context, testData.K8SClient, localSecretName, secondNamespace) mgr, err := k8s.BuildManager(&k8s.Config{ GlobalAPISecret: client.ObjectKey{ @@ -155,6 +164,96 @@ var _ = Describe("Operator watch all namespace should create connection secrets }) }) +var _ = Describe("Operator fails if local credentials is mentioned but unavailable", Label("users", "users-no-creds"), func() { + var testData *model.TestDataProvider + namespace := utils.RandomName("namespace") + + _ = AfterEach(func() { + actions.DeleteTestDataUsers(testData) + actions.DeleteTestDataProject(testData) + actions.AfterEachFinalCleanup([]model.TestDataProvider{*testData}) + Expect(k8s.DeleteNamespace(testData.Context, testData.K8SClient, namespace)).Should(Succeed()) + }) + + It("Operator run on global namespace to test bogus local credential", func() { + By("Setting up test data", func() { + project := data.DefaultProject() + project.Namespace = namespace + + testData = model.DataProvider( + "dbusers-operator-global", + model.NewEmptyAtlasKeyType().UseDefaultFullAccess(), + 30008, + []func(*model.TestDataProvider){}, + ).WithProject(project). + WithUsers( + data.BasicUser( + "reader1", + "reader1", + data.WithSecretRef("dbuser-secret-u1"), + data.WithReadWriteRole(), + data.WithNamespace(namespace), + data.WithLabels([]common.LabelSpec{ + {Key: "type", Value: "e2e-test"}, + {Key: "context", Value: "cloud"}, + }), + // user 2 access Atlas a local secret + data.WithCredentials(localSecretName), + ), + ) + testData.Resources.Namespace = namespace + }) + By("Running operator watching global namespace missing credentials", func() { + Expect(k8s.CreateNamespace(testData.Context, testData.K8SClient, namespace)).NotTo(HaveOccurred()) + k8s.CreateDefaultSecret(testData.Context, testData.K8SClient, config.DefaultOperatorGlobalKey, namespace) + + mgr, err := k8s.BuildManager(&k8s.Config{ + GlobalAPISecret: client.ObjectKey{ + Namespace: namespace, + Name: config.DefaultOperatorGlobalKey, + }, + WatchedNamespaces: map[string]bool{ + namespace: true, + }, + FeatureFlags: featureflags.NewFeatureFlags(func() []string { return []string{} }), + }) + Expect(err).NotTo(HaveOccurred()) + + go func(ctx context.Context) context.Context { + err := mgr.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + return ctx + }(testData.Context) + }) + By("Creating project", func() { + deploy.CreateProject(testData) + }) + By("Creating user with missing credentials", func() { + user := testData.Users[0] + if user.Spec.PasswordSecret != nil { + secret := utils.UserSecretPassword() + Expect(k8s.CreateUserSecret(testData.Context, testData.K8SClient, secret, + user.Spec.PasswordSecret.Name, user.Namespace)).Should(Succeed(), + "Create user secret failed") + } + err := testData.K8SClient.Create(testData.Context, user) + Expect(err).ShouldNot(HaveOccurred(), fmt.Sprintf("User was not created: %v", user)) + Eventually(func(g Gomega) { + g.Expect(testData.K8SClient.Get(testData.Context, types.NamespacedName{Name: user.GetName(), Namespace: user.GetNamespace()}, user)) + g.Expect(user.Status.Conditions).ShouldNot(BeEmpty()) + for _, condition := range user.Status.Conditions { + if condition.Type == api.ReadyType { + g.Expect(condition.Status).ShouldNot(Equal(corev1.ConditionTrue), "User should NOT be ready") + } + if condition.Type == api.DatabaseUserReadyType { + g.Expect(condition.Message).Should(ContainSubstring(`Secret "local-secret" not found`)) + } + } + }).WithTimeout(2*time.Minute).WithPolling(20*time.Second).Should(Succeed(), "User did not fail as expected") + }) + }) +}) + func countConnectionSecrets(k8sClient client.Client, projectName string) int { secretList := corev1.SecretList{} Expect(k8sClient.List(context.Background(), &secretList)).To(Succeed()) diff --git a/test/helper/e2e/data/user.go b/test/helper/e2e/data/user.go index 6da2116950..27c9d848bb 100644 --- a/test/helper/e2e/data/user.go +++ b/test/helper/e2e/data/user.go @@ -3,6 +3,7 @@ package data import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api" akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1" "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1/common" ) @@ -104,3 +105,9 @@ func WithLabels(labels []common.LabelSpec) func(user *akov2.AtlasDatabaseUser) { user.Spec.Labels = labels } } + +func WithCredentials(secretName string) func(user *akov2.AtlasDatabaseUser) { + return func(user *akov2.AtlasDatabaseUser) { + user.Spec.ConnectionSecret = &api.LocalObjectReference{Name: secretName} + } +}