From 8bfc09cd39755eb0b52ccea8aba0c40f209750da Mon Sep 17 00:00:00 2001 From: Conor Nolan Date: Fri, 28 Jun 2024 10:32:37 +0100 Subject: [PATCH] Support versioning (#268) * Add VersioningConfiguration APIs * Add subresource versioning * Add versioning configuration subresource unit tests * Add chainsaw tests cases * Additional unit tests for updated subresources --- apis/provider-ceph/v1alpha1/bucket_types.go | 12 + .../v1alpha1/versioningconfiguration_types.go | 28 + .../v1alpha1/zz_generated.deepcopy.go | 35 + e2e/tests/ceph/chainsaw-test.yaml | 15 +- e2e/tests/stable/chainsaw-test.yaml | 74 ++- internal/backendstore/backend.go | 2 + .../backendstorefakes/fake_s3client.go | 166 +++++ internal/controller/bucket/bucket_backends.go | 80 ++- internal/controller/bucket/consts.go | 4 + internal/controller/bucket/helpers.go | 17 +- internal/controller/bucket/subresources.go | 1 + internal/controller/bucket/update.go | 2 +- internal/controller/bucket/update_test.go | 616 ++++++++++++++++++ .../bucket/versioningconfiguration.go | 214 ++++++ .../bucket/versioningconfiguration_test.go | 553 ++++++++++++++++ internal/rgw/versioningconfiguration.go | 48 ++ .../rgw/versioningconfiguration_helpers.go | 32 + ...vider-ceph.ceph.crossplane.io_buckets.yaml | 59 ++ 18 files changed, 1924 insertions(+), 34 deletions(-) create mode 100644 apis/provider-ceph/v1alpha1/versioningconfiguration_types.go create mode 100644 internal/controller/bucket/versioningconfiguration.go create mode 100644 internal/controller/bucket/versioningconfiguration_test.go create mode 100644 internal/rgw/versioningconfiguration.go create mode 100644 internal/rgw/versioningconfiguration_helpers.go diff --git a/apis/provider-ceph/v1alpha1/bucket_types.go b/apis/provider-ceph/v1alpha1/bucket_types.go index 0f7f760c..0a15b55f 100644 --- a/apis/provider-ceph/v1alpha1/bucket_types.go +++ b/apis/provider-ceph/v1alpha1/bucket_types.go @@ -82,6 +82,12 @@ type BucketParameters struct { // +optional LifecycleConfiguration *BucketLifecycleConfiguration `json:"lifecycleConfiguration,omitempty"` + // VersioningConfiguration describes the desired versioning state of an S3 bucket. + // See the API reference guide for PutBucketVersioning for usage and error information. + // See also, https://docs.aws.amazon.com/goto/WebAPI/s3-2006-03-01/PutBucketVersioning + // +optional + VersioningConfiguration *VersioningConfiguration `json:"versioningConfiguration,omitempty"` + // AssumeRoleTags may be used to add custom values to an AssumeRole request. // +optional AssumeRoleTags []Tag `json:"assumeRoleTags,omitempty"` @@ -98,10 +104,16 @@ type BucketParameters struct { type BackendInfo struct { // BucketCondition is the condition of the Bucket on the S3 backend. BucketCondition xpv1.Condition `json:"bucketCondition,omitempty"` + // +optional // LifecycleConfigurationCondition is the condition of the bucket lifecycle // configuration on the S3 backend. Use a pointer to allow nil value when // there is no lifecycle configuration. LifecycleConfigurationCondition *xpv1.Condition `json:"lifecycleConfigurationCondition,omitempty"` + // +optional + // VersioningConfigurationCondition is the condition of the versioning + // configuration on the S3 backend. Use a pointer to allow nil value when + // there is no versioning configuration. + VersioningConfigurationCondition *xpv1.Condition `json:"versioningConfigurationCondition,omitempty"` } // Backends is a map of the names of the S3 backends to BackendInfo. diff --git a/apis/provider-ceph/v1alpha1/versioningconfiguration_types.go b/apis/provider-ceph/v1alpha1/versioningconfiguration_types.go new file mode 100644 index 00000000..9ed1d2a1 --- /dev/null +++ b/apis/provider-ceph/v1alpha1/versioningconfiguration_types.go @@ -0,0 +1,28 @@ +package v1alpha1 + +type VersioningStatus string + +const ( + VersioningStatusEnabled VersioningStatus = "Enabled" + VersioningStatusSuspended VersioningStatus = "Suspended" +) + +type MFADelete string + +const ( + MFADeleteEnabled MFADelete = "Enabled" + MFADeleteDisabled MFADelete = "Disabled" +) + +// VersioningConfiguration describes the versioning state of an S3 bucket. +type VersioningConfiguration struct { + // MFADelete specifies whether MFA delete is enabled in the bucket versioning configuration. + // This element is only returned if the bucket has been configured with MFA + // delete. If the bucket has never been so configured, this element is not returned. + // +kubebuilder:validation:Enum=Enabled;Disabled + MFADelete *MFADelete `json:"mfaDelete,omitempty"` + + // Status is the desired versioning state of the bucket. + // +kubebuilder:validation:Enum=Enabled;Suspended + Status *VersioningStatus `json:"status,omitempty"` +} diff --git a/apis/provider-ceph/v1alpha1/zz_generated.deepcopy.go b/apis/provider-ceph/v1alpha1/zz_generated.deepcopy.go index 9bcfe66a..c5e7a6ef 100644 --- a/apis/provider-ceph/v1alpha1/zz_generated.deepcopy.go +++ b/apis/provider-ceph/v1alpha1/zz_generated.deepcopy.go @@ -81,6 +81,11 @@ func (in *BackendInfo) DeepCopyInto(out *BackendInfo) { *out = new(v1.Condition) (*in).DeepCopyInto(*out) } + if in.VersioningConfigurationCondition != nil { + in, out := &in.VersioningConfigurationCondition, &out.VersioningConfigurationCondition + *out = new(v1.Condition) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendInfo. @@ -288,6 +293,11 @@ func (in *BucketParameters) DeepCopyInto(out *BucketParameters) { *out = new(BucketLifecycleConfiguration) (*in).DeepCopyInto(*out) } + if in.VersioningConfiguration != nil { + in, out := &in.VersioningConfiguration, &out.VersioningConfiguration + *out = new(VersioningConfiguration) + (*in).DeepCopyInto(*out) + } if in.AssumeRoleTags != nil { in, out := &in.AssumeRoleTags, &out.AssumeRoleTags *out = make([]Tag, len(*in)) @@ -675,3 +685,28 @@ func (in *Transition) DeepCopy() *Transition { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *VersioningConfiguration) DeepCopyInto(out *VersioningConfiguration) { + *out = *in + if in.MFADelete != nil { + in, out := &in.MFADelete, &out.MFADelete + *out = new(MFADelete) + **out = **in + } + if in.Status != nil { + in, out := &in.Status, &out.Status + *out = new(VersioningStatus) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VersioningConfiguration. +func (in *VersioningConfiguration) DeepCopy() *VersioningConfiguration { + if in == nil { + return nil + } + out := new(VersioningConfiguration) + in.DeepCopyInto(out) + return out +} diff --git a/e2e/tests/ceph/chainsaw-test.yaml b/e2e/tests/ceph/chainsaw-test.yaml index 473591d8..1a1ccaf9 100755 --- a/e2e/tests/ceph/chainsaw-test.yaml +++ b/e2e/tests/ceph/chainsaw-test.yaml @@ -135,7 +135,7 @@ spec: - $CEPH_ADDRESS entrypoint: ../../../hack/expect_bucket.sh - - name: Apply lifecycle configuration to test-bucket. + - name: Apply lifecycle configuration and versioning configuration to test-bucket. try: - apply: resource: @@ -147,6 +147,8 @@ spec: providers: - ceph-cluster forProvider: + versioningConfiguration: + status: "Enabled" lifecycleConfiguration: # Example rules from https://docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-configuration-examples.html rules: @@ -195,6 +197,10 @@ spec: reason: Available status: "True" type: Ready + versioningConfigurationCondition: + reason: Available + status: "True" + type: Ready conditions: - reason: Available status: "True" @@ -203,7 +209,7 @@ spec: status: "True" type: Synced - - name: Disable lifecycle configuration on test-bucket. + - name: Disable lifecycle configuration and remove versioning configuration on test-bucket. try: - apply: resource: @@ -218,6 +224,7 @@ spec: - ceph-cluster lifecycleConfigurationDisabled: true forProvider: + versioningConfiguration: lifecycleConfiguration: # Example rules https://docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-configuration-examples.html rules: @@ -262,6 +269,10 @@ spec: reason: Available status: "True" type: Ready + versioningConfigurationCondition: + reason: Available + status: "True" + type: Ready conditions: - reason: Available status: "True" diff --git a/e2e/tests/stable/chainsaw-test.yaml b/e2e/tests/stable/chainsaw-test.yaml index 03e6bb95..0f0e92c3 100755 --- a/e2e/tests/stable/chainsaw-test.yaml +++ b/e2e/tests/stable/chainsaw-test.yaml @@ -114,14 +114,14 @@ spec: status: availableReplicas: 1 - - name: Apply lc-config-bucket and auto-pause-bucket. + - name: Apply lc-and-version-configs and auto-pause-bucket. try: - apply: resource: apiVersion: provider-ceph.ceph.crossplane.io/v1alpha1 kind: Bucket metadata: - name: lc-config-bucket + name: lc-and-version-configs labels: provider-ceph.crossplane.io/validation-required: "true" spec: @@ -135,6 +135,8 @@ spec: days: 1 filter: prefix: "images/" + versioningConfiguration: + status: "Enabled" - apply: resource: apiVersion: provider-ceph.ceph.crossplane.io/v1alpha1 @@ -146,13 +148,13 @@ spec: spec: autoPause: true forProvider: {} - # Assert lc-config-bucket is synced with LC configs on backends. + # Assert lc-and-version-configs is synced with LC and Versioning configs on backends. - assert: resource: apiVersion: provider-ceph.ceph.crossplane.io/v1alpha1 kind: Bucket metadata: - name: lc-config-bucket + name: lc-and-version-configs finalizers: - "finalizer.managedresource.crossplane.io" labels: @@ -171,6 +173,10 @@ spec: reason: Available status: "True" type: Ready + versioningConfigurationCondition: + reason: Available + status: "True" + type: Ready localstack-b: bucketCondition: reason: Available @@ -180,6 +186,10 @@ spec: reason: Available status: "True" type: Ready + versioningConfigurationCondition: + reason: Available + status: "True" + type: Ready localstack-c: bucketCondition: reason: Available @@ -189,6 +199,10 @@ spec: reason: Available status: "True" type: Ready + versioningConfigurationCondition: + reason: Available + status: "True" + type: Ready # Extra assertion for overall Bucket conditions. # This method of iterative assertions is necessary here because # these conditions do not always appear in the same order. @@ -200,7 +214,7 @@ spec: apiVersion: provider-ceph.ceph.crossplane.io/v1alpha1 kind: Bucket metadata: - name: lc-config-bucket + name: lc-and-version-configs status: ~.(conditions[?reason == 'Available']): status: "True" @@ -250,14 +264,14 @@ spec: status: "True" type: Synced - - name: Disable LC config on lc-config-bucket. + - name: Disable LC config and remove versioning on lc-and-version-configs. try: - apply: resource: apiVersion: provider-ceph.ceph.crossplane.io/v1alpha1 kind: Bucket metadata: - name: lc-config-bucket + name: lc-and-version-configs labels: provider-ceph.crossplane.io/validation-required: "true" spec: @@ -275,13 +289,16 @@ spec: days: 1 filter: prefix: "images/" - # Assert that the LC config has been removed from lc-config-bucket. + versioningConfiguration: + # Assert that the LC config has been removed from lc-and-version-configs. + # Assert that the versioning config remains. By removing it from the Spec above + # we are only suspending versioning, buckets cannot be un-versioned. - assert: resource: apiVersion: provider-ceph.ceph.crossplane.io/v1alpha1 kind: Bucket metadata: - name: lc-config-bucket + name: lc-and-version-configs status: atProvider: backends: @@ -290,16 +307,29 @@ spec: reason: Available status: "True" type: Ready + versioningConfigurationCondition: + reason: Available + status: "True" + type: Ready localstack-b: bucketCondition: reason: Available status: "True" type: Ready + versioningConfigurationCondition: + reason: Available + status: "True" + type: Ready localstack-c: bucketCondition: reason: Available status: "True" type: Ready + versioningConfigurationCondition: + reason: Available + status: "True" + type: Ready + # This method of iterative assertions is necessary here because # these conditions do not always appear in the same order. # It's safe to perform this assertions because we have already @@ -313,23 +343,23 @@ spec: - name: Check for buckets on Localstack backends. try: - # Check for lc-config-bucket on all backends. + # Check for lc-and-version-configs on all backends. - command: args: - bucket_exists - - lc-config-bucket + - lc-and-version-configs - local-dev-control-plane:32566 entrypoint: ../../../hack/expect_bucket.sh - command: args: - bucket_exists - - lc-config-bucket + - lc-and-version-configs - local-dev-control-plane:32567 entrypoint: ../../../hack/expect_bucket.sh - command: args: - bucket_exists - - lc-config-bucket + - lc-and-version-configs - local-dev-control-plane:32568 entrypoint: ../../../hack/expect_bucket.sh # Check for auto-pause-bucket on all backends. @@ -412,16 +442,16 @@ spec: - local-dev-control-plane:32568 entrypoint: ../../../hack/expect_bucket.sh - - name: Delete lc-config-bucket and auto-pause-bucket. + - name: Delete lc-and-version-configs and auto-pause-bucket. try: - command: args: - delete - bucket - - lc-config-bucket + - lc-and-version-configs entrypoint: kubectl - command: - # We need to "unpause" lc-config-bucket to allow deletion. + # We need to "unpause" lc-and-version-configs to allow deletion. args: - patch - --type=merge @@ -441,7 +471,7 @@ spec: apiVersion: provider-ceph.ceph.crossplane.io/v1alpha1 kind: Bucket metadata: - name: lc-config-bucket + name: lc-and-version-configs - error: resource: apiVersion: provider-ceph.ceph.crossplane.io/v1alpha1 @@ -449,25 +479,25 @@ spec: metadata: name: auto-pause-bucket - - name: Check for lc-config-bucket and auto-pause-bucket on backends. + - name: Check for lc-and-version-configs and auto-pause-bucket on backends. try: - # Check for lc-config-bucket on all backends. + # Check for lc-and-version-configs on all backends. - command: args: - bucket_does_not_exist - - lc-config-bucket + - lc-and-version-configs - local-dev-control-plane:32566 entrypoint: ../../../hack/expect_bucket.sh - command: args: - bucket_does_not_exist - - lc-config-bucket + - lc-and-version-configs - local-dev-control-plane:32567 entrypoint: ../../../hack/expect_bucket.sh - command: args: - bucket_does_not_exist - - lc-config-bucket + - lc-and-version-configs - local-dev-control-plane:32568 entrypoint: ../../../hack/expect_bucket.sh # Check for auto-pause-bucket on all backends. diff --git a/internal/backendstore/backend.go b/internal/backendstore/backend.go index e152210b..069c76c8 100644 --- a/internal/backendstore/backend.go +++ b/internal/backendstore/backend.go @@ -44,6 +44,8 @@ type S3Client interface { PutBucketPolicy(context.Context, *s3.PutBucketPolicyInput, ...func(*s3.Options)) (*s3.PutBucketPolicyOutput, error) GetBucketPolicy(context.Context, *s3.GetBucketPolicyInput, ...func(*s3.Options)) (*s3.GetBucketPolicyOutput, error) DeleteBucketPolicy(context.Context, *s3.DeleteBucketPolicyInput, ...func(*s3.Options)) (*s3.DeleteBucketPolicyOutput, error) + PutBucketVersioning(context.Context, *s3.PutBucketVersioningInput, ...func(*s3.Options)) (*s3.PutBucketVersioningOutput, error) + GetBucketVersioning(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) } //counterfeiter:generate . STSClient diff --git a/internal/backendstore/backendstorefakes/fake_s3client.go b/internal/backendstore/backendstorefakes/fake_s3client.go index 90c41468..844e9c0c 100644 --- a/internal/backendstore/backendstorefakes/fake_s3client.go +++ b/internal/backendstore/backendstorefakes/fake_s3client.go @@ -130,6 +130,21 @@ type FakeS3Client struct { result1 *s3.GetBucketPolicyOutput result2 error } + GetBucketVersioningStub func(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) + getBucketVersioningMutex sync.RWMutex + getBucketVersioningArgsForCall []struct { + arg1 context.Context + arg2 *s3.GetBucketVersioningInput + arg3 []func(*s3.Options) + } + getBucketVersioningReturns struct { + result1 *s3.GetBucketVersioningOutput + result2 error + } + getBucketVersioningReturnsOnCall map[int]struct { + result1 *s3.GetBucketVersioningOutput + result2 error + } GetObjectStub func(context.Context, *s3.GetObjectInput, ...func(*s3.Options)) (*s3.GetObjectOutput, error) getObjectMutex sync.RWMutex getObjectArgsForCall []struct { @@ -235,6 +250,21 @@ type FakeS3Client struct { result1 *s3.PutBucketPolicyOutput result2 error } + PutBucketVersioningStub func(context.Context, *s3.PutBucketVersioningInput, ...func(*s3.Options)) (*s3.PutBucketVersioningOutput, error) + putBucketVersioningMutex sync.RWMutex + putBucketVersioningArgsForCall []struct { + arg1 context.Context + arg2 *s3.PutBucketVersioningInput + arg3 []func(*s3.Options) + } + putBucketVersioningReturns struct { + result1 *s3.PutBucketVersioningOutput + result2 error + } + putBucketVersioningReturnsOnCall map[int]struct { + result1 *s3.PutBucketVersioningOutput + result2 error + } PutObjectStub func(context.Context, *s3.PutObjectInput, ...func(*s3.Options)) (*s3.PutObjectOutput, error) putObjectMutex sync.RWMutex putObjectArgsForCall []struct { @@ -782,6 +812,72 @@ func (fake *FakeS3Client) GetBucketPolicyReturnsOnCall(i int, result1 *s3.GetBuc }{result1, result2} } +func (fake *FakeS3Client) GetBucketVersioning(arg1 context.Context, arg2 *s3.GetBucketVersioningInput, arg3 ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { + fake.getBucketVersioningMutex.Lock() + ret, specificReturn := fake.getBucketVersioningReturnsOnCall[len(fake.getBucketVersioningArgsForCall)] + fake.getBucketVersioningArgsForCall = append(fake.getBucketVersioningArgsForCall, struct { + arg1 context.Context + arg2 *s3.GetBucketVersioningInput + arg3 []func(*s3.Options) + }{arg1, arg2, arg3}) + stub := fake.GetBucketVersioningStub + fakeReturns := fake.getBucketVersioningReturns + fake.recordInvocation("GetBucketVersioning", []interface{}{arg1, arg2, arg3}) + fake.getBucketVersioningMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3...) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeS3Client) GetBucketVersioningCallCount() int { + fake.getBucketVersioningMutex.RLock() + defer fake.getBucketVersioningMutex.RUnlock() + return len(fake.getBucketVersioningArgsForCall) +} + +func (fake *FakeS3Client) GetBucketVersioningCalls(stub func(context.Context, *s3.GetBucketVersioningInput, ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error)) { + fake.getBucketVersioningMutex.Lock() + defer fake.getBucketVersioningMutex.Unlock() + fake.GetBucketVersioningStub = stub +} + +func (fake *FakeS3Client) GetBucketVersioningArgsForCall(i int) (context.Context, *s3.GetBucketVersioningInput, []func(*s3.Options)) { + fake.getBucketVersioningMutex.RLock() + defer fake.getBucketVersioningMutex.RUnlock() + argsForCall := fake.getBucketVersioningArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeS3Client) GetBucketVersioningReturns(result1 *s3.GetBucketVersioningOutput, result2 error) { + fake.getBucketVersioningMutex.Lock() + defer fake.getBucketVersioningMutex.Unlock() + fake.GetBucketVersioningStub = nil + fake.getBucketVersioningReturns = struct { + result1 *s3.GetBucketVersioningOutput + result2 error + }{result1, result2} +} + +func (fake *FakeS3Client) GetBucketVersioningReturnsOnCall(i int, result1 *s3.GetBucketVersioningOutput, result2 error) { + fake.getBucketVersioningMutex.Lock() + defer fake.getBucketVersioningMutex.Unlock() + fake.GetBucketVersioningStub = nil + if fake.getBucketVersioningReturnsOnCall == nil { + fake.getBucketVersioningReturnsOnCall = make(map[int]struct { + result1 *s3.GetBucketVersioningOutput + result2 error + }) + } + fake.getBucketVersioningReturnsOnCall[i] = struct { + result1 *s3.GetBucketVersioningOutput + result2 error + }{result1, result2} +} + func (fake *FakeS3Client) GetObject(arg1 context.Context, arg2 *s3.GetObjectInput, arg3 ...func(*s3.Options)) (*s3.GetObjectOutput, error) { fake.getObjectMutex.Lock() ret, specificReturn := fake.getObjectReturnsOnCall[len(fake.getObjectArgsForCall)] @@ -1244,6 +1340,72 @@ func (fake *FakeS3Client) PutBucketPolicyReturnsOnCall(i int, result1 *s3.PutBuc }{result1, result2} } +func (fake *FakeS3Client) PutBucketVersioning(arg1 context.Context, arg2 *s3.PutBucketVersioningInput, arg3 ...func(*s3.Options)) (*s3.PutBucketVersioningOutput, error) { + fake.putBucketVersioningMutex.Lock() + ret, specificReturn := fake.putBucketVersioningReturnsOnCall[len(fake.putBucketVersioningArgsForCall)] + fake.putBucketVersioningArgsForCall = append(fake.putBucketVersioningArgsForCall, struct { + arg1 context.Context + arg2 *s3.PutBucketVersioningInput + arg3 []func(*s3.Options) + }{arg1, arg2, arg3}) + stub := fake.PutBucketVersioningStub + fakeReturns := fake.putBucketVersioningReturns + fake.recordInvocation("PutBucketVersioning", []interface{}{arg1, arg2, arg3}) + fake.putBucketVersioningMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3...) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeS3Client) PutBucketVersioningCallCount() int { + fake.putBucketVersioningMutex.RLock() + defer fake.putBucketVersioningMutex.RUnlock() + return len(fake.putBucketVersioningArgsForCall) +} + +func (fake *FakeS3Client) PutBucketVersioningCalls(stub func(context.Context, *s3.PutBucketVersioningInput, ...func(*s3.Options)) (*s3.PutBucketVersioningOutput, error)) { + fake.putBucketVersioningMutex.Lock() + defer fake.putBucketVersioningMutex.Unlock() + fake.PutBucketVersioningStub = stub +} + +func (fake *FakeS3Client) PutBucketVersioningArgsForCall(i int) (context.Context, *s3.PutBucketVersioningInput, []func(*s3.Options)) { + fake.putBucketVersioningMutex.RLock() + defer fake.putBucketVersioningMutex.RUnlock() + argsForCall := fake.putBucketVersioningArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeS3Client) PutBucketVersioningReturns(result1 *s3.PutBucketVersioningOutput, result2 error) { + fake.putBucketVersioningMutex.Lock() + defer fake.putBucketVersioningMutex.Unlock() + fake.PutBucketVersioningStub = nil + fake.putBucketVersioningReturns = struct { + result1 *s3.PutBucketVersioningOutput + result2 error + }{result1, result2} +} + +func (fake *FakeS3Client) PutBucketVersioningReturnsOnCall(i int, result1 *s3.PutBucketVersioningOutput, result2 error) { + fake.putBucketVersioningMutex.Lock() + defer fake.putBucketVersioningMutex.Unlock() + fake.PutBucketVersioningStub = nil + if fake.putBucketVersioningReturnsOnCall == nil { + fake.putBucketVersioningReturnsOnCall = make(map[int]struct { + result1 *s3.PutBucketVersioningOutput + result2 error + }) + } + fake.putBucketVersioningReturnsOnCall[i] = struct { + result1 *s3.PutBucketVersioningOutput + result2 error + }{result1, result2} +} + func (fake *FakeS3Client) PutObject(arg1 context.Context, arg2 *s3.PutObjectInput, arg3 ...func(*s3.Options)) (*s3.PutObjectOutput, error) { fake.putObjectMutex.Lock() ret, specificReturn := fake.putObjectReturnsOnCall[len(fake.putObjectArgsForCall)] @@ -1329,6 +1491,8 @@ func (fake *FakeS3Client) Invocations() map[string][][]interface{} { defer fake.getBucketLifecycleConfigurationMutex.RUnlock() fake.getBucketPolicyMutex.RLock() defer fake.getBucketPolicyMutex.RUnlock() + fake.getBucketVersioningMutex.RLock() + defer fake.getBucketVersioningMutex.RUnlock() fake.getObjectMutex.RLock() defer fake.getObjectMutex.RUnlock() fake.headBucketMutex.RLock() @@ -1343,6 +1507,8 @@ func (fake *FakeS3Client) Invocations() map[string][][]interface{} { defer fake.putBucketLifecycleConfigurationMutex.RUnlock() fake.putBucketPolicyMutex.RLock() defer fake.putBucketPolicyMutex.RUnlock() + fake.putBucketVersioningMutex.RLock() + defer fake.putBucketVersioningMutex.RUnlock() fake.putObjectMutex.RLock() defer fake.putObjectMutex.RUnlock() copiedInvocations := map[string][][]interface{}{} diff --git a/internal/controller/bucket/bucket_backends.go b/internal/controller/bucket/bucket_backends.go index c69a4117..3e105139 100644 --- a/internal/controller/bucket/bucket_backends.go +++ b/internal/controller/bucket/bucket_backends.go @@ -81,6 +81,36 @@ func (b *bucketBackends) getLifecycleConfigCondition(bucketName, backendName str return b.backends[bucketName][backendName].LifecycleConfigurationCondition } +func (b *bucketBackends) setVersioningConfigCondition(bucketName, backendName string, c *xpv1.Condition) { + b.mu.Lock() + defer b.mu.Unlock() + + if b.backends[bucketName] == nil { + b.backends[bucketName] = make(v1alpha1.Backends) + } + + if b.backends[bucketName][backendName] == nil { + b.backends[bucketName][backendName] = &v1alpha1.BackendInfo{} + } + + b.backends[bucketName][backendName].VersioningConfigurationCondition = c +} + +func (b *bucketBackends) getVersioningConfigCondition(bucketName, backendName string) *xpv1.Condition { + b.mu.RLock() + defer b.mu.RUnlock() + + if _, ok := b.backends[bucketName]; !ok { + return nil + } + + if _, ok := b.backends[bucketName][backendName]; !ok { + return nil + } + + return b.backends[bucketName][backendName].VersioningConfigurationCondition +} + func (b *bucketBackends) deleteBackend(bucketName, backendName string) { b.mu.Lock() defer b.mu.Unlock() @@ -117,7 +147,7 @@ func (b *bucketBackends) getBackends(bucketName string, beNames []string) v1alph } // countBucketsAvailableOnBackends counts the backends listed in providerNames which are considered Available on the backends provided. -func (b *bucketBackends) countBucketsAvailableOnBackends(bucket *v1alpha1.Bucket, providerNames []string, c map[string]backendstore.S3Client) uint { +func (b *bucketBackends) countBucketsAvailableOnBackends(bucketName string, providerNames []string, c map[string]backendstore.S3Client) uint { i := uint(0) for _, backendName := range providerNames { if _, ok := c[backendName]; !ok { @@ -126,7 +156,7 @@ func (b *bucketBackends) countBucketsAvailableOnBackends(bucket *v1alpha1.Bucket continue } - bucketCondition := b.getBucketCondition(bucket.Name, backendName) + bucketCondition := b.getBucketCondition(bucketName, backendName) if bucketCondition == nil { // The bucket has not been created on this backend. continue @@ -154,12 +184,7 @@ func (b *bucketBackends) isLifecycleConfigAvailableOnBackends(bucket *v1alpha1.B } lcCondition := b.getLifecycleConfigCondition(bucket.Name, backendName) - if lcCondition == nil { - // The lifecycleconfig has not been created on this backend. - return false - } - - if !lcCondition.Equal(xpv1.Available()) { + if lcCondition == nil || !lcCondition.Equal(xpv1.Available()) { // The lifecycleconfig is not Available on this backend. return false } @@ -187,3 +212,42 @@ func (b *bucketBackends) isLifecycleConfigRemovedFromBackends(bucket *v1alpha1.B return true } + +// isVersioningConfigAvailableOnBackends checks the backends listed in providerNames against +// bucketBackends to ensure versioning configurations are considered Available on all desired backends. +func (b *bucketBackends) isVersioningConfigAvailableOnBackends(bucketName string, providerNames []string, c map[string]backendstore.S3Client) bool { + for _, backendName := range providerNames { + if _, ok := c[backendName]; !ok { + // This backend does not exist in the list of available backends. + // The backend may be offline, so it is skipped. + continue + } + + vCondition := b.getVersioningConfigCondition(bucketName, backendName) + if vCondition == nil || !vCondition.Equal(xpv1.Available()) { + // The versioningconfig is not Available on this backend. + return false + } + } + + return true +} + +// isVersioningConfigRemovedFromBackends checks the backends listed in providerNames against +// bucketBackends to verify a versioning configuration does not exist on any backend. +func (b *bucketBackends) isVersioningConfigRemovedFromBackends(bucketName string, providerNames []string, c map[string]backendstore.S3Client) bool { + for _, backendName := range providerNames { + if _, ok := c[backendName]; !ok { + // This backend does not exist in the list of available backends. + // The backend may be offline, so it is skipped. + continue + } + + vCondition := b.getVersioningConfigCondition(bucketName, backendName) + if vCondition != nil { + return false + } + } + + return true +} diff --git a/internal/controller/bucket/consts.go b/internal/controller/bucket/consts.go index 8ab0bfef..3c7f1df7 100644 --- a/internal/controller/bucket/consts.go +++ b/internal/controller/bucket/consts.go @@ -20,6 +20,10 @@ const ( errObserveLifecycleConfig = "failed to observe bucket lifecycle configuration" errHandleLifecycleConfig = "failed to handle bucket lifecycle configuration" + // Versioning configuration error messages. + errObserveVersioningConfig = "failed to observe bucket versioning configuration" + errHandleVersioningConfig = "failed to handle bucket versioning configuration" + // ACL error messages. errObserveAcl = "failed to observe bucket acl" errHandleAcl = "failed to handle bucket acl" diff --git a/internal/controller/bucket/helpers.go b/internal/controller/bucket/helpers.go index 5121d979..7775dee1 100644 --- a/internal/controller/bucket/helpers.go +++ b/internal/controller/bucket/helpers.go @@ -34,9 +34,11 @@ func isBucketPaused(bucket *v1alpha1.Bucket) bool { } // isPauseRequired determines if the Bucket should be paused. +// +//nolint:gocyclo,cyclop // Function requires numerous checks. func isPauseRequired(bucket *v1alpha1.Bucket, providerNames []string, minReplicas uint, c map[string]backendstore.S3Client, bb *bucketBackends, autopauseEnabled bool) bool { // If the number of backends on which the bucket is available is less than the number of providerNames or minReplicas, then the bucket must not be paused. - if float64(bb.countBucketsAvailableOnBackends(bucket, providerNames, c)) < math.Min(float64(len(providerNames)), float64(minReplicas)) { + if float64(bb.countBucketsAvailableOnBackends(bucket.Name, providerNames, c)) < math.Min(float64(len(providerNames)), float64(minReplicas)) { return false } @@ -52,6 +54,19 @@ func isPauseRequired(bucket *v1alpha1.Bucket, providerNames []string, minReplica return false } + // Avoid pausing when a versioning configuration is specified in the spec, but not all + // versioning configs are available. + if bucket.Spec.ForProvider.VersioningConfiguration != nil && !bb.isVersioningConfigAvailableOnBackends(bucket.Name, providerNames, c) { + return false + } + + // Avoid pausing when versioning configurations exist on backends, but not all + // versioning configs are available. This scenario can occur when the versioning + // config has been removed from the Spec (and is therefore suspended). + if !bb.isVersioningConfigRemovedFromBackends(bucket.Name, providerNames, c) && !bb.isVersioningConfigAvailableOnBackends(bucket.Name, providerNames, c) { + return false + } + return (bucket.Spec.AutoPause || autopauseEnabled) && // Only return true if this label value is "". // This is to allow the user to delete a paused bucket with autopause enabled. diff --git a/internal/controller/bucket/subresources.go b/internal/controller/bucket/subresources.go index 987950b2..0aa67d95 100644 --- a/internal/controller/bucket/subresources.go +++ b/internal/controller/bucket/subresources.go @@ -38,6 +38,7 @@ func NewSubresourceClients(b *backendstore.BackendStore, h *s3clienthandler.Hand NewLifecycleConfigurationClient(b, h, l.WithValues("lifecycle-configuration-client", managed.ControllerName(v1alpha1.BucketGroupKind))), NewACLClient(b, h, l.WithValues("acl-client", managed.ControllerName(v1alpha1.BucketGroupKind))), NewPolicyClient(b, h, l.WithValues("policy-client", managed.ControllerName(v1alpha1.BucketGroupKind))), + NewVersioningConfigurationClient(b, h, l.WithValues("versioning-configuration-client", managed.ControllerName(v1alpha1.BucketGroupKind))), } } diff --git a/internal/controller/bucket/update.go b/internal/controller/bucket/update.go index 9e7016f5..47e2de52 100644 --- a/internal/controller/bucket/update.go +++ b/internal/controller/bucket/update.go @@ -83,6 +83,7 @@ func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext // Whether buckets are updated successfully or not on backends, we need to update the // Bucket CR Status in all cases to represent the conditions of each individual bucket. + cls := c.backendStore.GetBackendS3Clients(allBackendsToUpdateOn) if err := c.updateBucketCR(ctx, bucket, func(bucketDeepCopy, bucketLatest *v1alpha1.Bucket) UpdateRequired { setBucketStatus(bucketLatest, bucketBackends, allBackendsToUpdateOn, c.minReplicas) @@ -107,7 +108,6 @@ func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext // criteria is met before pausing a Bucket CR. Otherwise we check to see if there are // backends that the bucket was not updated on and if so, we set the updateAllErr // which will be returned at the end of this function, triggering a requeue. - cls := c.backendStore.GetBackendS3Clients(allBackendsToUpdateOn) if isPauseRequired(bucketLatest, allBackendsToUpdateOn, c.minReplicas, cls, bucketBackends, c.autoPauseBucket) { c.log.Info("Auto pausing bucket", consts.KeyBucketName, bucket.Name) bucketLatest.Labels[meta.AnnotationKeyReconciliationPaused] = True diff --git a/internal/controller/bucket/update_test.go b/internal/controller/bucket/update_test.go index 991f04ed..750420a6 100644 --- a/internal/controller/bucket/update_test.go +++ b/internal/controller/bucket/update_test.go @@ -464,3 +464,619 @@ func TestUpdate(t *testing.T) { }) } } + +//nolint:maintidx // Function requires numerous checks. +func TestUpdateLifecycleConfigSubResource(t *testing.T) { + t.Parallel() + someError := errors.New("some error") + + type fields struct { + backendStore *backendstore.BackendStore + autoPauseBucket bool + roleArn *string + initObjects []client.Object + } + + type args struct { + mg resource.Managed + } + + type want struct { + o managed.ExternalUpdate + err error + specificDiff func(t *testing.T, mg resource.Managed) + } + + cases := map[string]struct { + reason string + fields fields + args args + want want + }{ + "Two backends update lifecycle config successfully": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{} + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + bs.AddOrUpdateBackend("s3-backend-2", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + mg: &v1alpha1.Bucket{ + Spec: v1alpha1.BucketSpec{ + Providers: []string{ + "s3-backend-1", + "s3-backend-2", + }, + ForProvider: v1alpha1.BucketParameters{ + LifecycleConfiguration: &v1alpha1.BucketLifecycleConfiguration{ + Rules: []v1alpha1.LifecycleRule{ + { + Status: "Enabled", + }, + }, + }, + }, + }, + }, + }, + want: want{ + o: managed.ExternalUpdate{}, + specificDiff: func(t *testing.T, mg resource.Managed) { + t.Helper() + bucket, _ := mg.(*v1alpha1.Bucket) + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-1"].LifecycleConfigurationCondition.Equal(v1.Available()), + "lifecycle configuration condition on s3-backend-1 is not available") + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-2"].LifecycleConfigurationCondition.Equal(v1.Available()), + "lifecycle configuration condition on s3-backend-2 is not available") + }, + }, + }, + "Two backends fail to update lifecycle config": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + PutBucketLifecycleConfigurationStub: func(ctx context.Context, hbi *s3.PutBucketLifecycleConfigurationInput, f ...func(*s3.Options)) (*s3.PutBucketLifecycleConfigurationOutput, error) { + return &s3.PutBucketLifecycleConfigurationOutput{}, someError + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + bs.AddOrUpdateBackend("s3-backend-2", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + mg: &v1alpha1.Bucket{ + Spec: v1alpha1.BucketSpec{ + Providers: []string{ + "s3-backend-1", + "s3-backend-2", + }, + ForProvider: v1alpha1.BucketParameters{ + LifecycleConfiguration: &v1alpha1.BucketLifecycleConfiguration{ + Rules: []v1alpha1.LifecycleRule{ + { + Status: "Enabled", + }, + }, + }, + }, + }, + }, + }, + want: want{ + err: someError, + o: managed.ExternalUpdate{}, + specificDiff: func(t *testing.T, mg resource.Managed) { + t.Helper() + bucket, _ := mg.(*v1alpha1.Bucket) + unavailableBackends := []string{"s3-backend-1", "s3-backend-2"} + slices.Sort(unavailableBackends) + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-1"].LifecycleConfigurationCondition.Equal( + v1.Unavailable().WithMessage( + errors.Wrap( + errors.Wrap(someError, "failed to put bucket lifecycle configuration"), + "failed to handle bucket lifecycle configuration").Error(), + ), + ), + "unexpected lifecycle configuration condition for s3-backend-1") + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-2"].LifecycleConfigurationCondition.Equal( + v1.Unavailable().WithMessage( + errors.Wrap( + errors.Wrap(someError, "failed to put bucket lifecycle configuration"), + "failed to handle bucket lifecycle configuration").Error(), + ), + ), + "unexpected lifecycle configuration condition for s3-backend-2") + }, + }, + }, + "One backend updates lifecycle config successfully and one fails to update lifecycle config": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fakeErr := backendstorefakes.FakeS3Client{ + PutBucketLifecycleConfigurationStub: func(ctx context.Context, hbi *s3.PutBucketLifecycleConfigurationInput, f ...func(*s3.Options)) (*s3.PutBucketLifecycleConfigurationOutput, error) { + return &s3.PutBucketLifecycleConfigurationOutput{}, someError + }, + } + fakeOK := backendstorefakes.FakeS3Client{} + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fakeOK, nil, true, apisv1alpha1.HealthStatusHealthy) + bs.AddOrUpdateBackend("s3-backend-2", &fakeErr, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + mg: &v1alpha1.Bucket{ + Spec: v1alpha1.BucketSpec{ + Providers: []string{ + "s3-backend-1", + "s3-backend-2", + }, + ForProvider: v1alpha1.BucketParameters{ + LifecycleConfiguration: &v1alpha1.BucketLifecycleConfiguration{ + Rules: []v1alpha1.LifecycleRule{ + { + Status: "Enabled", + }, + }, + }, + }, + }, + }, + }, + want: want{ + err: someError, + o: managed.ExternalUpdate{}, + specificDiff: func(t *testing.T, mg resource.Managed) { + t.Helper() + bucket, _ := mg.(*v1alpha1.Bucket) + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-1"].LifecycleConfigurationCondition.Equal(v1.Available()), + "unexpected lifecycle configuration condition for s3-backend-1") + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-2"].LifecycleConfigurationCondition.Equal( + v1.Unavailable().WithMessage( + errors.Wrap( + errors.Wrap(someError, "failed to put bucket lifecycle configuration"), + "failed to handle bucket lifecycle configuration").Error(), + ), + ), + "unexpected lifecycle configuration condition for s3-backend-2") + }, + }, + }, + "Single backend updates lifecycle configuration successfully and is autopaused": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{} + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + autoPauseBucket: true, + initObjects: []client.Object{ + &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + Annotations: map[string]string{ + "test": "test", + }, + }, + }, + }, + }, + args: args{ + mg: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + Annotations: map[string]string{ + "test": "test", + }, + }, + Spec: v1alpha1.BucketSpec{ + Providers: []string{ + "s3-backend-1", + }, + ForProvider: v1alpha1.BucketParameters{ + LifecycleConfiguration: &v1alpha1.BucketLifecycleConfiguration{ + Rules: []v1alpha1.LifecycleRule{ + { + Status: "Enabled", + }, + }, + }, + }, + }, + }, + }, + want: want{ + o: managed.ExternalUpdate{}, + specificDiff: func(t *testing.T, mg resource.Managed) { + t.Helper() + bucket, _ := mg.(*v1alpha1.Bucket) + assert.True(t, + bucket.Status.Conditions[0].Equal(v1.Available()), + "unexpected bucket ready condition") + + assert.True(t, + bucket.Status.Conditions[1].Equal(v1.ReconcileSuccess()), + "unexpected bucket synced condition") + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-1"].LifecycleConfigurationCondition.Equal(v1.Available()), + "lifecycle configuration condition on s3-backend-1 is not available") + + assert.Equal(t, + map[string]string{ + meta.AnnotationKeyReconciliationPaused: True, + "provider-ceph.backends.s3-backend-1": True, + }, + bucket.Labels, + "unexpected bucket labels", + ) + }, + }, + }, + } + + bk := &v1alpha1.Bucket{} + s := scheme.Scheme + s.AddKnownTypes(apisv1alpha1.SchemeGroupVersion, bk) + + for name, tc := range cases { + tc := tc + + t.Run(name, func(t *testing.T) { + t.Parallel() + + cl := fake.NewClientBuilder(). + WithObjects(tc.fields.initObjects...). + WithStatusSubresource(tc.fields.initObjects...). + WithScheme(s).Build() + + s3ClientHandler := s3clienthandler.NewHandler( + s3clienthandler.WithAssumeRoleArn(tc.fields.roleArn), + s3clienthandler.WithBackendStore(tc.fields.backendStore), + s3clienthandler.WithKubeClient(cl)) + + e := external{ + kubeClient: cl, + backendStore: tc.fields.backendStore, + s3ClientHandler: s3ClientHandler, + autoPauseBucket: tc.fields.autoPauseBucket, + minReplicas: 2, + log: logging.NewNopLogger(), + subresourceClients: NewSubresourceClients(tc.fields.backendStore, s3ClientHandler, logging.NewNopLogger()), + } + + got, err := e.Update(context.Background(), tc.args.mg) + require.ErrorIs(t, err, tc.want.err, "unexpected err") + assert.Equal(t, got, tc.want.o, "unexpected result") + if tc.want.specificDiff != nil { + tc.want.specificDiff(t, tc.args.mg) + } + }) + } +} + +//nolint:maintidx // Function requires numerous checks. +func TestUpdateVersioningConfigSubResource(t *testing.T) { + t.Parallel() + someError := errors.New("some error") + vEnabled := v1alpha1.VersioningStatusEnabled + + type fields struct { + backendStore *backendstore.BackendStore + autoPauseBucket bool + roleArn *string + initObjects []client.Object + } + + type args struct { + mg resource.Managed + } + + type want struct { + o managed.ExternalUpdate + err error + specificDiff func(t *testing.T, mg resource.Managed) + } + + cases := map[string]struct { + reason string + fields fields + args args + want want + }{ + "Two backends update versioning configuration successfully": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{} + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + bs.AddOrUpdateBackend("s3-backend-2", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + mg: &v1alpha1.Bucket{ + Spec: v1alpha1.BucketSpec{ + Providers: []string{ + "s3-backend-1", + "s3-backend-2", + }, + ForProvider: v1alpha1.BucketParameters{ + VersioningConfiguration: &v1alpha1.VersioningConfiguration{ + Status: &vEnabled, + }, + }, + }, + }, + }, + want: want{ + o: managed.ExternalUpdate{}, + specificDiff: func(t *testing.T, mg resource.Managed) { + t.Helper() + bucket, _ := mg.(*v1alpha1.Bucket) + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-1"].VersioningConfigurationCondition.Equal(v1.Available()), + "versioning configuration condition on s3-backend-1 is not available") + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-2"].VersioningConfigurationCondition.Equal(v1.Available()), + "versioning configuration condition on s3-backend-2 is not available") + }, + }, + }, + "Two backends fail to update versioning config": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + PutBucketVersioningStub: func(ctx context.Context, hbi *s3.PutBucketVersioningInput, f ...func(*s3.Options)) (*s3.PutBucketVersioningOutput, error) { + return &s3.PutBucketVersioningOutput{}, someError + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + bs.AddOrUpdateBackend("s3-backend-2", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + mg: &v1alpha1.Bucket{ + Spec: v1alpha1.BucketSpec{ + Providers: []string{ + "s3-backend-1", + "s3-backend-2", + }, + ForProvider: v1alpha1.BucketParameters{ + VersioningConfiguration: &v1alpha1.VersioningConfiguration{ + Status: &vEnabled, + }, + }, + }, + }, + }, + want: want{ + err: someError, + o: managed.ExternalUpdate{}, + specificDiff: func(t *testing.T, mg resource.Managed) { + t.Helper() + bucket, _ := mg.(*v1alpha1.Bucket) + unavailableBackends := []string{"s3-backend-1", "s3-backend-2"} + slices.Sort(unavailableBackends) + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-1"].VersioningConfigurationCondition.Equal( + v1.Unavailable().WithMessage( + errors.Wrap( + errors.Wrap(someError, "failed to put bucket versioning"), + "failed to handle bucket versioning configuration").Error(), + ), + ), + "unexpected versioning configuration condition for s3-backend-1") + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-2"].VersioningConfigurationCondition.Equal( + v1.Unavailable().WithMessage( + errors.Wrap( + errors.Wrap(someError, "failed to put bucket versioning"), + "failed to handle bucket versioning configuration").Error(), + ), + ), + "unexpected versioning configuration condition for s3-backend-2") + }, + }, + }, + "One backend updates versioning configuration successfully and one fails to update": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fakeErr := backendstorefakes.FakeS3Client{ + PutBucketVersioningStub: func(ctx context.Context, hbi *s3.PutBucketVersioningInput, f ...func(*s3.Options)) (*s3.PutBucketVersioningOutput, error) { + return &s3.PutBucketVersioningOutput{}, someError + }, + } + fakeOK := backendstorefakes.FakeS3Client{} + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fakeOK, nil, true, apisv1alpha1.HealthStatusHealthy) + bs.AddOrUpdateBackend("s3-backend-2", &fakeErr, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + mg: &v1alpha1.Bucket{ + Spec: v1alpha1.BucketSpec{ + Providers: []string{ + "s3-backend-1", + "s3-backend-2", + }, + ForProvider: v1alpha1.BucketParameters{ + VersioningConfiguration: &v1alpha1.VersioningConfiguration{ + Status: &vEnabled, + }, + }, + }, + }, + }, + want: want{ + err: someError, + o: managed.ExternalUpdate{}, + specificDiff: func(t *testing.T, mg resource.Managed) { + t.Helper() + bucket, _ := mg.(*v1alpha1.Bucket) + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-1"].VersioningConfigurationCondition.Equal(v1.Available()), + "unexpected versioning configuration condition for s3-backend-1") + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-2"].VersioningConfigurationCondition.Equal( + v1.Unavailable().WithMessage( + errors.Wrap( + errors.Wrap(someError, "failed to put bucket versioning"), + "failed to handle bucket versioning configuration").Error(), + ), + ), + "unexpected versioning configuration condition for s3-backend-2") + }, + }, + }, + "Single backend updates versioning configuration successfully and is autopaused": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{} + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + autoPauseBucket: true, + initObjects: []client.Object{ + &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + Annotations: map[string]string{ + "test": "test", + }, + }, + }, + }, + }, + args: args{ + mg: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + Annotations: map[string]string{ + "test": "test", + }, + }, + Spec: v1alpha1.BucketSpec{ + Providers: []string{ + "s3-backend-1", + }, + ForProvider: v1alpha1.BucketParameters{ + VersioningConfiguration: &v1alpha1.VersioningConfiguration{ + Status: &vEnabled, + }, + }, + }, + }, + }, + want: want{ + o: managed.ExternalUpdate{}, + specificDiff: func(t *testing.T, mg resource.Managed) { + t.Helper() + bucket, _ := mg.(*v1alpha1.Bucket) + assert.True(t, + bucket.Status.Conditions[0].Equal(v1.Available()), + "unexpected bucket ready condition") + + assert.True(t, + bucket.Status.Conditions[1].Equal(v1.ReconcileSuccess()), + "unexpected bucket synced condition") + + assert.True(t, + bucket.Status.AtProvider.Backends["s3-backend-1"].VersioningConfigurationCondition.Equal(v1.Available()), + "versioning configuration condition on s3-backend-1 is not available") + + assert.Equal(t, + map[string]string{ + meta.AnnotationKeyReconciliationPaused: True, + "provider-ceph.backends.s3-backend-1": True, + }, + bucket.Labels, + "unexpected bucket labels", + ) + }, + }, + }, + } + + bk := &v1alpha1.Bucket{} + s := scheme.Scheme + s.AddKnownTypes(apisv1alpha1.SchemeGroupVersion, bk) + + for name, tc := range cases { + tc := tc + + t.Run(name, func(t *testing.T) { + t.Parallel() + + cl := fake.NewClientBuilder(). + WithObjects(tc.fields.initObjects...). + WithStatusSubresource(tc.fields.initObjects...). + WithScheme(s).Build() + + s3ClientHandler := s3clienthandler.NewHandler( + s3clienthandler.WithAssumeRoleArn(tc.fields.roleArn), + s3clienthandler.WithBackendStore(tc.fields.backendStore), + s3clienthandler.WithKubeClient(cl)) + + e := external{ + kubeClient: cl, + backendStore: tc.fields.backendStore, + s3ClientHandler: s3ClientHandler, + autoPauseBucket: tc.fields.autoPauseBucket, + minReplicas: 2, + log: logging.NewNopLogger(), + subresourceClients: NewSubresourceClients(tc.fields.backendStore, s3ClientHandler, logging.NewNopLogger()), + } + + got, err := e.Update(context.Background(), tc.args.mg) + require.ErrorIs(t, err, tc.want.err, "unexpected err") + assert.Equal(t, got, tc.want.o, "unexpected result") + if tc.want.specificDiff != nil { + tc.want.specificDiff(t, tc.args.mg) + } + }) + } +} diff --git a/internal/controller/bucket/versioningconfiguration.go b/internal/controller/bucket/versioningconfiguration.go new file mode 100644 index 00000000..ec9bf4f3 --- /dev/null +++ b/internal/controller/bucket/versioningconfiguration.go @@ -0,0 +1,214 @@ +package bucket + +import ( + "context" + + "github.com/aws/aws-sdk-go-v2/aws" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/smithy-go/document" + + xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + "github.com/crossplane/crossplane-runtime/pkg/errors" + "github.com/crossplane/crossplane-runtime/pkg/logging" + + "github.com/linode/provider-ceph/apis/provider-ceph/v1alpha1" + apisv1alpha1 "github.com/linode/provider-ceph/apis/v1alpha1" + "github.com/linode/provider-ceph/internal/backendstore" + "github.com/linode/provider-ceph/internal/consts" + "github.com/linode/provider-ceph/internal/controller/s3clienthandler" + "github.com/linode/provider-ceph/internal/otel/traces" + "github.com/linode/provider-ceph/internal/rgw" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "go.opentelemetry.io/otel" +) + +// VersioningConfigurationClient is the client for API methods and reconciling the VersioningConfiguration +type VersioningConfigurationClient struct { + backendStore *backendstore.BackendStore + s3ClientHandler *s3clienthandler.Handler + log logging.Logger +} + +func NewVersioningConfigurationClient(b *backendstore.BackendStore, h *s3clienthandler.Handler, l logging.Logger) *VersioningConfigurationClient { + return &VersioningConfigurationClient{backendStore: b, s3ClientHandler: h, log: l} +} + +//nolint:dupl // VersioningConfiguration and Policy are different feature. +func (l *VersioningConfigurationClient) Observe(ctx context.Context, bucket *v1alpha1.Bucket, backendNames []string) (ResourceStatus, error) { + ctx, span := otel.Tracer("").Start(ctx, "bucket.VersioningConfigurationClient.Observe") + defer span.End() + + observationChan := make(chan ResourceStatus) + errChan := make(chan error) + + for _, backendName := range backendNames { + beName := backendName + go func() { + observation, err := l.observeBackend(ctx, bucket, beName) + if err != nil { + errChan <- err + + return + } + observationChan <- observation + }() + } + + for i := 0; i < len(backendNames); i++ { + select { + case <-ctx.Done(): + l.log.Info("Context timeout during bucket versioning configuration observation", consts.KeyBucketName, bucket.Name) + err := errors.Wrap(ctx.Err(), errObserveVersioningConfig) + traces.SetAndRecordError(span, err) + + return NeedsUpdate, err + case observation := <-observationChan: + if observation != Updated { + return observation, nil + } + case err := <-errChan: + err = errors.Wrap(err, errObserveVersioningConfig) + traces.SetAndRecordError(span, err) + + return NeedsUpdate, err + } + } + + return Updated, nil +} + +func (l *VersioningConfigurationClient) observeBackend(ctx context.Context, bucket *v1alpha1.Bucket, backendName string) (ResourceStatus, error) { + l.log.Info("Observing subresource versioning configuration on backend", consts.KeyBucketName, bucket.Name, consts.KeyBackendName, backendName) + + if l.backendStore.GetBackendHealthStatus(backendName) == apisv1alpha1.HealthStatusUnhealthy { + // If a backend is marked as unhealthy, we can ignore it for now by returning Updated. + // The backend may be down for some time and we do not want to block Create/Update/Delete + // calls on other backends. By returning NeedsUpdate here, we would never pass the Observe + // phase until the backend becomes Healthy or Disabled. + return Updated, nil + } + + s3Client, err := l.s3ClientHandler.GetS3Client(ctx, bucket, backendName) + if err != nil { + return NeedsUpdate, err + } + response, err := rgw.GetBucketVersioning(ctx, s3Client, aws.String(bucket.Name)) + if err != nil { + return NeedsUpdate, err + } + + if bucket.Spec.ForProvider.VersioningConfiguration == nil { + // No versioining config was defined by the user in the Bucket CR Spec. + // This is should result in (a) an unversioned bucket remaining unversioned + // OR (b) a versioned bucket having versioning suspended. + if response == nil || (response.Status == "" && response.MFADelete == "") { + // An empty versioning configuration was returned from the backend, signifying + // that versioning was never enabled on this bucket. Therefore versioning is + // considered Updated for the bucket and we do nothing. + l.log.Info("Versioning is not enabled for bucket on backend - no action required", consts.KeyBucketName, bucket.Name, consts.KeyBackendName, backendName) + + return Updated, nil + } else { + // A non-empty versioning configuration was returned from the backend, signifying + // that versioning was previously enabled for this bucket. A bucket cannot be un-versioned, + // it can only be suspended so we execute this via the NeedsDeletion path. + l.log.Info("Versioning is enabled for bucket on backend - requires suspension", consts.KeyBucketName, bucket.Name, consts.KeyBackendName, backendName) + + return NeedsDeletion, nil + } + } + + external := &s3types.VersioningConfiguration{} + if response != nil { + external.Status = response.Status + external.MFADelete = s3types.MFADelete(response.MFADelete) + } + + desiredVersioningConfig := rgw.GenerateVersioningConfiguration(bucket.Spec.ForProvider.VersioningConfiguration) + + if !cmp.Equal(external, desiredVersioningConfig, cmpopts.IgnoreTypes(document.NoSerde{})) { + l.log.Info("Versioning configuration requires update on backend", consts.KeyBucketName, bucket.Name, consts.KeyBackendName, backendName) + + return NeedsUpdate, nil + } + + return Updated, nil +} + +func (l *VersioningConfigurationClient) Handle(ctx context.Context, b *v1alpha1.Bucket, backendName string, bb *bucketBackends) error { + ctx, span := otel.Tracer("").Start(ctx, "bucket.VersioningConfigurationClient.Handle") + defer span.End() + + observation, err := l.observeBackend(ctx, b, backendName) + if err != nil { + err = errors.Wrap(err, errHandleVersioningConfig) + traces.SetAndRecordError(span, err) + + return err + } + + switch observation { + case Updated: + return nil + case NeedsDeletion: + // Versioning Configurations are not deleted, only suspended, which requires an update. + // Create a deep copy of bucket and give it a suspended version config. + // This will be used in th PutBucketVersioning request to suspend versioning. + bucketCopy := b.DeepCopy() + disabled := v1alpha1.MFADeleteDisabled + suspended := v1alpha1.VersioningStatusSuspended + + bucketCopy.Spec.ForProvider.VersioningConfiguration = &v1alpha1.VersioningConfiguration{ + MFADelete: &disabled, + Status: &suspended, + } + if err := l.createOrUpdate(ctx, bucketCopy, backendName); err != nil { + err = errors.Wrap(err, errHandleVersioningConfig) + unavailable := xpv1.Unavailable().WithMessage(err.Error()) + bb.setVersioningConfigCondition(b.Name, backendName, &unavailable) + + traces.SetAndRecordError(span, err) + + return err + } + // Successfully suspended versioning for the backend. Because we cannot + // un-version a bucket, we must not remove its versioningConfigCondition. + // Instead, we set it as Available, signifying that the update was a success. + available := xpv1.Available() + bb.setVersioningConfigCondition(b.Name, backendName, &available) + + return nil + case NeedsUpdate: + if err := l.createOrUpdate(ctx, b, backendName); err != nil { + err = errors.Wrap(err, errHandleVersioningConfig) + unavailable := xpv1.Unavailable().WithMessage(err.Error()) + bb.setVersioningConfigCondition(b.Name, backendName, &unavailable) + + traces.SetAndRecordError(span, err) + + return err + } + available := xpv1.Available() + bb.setVersioningConfigCondition(b.Name, backendName, &available) + } + + return nil +} + +func (l *VersioningConfigurationClient) createOrUpdate(ctx context.Context, b *v1alpha1.Bucket, backendName string) error { + l.log.Info("Updating versioniong configuration", consts.KeyBucketName, b.Name, consts.KeyBackendName, backendName) + s3Client, err := l.s3ClientHandler.GetS3Client(ctx, b, backendName) + if err != nil { + return err + } + + _, err = rgw.PutBucketVersioning(ctx, s3Client, b) + if err != nil { + return err + } + + return nil +} diff --git a/internal/controller/bucket/versioningconfiguration_test.go b/internal/controller/bucket/versioningconfiguration_test.go new file mode 100644 index 00000000..0dff9253 --- /dev/null +++ b/internal/controller/bucket/versioningconfiguration_test.go @@ -0,0 +1,553 @@ +/* +Copyright 2022 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package bucket + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + "github.com/crossplane/crossplane-runtime/pkg/errors" + "github.com/crossplane/crossplane-runtime/pkg/logging" + "github.com/linode/provider-ceph/apis/provider-ceph/v1alpha1" + apisv1alpha1 "github.com/linode/provider-ceph/apis/v1alpha1" + "github.com/linode/provider-ceph/internal/backendstore" + "github.com/linode/provider-ceph/internal/backendstore/backendstorefakes" + "github.com/linode/provider-ceph/internal/controller/s3clienthandler" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +var ( + mfaDeleteEnabled = v1alpha1.MFADeleteEnabled + vStatusEnabled = v1alpha1.VersioningStatusEnabled +) + +func TestVersioningConfigObserveBackend(t *testing.T) { + t.Parallel() + + type fields struct { + backendStore *backendstore.BackendStore + } + + type args struct { + bucket *v1alpha1.Bucket + backendName string + } + + type want struct { + status ResourceStatus + err error + } + + cases := map[string]struct { + reason string + fields fields + args args + want want + }{ + "External error getting bucket versioning": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + + GetBucketVersioningStub: func(ctx context.Context, lci *s3.GetBucketVersioningInput, f ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { + return &s3.GetBucketVersioningOutput{}, errExternal + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + }, + }, + backendName: "s3-backend-1", + }, + want: want{ + status: NeedsUpdate, + err: errExternal, + }, + }, + "Attempt to observe versioniong config on unhealthy backend (consider it updated to unblock)": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{} + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusUnhealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + }, + }, + backendName: "s3-backend-1", + }, + want: want{ + status: Updated, + err: nil, + }, + }, + "Versioning config not specified in CR but exists on backend so NeedsDeletion": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + + GetBucketVersioningStub: func(ctx context.Context, lci *s3.GetBucketVersioningInput, f ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { + return &s3.GetBucketVersioningOutput{ + Status: s3types.BucketVersioningStatusEnabled, + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + }, + }, + backendName: "s3-backend-1", + }, + want: want{ + status: NeedsDeletion, + err: nil, + }, + }, + "Versioning config not specified in CR and does not exist on backend so is Updated": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + + GetBucketVersioningStub: func(ctx context.Context, lci *s3.GetBucketVersioningInput, f ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { + return &s3.GetBucketVersioningOutput{}, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + }, + }, + backendName: "s3-backend-1", + }, + want: want{ + status: Updated, + err: nil, + }, + }, + "Versioning config specified in CR and exists on backend and is the same so is Updated": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + + GetBucketVersioningStub: func(ctx context.Context, lci *s3.GetBucketVersioningInput, f ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { + return &s3.GetBucketVersioningOutput{ + Status: "Enabled", + MFADelete: "Enabled", + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + }, + Spec: v1alpha1.BucketSpec{ + ForProvider: v1alpha1.BucketParameters{ + VersioningConfiguration: &v1alpha1.VersioningConfiguration{ + MFADelete: &mfaDeleteEnabled, + Status: &vStatusEnabled, + }, + }, + }, + }, + backendName: "s3-backend-1", + }, + want: want{ + status: Updated, + err: nil, + }, + }, + "Versioning config specified in CR and exists on backend but is different so NeedsUpdate": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + + GetBucketVersioningStub: func(ctx context.Context, lci *s3.GetBucketVersioningInput, f ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { + return &s3.GetBucketVersioningOutput{ + Status: "Suspended", + MFADelete: "Disabled", + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + }, + Spec: v1alpha1.BucketSpec{ + ForProvider: v1alpha1.BucketParameters{ + VersioningConfiguration: &v1alpha1.VersioningConfiguration{ + MFADelete: &mfaDeleteEnabled, + Status: &vStatusEnabled, + }, + }, + }, + }, + backendName: "s3-backend-1", + }, + want: want{ + status: NeedsUpdate, + err: nil, + }, + }, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + c := NewVersioningConfigurationClient( + tc.fields.backendStore, + s3clienthandler.NewHandler( + s3clienthandler.WithAssumeRoleArn(nil), + s3clienthandler.WithBackendStore(tc.fields.backendStore)), + logging.NewNopLogger()) + + got, err := c.observeBackend(context.Background(), tc.args.bucket, tc.args.backendName) + require.ErrorIs(t, err, tc.want.err, "unexpected error") + assert.Equal(t, tc.want.status, got, "unexpected status") + }) + } +} + +//nolint:maintidx // Function requires numerous checks. +func TestVersioningConfigurationHandle(t *testing.T) { + t.Parallel() + bucketName := "bucket" + beName := "s3-backend-1" + creating := v1.Creating() + errRandom := errors.New("some error") + type fields struct { + backendStore *backendstore.BackendStore + } + + type args struct { + bucket *v1alpha1.Bucket + backendName string + } + + type want struct { + err error + specificDiff func(t *testing.T, bb *bucketBackends) + } + + cases := map[string]struct { + reason string + fields fields + args args + want want + }{ + "Versioning config suspends successfully": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + + GetBucketVersioningStub: func(ctx context.Context, lci *s3.GetBucketVersioningInput, f ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { + return &s3.GetBucketVersioningOutput{ + Status: "Enabled", + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: bucketName, + }, + Spec: v1alpha1.BucketSpec{ + ForProvider: v1alpha1.BucketParameters{ + VersioningConfiguration: nil, + }, + }, + }, + backendName: beName, + }, + want: want{ + err: nil, + specificDiff: func(t *testing.T, bb *bucketBackends) { + t.Helper() + backends := bb.getBackends(bucketName, []string{beName}) + // s3-backend-1 versioning config was successfully suspended. + assert.True(t, + backends[beName].VersioningConfigurationCondition.Equal(v1.Available()), + "unexpected versioning config condition on s3-backend-1") + }, + }, + }, + "Versioning config suspension fails": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketVersioningStub: func(ctx context.Context, lci *s3.GetBucketVersioningInput, f ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { + return &s3.GetBucketVersioningOutput{ + Status: "Enabled", + }, nil + }, + + PutBucketVersioningStub: func(ctx context.Context, lci *s3.PutBucketVersioningInput, f ...func(*s3.Options)) (*s3.PutBucketVersioningOutput, error) { + return &s3.PutBucketVersioningOutput{}, errRandom + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(beName, &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + }, + Spec: v1alpha1.BucketSpec{ + ForProvider: v1alpha1.BucketParameters{ + VersioningConfiguration: nil, + }, + }, + }, + backendName: "s3-backend-1", + }, + want: want{ + err: errRandom, + specificDiff: func(t *testing.T, bb *bucketBackends) { + t.Helper() + backends := bb.getBackends(bucketName, []string{beName}) + assert.True(t, + backends[beName].VersioningConfigurationCondition.Equal(v1.Unavailable(). + WithMessage(errors.Wrap(errors.Wrap(errRandom, "failed to put bucket versioning"), errHandleVersioningConfig).Error())), + "unexpected versioning config condition on s3-backend-1") + }, + }, + }, + "Versioning config is up to date so no action required": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + + GetBucketVersioningStub: func(ctx context.Context, lci *s3.GetBucketVersioningInput, f ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { + return &s3.GetBucketVersioningOutput{ + MFADelete: s3types.MFADeleteStatusEnabled, + Status: s3types.BucketVersioningStatusEnabled, + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + }, + Spec: v1alpha1.BucketSpec{ + ForProvider: v1alpha1.BucketParameters{ + VersioningConfiguration: &v1alpha1.VersioningConfiguration{ + MFADelete: &mfaDeleteEnabled, + Status: &vStatusEnabled, + }, + }, + }, + }, + backendName: "s3-backend-1", + }, + want: want{ + err: nil, + }, + }, + "Versioning config updates successfully": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + + GetBucketVersioningStub: func(ctx context.Context, lci *s3.GetBucketVersioningInput, f ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { + return &s3.GetBucketVersioningOutput{ + Status: s3types.BucketVersioningStatusSuspended, + MFADelete: s3types.MFADeleteStatusDisabled, + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + }, + Spec: v1alpha1.BucketSpec{ + ForProvider: v1alpha1.BucketParameters{ + VersioningConfiguration: &v1alpha1.VersioningConfiguration{ + MFADelete: &mfaDeleteEnabled, + Status: &vStatusEnabled, + }, + }, + }, + }, + backendName: "s3-backend-1", + }, + want: want{ + err: nil, + specificDiff: func(t *testing.T, bb *bucketBackends) { + t.Helper() + backends := bb.getBackends(bucketName, []string{beName}) + assert.True(t, + backends[beName].VersioningConfigurationCondition.Equal(v1.Available()), + "unexpected versioning config condition on s3-backend-1") + }, + }, + }, + "Versioning config update fails": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + + GetBucketVersioningStub: func(ctx context.Context, lci *s3.GetBucketVersioningInput, f ...func(*s3.Options)) (*s3.GetBucketVersioningOutput, error) { + return &s3.GetBucketVersioningOutput{ + Status: s3types.BucketVersioningStatusSuspended, + MFADelete: s3types.MFADeleteStatusDisabled, + }, nil + }, + PutBucketVersioningStub: func(ctx context.Context, lci *s3.PutBucketVersioningInput, f ...func(*s3.Options)) (*s3.PutBucketVersioningOutput, error) { + return &s3.PutBucketVersioningOutput{}, errRandom + }, + } + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend("s3-backend-1", &fake, nil, true, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bucket", + }, + Spec: v1alpha1.BucketSpec{ + ForProvider: v1alpha1.BucketParameters{ + VersioningConfiguration: &v1alpha1.VersioningConfiguration{ + MFADelete: &mfaDeleteEnabled, + Status: &vStatusEnabled, + }, + }, + }, + }, + backendName: "s3-backend-1", + }, + want: want{ + err: errRandom, + specificDiff: func(t *testing.T, bb *bucketBackends) { + t.Helper() + backends := bb.getBackends(bucketName, []string{beName}) + assert.True(t, + backends[beName].VersioningConfigurationCondition.Equal(v1.Unavailable(). + WithMessage(errors.Wrap(errors.Wrap(errRandom, "failed to put bucket versioning"), errHandleVersioningConfig).Error())), + "unexpected versioning config condition on s3-backend-1") + }, + }, + }, + } + for name, tc := range cases { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + c := NewVersioningConfigurationClient( + tc.fields.backendStore, + s3clienthandler.NewHandler( + s3clienthandler.WithAssumeRoleArn(nil), + s3clienthandler.WithBackendStore(tc.fields.backendStore)), + logging.NewNopLogger()) + + bb := newBucketBackends() + bb.setVersioningConfigCondition(bucketName, beName, &creating) + + err := c.Handle(context.Background(), tc.args.bucket, tc.args.backendName, bb) + require.ErrorIs(t, err, tc.want.err, "unexpected error") + if tc.want.specificDiff != nil { + tc.want.specificDiff(t, bb) + } + }) + } +} diff --git a/internal/rgw/versioningconfiguration.go b/internal/rgw/versioningconfiguration.go new file mode 100644 index 00000000..b80cf98d --- /dev/null +++ b/internal/rgw/versioningconfiguration.go @@ -0,0 +1,48 @@ +package rgw + +import ( + "context" + + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/crossplane/crossplane-runtime/pkg/errors" + "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/linode/provider-ceph/apis/provider-ceph/v1alpha1" + "github.com/linode/provider-ceph/internal/backendstore" + "github.com/linode/provider-ceph/internal/otel/traces" + "go.opentelemetry.io/otel" +) + +const ( + errGetBucketVersioning = "failed to get bucket versioning" + errPutBucketVersioning = "failed to put bucket versioning" +) + +func PutBucketVersioning(ctx context.Context, s3Backend backendstore.S3Client, b *v1alpha1.Bucket) (*awss3.PutBucketVersioningOutput, error) { + ctx, span := otel.Tracer("").Start(ctx, "PutBucketVersioning") + defer span.End() + + resp, err := s3Backend.PutBucketVersioning(ctx, GeneratePutBucketVersioningInput(b.Name, b.Spec.ForProvider.VersioningConfiguration)) + if err != nil { + err := errors.Wrap(err, errPutBucketVersioning) + traces.SetAndRecordError(span, err) + + return resp, err + } + + return resp, nil +} + +func GetBucketVersioning(ctx context.Context, s3Backend backendstore.S3Client, bucketName *string) (*awss3.GetBucketVersioningOutput, error) { + ctx, span := otel.Tracer("").Start(ctx, "GetBucketVersioning") + defer span.End() + + resp, err := s3Backend.GetBucketVersioning(ctx, &awss3.GetBucketVersioningInput{Bucket: bucketName}) + if resource.IgnoreAny(err, IsBucketNotFound) != nil { + err = errors.Wrap(err, errGetBucketVersioning) + traces.SetAndRecordError(span, err) + + return resp, err + } + + return resp, nil +} diff --git a/internal/rgw/versioningconfiguration_helpers.go b/internal/rgw/versioningconfiguration_helpers.go new file mode 100644 index 00000000..d4a471d6 --- /dev/null +++ b/internal/rgw/versioningconfiguration_helpers.go @@ -0,0 +1,32 @@ +package rgw + +import ( + "github.com/aws/aws-sdk-go-v2/aws" + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/linode/provider-ceph/apis/provider-ceph/v1alpha1" +) + +// GeneratePutBucketVersioningInput creates the PutBucketVersioningInput for the AWS SDK +func GeneratePutBucketVersioningInput(name string, config *v1alpha1.VersioningConfiguration) *awss3.PutBucketVersioningInput { + return &awss3.PutBucketVersioningInput{ + Bucket: aws.String(name), + VersioningConfiguration: GenerateVersioningConfiguration(config), + } +} + +func GenerateVersioningConfiguration(inputConfig *v1alpha1.VersioningConfiguration) *types.VersioningConfiguration { + if inputConfig == nil { + return nil + } + + outputConfig := &types.VersioningConfiguration{} + if inputConfig.MFADelete != nil { + outputConfig.MFADelete = types.MFADelete(*inputConfig.MFADelete) + } + if inputConfig.Status != nil { + outputConfig.Status = types.BucketVersioningStatus(*inputConfig.Status) + } + + return outputConfig +} diff --git a/package/crds/provider-ceph.ceph.crossplane.io_buckets.yaml b/package/crds/provider-ceph.ceph.crossplane.io_buckets.yaml index dadcef1b..c8cc6b9b 100644 --- a/package/crds/provider-ceph.ceph.crossplane.io_buckets.yaml +++ b/package/crds/provider-ceph.ceph.crossplane.io_buckets.yaml @@ -485,6 +485,29 @@ spec: If it is set, Provider-Ceph calls PutBucketPolicy API after creating the bucket. Before adding it, you should validate the JSON string. type: string + versioningConfiguration: + description: |- + VersioningConfiguration describes the desired versioning state of an S3 bucket. + See the API reference guide for PutBucketVersioning for usage and error information. + See also, https://docs.aws.amazon.com/goto/WebAPI/s3-2006-03-01/PutBucketVersioning + properties: + mfaDelete: + description: |- + MFADelete specifies whether MFA delete is enabled in the bucket versioning configuration. + This element is only returned if the bucket has been configured with MFA + delete. If the bucket has never been so configured, this element is not returned. + enum: + - Enabled + - Disabled + type: string + status: + description: Status is the desired versioning state of the + bucket. + enum: + - Enabled + - Suspended + type: string + type: object type: object lifecycleConfigurationDisabled: description: |- @@ -749,6 +772,42 @@ spec: - status - type type: object + versioningConfigurationCondition: + description: |- + VersioningConfigurationCondition is the condition of the versioning + configuration on the S3 backend. Use a pointer to allow nil value when + there is no versioning configuration. + properties: + lastTransitionTime: + description: |- + LastTransitionTime is the last time this condition transitioned from one + status to another. + format: date-time + type: string + message: + description: |- + A Message containing details about this condition's last transition from + one status to another, if any. + type: string + reason: + description: A Reason for this condition's last transition + from one status to another. + type: string + status: + description: Status of this condition; is it currently + True, False, or Unknown? + type: string + type: + description: |- + Type of this condition. At most one of each condition type may apply to + a resource at any point in time. + type: string + required: + - lastTransitionTime + - reason + - status + - type + type: object type: object description: Backends is a map of the names of the S3 backends to BackendInfo.