Skip to content

Commit

Permalink
CLOUDP-186825: Add deletion protection to deployments
Browse files Browse the repository at this point in the history
Signed-off-by: Jose Vazquez <jose.vazquez@mongodb.com>
  • Loading branch information
josvazg committed Jul 12, 2023
1 parent d44d490 commit 0157cb3
Show file tree
Hide file tree
Showing 11 changed files with 719 additions and 35 deletions.
18 changes: 10 additions & 8 deletions cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,16 @@ func main() {
}

if err = (&atlasdeployment.AtlasDeploymentReconciler{
Client: mgr.GetClient(),
Log: logger.Named("controllers").Named("AtlasDeployment").Sugar(),
Scheme: mgr.GetScheme(),
AtlasDomain: config.AtlasDomain,
GlobalAPISecret: config.GlobalAPISecret,
ResourceWatcher: watch.NewResourceWatcher(),
GlobalPredicates: globalPredicates,
EventRecorder: mgr.GetEventRecorderFor("AtlasDeployment"),
Client: mgr.GetClient(),
Log: logger.Named("controllers").Named("AtlasDeployment").Sugar(),
Scheme: mgr.GetScheme(),
AtlasDomain: config.AtlasDomain,
GlobalAPISecret: config.GlobalAPISecret,
ResourceWatcher: watch.NewResourceWatcher(),
GlobalPredicates: globalPredicates,
EventRecorder: mgr.GetEventRecorderFor("AtlasDeployment"),
ObjectDeletionProtection: config.ObjectDeletionProtection,
SubObjectDeletionProtection: config.SubObjectDeletionProtection,
}).SetupWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "AtlasDeployment")
os.Exit(1)
Expand Down
3 changes: 1 addition & 2 deletions pkg/api/v1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 13 additions & 1 deletion pkg/controller/atlas/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,18 @@ import (
"github.com/mongodb/mongodb-atlas-kubernetes/pkg/util/httputil"
)

type HTTPClientFn func() *http.Client

// CustomHTTPClientFn allows to override the default, usually for tests
var CustomHTTPClientFn HTTPClientFn

