diff --git a/pkg/api/v1/zz_generated.deepcopy.go b/pkg/api/v1/zz_generated.deepcopy.go index e9496821a6..70057acb98 100644 --- a/pkg/api/v1/zz_generated.deepcopy.go +++ b/pkg/api/v1/zz_generated.deepcopy.go @@ -14,9 +14,10 @@ a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 package v1 import ( + "k8s.io/apimachinery/pkg/runtime" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/common" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/project" - "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. diff --git a/pkg/util/atlastest/project.go b/pkg/util/atlastest/project.go new file mode 100644 index 0000000000..f1cdbe6f7a --- /dev/null +++ b/pkg/util/atlastest/project.go @@ -0,0 +1,103 @@ +package atlastest + +import ( + "context" + + "go.mongodb.org/atlas/mongodbatlas" +) + +type ProjectsServiceMock struct { + GetAllProjectsFn func(*mongodbatlas.ListOptions) (*mongodbatlas.Projects, *mongodbatlas.Response, error) + DeleteFn func(string) (*mongodbatlas.Response, error) +} + +// AddTeamsToProject implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) AddTeamsToProject(context.Context, string, []*mongodbatlas.ProjectTeam) (*mongodbatlas.TeamsAssigned, *mongodbatlas.Response, error) { + panic("unimplemented") +} + +// Create implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) Create(context.Context, *mongodbatlas.Project, *mongodbatlas.CreateProjectOptions) (*mongodbatlas.Project, *mongodbatlas.Response, error) { + panic("unimplemented") +} + +// Delete implements mongodbatlas.ProjectsService. +func (ps *ProjectsServiceMock) Delete(_ context.Context, id string) (*mongodbatlas.Response, error) { + if ps.DeleteFn == nil { + panic("Delete was not set for test") + } + return ps.DeleteFn(id) +} + +// DeleteInvitation implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) DeleteInvitation(context.Context, string, string) (*mongodbatlas.Response, error) { + panic("unimplemented") +} + +// GetAllProjects implements mongodbatlas.ProjectsService. +func (ps *ProjectsServiceMock) GetAllProjects(_ context.Context, listOptions *mongodbatlas.ListOptions) (*mongodbatlas.Projects, *mongodbatlas.Response, error) { + if ps.GetAllProjectsFn == nil { + panic("GetAllProjects was not set for test") + } + return ps.GetAllProjectsFn(listOptions) +} + +// GetOneProject implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) GetOneProject(context.Context, string) (*mongodbatlas.Project, *mongodbatlas.Response, error) { + panic("unimplemented") +} + +// GetOneProjectByName implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) GetOneProjectByName(context.Context, string) (*mongodbatlas.Project, *mongodbatlas.Response, error) { + panic("unimplemented") +} + +// GetProjectSettings implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) GetProjectSettings(context.Context, string) (*mongodbatlas.ProjectSettings, *mongodbatlas.Response, error) { + panic("unimplemented") +} + +// GetProjectTeamsAssigned implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) GetProjectTeamsAssigned(context.Context, string) (*mongodbatlas.TeamsAssigned, *mongodbatlas.Response, error) { + panic("unimplemented") +} + +// Invitation implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) Invitation(context.Context, string, string) (*mongodbatlas.Invitation, *mongodbatlas.Response, error) { + panic("unimplemented") +} + +// Invitations implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) Invitations(context.Context, string, *mongodbatlas.InvitationOptions) ([]*mongodbatlas.Invitation, *mongodbatlas.Response, error) { + panic("unimplemented") +} + +// InviteUser implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) InviteUser(context.Context, string, *mongodbatlas.Invitation) (*mongodbatlas.Invitation, *mongodbatlas.Response, error) { + panic("unimplemented") +} + +// RemoveUserFromProject implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) RemoveUserFromProject(context.Context, string, string) (*mongodbatlas.Response, error) { + panic("unimplemented") +} + +// Update implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) Update(context.Context, string, *mongodbatlas.ProjectUpdateRequest) (*mongodbatlas.Project, *mongodbatlas.Response, error) { + panic("unimplemented") +} + +// UpdateInvitation implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) UpdateInvitation(context.Context, string, *mongodbatlas.Invitation) (*mongodbatlas.Invitation, *mongodbatlas.Response, error) { + panic("unimplemented") +} + +// UpdateInvitationByID implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) UpdateInvitationByID(context.Context, string, string, *mongodbatlas.Invitation) (*mongodbatlas.Invitation, *mongodbatlas.Response, error) { + panic("unimplemented") +} + +// UpdateProjectSettings implements mongodbatlas.ProjectsService. +func (*ProjectsServiceMock) UpdateProjectSettings(context.Context, string, *mongodbatlas.ProjectSettings) (*mongodbatlas.ProjectSettings, *mongodbatlas.Response, error) { + panic("unimplemented") +} diff --git a/pkg/util/fixtest/remove_duplicates.go b/pkg/util/fixtest/remove_duplicates.go new file mode 100644 index 0000000000..e7a6438552 --- /dev/null +++ b/pkg/util/fixtest/remove_duplicates.go @@ -0,0 +1,57 @@ +package fixtest + +import ( + "context" + "sort" + + "go.mongodb.org/atlas/mongodbatlas" + "go.uber.org/zap" +) + +// EnsureNoDuplicates removes projects with same name but different ID. +// Atlas sometimes creates duplicate projects, we need our tests to defend +// against that to avoid flaky tests +func EnsureNoDuplicates(client *mongodbatlas.Client, logger *zap.SugaredLogger, projectName string) error { + found, err := listProjectsByName(client, projectName) + if err != nil || len(found) <= 1 { + return err + } + logger.Warnf("Found more than one project with name %q", projectName) + keep, rest := selectProject(found) + logger.Warnf("Will keep project ID %s as %s and remove the rest", keep.ID, projectName) + return removeProjects(client, rest) +} + +func listProjectsByName(client *mongodbatlas.Client, projectName string) ([]*mongodbatlas.Project, error) { + projects, _, err := client.Projects.GetAllProjects( + context.Background(), + &mongodbatlas.ListOptions{}, + ) + if err != nil { + return nil, err + } + found := []*mongodbatlas.Project{} + for _, project := range projects.Results { + if project.Name == projectName { + found = append(found, project) + } + } + return found, nil +} + +func selectProject(projects []*mongodbatlas.Project) (*mongodbatlas.Project, []*mongodbatlas.Project) { + sort.Slice(projects, func(i, j int) bool { + return projects[i].ID < projects[j].ID + }) + return projects[0], projects[1:] +} + +func removeProjects(client *mongodbatlas.Client, projects []*mongodbatlas.Project) error { + for _, project := range projects { + _, err := client.Projects.Delete(context.Background(), project.ID) + if err != nil { + return err + } + } + return nil +} diff --git a/pkg/util/fixtest/remove_duplicates_test.go b/pkg/util/fixtest/remove_duplicates_test.go new file mode 100644 index 0000000000..5e5c2cdbe2 --- /dev/null +++ b/pkg/util/fixtest/remove_duplicates_test.go @@ -0,0 +1,109 @@ +package fixtest_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.mongodb.org/atlas/mongodbatlas" + "go.uber.org/zap" + + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/atlastest" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/fixtest" +) + +const ( + fakeDomain = "fake-atlas.local" + fakeProject = "fake-project" +) + +func TestEnsureNoDuplicates(t *testing.T) { + testCases := []struct { + title string + duplicates int + expectedRemovedIds []string + }{ + { + title: "Triplet projects on same name remove the highest ids", + duplicates: 3, + expectedRemovedIds: []string{"2", "3"}, + }, + { + title: "one projects is respected", + duplicates: 1, + expectedRemovedIds: []string{}, + }, + { + title: "zero projects are ignored", + duplicates: 0, + expectedRemovedIds: []string{}, + }, + } + logger, err := zap.NewDevelopment() + require.NoError(t, err) + for _, tc := range testCases { + t.Run(tc.title, func(t *testing.T) { + numberOfProjects := tc.duplicates + projectTriplets := genProjects(fakeProject, numberOfProjects) + removed := []string{} + client := &mongodbatlas.Client{ + Projects: &atlastest.ProjectsServiceMock{ + GetAllProjectsFn: func(listOptions *mongodbatlas.ListOptions) (*mongodbatlas.Projects, *mongodbatlas.Response, error) { + return &mongodbatlas.Projects{ + Results: projectTriplets, + TotalCount: numberOfProjects, + }, &mongodbatlas.Response{}, nil + }, + DeleteFn: func(id string) (*mongodbatlas.Response, error) { + removed = append(removed, id) + return &mongodbatlas.Response{}, nil + }, + }, + } + + err := fixtest.EnsureNoDuplicates(client, logger.Sugar(), fakeProject) + require.NoError(t, err) + assert.Equal(t, tc.expectedRemovedIds, removed) + }) + } +} + +func genProjects(projectName string, max int) []*mongodbatlas.Project { + projects := []*mongodbatlas.Project{} + // generate ids in reverese order N -> 1 to test re-ordering + for i := max; i > 0; i-- { + prj := &mongodbatlas.Project{ + ID: fmt.Sprintf("%d", i), + Name: projectName, + } + projects = append(projects, prj) + } + return projects +} + +func TestEnsureNoDuplicatesIgnoresOne(t *testing.T) { + numberOfProjects := 1 + projectTriplets := genProjects(fakeProject, numberOfProjects) + removed := []string{} + client := &mongodbatlas.Client{ + Projects: &atlastest.ProjectsServiceMock{ + GetAllProjectsFn: func(listOptions *mongodbatlas.ListOptions) (*mongodbatlas.Projects, *mongodbatlas.Response, error) { + return &mongodbatlas.Projects{ + Results: projectTriplets, + TotalCount: numberOfProjects, + }, &mongodbatlas.Response{}, nil + }, + DeleteFn: func(id string) (*mongodbatlas.Response, error) { + removed = append(removed, id) + return &mongodbatlas.Response{}, nil + }, + }, + } + logger, err := zap.NewDevelopment() + require.NoError(t, err) + + err = fixtest.EnsureNoDuplicates(client, logger.Sugar(), fakeProject) + require.NoError(t, err) + assert.Equal(t, []string{}, removed) +} diff --git a/test/e2e/actions/deploy/deploy_operator.go b/test/e2e/actions/deploy/deploy_operator.go index f2474c9461..af0ee57516 100644 --- a/test/e2e/actions/deploy/deploy_operator.go +++ b/test/e2e/actions/deploy/deploy_operator.go @@ -8,12 +8,16 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1/status" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/fixtest" "github.com/mongodb/mongodb-atlas-kubernetes/test/e2e/actions/kube" + "github.com/mongodb/mongodb-atlas-kubernetes/test/e2e/api/atlas" "github.com/mongodb/mongodb-atlas-kubernetes/test/e2e/config" "github.com/mongodb/mongodb-atlas-kubernetes/test/e2e/k8s" "github.com/mongodb/mongodb-atlas-kubernetes/test/e2e/model" @@ -45,14 +49,30 @@ func MultiNamespaceOperator(data *model.TestDataProvider, watchNamespace []strin }) } +func ginkgoZapLogger() *zap.SugaredLogger { + zcore := zapcore.NewCore( + zapcore.NewJSONEncoder(zap.NewDevelopmentEncoderConfig()), + zapcore.Lock(zapcore.AddSync(GinkgoWriter)), + zap.NewAtomicLevel(), + ) + return zap.New(zcore).Sugar() +} + func CreateProject(testData *model.TestDataProvider) { if testData.Project.GetNamespace() == "" { testData.Project.Namespace = testData.Resources.Namespace } By(fmt.Sprintf("Deploy Project %s", testData.Project.GetName()), func() { + aClient := atlas.GetClientOrFail() err := testData.K8SClient.Create(testData.Context, testData.Project) Expect(err).ShouldNot(HaveOccurred(), "Project %s was not created", testData.Project.GetName()) Eventually(func(g Gomega) { + // We reported Atlas creating duplicates of a project with the same name + // See https://jira.mongodb.org/browse/CLOUDP-187749 + // this fix in our tests allows them to automatically fix this issue + // and thus avoid a flaky failure when this duplicates happens + g.Expect(fixtest.EnsureNoDuplicates(aClient.Client, ginkgoZapLogger(), testData.Project.GetName())).ToNot(HaveOccurred()) + condition, _ := k8s.GetProjectStatusCondition( testData.Context, testData.K8SClient,