From edd9347063e941bc08e13070e1d797f87e689de5 Mon Sep 17 00:00:00 2001 From: mariomalinditex Date: Fri, 24 Jul 2026 09:26:02 +0200 Subject: [PATCH 1/2] feat(bucket): add CORS configuration support Add a CORSConfiguration sub-resource to the Bucket managed resource, following the existing ServerSideEncryptionConfiguration pattern. - New CORSConfiguration/CORSRule API types and Bucket spec fields (corsConfiguration, corsConfigurationDisabled) plus the corsConfigurationCondition backend status. - S3-only implementation via Put/Get/DeleteBucketCors on the S3Client interface; no new external dependencies. - New CORSConfigurationClient sub-resource reconciler wired into the bucket controller, autopause checks and per-backend conditions. - --disable-cors-config-reconcile flag (DISABLE_CORS_CONFIG_RECONCILE). - Regenerated deepcopy, S3 client fakes and Bucket CRD; added example CR. Signed-off-by: mariomalinditex --- apis/provider-ceph/v1alpha1/bucket_types.go | 13 + .../v1alpha1/corsconfiguration_types.go | 40 + .../v1alpha1/zz_generated.deepcopy.go | 77 ++ cmd/provider/main.go | 6 +- examples/sample/bucket-cors.yaml | 22 + internal/backendstore/backend.go | 3 + .../backendstorefakes/fake_s3client.go | 243 ++++++ internal/controller/bucket/bucket_backends.go | 70 ++ internal/controller/bucket/consts.go | 4 + .../controller/bucket/corsconfiguration.go | 210 +++++ .../bucket/corsconfiguration_test.go | 812 ++++++++++++++++++ internal/controller/bucket/helpers.go | 14 + internal/controller/bucket/subresources.go | 4 + internal/rgw/corsconfiguration.go | 76 ++ internal/rgw/corsconfiguration_helpers.go | 61 ++ internal/rgw/corsconfiguration_test.go | 351 ++++++++ ...vider-ceph.ceph.crossplane.io_buckets.yaml | 109 +++ 17 files changed, 2114 insertions(+), 1 deletion(-) create mode 100644 apis/provider-ceph/v1alpha1/corsconfiguration_types.go create mode 100644 examples/sample/bucket-cors.yaml create mode 100644 internal/controller/bucket/corsconfiguration.go create mode 100644 internal/controller/bucket/corsconfiguration_test.go create mode 100644 internal/rgw/corsconfiguration.go create mode 100644 internal/rgw/corsconfiguration_helpers.go create mode 100644 internal/rgw/corsconfiguration_test.go diff --git a/apis/provider-ceph/v1alpha1/bucket_types.go b/apis/provider-ceph/v1alpha1/bucket_types.go index 2a0da07f..c7d0cc31 100644 --- a/apis/provider-ceph/v1alpha1/bucket_types.go +++ b/apis/provider-ceph/v1alpha1/bucket_types.go @@ -107,6 +107,10 @@ type BucketParameters struct { // Before adding it, you should validate the JSON string. // +optional Policy string `json:"policy,omitempty"` + + // CORSConfiguration describes the cross-origin access configuration for the bucket. + // +optional + CORSConfiguration *CORSConfiguration `json:"corsConfiguration,omitempty"` } // BackendInfo contains relevant information about an S3 backend for @@ -134,6 +138,11 @@ type BackendInfo struct { // side encryption configuration on the S3 backend. Use a pointer to allow nil // value when there is no serverside encryption configuration. ServerSideEncryptionConfigurationCondition *xpv1.Condition `json:"serverSideEncryptionConfigurationCondition,omitempty"` + // +optional + // CORSConfigurationCondition is the condition of the CORS configuration + // on the S3 backend. Use a pointer to allow nil value when there is + // no CORS configuration. + CORSConfigurationCondition *xpv1.Condition `json:"corsConfigurationCondition,omitempty"` } // Backends is a map of the names of the S3 backends to BackendInfo. @@ -174,6 +183,10 @@ type BucketSpec struct { // for the bucket on all of the bucket's backends. The Bucket CR's // status is updated accordingly. ServerSideEncryptionConfigurationDisabled bool `json:"serverSideEncryptionConfigurationDisabled,omitempty"` + // CORSConfigurationDisabled causes provider-ceph to attempt deletion + // and/or avoid create/updates of the CORS config for the bucket on + // all of the bucket's backends. The Bucket CR's status is updated accordingly. + CORSConfigurationDisabled bool `json:"corsConfigurationDisabled,omitempty"` // +optional // AutoPause allows the user to disable further reconciliation diff --git a/apis/provider-ceph/v1alpha1/corsconfiguration_types.go b/apis/provider-ceph/v1alpha1/corsconfiguration_types.go new file mode 100644 index 00000000..50201351 --- /dev/null +++ b/apis/provider-ceph/v1alpha1/corsconfiguration_types.go @@ -0,0 +1,40 @@ +package v1alpha1 + +// CORSConfiguration describes the cross-origin access configuration for a bucket. +type CORSConfiguration struct { + // A set of origins and methods (cross-origin access that you want to allow). + // You can add up to 100 rules to the configuration. + Rules []CORSRule `json:"rules"` +} + +// CORSRule specifies a cross-origin access rule for a bucket. +type CORSRule struct { + // Headers that are specified in the Access-Control-Request-Headers header. + // These headers are allowed in a preflight OPTIONS request. In response to + // any preflight OPTIONS request, Amazon S3 returns any requested headers that + // are allowed. + // +optional + AllowedHeaders []string `json:"allowedHeaders,omitempty"` + + // An HTTP method that you allow the origin to execute. Valid values are GET, + // PUT, HEAD, POST, and DELETE. + AllowedMethods []string `json:"allowedMethods"` + + // One or more origins you want customers to be able to access the bucket from. + AllowedOrigins []string `json:"allowedOrigins"` + + // One or more headers in the response that you want customers to be able to + // access from their applications (for example, from a JavaScript XMLHttpRequest + // object). + // +optional + ExposeHeaders []string `json:"exposeHeaders,omitempty"` + + // Unique identifier for the rule. The value cannot be longer than 255 characters. + // +optional + ID *string `json:"id,omitempty"` + + // The time in seconds that your browser is to cache the preflight response + // for the specified resource. + // +optional + MaxAgeSeconds *int32 `json:"maxAgeSeconds,omitempty"` +} diff --git a/apis/provider-ceph/v1alpha1/zz_generated.deepcopy.go b/apis/provider-ceph/v1alpha1/zz_generated.deepcopy.go index ff5cd1b3..8c6a9596 100644 --- a/apis/provider-ceph/v1alpha1/zz_generated.deepcopy.go +++ b/apis/provider-ceph/v1alpha1/zz_generated.deepcopy.go @@ -96,6 +96,11 @@ func (in *BackendInfo) DeepCopyInto(out *BackendInfo) { *out = new(v1.Condition) (*in).DeepCopyInto(*out) } + if in.CORSConfigurationCondition != nil { + in, out := &in.CORSConfigurationCondition, &out.CORSConfigurationCondition + *out = new(v1.Condition) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BackendInfo. @@ -323,6 +328,11 @@ func (in *BucketParameters) DeepCopyInto(out *BucketParameters) { *out = make([]Tag, len(*in)) copy(*out, *in) } + if in.CORSConfiguration != nil { + in, out := &in.CORSConfiguration, &out.CORSConfiguration + *out = new(CORSConfiguration) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BucketParameters. @@ -374,6 +384,73 @@ func (in *BucketStatus) DeepCopy() *BucketStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CORSConfiguration) DeepCopyInto(out *CORSConfiguration) { + *out = *in + if in.Rules != nil { + in, out := &in.Rules, &out.Rules + *out = make([]CORSRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CORSConfiguration. +func (in *CORSConfiguration) DeepCopy() *CORSConfiguration { + if in == nil { + return nil + } + out := new(CORSConfiguration) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *CORSRule) DeepCopyInto(out *CORSRule) { + *out = *in + if in.AllowedHeaders != nil { + in, out := &in.AllowedHeaders, &out.AllowedHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AllowedMethods != nil { + in, out := &in.AllowedMethods, &out.AllowedMethods + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.AllowedOrigins != nil { + in, out := &in.AllowedOrigins, &out.AllowedOrigins + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ExposeHeaders != nil { + in, out := &in.ExposeHeaders, &out.ExposeHeaders + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ID != nil { + in, out := &in.ID, &out.ID + *out = new(string) + **out = **in + } + if in.MaxAgeSeconds != nil { + in, out := &in.MaxAgeSeconds, &out.MaxAgeSeconds + *out = new(int32) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CORSRule. +func (in *CORSRule) DeepCopy() *CORSRule { + if in == nil { + return nil + } + out := new(CORSRule) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DefaultRetention) DeepCopyInto(out *DefaultRetention) { *out = *in diff --git a/cmd/provider/main.go b/cmd/provider/main.go index 08a34633..567fe53c 100644 --- a/cmd/provider/main.go +++ b/cmd/provider/main.go @@ -207,6 +207,7 @@ func createBucketConnector( disableVersioningConfigReconcile *bool, disableObjectLockConfigReconcile *bool, disableSSEConfigReconcile *bool, + disableCORSConfigReconcile *bool, ) *bucket.Connector { return bucket.NewConnector( bucket.WithAutoPause(autoPauseBucket), @@ -229,7 +230,8 @@ func createBucketConnector( PolicyClientDisabled: *disablePolicyReconcile, VersioningConfigurationClientDisabled: *disableVersioningConfigReconcile, ObjectLockConfigurationClientDisabled: *disableObjectLockConfigReconcile, - ServerSideEncryptionConfigurationClientDisabled: *disableSSEConfigReconcile}, + ServerSideEncryptionConfigurationClientDisabled: *disableSSEConfigReconcile, + CORSConfigurationClientDisabled: *disableCORSConfigReconcile}, log)), bucket.WithS3ClientHandler(s3ClientHandler), bucket.WithUsage(resource.NewLegacyProviderConfigUsageTracker(mgr.GetClient(), &v1alpha1.ProviderConfigUsage{})), @@ -347,6 +349,7 @@ func main() { disableVersioningConfigReconcile = app.Flag("disable-versioning-config-reconcile", "Disable reconciliation of Bucket Versioning Configurations.").Default("false").Envar("DISABLE_VERSIONING_CONFIG_RECONCILE").Bool() disableObjectLockConfigReconcile = app.Flag("disable-object-lock-config-reconcile", "Disable reconciliation of Object Lock Configurations.").Default("false").Envar("DISABLE_OBJECT_LOCK_CONFIG_RECONCILE").Bool() disableSSEConfigReconcile = app.Flag("disable-sse-config-reconcile", "Disable reconciliation of Server Side Encryption Configurations.").Default("false").Envar("DISABLE_SSE_CONFIG_RECONCILE").Bool() + disableCORSConfigReconcile = app.Flag("disable-cors-config-reconcile", "Disable reconciliation of CORS Configurations.").Default("false").Envar("DISABLE_CORS_CONFIG_RECONCILE").Bool() ) debugFlag := app.Flag("debug", "Enable debug logging (sets zap-log-level to debug)").Default("false").Bool() @@ -517,6 +520,7 @@ func main() { disableVersioningConfigReconcile, disableObjectLockConfigReconcile, disableSSEConfigReconcile, + disableCORSConfigReconcile, ) setupControllers(mgr, o, connector, canSafeStart, log) diff --git a/examples/sample/bucket-cors.yaml b/examples/sample/bucket-cors.yaml new file mode 100644 index 00000000..069f3ed8 --- /dev/null +++ b/examples/sample/bucket-cors.yaml @@ -0,0 +1,22 @@ +apiVersion: provider-ceph.ceph.crossplane.io/v1alpha1 +kind: Bucket +metadata: + name: test-bucket-cors +spec: + forProvider: + corsConfiguration: + rules: + - allowedOrigins: + - "*" + allowedMethods: + - GET + - PUT + - POST + - DELETE + - HEAD + allowedHeaders: + - "*" + exposeHeaders: + - ETag + - x-amz-request-id + maxAgeSeconds: 3600 diff --git a/internal/backendstore/backend.go b/internal/backendstore/backend.go index cdbfdf08..142e8436 100644 --- a/internal/backendstore/backend.go +++ b/internal/backendstore/backend.go @@ -49,6 +49,9 @@ type S3Client interface { PutBucketEncryption(context.Context, *s3.PutBucketEncryptionInput, ...func(*s3.Options)) (*s3.PutBucketEncryptionOutput, error) GetBucketEncryption(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) DeleteBucketEncryption(context.Context, *s3.DeleteBucketEncryptionInput, ...func(*s3.Options)) (*s3.DeleteBucketEncryptionOutput, error) + PutBucketCors(context.Context, *s3.PutBucketCorsInput, ...func(*s3.Options)) (*s3.PutBucketCorsOutput, error) + GetBucketCors(context.Context, *s3.GetBucketCorsInput, ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) + DeleteBucketCors(context.Context, *s3.DeleteBucketCorsInput, ...func(*s3.Options)) (*s3.DeleteBucketCorsOutput, error) } //counterfeiter:generate . STSClient diff --git a/internal/backendstore/backendstorefakes/fake_s3client.go b/internal/backendstore/backendstorefakes/fake_s3client.go index 1036e5b5..6d58972f 100644 --- a/internal/backendstore/backendstorefakes/fake_s3client.go +++ b/internal/backendstore/backendstorefakes/fake_s3client.go @@ -40,6 +40,21 @@ type FakeS3Client struct { result1 *s3.DeleteBucketOutput result2 error } + DeleteBucketCorsStub func(context.Context, *s3.DeleteBucketCorsInput, ...func(*s3.Options)) (*s3.DeleteBucketCorsOutput, error) + deleteBucketCorsMutex sync.RWMutex + deleteBucketCorsArgsForCall []struct { + arg1 context.Context + arg2 *s3.DeleteBucketCorsInput + arg3 []func(*s3.Options) + } + deleteBucketCorsReturns struct { + result1 *s3.DeleteBucketCorsOutput + result2 error + } + deleteBucketCorsReturnsOnCall map[int]struct { + result1 *s3.DeleteBucketCorsOutput + result2 error + } DeleteBucketEncryptionStub func(context.Context, *s3.DeleteBucketEncryptionInput, ...func(*s3.Options)) (*s3.DeleteBucketEncryptionOutput, error) deleteBucketEncryptionMutex sync.RWMutex deleteBucketEncryptionArgsForCall []struct { @@ -115,6 +130,21 @@ type FakeS3Client struct { result1 *s3.GetBucketAclOutput result2 error } + GetBucketCorsStub func(context.Context, *s3.GetBucketCorsInput, ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) + getBucketCorsMutex sync.RWMutex + getBucketCorsArgsForCall []struct { + arg1 context.Context + arg2 *s3.GetBucketCorsInput + arg3 []func(*s3.Options) + } + getBucketCorsReturns struct { + result1 *s3.GetBucketCorsOutput + result2 error + } + getBucketCorsReturnsOnCall map[int]struct { + result1 *s3.GetBucketCorsOutput + result2 error + } GetBucketEncryptionStub func(context.Context, *s3.GetBucketEncryptionInput, ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) getBucketEncryptionMutex sync.RWMutex getBucketEncryptionArgsForCall []struct { @@ -265,6 +295,21 @@ type FakeS3Client struct { result1 *s3.PutBucketAclOutput result2 error } + PutBucketCorsStub func(context.Context, *s3.PutBucketCorsInput, ...func(*s3.Options)) (*s3.PutBucketCorsOutput, error) + putBucketCorsMutex sync.RWMutex + putBucketCorsArgsForCall []struct { + arg1 context.Context + arg2 *s3.PutBucketCorsInput + arg3 []func(*s3.Options) + } + putBucketCorsReturns struct { + result1 *s3.PutBucketCorsOutput + result2 error + } + putBucketCorsReturnsOnCall map[int]struct { + result1 *s3.PutBucketCorsOutput + result2 error + } PutBucketEncryptionStub func(context.Context, *s3.PutBucketEncryptionInput, ...func(*s3.Options)) (*s3.PutBucketEncryptionOutput, error) putBucketEncryptionMutex sync.RWMutex putBucketEncryptionArgsForCall []struct { @@ -491,6 +536,72 @@ func (fake *FakeS3Client) DeleteBucketReturnsOnCall(i int, result1 *s3.DeleteBuc }{result1, result2} } +func (fake *FakeS3Client) DeleteBucketCors(arg1 context.Context, arg2 *s3.DeleteBucketCorsInput, arg3 ...func(*s3.Options)) (*s3.DeleteBucketCorsOutput, error) { + fake.deleteBucketCorsMutex.Lock() + ret, specificReturn := fake.deleteBucketCorsReturnsOnCall[len(fake.deleteBucketCorsArgsForCall)] + fake.deleteBucketCorsArgsForCall = append(fake.deleteBucketCorsArgsForCall, struct { + arg1 context.Context + arg2 *s3.DeleteBucketCorsInput + arg3 []func(*s3.Options) + }{arg1, arg2, arg3}) + stub := fake.DeleteBucketCorsStub + fakeReturns := fake.deleteBucketCorsReturns + fake.recordInvocation("DeleteBucketCors", []interface{}{arg1, arg2, arg3}) + fake.deleteBucketCorsMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3...) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeS3Client) DeleteBucketCorsCallCount() int { + fake.deleteBucketCorsMutex.RLock() + defer fake.deleteBucketCorsMutex.RUnlock() + return len(fake.deleteBucketCorsArgsForCall) +} + +func (fake *FakeS3Client) DeleteBucketCorsCalls(stub func(context.Context, *s3.DeleteBucketCorsInput, ...func(*s3.Options)) (*s3.DeleteBucketCorsOutput, error)) { + fake.deleteBucketCorsMutex.Lock() + defer fake.deleteBucketCorsMutex.Unlock() + fake.DeleteBucketCorsStub = stub +} + +func (fake *FakeS3Client) DeleteBucketCorsArgsForCall(i int) (context.Context, *s3.DeleteBucketCorsInput, []func(*s3.Options)) { + fake.deleteBucketCorsMutex.RLock() + defer fake.deleteBucketCorsMutex.RUnlock() + argsForCall := fake.deleteBucketCorsArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeS3Client) DeleteBucketCorsReturns(result1 *s3.DeleteBucketCorsOutput, result2 error) { + fake.deleteBucketCorsMutex.Lock() + defer fake.deleteBucketCorsMutex.Unlock() + fake.DeleteBucketCorsStub = nil + fake.deleteBucketCorsReturns = struct { + result1 *s3.DeleteBucketCorsOutput + result2 error + }{result1, result2} +} + +func (fake *FakeS3Client) DeleteBucketCorsReturnsOnCall(i int, result1 *s3.DeleteBucketCorsOutput, result2 error) { + fake.deleteBucketCorsMutex.Lock() + defer fake.deleteBucketCorsMutex.Unlock() + fake.DeleteBucketCorsStub = nil + if fake.deleteBucketCorsReturnsOnCall == nil { + fake.deleteBucketCorsReturnsOnCall = make(map[int]struct { + result1 *s3.DeleteBucketCorsOutput + result2 error + }) + } + fake.deleteBucketCorsReturnsOnCall[i] = struct { + result1 *s3.DeleteBucketCorsOutput + result2 error + }{result1, result2} +} + func (fake *FakeS3Client) DeleteBucketEncryption(arg1 context.Context, arg2 *s3.DeleteBucketEncryptionInput, arg3 ...func(*s3.Options)) (*s3.DeleteBucketEncryptionOutput, error) { fake.deleteBucketEncryptionMutex.Lock() ret, specificReturn := fake.deleteBucketEncryptionReturnsOnCall[len(fake.deleteBucketEncryptionArgsForCall)] @@ -821,6 +932,72 @@ func (fake *FakeS3Client) GetBucketAclReturnsOnCall(i int, result1 *s3.GetBucket }{result1, result2} } +func (fake *FakeS3Client) GetBucketCors(arg1 context.Context, arg2 *s3.GetBucketCorsInput, arg3 ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + fake.getBucketCorsMutex.Lock() + ret, specificReturn := fake.getBucketCorsReturnsOnCall[len(fake.getBucketCorsArgsForCall)] + fake.getBucketCorsArgsForCall = append(fake.getBucketCorsArgsForCall, struct { + arg1 context.Context + arg2 *s3.GetBucketCorsInput + arg3 []func(*s3.Options) + }{arg1, arg2, arg3}) + stub := fake.GetBucketCorsStub + fakeReturns := fake.getBucketCorsReturns + fake.recordInvocation("GetBucketCors", []interface{}{arg1, arg2, arg3}) + fake.getBucketCorsMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3...) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeS3Client) GetBucketCorsCallCount() int { + fake.getBucketCorsMutex.RLock() + defer fake.getBucketCorsMutex.RUnlock() + return len(fake.getBucketCorsArgsForCall) +} + +func (fake *FakeS3Client) GetBucketCorsCalls(stub func(context.Context, *s3.GetBucketCorsInput, ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error)) { + fake.getBucketCorsMutex.Lock() + defer fake.getBucketCorsMutex.Unlock() + fake.GetBucketCorsStub = stub +} + +func (fake *FakeS3Client) GetBucketCorsArgsForCall(i int) (context.Context, *s3.GetBucketCorsInput, []func(*s3.Options)) { + fake.getBucketCorsMutex.RLock() + defer fake.getBucketCorsMutex.RUnlock() + argsForCall := fake.getBucketCorsArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeS3Client) GetBucketCorsReturns(result1 *s3.GetBucketCorsOutput, result2 error) { + fake.getBucketCorsMutex.Lock() + defer fake.getBucketCorsMutex.Unlock() + fake.GetBucketCorsStub = nil + fake.getBucketCorsReturns = struct { + result1 *s3.GetBucketCorsOutput + result2 error + }{result1, result2} +} + +func (fake *FakeS3Client) GetBucketCorsReturnsOnCall(i int, result1 *s3.GetBucketCorsOutput, result2 error) { + fake.getBucketCorsMutex.Lock() + defer fake.getBucketCorsMutex.Unlock() + fake.GetBucketCorsStub = nil + if fake.getBucketCorsReturnsOnCall == nil { + fake.getBucketCorsReturnsOnCall = make(map[int]struct { + result1 *s3.GetBucketCorsOutput + result2 error + }) + } + fake.getBucketCorsReturnsOnCall[i] = struct { + result1 *s3.GetBucketCorsOutput + result2 error + }{result1, result2} +} + func (fake *FakeS3Client) GetBucketEncryption(arg1 context.Context, arg2 *s3.GetBucketEncryptionInput, arg3 ...func(*s3.Options)) (*s3.GetBucketEncryptionOutput, error) { fake.getBucketEncryptionMutex.Lock() ret, specificReturn := fake.getBucketEncryptionReturnsOnCall[len(fake.getBucketEncryptionArgsForCall)] @@ -1481,6 +1658,72 @@ func (fake *FakeS3Client) PutBucketAclReturnsOnCall(i int, result1 *s3.PutBucket }{result1, result2} } +func (fake *FakeS3Client) PutBucketCors(arg1 context.Context, arg2 *s3.PutBucketCorsInput, arg3 ...func(*s3.Options)) (*s3.PutBucketCorsOutput, error) { + fake.putBucketCorsMutex.Lock() + ret, specificReturn := fake.putBucketCorsReturnsOnCall[len(fake.putBucketCorsArgsForCall)] + fake.putBucketCorsArgsForCall = append(fake.putBucketCorsArgsForCall, struct { + arg1 context.Context + arg2 *s3.PutBucketCorsInput + arg3 []func(*s3.Options) + }{arg1, arg2, arg3}) + stub := fake.PutBucketCorsStub + fakeReturns := fake.putBucketCorsReturns + fake.recordInvocation("PutBucketCors", []interface{}{arg1, arg2, arg3}) + fake.putBucketCorsMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3...) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeS3Client) PutBucketCorsCallCount() int { + fake.putBucketCorsMutex.RLock() + defer fake.putBucketCorsMutex.RUnlock() + return len(fake.putBucketCorsArgsForCall) +} + +func (fake *FakeS3Client) PutBucketCorsCalls(stub func(context.Context, *s3.PutBucketCorsInput, ...func(*s3.Options)) (*s3.PutBucketCorsOutput, error)) { + fake.putBucketCorsMutex.Lock() + defer fake.putBucketCorsMutex.Unlock() + fake.PutBucketCorsStub = stub +} + +func (fake *FakeS3Client) PutBucketCorsArgsForCall(i int) (context.Context, *s3.PutBucketCorsInput, []func(*s3.Options)) { + fake.putBucketCorsMutex.RLock() + defer fake.putBucketCorsMutex.RUnlock() + argsForCall := fake.putBucketCorsArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeS3Client) PutBucketCorsReturns(result1 *s3.PutBucketCorsOutput, result2 error) { + fake.putBucketCorsMutex.Lock() + defer fake.putBucketCorsMutex.Unlock() + fake.PutBucketCorsStub = nil + fake.putBucketCorsReturns = struct { + result1 *s3.PutBucketCorsOutput + result2 error + }{result1, result2} +} + +func (fake *FakeS3Client) PutBucketCorsReturnsOnCall(i int, result1 *s3.PutBucketCorsOutput, result2 error) { + fake.putBucketCorsMutex.Lock() + defer fake.putBucketCorsMutex.Unlock() + fake.PutBucketCorsStub = nil + if fake.putBucketCorsReturnsOnCall == nil { + fake.putBucketCorsReturnsOnCall = make(map[int]struct { + result1 *s3.PutBucketCorsOutput + result2 error + }) + } + fake.putBucketCorsReturnsOnCall[i] = struct { + result1 *s3.PutBucketCorsOutput + result2 error + }{result1, result2} +} + func (fake *FakeS3Client) PutBucketEncryption(arg1 context.Context, arg2 *s3.PutBucketEncryptionInput, arg3 ...func(*s3.Options)) (*s3.PutBucketEncryptionOutput, error) { fake.putBucketEncryptionMutex.Lock() ret, specificReturn := fake.putBucketEncryptionReturnsOnCall[len(fake.putBucketEncryptionArgsForCall)] diff --git a/internal/controller/bucket/bucket_backends.go b/internal/controller/bucket/bucket_backends.go index e5010949..c0411475 100644 --- a/internal/controller/bucket/bucket_backends.go +++ b/internal/controller/bucket/bucket_backends.go @@ -111,6 +111,36 @@ func (b *bucketBackends) getSSEConfigCondition(bucketName, backendName string) * return b.backends[bucketName][backendName].ServerSideEncryptionConfigurationCondition } +func (b *bucketBackends) setCORSConfigCondition(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].CORSConfigurationCondition = c +} + +func (b *bucketBackends) getCORSConfigCondition(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].CORSConfigurationCondition +} + func (b *bucketBackends) setVersioningConfigCondition(bucketName, backendName string, c *xpv1.Condition) { b.mu.Lock() defer b.mu.Unlock() @@ -313,6 +343,46 @@ func (b *bucketBackends) isSSEConfigRemovedFromBackends(bucket *v1alpha1.Bucket, return true } +// isCORSConfigAvailableOnBackends checks the backends listed in providerNames against +// bucketBackends to ensure CORS configurations are considered Available on all desired backends. +func (b *bucketBackends) isCORSConfigAvailableOnBackends(bucket *v1alpha1.Bucket, 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 + } + + corsCondition := b.getCORSConfigCondition(bucket.Name, backendName) + if corsCondition == nil || !corsCondition.Equal(xpv1.Available()) { + // The CORS config is not Available on this backend. + return false + } + } + + return true +} + +// isCORSConfigRemovedFromBackends checks the backends listed in providerNames against +// bucketBackends to ensure CORS configurations are removed from all desired backends. +func (b *bucketBackends) isCORSConfigRemovedFromBackends(bucket *v1alpha1.Bucket, 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 + } + + corsCondition := b.getCORSConfigCondition(bucket.Name, backendName) + if corsCondition != nil { + // The CORS config is still present on this backend and has not been removed yet. + return false + } + } + + 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 { diff --git a/internal/controller/bucket/consts.go b/internal/controller/bucket/consts.go index 3ae0dbfa..ae7979be 100644 --- a/internal/controller/bucket/consts.go +++ b/internal/controller/bucket/consts.go @@ -35,6 +35,10 @@ const ( errObserveSSEConfig = "failed to observe bucket server side encryption configuration" errHandleSSEConfig = "failed to handle bucket server side encryption configuration" + // CORS configuration error messages. + errObserveCORSConfig = "failed to observe bucket CORS configuration" + errHandleCORSConfig = "failed to handle bucket CORS configuration" + // ACL error messages. errObserveAcl = "failed to observe bucket acl" errHandleAcl = "failed to handle bucket acl" diff --git a/internal/controller/bucket/corsconfiguration.go b/internal/controller/bucket/corsconfiguration.go new file mode 100644 index 00000000..2dc63f79 --- /dev/null +++ b/internal/controller/bucket/corsconfiguration.go @@ -0,0 +1,210 @@ +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/go-logr/logr" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + xpv1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "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" + + "go.opentelemetry.io/otel" +) + +// CORSConfigurationClient is the client for API methods and reconciling the CORSConfiguration. +type CORSConfigurationClient struct { + backendStore *backendstore.BackendStore + s3ClientHandler *s3clienthandler.Handler + log logr.Logger +} + +func NewCORSConfigurationClient(b *backendstore.BackendStore, h *s3clienthandler.Handler, l logr.Logger) *CORSConfigurationClient { + return &CORSConfigurationClient{backendStore: b, s3ClientHandler: h, log: l} +} + +//nolint:dupl // CORSConfiguration is similar to other subresource clients. +func (l *CORSConfigurationClient) Observe(ctx context.Context, bucket *v1alpha1.Bucket, backendNames []string) (ResourceStatus, error) { + ctx, span := otel.Tracer("").Start(ctx, "bucket.CORSConfigurationClient.Observe") + defer span.End() + ctx, log := traces.InjectTraceAndLogger(ctx, l.log) + + observationChan := make(chan ResourceStatus) + errChan := make(chan error) + + for _, backendName := range backendNames { + beName := backendName + go func() { + if l.backendStore.GetBackendHealthStatus(beName) == apisv1alpha1.HealthStatusUnhealthy { + observationChan <- NoAction + + return + } + + 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(): + log.Info("Context timeout during bucket CORS configuration observation", consts.KeyBucketName, bucket.Name) + err := errors.Wrap(ctx.Err(), errObserveCORSConfig) + traces.SetAndRecordError(span, err) + + return NeedsUpdate, err + case observation := <-observationChan: + if observation == NeedsUpdate || observation == NeedsDeletion { + return observation, nil + } + case err := <-errChan: + err = errors.Wrap(err, errObserveCORSConfig) + traces.SetAndRecordError(span, err) + + return NeedsUpdate, err + } + } + + return Updated, nil +} + +func (l *CORSConfigurationClient) observeBackend(ctx context.Context, bucket *v1alpha1.Bucket, backendName string) (ResourceStatus, error) { + _, log := traces.InjectTraceAndLogger(ctx, l.log) + + log.V(1).Info("Observing subresource CORS configuration on backend", consts.KeyBucketName, bucket.Name, consts.KeyBackendName, backendName) + + s3Client, err := l.s3ClientHandler.GetS3Client(ctx, bucket, backendName) + if err != nil { + return NeedsUpdate, err + } + response, err := rgw.GetBucketCors(ctx, s3Client, aws.String(bucket.Name)) + if err != nil { + return NeedsUpdate, err + } + + if bucket.Spec.ForProvider.CORSConfiguration == nil || bucket.Spec.CORSConfigurationDisabled { + if response == nil || len(response.CORSRules) == 0 { + log.V(1).Info("No CORS configuration found on backend - no action required", consts.KeyBucketName, bucket.Name, consts.KeyBackendName, backendName) + + return NoAction, nil + } + + log.V(1).Info("CORS configuration found on backend - requires deletion", consts.KeyBucketName, bucket.Name, consts.KeyBackendName, backendName) + + return NeedsDeletion, nil + } + + var corsRulesInCR []v1alpha1.CORSRule + if bucket.Spec.ForProvider.CORSConfiguration != nil { + corsRulesInCR = bucket.Spec.ForProvider.CORSConfiguration.Rules + } + + var corsRulesOnBackend []s3types.CORSRule + if response != nil { + corsRulesOnBackend = response.CORSRules + } + + if len(corsRulesOnBackend) != 0 && len(corsRulesInCR) == 0 { + return NeedsDeletion, nil + } + + if !cmp.Equal(corsRulesOnBackend, rgw.GenerateCORSRules(corsRulesInCR), cmpopts.EquateEmpty(), cmpopts.IgnoreUnexported(s3types.CORSRule{})) { + log.V(1).Info("CORS configuration requires update on backend", consts.KeyBucketName, bucket.Name, consts.KeyBackendName, backendName) + + return NeedsUpdate, nil + } + + return Updated, nil +} + +//nolint:dupl // CORSConfiguration and other configurations have similar Handle logic. +func (l *CORSConfigurationClient) Handle(ctx context.Context, b *v1alpha1.Bucket, backendName string, bb *bucketBackends) error { + ctx, span := otel.Tracer("").Start(ctx, "bucket.CORSConfigurationClient.Handle") + defer span.End() + + if l.backendStore.GetBackendHealthStatus(backendName) == apisv1alpha1.HealthStatusUnhealthy { + traces.SetAndRecordError(span, errUnhealthyBackend) + + return errUnhealthyBackend + } + + observation, err := l.observeBackend(ctx, b, backendName) + if err != nil { + err = errors.Wrap(err, errHandleCORSConfig) + traces.SetAndRecordError(span, err) + + return err + } + + switch observation { + case NoAction: + return nil + case Updated: + available := xpv1.Available() + bb.setCORSConfigCondition(b.Name, backendName, &available) + + case NeedsDeletion: + if err := l.delete(ctx, b, backendName); err != nil { + err = errors.Wrap(err, errHandleCORSConfig) + deleting := xpv1.Deleting().WithMessage(err.Error()) + bb.setCORSConfigCondition(b.Name, backendName, &deleting) + + traces.SetAndRecordError(span, err) + + return err + } + bb.setCORSConfigCondition(b.Name, backendName, nil) + + case NeedsUpdate: + if err := l.createOrUpdate(ctx, b, backendName); err != nil { + err = errors.Wrap(err, errHandleCORSConfig) + unavailable := xpv1.Unavailable().WithMessage(err.Error()) + bb.setCORSConfigCondition(b.Name, backendName, &unavailable) + + traces.SetAndRecordError(span, err) + + return err + } + available := xpv1.Available() + bb.setCORSConfigCondition(b.Name, backendName, &available) + } + + return nil +} + +func (l *CORSConfigurationClient) createOrUpdate(ctx context.Context, b *v1alpha1.Bucket, backendName string) error { + s3Client, err := l.s3ClientHandler.GetS3Client(ctx, b, backendName) + if err != nil { + return err + } + + _, err = rgw.PutBucketCors(ctx, s3Client, b) + + return err +} + +func (l *CORSConfigurationClient) delete(ctx context.Context, b *v1alpha1.Bucket, backendName string) error { + s3Client, err := l.s3ClientHandler.GetS3Client(ctx, b, backendName) + if err != nil { + return err + } + + return rgw.DeleteBucketCors(ctx, s3Client, aws.String(b.Name)) +} diff --git a/internal/controller/bucket/corsconfiguration_test.go b/internal/controller/bucket/corsconfiguration_test.go new file mode 100644 index 00000000..7745ce9b --- /dev/null +++ b/internal/controller/bucket/corsconfiguration_test.go @@ -0,0 +1,812 @@ +/* +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/aws/smithy-go" + v1 "github.com/crossplane/crossplane-runtime/v2/apis/common/v1" + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/go-logr/logr" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "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/consts" + "github.com/linode/provider-ceph/internal/controller/s3clienthandler" + "github.com/linode/provider-ceph/internal/rgw" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const corsMethodGet = "GET" + +//nolint:maintidx // Function requires numerous checks. +func TestCORSConfigObserveBackend(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 CORS config": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{ + CORSRules: []s3types.CORSRule{}, + }, errExternal + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + status: NeedsUpdate, + err: errExternal, + }, + }, + "Attempt to observe CORS config on unhealthy backend (consider it NoAction to unblock)": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{} + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusUnhealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + status: NoAction, + err: nil, + }, + }, + "CORS config not specified in CR but exists on backend so NeedsDeletion": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{ + CORSRules: []s3types.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: false, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + status: NeedsDeletion, + err: nil, + }, + }, + "CORS config not specified in CR and does not exist on backend so NoAction": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{}, &smithy.GenericAPIError{Code: rgw.CORSConfigurationNotFoundErrCode} + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: false, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + status: NoAction, + err: nil, + }, + }, + "CORS config specified in CR and disabled but exists on backend so NeedsDeletion": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{ + CORSRules: []s3types.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: true, + ForProvider: v1alpha1.BucketParameters{ + CORSConfiguration: &v1alpha1.CORSConfiguration{ + Rules: []v1alpha1.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + }, + }, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + status: NeedsDeletion, + err: nil, + }, + }, + "CORS config specified in CR and disabled but does not exist on backend so NoAction": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{}, &smithy.GenericAPIError{Code: rgw.CORSConfigurationNotFoundErrCode} + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: true, + ForProvider: v1alpha1.BucketParameters{ + CORSConfiguration: &v1alpha1.CORSConfiguration{ + Rules: []v1alpha1.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + }, + }, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + status: NoAction, + err: nil, + }, + }, + "CORS config has no rules in CR and is enabled but has rules on backend so NeedsDeletion": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{ + CORSRules: []s3types.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: false, + ForProvider: v1alpha1.BucketParameters{ + CORSConfiguration: &v1alpha1.CORSConfiguration{ + Rules: []v1alpha1.CORSRule{}, + }, + }, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + status: NeedsDeletion, + err: nil, + }, + }, + "CORS config has rules in CR and is enabled but has different rules on backend so NeedsUpdate": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{ + CORSRules: []s3types.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"https://old.example.com"}, + }, + }, + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: false, + ForProvider: v1alpha1.BucketParameters{ + CORSConfiguration: &v1alpha1.CORSConfiguration{ + Rules: []v1alpha1.CORSRule{ + { + AllowedMethods: []string{corsMethodGet, "PUT"}, + AllowedOrigins: []string{"https://new.example.com"}, + }, + }, + }, + }, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + status: NeedsUpdate, + err: nil, + }, + }, + "CORS config has rules in CR and is enabled and has same rules on backend so is Updated": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{ + CORSRules: []s3types.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + AllowedHeaders: []string{"*"}, + }, + }, + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: false, + ForProvider: v1alpha1.BucketParameters{ + CORSConfiguration: &v1alpha1.CORSConfiguration{ + Rules: []v1alpha1.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + AllowedHeaders: []string{"*"}, + }, + }, + }, + }, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + status: Updated, + err: nil, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + c := NewCORSConfigurationClient( + tc.fields.backendStore, + s3clienthandler.NewHandler( + s3clienthandler.WithAssumeRoleArn(nil), + s3clienthandler.WithBackendStore(tc.fields.backendStore)), + logr.Discard()) + + 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 TestCORSConfigHandle(t *testing.T) { + t.Parallel() + 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 + }{ + "Unhealthy backend returns error": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{} + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusUnhealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: false, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + err: errUnhealthyBackend, + }, + }, + "CORS config deletes successfully": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{ + CORSRules: []s3types.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: false, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + err: nil, + specificDiff: func(t *testing.T, bb *bucketBackends) { + t.Helper() + backends := bb.getBackends(consts.TestBucket, []string{consts.S3Backend1}) + assert.True(t, + func(bb v1alpha1.Backends) bool { + return bb[consts.S3Backend1].CORSConfigurationCondition == nil + }(backends), + "s3-backend-1 should not have a CORS config condition") + }, + }, + }, + "CORS config delete fails": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{ + CORSRules: []s3types.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + }, nil + }, + DeleteBucketCorsStub: func(ctx context.Context, input *s3.DeleteBucketCorsInput, f ...func(*s3.Options)) (*s3.DeleteBucketCorsOutput, error) { + return &s3.DeleteBucketCorsOutput{}, errRandom + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: false, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + err: errRandom, + specificDiff: func(t *testing.T, bb *bucketBackends) { + t.Helper() + backends := bb.getBackends(consts.TestBucket, []string{consts.S3Backend1}) + assert.True(t, + backends[consts.S3Backend1].CORSConfigurationCondition.Equal(v1.Deleting(). + WithMessage(errors.Wrap(errors.Wrap(errRandom, "failed to delete bucket CORS configuration"), errHandleCORSConfig).Error())), + "unexpected CORS config condition on s3-backend-1") + }, + }, + }, + "CORS config is not found and disabled on CR so no action required": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{}, &smithy.GenericAPIError{Code: rgw.CORSConfigurationNotFoundErrCode} + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: true, + ForProvider: v1alpha1.BucketParameters{ + CORSConfiguration: &v1alpha1.CORSConfiguration{ + Rules: []v1alpha1.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + }, + }, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + err: nil, + }, + }, + "CORS config updates successfully": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{ + CORSRules: []s3types.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"https://old.example.com"}, + }, + }, + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: false, + ForProvider: v1alpha1.BucketParameters{ + CORSConfiguration: &v1alpha1.CORSConfiguration{ + Rules: []v1alpha1.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + }, + }, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + err: nil, + specificDiff: func(t *testing.T, bb *bucketBackends) { + t.Helper() + backends := bb.getBackends(consts.TestBucket, []string{consts.S3Backend1}) + assert.True(t, + backends[consts.S3Backend1].CORSConfigurationCondition.Equal(v1.Available()), + "unexpected CORS config condition on s3-backend-1") + }, + }, + }, + "CORS config update fails": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{ + CORSRules: []s3types.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"https://old.example.com"}, + }, + }, + }, nil + }, + PutBucketCorsStub: func(ctx context.Context, input *s3.PutBucketCorsInput, f ...func(*s3.Options)) (*s3.PutBucketCorsOutput, error) { + return &s3.PutBucketCorsOutput{}, errRandom + }, + } + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: false, + ForProvider: v1alpha1.BucketParameters{ + CORSConfiguration: &v1alpha1.CORSConfiguration{ + Rules: []v1alpha1.CORSRule{ + { + AllowedMethods: []string{corsMethodGet, "PUT"}, + AllowedOrigins: []string{"https://new.example.com"}, + }, + }, + }, + }, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + err: errRandom, + specificDiff: func(t *testing.T, bb *bucketBackends) { + t.Helper() + backends := bb.getBackends(consts.TestBucket, []string{consts.S3Backend1}) + assert.True(t, + backends[consts.S3Backend1].CORSConfigurationCondition.Equal(v1.Unavailable(). + WithMessage(errors.Wrap(errors.Wrap(errRandom, "failed to put bucket CORS configuration"), errHandleCORSConfig).Error())), + "unexpected CORS config condition on s3-backend-1") + }, + }, + }, + "CORS config is already up to date so sets Available": { + fields: fields{ + backendStore: func() *backendstore.BackendStore { + fake := backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *s3.GetBucketCorsInput, f ...func(*s3.Options)) (*s3.GetBucketCorsOutput, error) { + return &s3.GetBucketCorsOutput{ + CORSRules: []s3types.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + }, nil + }, + } + + bs := backendstore.NewBackendStore() + bs.AddOrUpdateBackend(consts.S3Backend1, &fake, nil, apisv1alpha1.HealthStatusHealthy) + + return bs + }(), + }, + args: args{ + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: consts.TestBucket, + }, + Spec: v1alpha1.BucketSpec{ + CORSConfigurationDisabled: false, + ForProvider: v1alpha1.BucketParameters{ + CORSConfiguration: &v1alpha1.CORSConfiguration{ + Rules: []v1alpha1.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + }, + }, + }, + }, + backendName: consts.S3Backend1, + }, + want: want{ + err: nil, + specificDiff: func(t *testing.T, bb *bucketBackends) { + t.Helper() + backends := bb.getBackends(consts.TestBucket, []string{consts.S3Backend1}) + assert.True(t, + backends[consts.S3Backend1].CORSConfigurationCondition.Equal(v1.Available()), + "unexpected CORS config condition on s3-backend-1") + }, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + c := NewCORSConfigurationClient( + tc.fields.backendStore, + s3clienthandler.NewHandler( + s3clienthandler.WithAssumeRoleArn(nil), + s3clienthandler.WithBackendStore(tc.fields.backendStore)), + logr.Discard()) + + bb := newBucketBackends() + bb.setCORSConfigCondition(consts.TestBucket, consts.S3Backend1, &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/controller/bucket/helpers.go b/internal/controller/bucket/helpers.go index e02a877d..7af9b5e5 100644 --- a/internal/controller/bucket/helpers.go +++ b/internal/controller/bucket/helpers.go @@ -75,6 +75,20 @@ func isPauseRequired(bucket *v1alpha1.Bucket, providerNames []string, c map[stri return false } + // If CORS config is enabled and is specified in the spec, we should only pause once + // the CORS config is available on all backends. + if !bucket.Spec.CORSConfigurationDisabled && + bucket.Spec.ForProvider.CORSConfiguration != nil && + !bb.isCORSConfigAvailableOnBackends(bucket, providerNames, c) { + return false + } + + // If CORS config is disabled, we should only pause once the CORS config is + // removed from all backends. + if bucket.Spec.CORSConfigurationDisabled && !bb.isCORSConfigRemovedFromBackends(bucket, providerNames, c) { + 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) { diff --git a/internal/controller/bucket/subresources.go b/internal/controller/bucket/subresources.go index 745a5dd8..df2692d0 100644 --- a/internal/controller/bucket/subresources.go +++ b/internal/controller/bucket/subresources.go @@ -54,6 +54,9 @@ func NewSubresourceClients(b *backendstore.BackendStore, h *s3clienthandler.Hand if !config.ServerSideEncryptionConfigurationClientDisabled { subresourceClients = append(subresourceClients, NewServerSideEncryptionConfigurationClient(b, h, l.WithValues("server-side-encryption-configuration-client", managed.ControllerName(v1alpha1.BucketGroupKind)))) } + if !config.CORSConfigurationClientDisabled { + subresourceClients = append(subresourceClients, NewCORSConfigurationClient(b, h, l.WithValues("cors-configuration-client", managed.ControllerName(v1alpha1.BucketGroupKind)))) + } return subresourceClients } @@ -79,4 +82,5 @@ type SubresourceClientConfig struct { VersioningConfigurationClientDisabled bool ObjectLockConfigurationClientDisabled bool ServerSideEncryptionConfigurationClientDisabled bool + CORSConfigurationClientDisabled bool } diff --git a/internal/rgw/corsconfiguration.go b/internal/rgw/corsconfiguration.go new file mode 100644 index 00000000..b79b6539 --- /dev/null +++ b/internal/rgw/corsconfiguration.go @@ -0,0 +1,76 @@ +//nolint:dupl // Similar to serversideencryptionconfiguration.go +package rgw + +import ( + "context" + + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource" + "go.opentelemetry.io/otel" + + "github.com/linode/provider-ceph/apis/provider-ceph/v1alpha1" + "github.com/linode/provider-ceph/internal/backendstore" + "github.com/linode/provider-ceph/internal/otel/traces" +) + +const ( + errGetBucketCors = "failed to get bucket CORS configuration" + errPutBucketCors = "failed to put bucket CORS configuration" + errDeleteBucketCors = "failed to delete bucket CORS configuration" +) + +func PutBucketCors(ctx context.Context, s3Backend backendstore.S3Client, b *v1alpha1.Bucket) (*awss3.PutBucketCorsOutput, error) { + ctx, span := otel.Tracer("").Start(ctx, "PutBucketCors") + defer span.End() + + resp, err := s3Backend.PutBucketCors( + ctx, + GenerateCORSConfigurationInput( + b.Name, + b.Spec.ForProvider.CORSConfiguration, + ), + ) + if err != nil { + err := errors.Wrap(err, errPutBucketCors) + traces.SetAndRecordError(span, err) + + return resp, err + } + + return resp, nil +} + +func DeleteBucketCors(ctx context.Context, s3Backend backendstore.S3Client, bucketName *string) error { + ctx, span := otel.Tracer("").Start(ctx, "DeleteBucketCors") + defer span.End() + + _, err := s3Backend.DeleteBucketCors(ctx, + &awss3.DeleteBucketCorsInput{ + Bucket: bucketName, + }, + ) + if err != nil { + err := errors.Wrap(err, errDeleteBucketCors) + traces.SetAndRecordError(span, err) + + return err + } + + return nil +} + +func GetBucketCors(ctx context.Context, s3Backend backendstore.S3Client, bucketName *string) (*awss3.GetBucketCorsOutput, error) { + ctx, span := otel.Tracer("").Start(ctx, "GetBucketCors") + defer span.End() + + resp, err := s3Backend.GetBucketCors(ctx, &awss3.GetBucketCorsInput{Bucket: bucketName}) + if resource.IgnoreAny(err, CORSConfigurationNotFound, IsBucketNotFound) != nil { + err = errors.Wrap(err, errGetBucketCors) + traces.SetAndRecordError(span, err) + + return resp, err + } + + return resp, nil +} diff --git a/internal/rgw/corsconfiguration_helpers.go b/internal/rgw/corsconfiguration_helpers.go new file mode 100644 index 00000000..bbf04fd3 --- /dev/null +++ b/internal/rgw/corsconfiguration_helpers.go @@ -0,0 +1,61 @@ +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/aws/smithy-go" + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/linode/provider-ceph/apis/provider-ceph/v1alpha1" +) + +// GenerateCORSConfigurationInput creates the PutBucketCorsInput for the AWS SDK. +func GenerateCORSConfigurationInput(name string, config *v1alpha1.CORSConfiguration) *awss3.PutBucketCorsInput { + if config == nil { + return nil + } + + return &awss3.PutBucketCorsInput{ + Bucket: aws.String(name), + CORSConfiguration: &types.CORSConfiguration{ + CORSRules: GenerateCORSRules(config.Rules), + }, + } +} + +// GenerateCORSRules converts the Kubernetes CORSRule types to the AWS SDK CORSRule types. +func GenerateCORSRules(inRules []v1alpha1.CORSRule) []types.CORSRule { + var outRules []types.CORSRule //nolint:prealloc // prealloc is disabled due to AWS requiring nil instead of 0-length for empty slices. + for _, inRule := range inRules { + outRule := types.CORSRule{ + AllowedHeaders: inRule.AllowedHeaders, + AllowedMethods: inRule.AllowedMethods, + AllowedOrigins: inRule.AllowedOrigins, + ExposeHeaders: inRule.ExposeHeaders, + ID: inRule.ID, + } + if inRule.MaxAgeSeconds != nil { + maxAge := *inRule.MaxAgeSeconds + outRule.MaxAgeSeconds = &maxAge + } + outRules = append(outRules, outRule) + } + + return outRules +} + +// CORSConfigurationNotFoundErrCode is the error code sent by Ceph when the CORS +// configuration does not exist. +var CORSConfigurationNotFoundErrCode = "NoSuchCORSConfiguration" + +// CORSConfigurationNotFound parses the error and reports whether the CORS +// configuration does not exist. +func CORSConfigurationNotFound(err error) bool { + var awsErr smithy.APIError + + if !errors.As(err, &awsErr) { + return false + } + + return awsErr.ErrorCode() == CORSConfigurationNotFoundErrCode +} diff --git a/internal/rgw/corsconfiguration_test.go b/internal/rgw/corsconfiguration_test.go new file mode 100644 index 00000000..a6915fd3 --- /dev/null +++ b/internal/rgw/corsconfiguration_test.go @@ -0,0 +1,351 @@ +package rgw + +import ( + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awss3 "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/aws/smithy-go" + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/linode/provider-ceph/apis/provider-ceph/v1alpha1" + "github.com/linode/provider-ceph/internal/backendstore/backendstorefakes" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +const corsMethodGet = "GET" + +func TestGenerateCORSConfigurationInput(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + name string + config *v1alpha1.CORSConfiguration + want bool + }{ + "nil config returns nil": { + name: "my-bucket", + config: nil, + want: false, + }, + "valid config returns input": { + name: "my-bucket", + config: &v1alpha1.CORSConfiguration{ + Rules: []v1alpha1.CORSRule{ + { + AllowedOrigins: []string{"*"}, + AllowedMethods: []string{corsMethodGet}, + }, + }, + }, + want: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + result := GenerateCORSConfigurationInput(tc.name, tc.config) + if tc.want { + require.NotNil(t, result) + assert.Equal(t, aws.String(tc.name), result.Bucket) + assert.NotNil(t, result.CORSConfiguration) + } else { + assert.Nil(t, result) + } + }) + } +} + +func TestGenerateCORSRules(t *testing.T) { + t.Parallel() + + maxAge := int32(3600) + + testCases := map[string]struct { + input []v1alpha1.CORSRule + expected []s3types.CORSRule + }{ + "nil input returns nil": { + input: nil, + expected: nil, + }, + "empty input returns nil": { + input: []v1alpha1.CORSRule{}, + expected: nil, + }, + "single rule with all fields": { + input: []v1alpha1.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{corsMethodGet, "PUT"}, + AllowedOrigins: []string{"https://example.com"}, + ExposeHeaders: []string{"ETag"}, + ID: aws.String("rule-1"), + MaxAgeSeconds: &maxAge, + }, + }, + expected: []s3types.CORSRule{ + { + AllowedHeaders: []string{"*"}, + AllowedMethods: []string{corsMethodGet, "PUT"}, + AllowedOrigins: []string{"https://example.com"}, + ExposeHeaders: []string{"ETag"}, + ID: aws.String("rule-1"), + MaxAgeSeconds: &maxAge, + }, + }, + }, + "rule without optional fields": { + input: []v1alpha1.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + expected: []s3types.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"*"}, + }, + }, + }, + "multiple rules": { + input: []v1alpha1.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"https://foo.com"}, + }, + { + AllowedMethods: []string{"PUT"}, + AllowedOrigins: []string{"https://bar.com"}, + MaxAgeSeconds: &maxAge, + }, + }, + expected: []s3types.CORSRule{ + { + AllowedMethods: []string{corsMethodGet}, + AllowedOrigins: []string{"https://foo.com"}, + }, + { + AllowedMethods: []string{"PUT"}, + AllowedOrigins: []string{"https://bar.com"}, + MaxAgeSeconds: &maxAge, + }, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + result := GenerateCORSRules(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestCORSConfigurationNotFound(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + err error + expected bool + }{ + "true - CORS not found error": { + err: &smithy.GenericAPIError{Code: CORSConfigurationNotFoundErrCode}, + expected: true, + }, + "false - non-AWS error": { + err: errors.New("some error"), + expected: false, + }, + "false - different AWS error code": { + err: &smithy.GenericAPIError{Code: "SomeOtherError"}, + expected: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + actual := CORSConfigurationNotFound(tc.err) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func TestPutBucketCors(t *testing.T) { + t.Parallel() + + errSome := errors.New("put error") + + testCases := map[string]struct { + fake *backendstorefakes.FakeS3Client + bucket *v1alpha1.Bucket + wantErr bool + }{ + "success": { + fake: &backendstorefakes.FakeS3Client{ + PutBucketCorsStub: func(ctx context.Context, input *awss3.PutBucketCorsInput, f ...func(*awss3.Options)) (*awss3.PutBucketCorsOutput, error) { + return &awss3.PutBucketCorsOutput{}, nil + }, + }, + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{Name: "my-bucket"}, + Spec: v1alpha1.BucketSpec{ + ForProvider: v1alpha1.BucketParameters{ + CORSConfiguration: &v1alpha1.CORSConfiguration{ + Rules: []v1alpha1.CORSRule{ + {AllowedMethods: []string{corsMethodGet}, AllowedOrigins: []string{"*"}}, + }, + }, + }, + }, + }, + wantErr: false, + }, + "error": { + fake: &backendstorefakes.FakeS3Client{ + PutBucketCorsStub: func(ctx context.Context, input *awss3.PutBucketCorsInput, f ...func(*awss3.Options)) (*awss3.PutBucketCorsOutput, error) { + return nil, errSome + }, + }, + bucket: &v1alpha1.Bucket{ + ObjectMeta: metav1.ObjectMeta{Name: "my-bucket"}, + Spec: v1alpha1.BucketSpec{ + ForProvider: v1alpha1.BucketParameters{ + CORSConfiguration: &v1alpha1.CORSConfiguration{ + Rules: []v1alpha1.CORSRule{ + {AllowedMethods: []string{corsMethodGet}, AllowedOrigins: []string{"*"}}, + }, + }, + }, + }, + }, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + _, err := PutBucketCors(context.Background(), tc.fake, tc.bucket) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestDeleteBucketCors(t *testing.T) { + t.Parallel() + + errSome := errors.New("delete error") + + testCases := map[string]struct { + fake *backendstorefakes.FakeS3Client + wantErr bool + }{ + "success": { + fake: &backendstorefakes.FakeS3Client{ + DeleteBucketCorsStub: func(ctx context.Context, input *awss3.DeleteBucketCorsInput, f ...func(*awss3.Options)) (*awss3.DeleteBucketCorsOutput, error) { + return &awss3.DeleteBucketCorsOutput{}, nil + }, + }, + wantErr: false, + }, + "error": { + fake: &backendstorefakes.FakeS3Client{ + DeleteBucketCorsStub: func(ctx context.Context, input *awss3.DeleteBucketCorsInput, f ...func(*awss3.Options)) (*awss3.DeleteBucketCorsOutput, error) { + return nil, errSome + }, + }, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + err := DeleteBucketCors(context.Background(), tc.fake, aws.String("my-bucket")) + if tc.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestGetBucketCors(t *testing.T) { + t.Parallel() + + errSome := errors.New("unexpected error") + + testCases := map[string]struct { + fake *backendstorefakes.FakeS3Client + wantErr bool + wantResp bool + }{ + "success with rules": { + fake: &backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *awss3.GetBucketCorsInput, f ...func(*awss3.Options)) (*awss3.GetBucketCorsOutput, error) { + return &awss3.GetBucketCorsOutput{ + CORSRules: []s3types.CORSRule{ + {AllowedMethods: []string{corsMethodGet}, AllowedOrigins: []string{"*"}}, + }, + }, nil + }, + }, + wantErr: false, + wantResp: true, + }, + "not found error is ignored": { + fake: &backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *awss3.GetBucketCorsInput, f ...func(*awss3.Options)) (*awss3.GetBucketCorsOutput, error) { + return nil, &smithy.GenericAPIError{Code: CORSConfigurationNotFoundErrCode} + }, + }, + wantErr: false, + wantResp: false, + }, + "unexpected error is returned": { + fake: &backendstorefakes.FakeS3Client{ + GetBucketCorsStub: func(ctx context.Context, input *awss3.GetBucketCorsInput, f ...func(*awss3.Options)) (*awss3.GetBucketCorsOutput, error) { + return nil, errSome + }, + }, + wantErr: true, + wantResp: false, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp, err := GetBucketCors(context.Background(), tc.fake, aws.String("my-bucket")) + if tc.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + if tc.wantResp { + require.NotNil(t, resp) + assert.NotEmpty(t, resp.CORSRules) + } + }) + } +} diff --git a/package/crds/provider-ceph.ceph.crossplane.io_buckets.yaml b/package/crds/provider-ceph.ceph.crossplane.io_buckets.yaml index ae2d8b99..31601a0a 100644 --- a/package/crds/provider-ceph.ceph.crossplane.io_buckets.yaml +++ b/package/crds/provider-ceph.ceph.crossplane.io_buckets.yaml @@ -64,6 +64,12 @@ spec: If `crossplane.io/paused` label is missing or empty, triggers auto pause function. Any other value disables auto pause function on bucket. type: boolean + corsConfigurationDisabled: + description: |- + CORSConfigurationDisabled causes provider-ceph to attempt deletion + and/or avoid create/updates of the CORS config for the bucket on + all of the bucket's backends. The Bucket CR's status is updated accordingly. + type: boolean deletionPolicy: default: Delete description: |- @@ -179,6 +185,66 @@ spec: - value type: object type: array + corsConfiguration: + description: CORSConfiguration describes the cross-origin access + configuration for the bucket. + properties: + rules: + description: |- + A set of origins and methods (cross-origin access that you want to allow). + You can add up to 100 rules to the configuration. + items: + description: CORSRule specifies a cross-origin access rule + for a bucket. + properties: + allowedHeaders: + description: |- + Headers that are specified in the Access-Control-Request-Headers header. + These headers are allowed in a preflight OPTIONS request. In response to + any preflight OPTIONS request, Amazon S3 returns any requested headers that + are allowed. + items: + type: string + type: array + allowedMethods: + description: |- + An HTTP method that you allow the origin to execute. Valid values are GET, + PUT, HEAD, POST, and DELETE. + items: + type: string + type: array + allowedOrigins: + description: One or more origins you want customers + to be able to access the bucket from. + items: + type: string + type: array + exposeHeaders: + description: |- + One or more headers in the response that you want customers to be able to + access from their applications (for example, from a JavaScript XMLHttpRequest + object). + items: + type: string + type: array + id: + description: Unique identifier for the rule. The value + cannot be longer than 255 characters. + type: string + maxAgeSeconds: + description: |- + The time in seconds that your browser is to cache the preflight response + for the specified resource. + format: int32 + type: integer + required: + - allowedMethods + - allowedOrigins + type: object + type: array + required: + - rules + type: object grantFullControl: description: |- Allows grantee the read, write, read ACP, and write ACP permissions on the @@ -775,6 +841,49 @@ spec: - status - type type: object + corsConfigurationCondition: + description: |- + CORSConfigurationCondition is the condition of the CORS configuration + on the S3 backend. Use a pointer to allow nil value when there is + no CORS 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 + observedGeneration: + description: |- + ObservedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + type: integer + 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 lifecycleConfigurationCondition: description: |- LifecycleConfigurationCondition is the condition of the bucket lifecycle From 567af59e130319c1034ea38178022631f8cbce65 Mon Sep 17 00:00:00 2001 From: mariomalinditex Date: Fri, 24 Jul 2026 10:41:44 +0200 Subject: [PATCH 2/2] test(bucket): add CORS chainsaw e2e coverage Signed-off-by: mariomalinditex --- e2e/tests/ceph/chainsaw-test.yaml | 20 ++++++++++++++++++ e2e/tests/stable/chainsaw-test.yaml | 32 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/e2e/tests/ceph/chainsaw-test.yaml b/e2e/tests/ceph/chainsaw-test.yaml index b6b1381a..b57e38d4 100755 --- a/e2e/tests/ceph/chainsaw-test.yaml +++ b/e2e/tests/ceph/chainsaw-test.yaml @@ -158,6 +158,22 @@ spec: mode: "COMPLIANCE" versioningConfiguration: status: "Enabled" + corsConfiguration: + rules: + - allowedOrigins: + - "*" + allowedMethods: + - GET + - PUT + - POST + - DELETE + - HEAD + allowedHeaders: + - "*" + exposeHeaders: + - ETag + - x-amz-request-id + maxAgeSeconds: 3600 lifecycleConfiguration: # Example rules from https://docs.aws.amazon.com/AmazonS3/latest/userguide/lifecycle-configuration-examples.html rules: @@ -214,6 +230,10 @@ spec: reason: Available status: "True" type: Ready + corsConfigurationCondition: + 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 ec392358..d464b9a6 100755 --- a/e2e/tests/stable/chainsaw-test.yaml +++ b/e2e/tests/stable/chainsaw-test.yaml @@ -144,6 +144,16 @@ spec: prefix: "images/" versioningConfiguration: status: "Enabled" + corsConfiguration: + rules: + - allowedOrigins: + - "*" + allowedMethods: + - GET + - PUT + allowedHeaders: + - "*" + maxAgeSeconds: 3600 objectLockEnabledForBucket: true objectLockConfiguration: objectLockEnabled: "Enabled" @@ -200,6 +210,10 @@ spec: reason: Available status: "True" type: Ready + corsConfigurationCondition: + reason: Available + status: "True" + type: Ready localstack-b: bucketCondition: reason: Available @@ -221,6 +235,10 @@ spec: reason: Available status: "True" type: Ready + corsConfigurationCondition: + reason: Available + status: "True" + type: Ready localstack-c: bucketCondition: reason: Available @@ -242,6 +260,10 @@ spec: reason: Available status: "True" type: Ready + corsConfigurationCondition: + 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. @@ -331,6 +353,16 @@ spec: prefix: "images/" versioningConfiguration: status: "Enabled" + corsConfiguration: + rules: + - allowedOrigins: + - "*" + allowedMethods: + - GET + - PUT + allowedHeaders: + - "*" + maxAgeSeconds: 3600 objectLockEnabledForBucket: true objectLockConfiguration: # Assert that the LC config and SSE config have been removed from subresource-configs.