// Client is the central place to create a client for Atlas using specified API keys and a server URL.
// Note, that the default HTTP transport is reused globally by Go so all caching, keep-alive etc will be in action.
func Client(atlasDomain string, connection Connection, log *zap.SugaredLogger) (mongodbatlas.Client, error) {
withDigest := httputil.Digest(connection.PublicKey, connection.PrivateKey)
withLogging := httputil.LoggingTransport(log)

httpClient, err := httputil.DecorateClient(basicClient(), withDigest, withLogging)
httpClient, err := httputil.DecorateClient(newClient(), withDigest, withLogging)
if err != nil {
return mongodbatlas.Client{}, err
}
Expand All @@ -36,3 +41,10 @@ func basicClient() *http.Client {
// Do we need any custom configuration of timeout etc?
return &http.Client{Transport: http.DefaultTransport}
}

func newClient() *http.Client {
if CustomHTTPClientFn != nil {
return CustomHTTPClientFn()
}
return basicClient()
}
38 changes: 23 additions & 15 deletions pkg/controller/atlasdeployment/atlasdeployment_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,15 @@ import (
// AtlasDeploymentReconciler reconciles an AtlasDeployment object
type AtlasDeploymentReconciler struct {
watch.ResourceWatcher
Client client.Client
Log *zap.SugaredLogger
Scheme *runtime.Scheme
AtlasDomain string
GlobalAPISecret client.ObjectKey
GlobalPredicates []predicate.Predicate
EventRecorder record.EventRecorder
Client client.Client
Log *zap.SugaredLogger
Scheme *runtime.Scheme
AtlasDomain string
GlobalAPISecret client.ObjectKey
GlobalPredicates []predicate.Predicate
EventRecorder record.EventRecorder
ObjectDeletionProtection bool
SubObjectDeletionProtection bool
}

// +kubebuilder:rbac:groups=atlas.mongodb.com,resources=atlasdeployments,verbs=get;list;watch;create;update;patch;delete
Expand Down Expand Up @@ -161,17 +163,23 @@ func (r *AtlasDeploymentReconciler) Reconcile(context context.Context, req ctrl.

if !deployment.GetDeletionTimestamp().IsZero() {
if customresource.HaveFinalizer(deployment, customresource.FinalizerLabel) {
if customresource.ResourceShouldBeLeftInAtlas(deployment) {
log.Infof("Not removing Atlas Deployment from Atlas as the '%s' annotation is set", customresource.ResourcePolicyAnnotation)
isProtected := customresource.IsResourceProtected(deployment, r.ObjectDeletionProtection)
log.Infow("RESOURCE PROTECTED", r.ObjectDeletionProtection, isProtected)
if isProtected {
log.Info("Not removing Atlas deployment from Atlas as per configuration")
} else {
if err = r.deleteDeploymentFromAtlas(context, project, deployment, atlasClient, log); err != nil {
log.Errorf("failed to remove deployment from Atlas: %s", err)
result = workflow.Terminate(workflow.Internal, err.Error())
ctx.SetConditionFromResult(status.DeploymentReadyType, result)
return result.ReconcileResult(), nil
if customresource.ResourceShouldBeLeftInAtlas(deployment) {
log.Infof("Not removing Atlas Deployment from Atlas as the '%s' annotation is set", customresource.ResourcePolicyAnnotation)
} else {
if err = r.deleteDeploymentFromAtlas(context, project, deployment, atlasClient, log); err != nil {
log.Errorf("failed to remove deployment from Atlas: %s", err)
result = workflow.Terminate(workflow.Internal, err.Error())
ctx.SetConditionFromResult(status.DeploymentReadyType, result)
return result.ReconcileResult(), nil
}
}
}
err = r.removeDeletionFinalizer(context, deployment)
err = customresource.ManageFinalizer(context, r.Client, deployment, customresource.UnsetFinalizer)
if err != nil {
result = workflow.Terminate(workflow.Internal, err.Error())
log.Errorw("failed to remove finalizer", "error", err)
Expand Down
10 changes: 10 additions & 0 deletions pkg/controller/customresource/customresource.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,13 @@ func ReconciliationShouldBeSkipped(resource mdbv1.AtlasCustomResource) bool {
}
return false
}

// SetAnnotation sets an annotation in resource while respecting the rest of annotations.
func SetAnnotation(resource mdbv1.AtlasCustomResource, key, value string) {
annot := resource.GetAnnotations()
if annot == nil {
annot = map[string]string{}
}
annot[key] = value
resource.SetAnnotations(annot)
}
149 changes: 149 additions & 0 deletions pkg/controller/customresource/protection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package customresource_test

import (
"context"
"fmt"
"testing"

"github.com/stretchr/testify/assert"

"sigs.k8s.io/controller-runtime/pkg/client/fake"

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

func sampleResource() *mdbv1.AtlasDatabaseUser {
return &mdbv1.AtlasDatabaseUser{
Spec: mdbv1.AtlasDatabaseUserSpec{},
}
}

func taggedResource(tag, value string) *mdbv1.AtlasDatabaseUser {
dbUser := sampleResource()
annot := map[string]string{}
annot[tag] = value
dbUser.SetAnnotations(annot)
return dbUser
}

func testOpChecker(reply bool) customresource.OperatorChecker {
return func(resource mdbv1.AtlasCustomResource) (bool, error) {
return reply, nil
}
}

func testAtlasChecker(reply bool) customresource.AtlasChecker {
return func(resource mdbv1.AtlasCustomResource) (bool, error) {
return reply, nil
}
}

var ErrOpChecker = fmt.Errorf("operator checker failed")

func failedOpChecker(err error) customresource.OperatorChecker {
return func(resource mdbv1.AtlasCustomResource) (bool, error) {
return false, err
}
}

var ErrAtlasChecker = fmt.Errorf("atlas checker failed")

func failedAtlasChecker(err error) customresource.AtlasChecker {
return func(resource mdbv1.AtlasCustomResource) (bool, error) {
return false, err
}
}

func TestWithoutProtectionIsOwned(t *testing.T) {
owned, err := customresource.IsOwner(sampleResource(), false, nil, nil)
assert.NoError(t, err)
assert.Equal(t, owned, true)
}

func TestProtected(t *testing.T) {
tests := []struct {
title string
opChecker customresource.OperatorChecker
atlasChecker customresource.AtlasChecker
expectedOwned bool
}{
{"managed is owned", testOpChecker(true), nil, true},
{"unmanaged but not in Atlas is owned", testOpChecker(false), testAtlasChecker(false), true},
{"unmanaged but in Atlas is NOT owned", testOpChecker(false), testAtlasChecker(true), false},
}
for _, tc := range tests {
t.Run(fmt.Sprintf("Protected and %s", tc.title), func(t *testing.T) {
owned, err := customresource.IsOwner(sampleResource(), true, tc.opChecker, tc.atlasChecker)
assert.NoError(t, err)
assert.Equal(t, tc.expectedOwned, owned)
})
}
}

func TestProtectedFailures(t *testing.T) {
tests := []struct {
title string
opChecker customresource.OperatorChecker
atlasChecker customresource.AtlasChecker
expectedFailure error
}{
{"When all checkers fail, operator checker fails first", failedOpChecker(ErrOpChecker), failedAtlasChecker(ErrAtlasChecker), ErrOpChecker},
{"When unamanaged and atlas checker fails we get that its failure", testOpChecker(false), failedAtlasChecker(ErrAtlasChecker), ErrAtlasChecker},
}
for _, tc := range tests {
t.Run(tc.title, func(t *testing.T) {
_, err := customresource.IsOwner(sampleResource(), true, tc.opChecker, tc.atlasChecker)
assert.Equal(t, tc.expectedFailure, err)
})
}
}

func TestIsResourceProtected(t *testing.T) {
tests := []struct {
title string
protectionFlag bool
resource mdbv1.AtlasCustomResource
expectedProtected bool
}{
{"Resource without tags with the flag set is protected", true, sampleResource(), true},
{"Resource without tags with the flag unset isn't protected", false, sampleResource(), false},
{
"Resource with keep tag is protected",
false,
taggedResource(customresource.ResourcePolicyAnnotation, customresource.ResourcePolicyKeep),
true,
},
{
"Resource with delete tag and protected flag set is NOT protected",
true,
taggedResource(customresource.ResourcePolicyAnnotation, customresource.ResourcePolicyDelete),
false,
},
{
"Resource with delete tag and protected flag unset isn't protected",
false,
taggedResource(customresource.ResourcePolicyAnnotation, customresource.ResourcePolicyDelete),
false,
},
}
for _, tc := range tests {
t.Run(tc.title, func(t *testing.T) {
assert.Equal(t, tc.expectedProtected, customresource.IsResourceProtected(tc.resource, tc.protectionFlag))
})
}
}

func TestApplyLastConfigApplied(t *testing.T) {
resource := sampleResource()
resource.Spec.Username = "test-user"

// ignore the error due to not configuring the fake client
// we are not checking that, we are only interested on a new annotation in resource
_ = customresource.ApplyLastConfigApplied(context.Background(), resource, fake.NewClientBuilder().Build())

annot := resource.GetAnnotations()
assert.NotEmpty(t, annot)
expectedConfig := `{"projectRef":{"name":"","namespace":""},"roles":null,"username":"test-user"}`
assert.Equal(t, annot[customresource.AnnotationLastAppliedConfiguration], expectedConfig)
}
8 changes: 7 additions & 1 deletion pkg/controller/workflow/result.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ const (
DefaultTimeout = time.Minute * 20
)

// RetryTime configurable time for a retry
var RetryTime = DefaultRetry

// Timeout configurable timeout
var Timeout = DefaultTimeout

type Result struct {
terminated bool
requeueAfter time.Duration
Expand All @@ -36,7 +42,7 @@ func OK() Result {
func Terminate(reason ConditionReason, message string) Result {
return Result{
terminated: true,
requeueAfter: DefaultRetry,
requeueAfter: RetryTime,
reason: reason,
message: message,
warning: true,
Expand Down
Loading

0 comments on commit 0157cb3

Please sign in to comment.