Skip to content

Commit 94862b1

Browse files
authored
CLOUDP-264068: [Fix] Respect Teams deletion protection (#1722)
* CLOUDP-264068: Respect Teams deletion protection * Respect also the keep flag * Add e2e test coverage to fix * Fix linter
1 parent 818e9fb commit 94862b1

File tree

3 files changed

+195
-42
lines changed

3 files changed

+195
-42
lines changed

pkg/controller/atlasproject/teams.go

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1"
1111
"github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1/common"
1212
"github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1/status"
13+
"github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/controller/customresource"
1314
"github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/controller/statushandler"
1415
"github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/controller/workflow"
1516
)
@@ -194,12 +195,16 @@ func (r *AtlasProjectReconciler) updateTeamState(ctx *workflow.Context, project
194195
teamCtx.Client = atlasClient
195196

196197
if len(assignedProjects) == 0 {
197-
log.Debugf("team %s has no project associated to it. removing from atlas.", team.Spec.Name)
198-
_, err = teamCtx.Client.Teams.RemoveTeamFromOrganization(ctx.Context, orgID, team.Status.ID)
199-
if err != nil {
200-
return err
198+
if customresource.IsResourcePolicyKeepOrDefault(project, r.ObjectDeletionProtection) {
199+
log.Debug("team %s has no project associated, "+
200+
"skipping deletion from Atlas due to ObjectDeletionProtection being set", team.Spec.Name)
201+
} else {
202+
log.Debugf("team %s has no project associated to it. removing from atlas.", team.Spec.Name)
203+
_, err = teamCtx.Client.Teams.RemoveTeamFromOrganization(ctx.Context, orgID, team.Status.ID)
204+
if err != nil {
205+
return err
206+
}
201207
}
202-
203208
teamCtx.EnsureStatusOption(status.AtlasTeamUnsetID())
204209
}
205210

pkg/controller/atlasproject/teams_test.go

Lines changed: 115 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,14 @@ import (
1919
akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1"
2020
"github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1/common"
2121
"github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1/status"
22+
"github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/controller/customresource"
2223
"github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/controller/workflow"
2324
)
2425

2526
func TestUpdateTeamState(t *testing.T) {
2627
t.Run("should not duplicate projects listed", func(t *testing.T) {
2728
logger := zaptest.NewLogger(t).Sugar()
28-
workflowCtx := &workflow.Context{
29-
Context: context.Background(),
30-
Log: logger,
31-
}
32-
testScheme := runtime.NewScheme()
33-
akov2.AddToScheme(testScheme)
34-
corev1.AddToScheme(testScheme)
29+
workflowCtx := defaultTestWorkflow(logger)
3530
secret := &corev1.Secret{
3631
ObjectMeta: metav1.ObjectMeta{
3732
Name: "my-secret",
@@ -74,38 +69,25 @@ func TestUpdateTeamState(t *testing.T) {
7469
return &mongodbatlas.Client{}, "0987654321", nil
7570
},
7671
}
77-
k8sClient := fake.NewClientBuilder().
78-
WithScheme(testScheme).
79-
WithObjects(secret, project, team).
80-
Build()
72+
k8sClient := buildFakeKubernetesClient(secret, project, team)
8173
reconciler := &AtlasProjectReconciler{
8274
Client: k8sClient,
8375
Log: logger,
8476
AtlasProvider: atlasProvider,
8577
}
86-
teamRef := &common.ResourceRefNamespaced{
87-
Name: team.Name,
88-
Namespace: "testNS",
89-
}
9078
// check we have exactly 1 project in status
9179
assert.Equal(t, 1, len(team.Status.Projects))
9280

9381
// "reconcile" the team state and check we still have 1 project in status
94-
err := reconciler.updateTeamState(workflowCtx, project, teamRef, false)
82+
err := reconciler.updateTeamState(workflowCtx, project, reference(team), false)
9583
assert.NoError(t, err)
9684
k8sClient.Get(context.Background(), types.NamespacedName{Name: team.ObjectMeta.Name, Namespace: team.ObjectMeta.Namespace}, team)
9785
assert.Equal(t, 1, len(team.Status.Projects))
9886
})
9987

10088
t.Run("must remove a team from Atlas is a team is unassigned", func(t *testing.T) {
10189
logger := zaptest.NewLogger(t).Sugar()
102-
workflowCtx := &workflow.Context{
103-
Context: context.Background(),
104-
Log: logger,
105-
}
106-
testScheme := runtime.NewScheme()
107-
akov2.AddToScheme(testScheme)
108-
corev1.AddToScheme(testScheme)
90+
workflowCtx := defaultTestWorkflow(logger)
10991
secret := &corev1.Secret{
11092
ObjectMeta: metav1.ObjectMeta{
11193
Name: "my-secret",
@@ -151,23 +133,124 @@ func TestUpdateTeamState(t *testing.T) {
151133
}, "0987654321", nil
152134
},
153135
}
154-
k8sClient := fake.NewClientBuilder().
155-
WithScheme(testScheme).
156-
WithObjects(secret, project, team).
157-
Build()
136+
k8sClient := buildFakeKubernetesClient(secret, project, team)
158137
reconciler := &AtlasProjectReconciler{
159138
Client: k8sClient,
160139
Log: logger,
161140
AtlasProvider: atlasProvider,
162141
}
163-
teamRef := &common.ResourceRefNamespaced{
164-
Name: team.Name,
165-
Namespace: "testNS",
166-
}
167142

168-
err := reconciler.updateTeamState(workflowCtx, project, teamRef, true)
143+
err := reconciler.updateTeamState(workflowCtx, project, reference(team), true)
169144
assert.NoError(t, err)
170145
k8sClient.Get(context.Background(), types.NamespacedName{Name: team.ObjectMeta.Name, Namespace: team.ObjectMeta.Namespace}, team)
171146
assert.Len(t, teamsMock.RemoveTeamFromOrganizationRequests, 1)
172147
})
148+
149+
t.Run("must honor deletion protection flag for Teams", func(t *testing.T) {
150+
for _, tc := range []struct {
151+
title string
152+
deletionProtection bool
153+
keepFlag bool
154+
expectRemoval bool
155+
}{
156+
{
157+
title: "with deletion protection unassigned teams are not removed",
158+
deletionProtection: true,
159+
keepFlag: false,
160+
expectRemoval: false,
161+
},
162+
{
163+
title: "without deletion protection unassigned teams are removed",
164+
deletionProtection: false,
165+
keepFlag: false,
166+
expectRemoval: true,
167+
},
168+
{
169+
title: "with deletion protection & keep flag teams are not removed",
170+
deletionProtection: false,
171+
keepFlag: true,
172+
expectRemoval: false,
173+
},
174+
{
175+
title: "without deletion protection but keep flag teams are not removed",
176+
deletionProtection: true,
177+
keepFlag: true,
178+
expectRemoval: false,
179+
},
180+
} {
181+
t.Run(tc.title, func(t *testing.T) {
182+
logger := zaptest.NewLogger(t).Sugar()
183+
workflowCtx := defaultTestWorkflow(logger)
184+
project := &akov2.AtlasProject{
185+
Spec: akov2.AtlasProjectSpec{
186+
Name: "projectName",
187+
},
188+
}
189+
team := &akov2.AtlasTeam{
190+
ObjectMeta: metav1.ObjectMeta{
191+
Name: "testTeam",
192+
Namespace: "testNS",
193+
},
194+
}
195+
teamsMock := &atlas.TeamsClientMock{
196+
RemoveTeamFromOrganizationFunc: func(orgID string, teamID string) (*mongodbatlas.Response, error) {
197+
return nil, nil
198+
},
199+
RemoveTeamFromOrganizationRequests: map[string]struct{}{},
200+
}
201+
atlasProvider := &atlas.TestProvider{
202+
ClientFunc: func(secretRef *client.ObjectKey, log *zap.SugaredLogger) (*mongodbatlas.Client, string, error) {
203+
return &mongodbatlas.Client{
204+
Teams: teamsMock,
205+
}, "0987654321", nil
206+
},
207+
}
208+
reconciler := &AtlasProjectReconciler{
209+
Client: buildFakeKubernetesClient(project, team),
210+
Log: logger,
211+
AtlasProvider: atlasProvider,
212+
ObjectDeletionProtection: tc.deletionProtection,
213+
}
214+
if tc.keepFlag {
215+
customresource.SetAnnotation(project,
216+
customresource.ResourcePolicyAnnotation, customresource.ResourcePolicyKeep)
217+
}
218+
err := reconciler.updateTeamState(workflowCtx, project, reference(team), true)
219+
assert.NoError(t, err)
220+
expectedRemovals := 0
221+
if tc.expectRemoval {
222+
expectedRemovals = 1
223+
}
224+
assert.Len(t, teamsMock.RemoveTeamFromOrganizationRequests, expectedRemovals)
225+
})
226+
}
227+
})
228+
}
229+
230+
func defaultTestWorkflow(logger *zap.SugaredLogger) *workflow.Context {
231+
return &workflow.Context{
232+
Context: context.Background(),
233+
Log: logger,
234+
}
235+
}
236+
237+
func defaultTestScheme() *runtime.Scheme {
238+
scheme := runtime.NewScheme()
239+
akov2.AddToScheme(scheme)
240+
corev1.AddToScheme(scheme)
241+
return scheme
242+
}
243+
244+
func buildFakeKubernetesClient(objects ...client.Object) client.WithWatch {
245+
return fake.NewClientBuilder().
246+
WithScheme(defaultTestScheme()).
247+
WithObjects(objects...).
248+
Build()
249+
}
250+
251+
func reference(obj client.Object) *common.ResourceRefNamespaced {
252+
return &common.ResourceRefNamespaced{
253+
Name: obj.GetName(),
254+
Namespace: obj.GetNamespace(),
255+
}
173256
}

test/e2e/teams_test.go

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,22 @@
11
package e2e_test
22

33
import (
4+
"context"
5+
"errors"
6+
"fmt"
47
"time"
58

69
. "github.com/onsi/ginkgo/v2"
710
. "github.com/onsi/gomega"
11+
"go.mongodb.org/atlas-sdk/v20231115008/admin"
812
"k8s.io/apimachinery/pkg/types"
913
"sigs.k8s.io/controller-runtime/pkg/client"
1014

1115
"github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api"
1216
akov2 "github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1"
1317
"github.com/mongodb/mongodb-atlas-kubernetes/v2/pkg/api/v1/common"
1418
"github.com/mongodb/mongodb-atlas-kubernetes/v2/test/helper/e2e/actions"
19+
"github.com/mongodb/mongodb-atlas-kubernetes/v2/test/helper/e2e/api/atlas"
1520
"github.com/mongodb/mongodb-atlas-kubernetes/v2/test/helper/e2e/data"
1621
"github.com/mongodb/mongodb-atlas-kubernetes/v2/test/helper/e2e/model"
1722
"github.com/mongodb/mongodb-atlas-kubernetes/v2/test/helper/e2e/utils"
@@ -31,8 +36,6 @@ var _ = Describe("Teams", Label("teams"), func() {
3136
Expect(actions.SaveTeamsToFile(testData.Context, testData.K8SClient, testData.Resources.Namespace)).Should(Succeed())
3237
}
3338
By("Delete Resources", func() {
34-
actions.DeleteTestDataTeams(testData)
35-
actions.DeleteTestDataProject(testData)
3639
actions.AfterEachFinalCleanup([]model.TestDataProvider{*testData})
3740
})
3841
})
@@ -50,7 +53,7 @@ var _ = Describe("Teams", Label("teams"), func() {
5053
model.NewEmptyAtlasKeyType().UseDefaultFullAccess(),
5154
40000,
5255
[]func(*model.TestDataProvider){},
53-
).WithProject(data.DefaultProject()),
56+
).WithProject(data.DefaultProject()).WithObjectDeletionProtection(true),
5457
[]akov2.Team{
5558
{
5659
TeamRef: common.ResourceRefNamespaced{
@@ -120,10 +123,33 @@ func projectTeamsFlow(userData *model.TestDataProvider, teams []akov2.Team) {
120123
Expect(userData.K8SClient.Update(userData.Context, userData.Project)).Should(Succeed())
121124
Eventually(func(g Gomega) bool {
122125
return ensureTeamsStatus(g, *userData, teams, teamWasRemoved)
123-
}).WithTimeout(10*time.Minute).WithPolling(20*time.Second).Should(BeTrue(), "Team were not removed")
126+
}).WithTimeout(10*time.Minute).WithPolling(20*time.Second).Should(BeTrue(), "Teams were not removed")
124127

125128
actions.CheckProjectConditionsNotSet(userData, api.ProjectTeamsReadyType)
126129
})
130+
131+
if userData.ObjectDeletionProtection {
132+
aClient := atlas.GetClientOrFail()
133+
By("Cleanup Atlas Teams: which should have not been removed due to deletion protection", func() {
134+
atlasTeams, err := listAtLeastNTeams(userData.Context, aClient, len(teams))
135+
Expect(err).Should(Succeed())
136+
Expect(clearAtlasTeams(teams, atlasTeams, aClient, userData)).Should(Succeed())
137+
})
138+
139+
By("Cleanup Atlas Project: as deletion protection will skip that", func() {
140+
Expect(userData.K8SClient.Get(userData.Context, types.NamespacedName{
141+
Name: userData.Project.Name,
142+
Namespace: userData.Project.Namespace,
143+
}, userData.Project)).Should(Succeed())
144+
projectID := userData.Project.Status.ID
145+
Expect(userData.K8SClient.Delete(userData.Context, userData.Project)).Should(Succeed())
146+
_, _, err := aClient.Client.ProjectsApi.DeleteProject(userData.Context, projectID).Execute()
147+
Expect(err).Should(Succeed())
148+
})
149+
} else {
150+
actions.DeleteTestDataTeams(userData)
151+
actions.DeleteTestDataProject(userData)
152+
}
127153
}
128154

129155
func ensureTeamsStatus(g Gomega, testData model.TestDataProvider, teams []akov2.Team, check func(res *akov2.AtlasTeam) bool) bool {
@@ -136,7 +162,6 @@ func ensureTeamsStatus(g Gomega, testData model.TestDataProvider, teams []akov2.
136162
return false
137163
}
138164
}
139-
140165
return true
141166
}
142167

@@ -147,3 +172,43 @@ func teamWasCreated(team *akov2.AtlasTeam) bool {
147172
func teamWasRemoved(team *akov2.AtlasTeam) bool {
148173
return team.Status.ID == ""
149174
}
175+
176+
func listAtLeastNTeams(ctx context.Context, aClient *atlas.Atlas, minTeams int) ([]admin.TeamResponse, error) {
177+
results := []admin.TeamResponse{}
178+
teamsReply, _, err := aClient.Client.TeamsApi.ListOrganizationTeams(ctx, aClient.OrgID).Execute()
179+
if err != nil {
180+
return results, fmt.Errorf("failed to list teams: %w", err)
181+
}
182+
total, ok := teamsReply.GetTotalCountOk()
183+
if !ok {
184+
return results, errors.New("no results")
185+
}
186+
if *total < minTeams {
187+
return results, fmt.Errorf("not enough teams: expected %d but got %d", minTeams, *total)
188+
}
189+
return teamsReply.GetResults(), nil
190+
}
191+
192+
func clearAtlasTeams(teams []akov2.Team, atlasTeams []admin.TeamResponse, aClient *atlas.Atlas, userData *model.TestDataProvider) error {
193+
var errs error
194+
for _, team := range teams {
195+
foundAtlasTeam := findTeam(atlasTeams, team.TeamRef.Name)
196+
if foundAtlasTeam == nil {
197+
errs = errors.Join(errs, fmt.Errorf("failed to find expected Atlas team %s (was it wrongly removed?)", team.TeamRef.Name))
198+
}
199+
_, _, err := aClient.Client.TeamsApi.DeleteTeam(userData.Context, aClient.OrgID, foundAtlasTeam.GetId()).Execute()
200+
if err != nil {
201+
errs = errors.Join(errs, err)
202+
}
203+
}
204+
return errs
205+
}
206+
207+
func findTeam(atlasTeams []admin.TeamResponse, teamName string) *admin.TeamResponse {
208+
for _, atlasTeam := range atlasTeams {
209+
if teamName == atlasTeam.GetName() {
210+
return &atlasTeam
211+
}
212+
}
213+
return nil
214+
}

0 commit comments

Comments
 (0)