Skip to content

Commit 307fdf6

Browse files
author
Anton
authored
CLOUDP-84905: support user expiration (#178)
1 parent 1389eb8 commit 307fdf6

File tree

10 files changed

+322
-83
lines changed

10 files changed

+322
-83
lines changed

pkg/api/v1/atlasdatabaseuser_types.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,11 @@ func (p *AtlasDatabaseUser) ClearScopes() *AtlasDatabaseUser {
244244
return p
245245
}
246246

247+
func (p *AtlasDatabaseUser) WithDeleteAfterDate(date string) *AtlasDatabaseUser {
248+
p.Spec.DeleteAfterDate = date
249+
return p
250+
}
251+
247252
func DefaultDBUser(namespace, username, projectName string) *AtlasDatabaseUser {
248253
return NewDBUser(namespace, username, username, projectName).WithRole("clusterMonitor", "admin", "")
249254
}

pkg/controller/atlasdatabaseuser/atlasdatabaseuser_controller.go

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@ import (
3333
mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1"
3434
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status"
3535
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlas"
36-
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/connectionsecret"
3736
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/customresource"
3837
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/statushandler"
3938
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/watch"
@@ -171,24 +170,8 @@ func (r AtlasDatabaseUserReconciler) Delete(e event.DeleteEvent) error {
171170

172171
log.Infow("Started DatabaseUser deletion process in Atlas", "projectID", project.ID(), "userName", userName)
173172

174-
secrets, err := connectionsecret.ListByUserName(r.Client, dbUser.Namespace, project.ID(), userName)
175-
if err != nil {
176-
return fmt.Errorf("failed to find connection secrets for the user: %w", err)
177-
}
178-
179-
for _, secret := range secrets {
180-
// Solves the "Implicit memory aliasing in for loop" linter error
181-
s := secret.DeepCopy()
182-
err = r.Client.Delete(context.Background(), s)
183-
if err != nil {
184-
log.Errorf("Failed to remove connection Secret: %v", err)
185-
} else {
186-
log.Debugw("Removed connection Secret", "secret", kube.ObjectKeyFromObject(s))
187-
}
188-
}
189-
if len(secrets) > 0 {
190-
log.Infof("Removed %d connection secrets", len(secrets))
191-
}
173+
// We ignore the error as it will be printed by the function
174+
_ = removeStaleSecretsByUserName(r.Client, project.ID(), userName, *dbUser, log)
192175

193176
return nil
194177
}

pkg/controller/atlasdatabaseuser/connectionsecrets.go

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import (
44
"context"
55

66
"go.mongodb.org/atlas/mongodbatlas"
7+
"go.uber.org/zap"
78
"sigs.k8s.io/controller-runtime/pkg/client"
89

910
mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1"
1011
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/connectionsecret"
1112
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/workflow"
13+
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/kube"
1214
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/stringutil"
1315
)
1416

@@ -70,8 +72,10 @@ func cleanupStaleSecrets(ctx *workflow.Context, k8sClient client.Client, project
7072
if err := removeStaleByScope(ctx, k8sClient, projectID, user); err != nil {
7173
return err
7274
}
73-
if err := removeStaleByUserName(ctx, k8sClient, projectID, user); err != nil {
74-
return err
75+
// Performing the cleanup of old secrets only if the username has changed
76+
if user.Status.UserName != user.Spec.Username {
77+
// Note, that we pass the username from the status, not from the spec
78+
return removeStaleSecretsByUserName(k8sClient, projectID, user.Status.UserName, user, ctx.Log)
7579
}
7680
return nil
7781
}
@@ -101,20 +105,25 @@ func removeStaleByScope(ctx *workflow.Context, k8sClient client.Client, projectI
101105
return nil
102106
}
103107

104-
// removeStaleByUserName removes the stale secrets when the database user name changes (as it's used as a part of Secret name)
105-
func removeStaleByUserName(ctx *workflow.Context, k8sClient client.Client, projectID string, user mdbv1.AtlasDatabaseUser) error {
106-
if user.Status.UserName == user.Spec.Username {
107-
return nil
108-
}
109-
secrets, err := connectionsecret.ListByUserName(k8sClient, user.Namespace, projectID, user.Status.UserName)
108+
// removeStaleSecretsByUserName removes the stale secrets when the database user name changes (as it's used as a part of Secret name)
109+
func removeStaleSecretsByUserName(k8sClient client.Client, projectID, userName string, user mdbv1.AtlasDatabaseUser, log *zap.SugaredLogger) error {
110+
secrets, err := connectionsecret.ListByUserName(k8sClient, user.Namespace, projectID, userName)
110111
if err != nil {
111112
return err
112113
}
113-
for i, s := range secrets {
114+
var lastError error
115+
removed := 0
116+
for i := range secrets {
114117
if err = k8sClient.Delete(context.Background(), &secrets[i]); err != nil {
115-
return err
118+
log.Errorf("Failed to remove connection Secret: %v", err)
119+
lastError = err
120+
} else {
121+
log.Debugw("Removed connection Secret", "secret", kube.ObjectKeyFromObject(&secrets[i]))
122+
removed++
116123
}
117-
ctx.Log.Debugw("Removed connection Secret as the database user name has changed", "secretname", s.Name)
118124
}
119-
return nil
125+
if removed > 0 {
126+
log.Infof("Removed %d connection secrets", removed)
127+
}
128+
return lastError
120129
}

pkg/controller/atlasdatabaseuser/databaseuser.go

Lines changed: 86 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,36 +4,101 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"time"
78

89
"github.com/google/go-cmp/cmp"
910
"github.com/google/go-cmp/cmp/cmpopts"
1011
"go.mongodb.org/atlas/mongodbatlas"
1112
"go.uber.org/zap"
1213
corev1 "k8s.io/api/core/v1"
14+
"sigs.k8s.io/controller-runtime/pkg/client"
1315

1416
mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1"
1517
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status"
1618
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlas"
1719
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/workflow"
1820
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/compat"
21+
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/timeutil"
1922
)
2023

2124
func (r *AtlasDatabaseUserReconciler) ensureDatabaseUser(ctx *workflow.Context, project mdbv1.AtlasProject, dbUser mdbv1.AtlasDatabaseUser) workflow.Result {
22-
retryAfterUpdate := workflow.InProgress(workflow.DatabaseUserClustersAppliedChanges, "Clusters are scheduled to handle database users updates")
23-
2425
apiUser, err := dbUser.ToAtlas(r.Client)
2526
if err != nil {
2627
return workflow.Terminate(workflow.Internal, err.Error())
2728
}
28-
secret := &corev1.Secret{}
29-
if err := r.Client.Get(context.Background(), *dbUser.PasswordSecretObjectKey(), secret); err != nil {
30-
return workflow.Terminate(workflow.Internal, err.Error())
29+
30+
if result := checkUserExpired(ctx.Log, r.Client, project.ID(), dbUser); !result.IsOk() {
31+
return result
3132
}
32-
currentPasswordResourceVersion := secret.ResourceVersion
3333

3434
if err = validateScopes(ctx, project.ID(), dbUser); err != nil {
3535
return workflow.Terminate(workflow.DatabaseUserInvalidSpec, err.Error())
3636
}
37+
38+
if result := performUpdateInAtlas(ctx, r.Client, project, dbUser, apiUser); !result.IsOk() {
39+
return result
40+
}
41+
42+
if result := checkClustersHaveReachedGoalState(ctx, project.ID(), dbUser); !result.IsOk() {
43+
return result
44+
}
45+
46+
if result := createOrUpdateConnectionSecrets(ctx, r.Client, project, dbUser); !result.IsOk() {
47+
return result
48+
}
49+
50+
// We need to remove the old Atlas User right after all the connection secrets are ensured if username has changed.
51+
if result := handleUserNameChange(ctx, project.ID(), dbUser); !result.IsOk() {
52+
return result
53+
}
54+
55+
// We mark the status.Username only when everything is finished including connection secrets
56+
ctx.EnsureStatusOption(status.AtlasDatabaseUserNameOption(dbUser.Spec.Username))
57+
58+
return workflow.OK()
59+
}
60+
61+
func handleUserNameChange(ctx *workflow.Context, projectID string, dbUser mdbv1.AtlasDatabaseUser) workflow.Result {
62+
if dbUser.Spec.Username != dbUser.Status.UserName && dbUser.Status.UserName != "" {
63+
ctx.Log.Infow("'spec.username' has changed - removing the old user from Atlas", "newUserName", dbUser.Spec.Username, "oldUserName", dbUser.Status.UserName)
64+
65+
_, err := ctx.Client.DatabaseUsers.Delete(context.Background(), dbUser.Spec.DatabaseName, projectID, dbUser.Status.UserName)
66+
if err != nil {
67+
// There may be some rare errors due to the databaseName change or maybe the user has already been removed - this
68+
// is not-critical (the stale connection secret has already been removed) and we shouldn't retry to avoid infinite retries
69+
ctx.Log.Errorf("Failed to remove user %s from Atlas: %s", dbUser.Status.UserName, err)
70+
}
71+
}
72+
return workflow.OK()
73+
}
74+
75+
func checkUserExpired(log *zap.SugaredLogger, k8sClient client.Client, projectID string, dbUser mdbv1.AtlasDatabaseUser) workflow.Result {
76+
if dbUser.Spec.DeleteAfterDate == "" {
77+
return workflow.OK()
78+
}
79+
80+
deleteAfter, err := timeutil.ParseISO8601(dbUser.Spec.DeleteAfterDate)
81+
if err != nil {
82+
return workflow.Terminate(workflow.DatabaseUserInvalidSpec, err.Error()).WithoutRetry()
83+
}
84+
if deleteAfter.Before(time.Now()) {
85+
if err = removeStaleSecretsByUserName(k8sClient, projectID, dbUser.Spec.Username, dbUser, log); err != nil {
86+
return workflow.Terminate(workflow.Internal, err.Error())
87+
}
88+
return workflow.Terminate(workflow.DatabaseUserExpired, "The database user is expired and has been removed from Atlas").WithoutRetry()
89+
}
90+
return workflow.OK()
91+
}
92+
93+
func performUpdateInAtlas(ctx *workflow.Context, k8sClient client.Client, project mdbv1.AtlasProject, dbUser mdbv1.AtlasDatabaseUser, apiUser *mongodbatlas.DatabaseUser) workflow.Result {
94+
secret := &corev1.Secret{}
95+
if err := k8sClient.Get(context.Background(), *dbUser.PasswordSecretObjectKey(), secret); err != nil {
96+
return workflow.Terminate(workflow.Internal, err.Error())
97+
}
98+
currentPasswordResourceVersion := secret.ResourceVersion
99+
100+
retryAfterUpdate := workflow.InProgress(workflow.DatabaseUserClustersAppliedChanges, "Clusters are scheduled to handle database users updates")
101+
37102
// Try to find the user
38103
u, _, err := ctx.Client.DatabaseUsers.Get(context.Background(), dbUser.Spec.DatabaseName, project.ID(), dbUser.Spec.Username)
39104
if err != nil {
@@ -66,18 +131,6 @@ func (r *AtlasDatabaseUserReconciler) ensureDatabaseUser(ctx *workflow.Context,
66131
// after the successful update we'll retry reconciliation so that clusters had a chance to start working
67132
return retryAfterUpdate
68133
}
69-
70-
if result := checkClustersHaveReachedGoalState(ctx, project.ID(), dbUser); !result.IsOk() {
71-
return result
72-
}
73-
74-
if result := createOrUpdateConnectionSecrets(ctx, r.Client, project, dbUser); !result.IsOk() {
75-
return result
76-
}
77-
78-
// We mark the status.Username only when everything is finished including connection secrets
79-
ctx.EnsureStatusOption(status.AtlasDatabaseUserNameOption(dbUser.Spec.Username))
80-
81134
return workflow.OK()
82135
}
83136

@@ -180,6 +233,21 @@ func userMatchesSpec(log *zap.SugaredLogger, atlasSpec *mongodbatlas.DatabaseUse
180233
return false, err
181234
}
182235

236+
// performing some normalization of dates
237+
if atlasSpec.DeleteAfterDate != "" {
238+
atlasDeleteDate, err := timeutil.ParseISO8601(atlasSpec.DeleteAfterDate)
239+
if err != nil {
240+
return false, err
241+
}
242+
atlasSpec.DeleteAfterDate = timeutil.FormatISO8601(atlasDeleteDate)
243+
}
244+
if operatorSpec.DeleteAfterDate != "" {
245+
operatorDeleteDate, err := timeutil.ParseISO8601(operatorSpec.DeleteAfterDate)
246+
if err != nil {
247+
return false, err
248+
}
249+
userMerged.DeleteAfterDate = timeutil.FormatISO8601(operatorDeleteDate)
250+
}
183251
d := cmp.Diff(*atlasSpec, userMerged, cmpopts.EquateEmpty())
184252
if d != "" {
185253
log.Debugf("Users differs from spec: %s", d)

pkg/controller/atlasdatabaseuser/databaseuser_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,30 @@
11
package atlasdatabaseuser
22

33
import (
4+
"context"
5+
"fmt"
46
"testing"
7+
"time"
58

69
"github.com/stretchr/testify/assert"
710
"go.mongodb.org/atlas/mongodbatlas"
11+
"go.uber.org/zap"
12+
corev1 "k8s.io/api/core/v1"
13+
"k8s.io/apimachinery/pkg/runtime"
14+
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
15+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
16+
"sigs.k8s.io/controller-runtime/pkg/reconcile"
817

918
mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1"
19+
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/connectionsecret"
20+
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/kube"
1021
)
1122

23+
func init() {
24+
logger, _ := zap.NewDevelopment()
25+
zap.ReplaceGlobals(logger)
26+
}
27+
1228
func TestFilterScopeClusters(t *testing.T) {
1329
scopeSpecs := []mdbv1.ScopeSpec{{
1430
Name: "dbLake",
@@ -24,3 +40,69 @@ func TestFilterScopeClusters(t *testing.T) {
2440
scopeClusters := filterScopeClusters(mdbv1.AtlasDatabaseUser{Spec: mdbv1.AtlasDatabaseUserSpec{Scopes: scopeSpecs}}, clusters)
2541
assert.Equal(t, []string{"cluster1"}, scopeClusters)
2642
}
43+
44+
func TestCheckUserExpired(t *testing.T) {
45+
// Fake client
46+
scheme := runtime.NewScheme()
47+
utilruntime.Must(corev1.AddToScheme(scheme))
48+
utilruntime.Must(mdbv1.AddToScheme(scheme))
49+
fakeClient := fake.NewClientBuilder().WithScheme(scheme).Build()
50+
51+
t.Run("Validate DeleteAfterDate", func(t *testing.T) {
52+
result := checkUserExpired(zap.S(), fakeClient, "", *mdbv1.DefaultDBUser("ns", "theuser", "").WithDeleteAfterDate("foo"))
53+
assert.False(t, result.IsOk())
54+
assert.Equal(t, reconcile.Result{}, result.ReconcileResult())
55+
56+
result = checkUserExpired(zap.S(), fakeClient, "", *mdbv1.DefaultDBUser("ns", "theuser", "").WithDeleteAfterDate("2021/11/30T15:04:05"))
57+
assert.False(t, result.IsOk())
58+
})
59+
t.Run("User Marked Expired", func(t *testing.T) {
60+
data := dataForSecret()
61+
// Create a connection secret
62+
_, err := connectionsecret.Ensure(fakeClient, "testNs", "project1", "603e7bf38a94956835659ae5", "cluster1", data)
63+
assert.NoError(t, err)
64+
// The secret for the other project
65+
_, err = connectionsecret.Ensure(fakeClient, "testNs", "project2", "dsfsdf234234sdfdsf23423", "cluster1", data)
66+
assert.NoError(t, err)
67+
68+
before := time.Now().Add(time.Minute * -1).Format("2006-01-02T15:04:05")
69+
result := checkUserExpired(zap.S(), fakeClient, "603e7bf38a94956835659ae5", *mdbv1.DefaultDBUser("testNs", data.DBUserName, "").WithDeleteAfterDate(before))
70+
assert.False(t, result.IsOk())
71+
assert.Equal(t, reconcile.Result{}, result.ReconcileResult())
72+
73+
// The secret has been removed
74+
secret := corev1.Secret{}
75+
secretName := fmt.Sprintf("%s-%s-%s", "project1", "cluster1", kube.NormalizeIdentifier(data.DBUserName))
76+
err = fakeClient.Get(context.Background(), kube.ObjectKey("testNs", secretName), &secret)
77+
assert.Error(t, err)
78+
79+
// The other secret still exists
80+
secretName = fmt.Sprintf("%s-%s-%s", "project2", "cluster1", kube.NormalizeIdentifier(data.DBUserName))
81+
err = fakeClient.Get(context.Background(), kube.ObjectKey("testNs", secretName), &secret)
82+
assert.NoError(t, err)
83+
})
84+
t.Run("No expiration happened", func(t *testing.T) {
85+
data := dataForSecret()
86+
// Create a connection secret
87+
_, err := connectionsecret.Ensure(fakeClient, "testNs", "project1", "603e7bf38a94956835659ae5", "cluster1", data)
88+
assert.NoError(t, err)
89+
after := time.Now().Add(time.Minute * 1).Format("2006-01-02T15:04:05")
90+
result := checkUserExpired(zap.S(), fakeClient, "603e7bf38a94956835659ae5", *mdbv1.DefaultDBUser("testNs", data.DBUserName, "").WithDeleteAfterDate(after))
91+
assert.True(t, result.IsOk())
92+
93+
// The secret is still there
94+
secret := corev1.Secret{}
95+
secretName := fmt.Sprintf("%s-%s-%s", "project1", "cluster1", kube.NormalizeIdentifier(data.DBUserName))
96+
err = fakeClient.Get(context.Background(), kube.ObjectKey("testNs", secretName), &secret)
97+
assert.NoError(t, err)
98+
})
99+
}
100+
101+
func dataForSecret() connectionsecret.ConnectionData {
102+
return connectionsecret.ConnectionData{
103+
DBUserName: "admin",
104+
ConnURL: "mongodb://mongodb0.example.com:27017,mongodb1.example.com:27017/?authSource=admin",
105+
SrvConnURL: "mongodb+srv://mongodb.example.com:27017/?authSource=admin",
106+
Password: "m@gick%",
107+
}
108+
}

pkg/controller/workflow/reason.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,5 @@ const (
3333
DatabaseUserStaleConnectionSecrets ConditionReason = "DatabaseUserStaleConnectionSecrets"
3434
DatabaseUserClustersAppliedChanges ConditionReason = "ClustersAppliedDatabaseUsersChanges"
3535
DatabaseUserInvalidSpec ConditionReason = "DatabaseUserInvalidSpec"
36+
DatabaseUserExpired ConditionReason = "DatabaseUserExpired"
3637
)

pkg/controller/workflow/result.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ func (r Result) WithRetry(retry time.Duration) Result {
6060
return r
6161
}
6262

63+
// WithoutRetry indicates that no retry must happen after the reconciliation is over. This should usually be used
64+
// in cases when retry won't fix the situation like when the spec is incorrect and requires the user to update it.
6365
func (r Result) WithoutRetry() Result {
6466
r.requeueAfter = -1
6567
return r

pkg/util/timeutil/timeutil.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,17 @@ func ParseISO8601(dateTime string) (time.Time, error) {
3333
}
3434
return parse, err
3535
}
36+
37+
// MustParseISO8601 returns time or panics. Mostly needed for tests.
38+
func MustParseISO8601(dateTime string) time.Time {
39+
iso8601, err := ParseISO8601(dateTime)
40+
if err != nil {
41+
panic(err.Error())
42+
}
43+
return iso8601
44+
}
45+
46+
// FormatISO8601 returns the ISO8601 string format for the dateTime.
47+
func FormatISO8601(dateTime time.Time) string {
48+
return dateTime.Format("2006-01-02T15:04:05.999Z")
49+
}

0 commit comments

Comments
 (0)