Skip to content

Commit

Permalink
CLOUDP-178752: Deletion protection for Atlas Teams CR (#1108)
Browse files Browse the repository at this point in the history
  • Loading branch information
helderjs authored Aug 31, 2023
1 parent 338e39b commit 8a43b1e
Show file tree
Hide file tree
Showing 6 changed files with 264 additions and 27 deletions.
2 changes: 1 addition & 1 deletion pkg/controller/atlasproject/atlasproject_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ func (r *AtlasProjectReconciler) ensureDeletionFinalizer(ctx context.Context, wo
if !project.GetDeletionTimestamp().IsZero() {
if customresource.HaveFinalizer(project, customresource.FinalizerLabel) {
if customresource.IsResourceProtected(project, r.ObjectDeletionProtection) {
log.Info("Not removing Atlas database user from Atlas as per configuration")
log.Info("Not removing Project from Atlas as per configuration")
result = workflow.OK()
} else {
if result = DeleteAllPrivateEndpoints(workflowCtx, project.ID()); !result.IsOk() {
Expand Down
21 changes: 4 additions & 17 deletions pkg/controller/atlasproject/maintenancewindow.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,23 +233,10 @@ func isMaintenanceWindowConfigEqual(akoMWindow project.MaintenanceWindow, atlasM
atlasMWindow.AutoDeferOnceEnabled = toptr.MakePtr(false)
}

if akoMWindow.DayOfWeek != atlasMWindow.DayOfWeek {
return false
}

if akoMWindow.HourOfDay != *atlasMWindow.HourOfDay {
return false
}

if akoMWindow.StartASAP != *atlasMWindow.StartASAP {
return false
}

if akoMWindow.AutoDefer != *atlasMWindow.AutoDeferOnceEnabled {
return false
}

return true
return akoMWindow.DayOfWeek == atlasMWindow.DayOfWeek &&
akoMWindow.HourOfDay == *atlasMWindow.HourOfDay &&
akoMWindow.StartASAP == *atlasMWindow.StartASAP &&
akoMWindow.AutoDefer == *atlasMWindow.AutoDeferOnceEnabled
}

func isAtlasMaintenanceWindowEmpty(mWindow *mongodbatlas.MaintenanceWindow) bool {
Expand Down
72 changes: 69 additions & 3 deletions pkg/controller/atlasproject/team_reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import (
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/reconcile"

"github.com/google/go-cmp/cmp"

"github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status"
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlas"
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/customresource"
Expand All @@ -34,7 +36,7 @@ func (r *AtlasProjectReconciler) teamReconcile(
return result.ReconcileResult(), nil
}

if shouldSkip := customresource.ReconciliationShouldBeSkipped(team); shouldSkip {
if customresource.ReconciliationShouldBeSkipped(team) {
log.Infow(fmt.Sprintf("-> Skipping AtlasTeam reconciliation as annotation %s=%s", customresource.ReconciliationPolicyAnnotation, customresource.ReconciliationPolicySkip), "spec", team.Spec)
return workflow.OK().ReconcileResult(), nil
}
Expand All @@ -55,6 +57,26 @@ func (r *AtlasProjectReconciler) teamReconcile(

log.Infow("-> Starting AtlasTeam reconciliation", "spec", team.Spec)

owner, err := customresource.IsOwner(team, r.ObjectDeletionProtection, customresource.IsResourceManagedByOperator, teamsManagedByAtlas(ctx, teamCtx.Client, connection.OrgID))
if err != nil {
result = workflow.Terminate(workflow.Internal, fmt.Sprintf("unable to resolve ownership for deletion protection: %s", err))
teamCtx.SetConditionFromResult(status.ReadyType, result)
log.Error(result.GetMessage())

return result.ReconcileResult(), nil
}

if !owner {
result = workflow.Terminate(
workflow.AtlasDeletionProtection,
"unable to reconcile Team due to deletion protection being enabled. see https://dochub.mongodb.org/core/ako-deletion-protection for further information",
)
teamCtx.SetConditionFromResult(status.ReadyType, result)
log.Error(result.GetMessage())

return result.ReconcileResult(), nil
}

teamID, result := ensureTeamState(ctx, teamCtx, team)
if !result.IsOk() {
teamCtx.SetConditionFromResult(status.ReadyType, result)
Expand Down Expand Up @@ -92,8 +114,9 @@ func (r *AtlasProjectReconciler) teamReconcile(
if !team.GetDeletionTimestamp().IsZero() {
if customresource.HaveFinalizer(team, customresource.FinalizerLabel) {
log.Warnf("team %s is assigned to a project. Remove it from all projects before delete", team.Name)
} else if customresource.ResourceShouldBeLeftInAtlas(team) {
log.Infof("Not removing the Atlas Team from Atlas as the '%s' annotation is set", customresource.ResourcePolicyAnnotation)
} else if customresource.IsResourceProtected(team, r.ObjectDeletionProtection) {
log.Info("Not removing Team from Atlas as per configuration")
return workflow.OK().ReconcileResult(), nil
} else {
log.Infow("-> Starting AtlasTeam deletion", "spec", team.Spec)
_, err := teamCtx.Client.Teams.RemoveTeamFromOrganization(ctx, teamCtx.Connection.OrgID, team.Status.ID)
Expand All @@ -105,6 +128,15 @@ func (r *AtlasProjectReconciler) teamReconcile(
}
}

err = customresource.ApplyLastConfigApplied(ctx, team, r.Client)
if err != nil {
result = workflow.Terminate(workflow.Internal, err.Error())
teamCtx.SetConditionFromResult(status.ReadyType, result)
log.Error(result.GetMessage())

return result.ReconcileResult(), nil
}

teamCtx.SetConditionTrue(status.ReadyType)
return workflow.OK().ReconcileResult(), nil
}
Expand Down Expand Up @@ -295,3 +327,37 @@ func renameTeam(ctx context.Context, workflowCtx *workflow.Context, atlasTeam *m

return atlasTeam, nil
}

func teamsManagedByAtlas(ctx context.Context, atlasClient mongodbatlas.Client, orgID string) customresource.AtlasChecker {
return func(resource v1.AtlasCustomResource) (bool, error) {
team, ok := resource.(*v1.AtlasTeam)
if !ok {
return false, errors.New("failed to match resource type as AtlasTeams")
}

if team.Status.ID == "" {
return false, nil
}

atlasTeam, _, err := atlasClient.Teams.Get(ctx, orgID, team.Status.ID)
if err != nil {
var apiError *mongodbatlas.ErrorResponse
if errors.As(err, &apiError) && (apiError.ErrorCode == atlas.NotInGroup || apiError.ErrorCode == atlas.ResourceNotFound) {
return false, nil
}

return false, err
}

if team.Spec.Name != atlasTeam.Name || len(atlasTeam.Usernames) == 0 {
return false, err
}

usernames := make([]string, 0, len(team.Spec.Usernames))
for _, username := range team.Spec.Usernames {
usernames = append(usernames, string(username))
}

return cmp.Diff(usernames, atlasTeam.Usernames) != "", nil
}
}
172 changes: 172 additions & 0 deletions pkg/controller/atlasproject/team_reconciler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package atlasproject

import (
"context"
"errors"
"testing"

"github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status"

"github.com/stretchr/testify/assert"
"go.mongodb.org/atlas/mongodbatlas"

"github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlas"

v1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1"
)

type teamsClient struct {
GetFunc func() (*mongodbatlas.Team, *mongodbatlas.Response, error)
}

func (c *teamsClient) List(_ context.Context, _ string, _ *mongodbatlas.ListOptions) ([]mongodbatlas.Team, *mongodbatlas.Response, error) {
return nil, nil, nil
}

func (c *teamsClient) Get(_ context.Context, _ string, _ string) (*mongodbatlas.Team, *mongodbatlas.Response, error) {
return c.GetFunc()
}

func (c *teamsClient) GetOneTeamByName(_ context.Context, _ string, _ string) (*mongodbatlas.Team, *mongodbatlas.Response, error) {
return nil, nil, nil
}

func (c *teamsClient) GetTeamUsersAssigned(_ context.Context, _ string, _ string) ([]mongodbatlas.AtlasUser, *mongodbatlas.Response, error) {
return nil, nil, nil
}

func (c *teamsClient) Create(_ context.Context, _ string, _ *mongodbatlas.Team) (*mongodbatlas.Team, *mongodbatlas.Response, error) {
return nil, nil, nil
}

func (c *teamsClient) Rename(_ context.Context, _ string, _ string, _ string) (*mongodbatlas.Team, *mongodbatlas.Response, error) {
return nil, nil, nil
}

func (c *teamsClient) UpdateTeamRoles(_ context.Context, _ string, _ string, _ *mongodbatlas.TeamUpdateRoles) ([]mongodbatlas.TeamRoles, *mongodbatlas.Response, error) {
return nil, nil, nil
}

func (c *teamsClient) AddUsersToTeam(_ context.Context, _ string, _ string, _ []string) ([]mongodbatlas.AtlasUser, *mongodbatlas.Response, error) {
return nil, nil, nil
}

func (c *teamsClient) RemoveUserToTeam(_ context.Context, _ string, _ string, _ string) (*mongodbatlas.Response, error) {
return nil, nil
}

func (c *teamsClient) RemoveTeamFromOrganization(_ context.Context, _ string, _ string) (*mongodbatlas.Response, error) {
return nil, nil
}

func (c *teamsClient) RemoveTeamFromProject(_ context.Context, _ string, _ string) (*mongodbatlas.Response, error) {
return nil, nil
}

func TestTeamManagedByAtlas(t *testing.T) {
t.Run("should return error when passing wrong resource", func(t *testing.T) {
checker := teamsManagedByAtlas(context.TODO(), mongodbatlas.Client{}, "orgID")
result, err := checker(&v1.AtlasProject{})
assert.EqualError(t, err, "failed to match resource type as AtlasTeams")
assert.False(t, result)
})

t.Run("should return false when resource has no Atlas Team ID", func(t *testing.T) {
checker := teamsManagedByAtlas(context.TODO(), mongodbatlas.Client{}, "orgID")
result, err := checker(&v1.AtlasTeam{})
assert.NoError(t, err)
assert.False(t, result)
})

t.Run("should return false when resource was not found in Atlas", func(t *testing.T) {
atlasClient := mongodbatlas.Client{
Teams: &teamsClient{
GetFunc: func() (*mongodbatlas.Team, *mongodbatlas.Response, error) {
return nil, &mongodbatlas.Response{}, &mongodbatlas.ErrorResponse{ErrorCode: atlas.ResourceNotFound}
},
},
}
team := &v1.AtlasTeam{
Status: status.TeamStatus{
ID: "team-id-1",
},
}
checker := teamsManagedByAtlas(context.TODO(), atlasClient, "orgID")
result, err := checker(team)
assert.NoError(t, err)
assert.False(t, result)
})

t.Run("should return error when failed to fetch the team from Atlas", func(t *testing.T) {
atlasClient := mongodbatlas.Client{
Teams: &teamsClient{
GetFunc: func() (*mongodbatlas.Team, *mongodbatlas.Response, error) {
return nil, &mongodbatlas.Response{}, errors.New("unavailable")
},
},
}
team := &v1.AtlasTeam{
Status: status.TeamStatus{
ID: "team-id-1",
},
}
checker := teamsManagedByAtlas(context.TODO(), atlasClient, "orgID")
result, err := checker(team)
assert.EqualError(t, err, "unavailable")
assert.False(t, result)
})

t.Run("should return false when resource are equal", func(t *testing.T) {
atlasClient := mongodbatlas.Client{
Teams: &teamsClient{
GetFunc: func() (*mongodbatlas.Team, *mongodbatlas.Response, error) {
return &mongodbatlas.Team{
ID: "team-id-1",
Name: "My Team",
Usernames: []string{"[email protected]", "[email protected]"},
}, &mongodbatlas.Response{}, nil
},
},
}
team := &v1.AtlasTeam{
Spec: v1.TeamSpec{
Name: "My Team",
Usernames: []v1.TeamUser{"[email protected]", "[email protected]"},
},
Status: status.TeamStatus{
ID: "team-id-1",
},
}
checker := teamsManagedByAtlas(context.TODO(), atlasClient, "orgID-1")
result, err := checker(team)
assert.NoError(t, err)
assert.False(t, result)
})

t.Run("should return true when resource are different", func(t *testing.T) {
atlasClient := mongodbatlas.Client{
Teams: &teamsClient{
GetFunc: func() (*mongodbatlas.Team, *mongodbatlas.Response, error) {
return &mongodbatlas.Team{
ID: "team-id-1",
Name: "My Team",
Usernames: []string{"[email protected]", "[email protected]"},
}, &mongodbatlas.Response{}, nil
},
},
}
team := &v1.AtlasTeam{
Spec: v1.TeamSpec{
Name: "My Team",
Usernames: []v1.TeamUser{"[email protected]"},
},
Status: status.TeamStatus{
ID: "team-id-1",
},
}
checker := teamsManagedByAtlas(context.TODO(), atlasClient, "orgID-1")
result, err := checker(team)
assert.NoError(t, err)
assert.True(t, result)
})
}
3 changes: 2 additions & 1 deletion pkg/controller/atlasproject/teams.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ func (r *AtlasProjectReconciler) syncAssignedTeams(ctx *workflow.Context, projec
if len(teamsToAssign) > 0 {
ctx.Log.Debug("assigning teams to project")
projectTeams := make([]*mongodbatlas.ProjectTeam, 0, len(teamsToAssign))
for teamID, assignedTeam := range teamsToAssign {
for teamID := range teamsToAssign {
assignedTeam := teamsToAssign[teamID]
projectTeams = append(projectTeams, assignedTeam.ToAtlas(teamID))
currentProjectsStatus[teamID] = status.ProjectTeamStatus{
ID: teamID,
Expand Down
21 changes: 16 additions & 5 deletions test/e2e/project_deletion_protection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,20 +302,17 @@ var _ = Describe("Project Deletion Protection", Label("project", "deletion-prote
Expect(err).ToNot(HaveOccurred())
})

By("Creating a project to be managed by the operator", func() {
By("Creating a project and team to be managed by the operator", func() {
akoTeam := &mdbv1.AtlasTeam{
ObjectMeta: metav1.ObjectMeta{
Name: fmt.Sprintf("%s-team", projectName),
Namespace: testData.Resources.Namespace,
},
Spec: mdbv1.TeamSpec{
Name: fmt.Sprintf("%s-team", projectName),
Usernames: make([]mdbv1.TeamUser, 0, len(usernames)),
Usernames: []mdbv1.TeamUser{"[email protected]"},
},
}
for _, username := range usernames {
akoTeam.Spec.Usernames = append(akoTeam.Spec.Usernames, mdbv1.TeamUser(username))
}
testData.Teams = []*mdbv1.AtlasTeam{akoTeam}
Expect(testData.K8SClient.Create(ctx, testData.Teams[0]))

Expand Down Expand Up @@ -825,6 +822,20 @@ var _ = Describe("Project Deletion Protection", Label("project", "deletion-prote
}).WithTimeout(time.Minute * 5).WithPolling(time.Second * 20).Should(Succeed())
})

By("Team is ready after configured properly", func() {
Expect(testData.K8SClient.Get(context.TODO(), client.ObjectKeyFromObject(testData.Teams[0]), testData.Teams[0])).To(Succeed())
testData.Teams[0].Spec.Usernames = make([]mdbv1.TeamUser, 0, len(usernames))
for _, username := range usernames {
testData.Teams[0].Spec.Usernames = append(testData.Teams[0].Spec.Usernames, mdbv1.TeamUser(username))
}
Expect(testData.K8SClient.Update(context.TODO(), testData.Teams[0])).To(Succeed())

Eventually(func(g Gomega) {
g.Expect(testData.K8SClient.Get(context.TODO(), client.ObjectKeyFromObject(testData.Teams[0]), testData.Teams[0])).To(Succeed())
g.Expect(testData.Teams[0].Status.Conditions).To(ContainElements(testutil.MatchCondition(status.TrueCondition(status.ReadyType))))
}).WithTimeout(time.Minute * 1).WithPolling(time.Second * 20).Should(Succeed())
})

By("Assigned Teams is ready after configured properly", func() {
Expect(testData.K8SClient.Get(context.TODO(), client.ObjectKeyFromObject(testData.Project), testData.Project)).To(Succeed())
testData.Project.Spec.Teams[0].Roles[0] = "GROUP_OWNER"
Expand Down

0 comments on commit 8a43b1e

Please sign in to comment.