diff --git a/pkg/api/v1/atlascluster_types.go b/pkg/api/v1/atlascluster_types.go index 63a69329ec..418e2cafb1 100644 --- a/pkg/api/v1/atlascluster_types.go +++ b/pkg/api/v1/atlascluster_types.go @@ -17,13 +17,12 @@ limitations under the License. package v1 import ( - "encoding/json" - "go.mongodb.org/atlas/mongodbatlas" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "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/compat" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/kube" ) @@ -240,18 +239,9 @@ var _ = RegionsConfig(mongodbatlas.RegionsConfig{}) // Cluster converts the Spec to native Atlas client format. func (spec *AtlasClusterSpec) Cluster() (*mongodbatlas.Cluster, error) { - result := mongodbatlas.Cluster{} - b, err := json.Marshal(spec) - if err != nil { - return nil, err - } - - err = json.Unmarshal(b, &result) - if err != nil { - return nil, err - } - - return &result, nil + result := &mongodbatlas.Cluster{} + err := compat.JSONCopy(result, spec) + return result, err } // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object diff --git a/pkg/api/v1/atlasproject_types.go b/pkg/api/v1/atlasproject_types.go index eff9266bac..e30d244303 100644 --- a/pkg/api/v1/atlasproject_types.go +++ b/pkg/api/v1/atlasproject_types.go @@ -17,10 +17,12 @@ limitations under the License. package v1 import ( + "go.mongodb.org/atlas/mongodbatlas" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "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/compat" "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/kube" ) @@ -96,6 +98,19 @@ type ProjectIPAccessList struct { IPAddress string `json:"ipAddress,omitempty"` } +// ToAtlas converts the ProjectIPAccessList to native Atlas client format. +func (i ProjectIPAccessList) ToAtlas() (*mongodbatlas.ProjectIPAccessList, error) { + result := &mongodbatlas.ProjectIPAccessList{} + err := compat.JSONCopy(result, i) + return result, err +} + +// Identifier returns the "id" of the ProjectIPAccessList. Note, that it's an error to specify more than one of these +// fields - the business layer must validate this beforehand +func (i ProjectIPAccessList) Identifier() interface{} { + return i.CIDRBlock + i.AwsSecurityGroup + i.IPAddress +} + func (p *AtlasProject) ConnectionSecretObjectKey() *client.ObjectKey { if p.Spec.ConnectionSecret != nil { key := kube.ObjectKey(p.Namespace, p.Spec.ConnectionSecret.Name) diff --git a/pkg/controller/atlasproject/atlasproject_controller.go b/pkg/controller/atlasproject/atlasproject_controller.go index 49dc4a1559..fac7f7c0e8 100644 --- a/pkg/controller/atlasproject/atlasproject_controller.go +++ b/pkg/controller/atlasproject/atlasproject_controller.go @@ -86,9 +86,12 @@ func (r *AtlasProjectReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error // Updating the status with "projectReady = true" and "IPAccessListReady = false" (not as separate updates!) ctx.SetConditionTrue(status.ProjectReadyType) - statushandler.Update(ctx.SetConditionFalse(status.IPAccessListReadyType), r.Client, project) - // TODO projectAccessList + if result = r.ensureIPAccessList(ctx, connection, projectID, project); !result.IsOk() { + ctx.SetConditionFromResult(status.IPAccessListReadyType, result) + return result.ReconcileResult(), nil + } + ctx.SetConditionTrue(status.IPAccessListReadyType) ctx.SetConditionTrue(status.ReadyType) return ctrl.Result{}, nil } diff --git a/pkg/controller/atlasproject/ipaccess_list.go b/pkg/controller/atlasproject/ipaccess_list.go new file mode 100644 index 0000000000..4cd8efbc52 --- /dev/null +++ b/pkg/controller/atlasproject/ipaccess_list.go @@ -0,0 +1,129 @@ +package atlasproject + +import ( + "context" + "errors" + "time" + + "go.mongodb.org/atlas/mongodbatlas" + "go.uber.org/zap" + + mdbv1 "github.com/mongodb/mongodb-atlas-kubernetes/pkg/api/v1" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/atlas" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/controller/workflow" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/set" + "github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/timeutil" +) + +// atlasProjectIPAccessList is a synonym of Atlas object as we need to implement 'Identifier' (and we cannot modify +// their object) +type atlasProjectIPAccessList mongodbatlas.ProjectIPAccessList + +func (i atlasProjectIPAccessList) Identifier() interface{} { + return i.CIDRBlock + i.AwsSecurityGroup + i.IPAddress +} + +func (r *AtlasProjectReconciler) ensureIPAccessList(ctx *workflow.Context, connection atlas.Connection, projectID string, project *mdbv1.AtlasProject) workflow.Result { + if err := validateIPAccessLists(project.Spec.ProjectIPAccessList); err != nil { + return workflow.Terminate(workflow.ProjectIPAccessInvalid, err.Error()) + } + active, _ := filterActiveIPAccessLists(project) + + client, err := atlas.Client(r.AtlasDomain, connection, ctx.Log) + if err != nil { + return workflow.Terminate(workflow.Internal, err.Error()) + } + if result := createOrDeleteInAtlas(client, projectID, active, ctx.Log); !result.IsOk() { + return result + } + // TODO update status - add the expired project IP access list there + return workflow.OK() +} + +func validateIPAccessLists(ipAccessList []mdbv1.ProjectIPAccessList) error { + for _, list := range ipAccessList { + if list.DeleteAfterDate != "" { + _, err := timeutil.ParseISO8601(list.DeleteAfterDate) + if err != nil { + return err + } + } + // Go doesn't support XOR, but uses '!=' instead: https://stackoverflow.com/a/23025720/614239 + onlyOneSpecified := isNotEmpty(list.AwsSecurityGroup) != isNotEmpty(list.CIDRBlock) != isNotEmpty(list.IPAddress) + if !onlyOneSpecified { + return errors.New("only one of the 'awsSecurityGroup', 'cidrBlock' or 'ipAddress' is required be specified") + } + } + return nil +} + +func createOrDeleteInAtlas(client *mongodbatlas.Client, projectID string, operatorIPAccessLists []mdbv1.ProjectIPAccessList, log *zap.SugaredLogger) workflow.Result { + atlasAccess, _, err := client.ProjectIPAccessList.List(context.Background(), projectID, &mongodbatlas.ListOptions{}) + if err != nil { + return workflow.Terminate(workflow.ProjectIPNotCreatedInAtlas, err.Error()) + } + // Making a new slice with synonyms as Atlas IP Access list to enable usage of 'Identifiable' + atlasAccessLists := make([]atlasProjectIPAccessList, len(atlasAccess.Results)) + for i, r := range atlasAccess.Results { + atlasAccessLists[i] = atlasProjectIPAccessList(r) + } + + difference := set.Difference(atlasAccessLists, operatorIPAccessLists) + + if err := deleteIPAccessFromAtlas(client, projectID, difference, log); err != nil { + return workflow.Terminate(workflow.ProjectIPNotCreatedInAtlas, err.Error()) + } + + if result := createIPAccessListsInAtlas(client, projectID, operatorIPAccessLists); !result.IsOk() { + return result + } + return workflow.OK() +} + +func createIPAccessListsInAtlas(client *mongodbatlas.Client, projectID string, ipAccessLists []mdbv1.ProjectIPAccessList) workflow.Result { + operatorAccessLists := make([]*mongodbatlas.ProjectIPAccessList, len(ipAccessLists)) + for i, list := range ipAccessLists { + atlasFormat, err := list.ToAtlas() + if err != nil { + return workflow.Terminate(workflow.Internal, err.Error()) + } + operatorAccessLists[i] = atlasFormat + } + + if _, _, err := client.ProjectIPAccessList.Create(context.Background(), projectID, operatorAccessLists); err != nil { + return workflow.Terminate(workflow.ProjectIPNotCreatedInAtlas, err.Error()) + } + return workflow.OK() +} + +func deleteIPAccessFromAtlas(client *mongodbatlas.Client, projectID string, listsToRemove []set.Identifiable, log *zap.SugaredLogger) error { + for _, l := range listsToRemove { + if _, err := client.ProjectIPAccessList.Delete(context.Background(), projectID, l.Identifier().(string)); err != nil { + return err + } + log.Debugw("Removed IPAccessList from Atlas as it's not specified in current AtlasProject", "id", l.Identifier()) + } + return nil +} + +func filterActiveIPAccessLists(project *mdbv1.AtlasProject) ([]mdbv1.ProjectIPAccessList, []mdbv1.ProjectIPAccessList) { + active := make([]mdbv1.ProjectIPAccessList, 0) + expired := make([]mdbv1.ProjectIPAccessList, 0) + for _, list := range project.Spec.ProjectIPAccessList { + if list.DeleteAfterDate != "" { + // We are ignoring the error as it will never happen due to validation check before + iso8601, _ := timeutil.ParseISO8601(list.DeleteAfterDate) + if iso8601.Before(time.Now()) { + expired = append(expired, list) + continue + } + } + // Either 'deleteAfterDate' field is not specified or it's higher than the current time + active = append(active, list) + } + return active, expired +} + +func isNotEmpty(s string) bool { + return s != "" +} diff --git a/pkg/controller/workflow/reason.go b/pkg/controller/workflow/reason.go index 17d6c65675..096a01398b 100644 --- a/pkg/controller/workflow/reason.go +++ b/pkg/controller/workflow/reason.go @@ -12,7 +12,9 @@ const ( // Atlas Project reasons const ( - ProjectNotCreatedInAtlas ConditionReason = "ProjectNotCreatedInAtlas" + ProjectNotCreatedInAtlas ConditionReason = "ProjectNotCreatedInAtlas" + ProjectIPAccessInvalid ConditionReason = "ProjectIPAccessListInvalid" + ProjectIPNotCreatedInAtlas ConditionReason = "ProjectIPAccessListNotCreatedInAtlas" ) // Atlas Cluster reasons diff --git a/pkg/util/timeutil/timeutil.go b/pkg/util/timeutil/timeutil.go new file mode 100644 index 0000000000..8eb35388b8 --- /dev/null +++ b/pkg/util/timeutil/timeutil.go @@ -0,0 +1,7 @@ +package timeutil + +import "time" + +func ParseISO8601(dateTime string) (time.Time, error) { + return time.Parse(dateTime, "2006-01-02T15:04:05-0700") +} diff --git a/test/int/project_test.go b/test/int/project_test.go index 172103c337..50f627c60d 100644 --- a/test/int/project_test.go +++ b/test/int/project_test.go @@ -65,7 +65,7 @@ var _ = Describe("AtlasProject", func() { Expect(createdProject.Status.ID).NotTo(BeNil()) expectedConditionsMatchers := testutil.MatchConditions( status.TrueCondition(status.ProjectReadyType), - status.FalseCondition(status.IPAccessListReadyType), + status.TrueCondition(status.IPAccessListReadyType), status.TrueCondition(status.ReadyType), ) Expect(createdProject.Status.Conditions).To(ConsistOf(expectedConditionsMatchers)) @@ -181,6 +181,19 @@ var _ = Describe("AtlasProject", func() { }) }) }) + + Describe("Creating the project IP access list", func() { + It("Should Succeed", func() { + expectedProject := testAtlasProject(namespace.Name, "test-project", namespace.Name, connectionSecret.Name) + expectedProject.Spec.ProjectIPAccessList = []mdbv1.ProjectIPAccessList{{Comment: "bla", IPAddress: "192.0.2.15"}} + createdProject.ObjectMeta = expectedProject.ObjectMeta + Expect(k8sClient.Create(context.Background(), expectedProject)).ToNot(HaveOccurred()) + + Eventually(testutil.WaitFor(k8sClient, createdProject, status.TrueCondition(status.ReadyType)), + 20, interval).Should(BeTrue()) + }) + }) + }) // TODO builders