diff --git a/apis/minio/v1/bucket_types.go b/apis/minio/v1/bucket_types.go index f626798..90cef89 100644 --- a/apis/minio/v1/bucket_types.go +++ b/apis/minio/v1/bucket_types.go @@ -84,6 +84,21 @@ type BucketParameters struct { // Policy is a raw S3 bucket policy. // Please consult https://min.io/docs/minio/linux/administration/identity-access-management/policy-based-access-control.html for more details about the policy. Policy *string `json:"policy,omitempty"` + + // Bucket lifecycle rules. + // Please consult https://min.io/docs/minio/linux/administration/object-management/object-lifecycle-management.html for more details about object lifecycle management. + LifecycleRules []LifecycleRules `json:"lifecycleRules,omitempty"` +} + +type LifecycleRules struct { + // ID is the unique identifier for the rule. + ID string `json:"id,omitempty"` + + // ExpirationDays is the number of days after which the object expires. + ExpirationDays int `json:"expirationDays,omitempty"` + + // NoncurrentVersionExpirationDays is the number of days after which the noncurrent versions expire. + NoncurrentVersionExpirationDays int `json:"noncurrentVersionExpirationDays,omitempty"` } type BucketProviderStatus struct { diff --git a/apis/minio/v1/zz_generated.deepcopy.go b/apis/minio/v1/zz_generated.deepcopy.go index 78148e5..3a2f951 100644 --- a/apis/minio/v1/zz_generated.deepcopy.go +++ b/apis/minio/v1/zz_generated.deepcopy.go @@ -76,6 +76,11 @@ func (in *BucketParameters) DeepCopyInto(out *BucketParameters) { *out = new(string) **out = **in } + if in.LifecycleRules != nil { + in, out := &in.LifecycleRules, &out.LifecycleRules + *out = make([]LifecycleRules, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BucketParameters. @@ -142,6 +147,21 @@ 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 *LifecycleRules) DeepCopyInto(out *LifecycleRules) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LifecycleRules. +func (in *LifecycleRules) DeepCopy() *LifecycleRules { + if in == nil { + return nil + } + out := new(LifecycleRules) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Policy) DeepCopyInto(out *Policy) { *out = *in diff --git a/operator/bucket/create.go b/operator/bucket/create.go index 485ac92..e1710e6 100644 --- a/operator/bucket/create.go +++ b/operator/bucket/create.go @@ -7,6 +7,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" "github.com/crossplane/crossplane-runtime/pkg/resource" "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/lifecycle" miniov1 "github.com/vshn/provider-minio/apis/minio/v1" controllerruntime "sigs.k8s.io/controller-runtime" ) @@ -32,6 +33,28 @@ func (b *bucketClient) Create(ctx context.Context, mg resource.Managed) (managed } } + if bucket.Spec.ForProvider.LifecycleRules != nil { + lifecycleConfiguration := lifecycle.NewConfiguration() + for _, rule := range bucket.Spec.ForProvider.LifecycleRules { + lifecycleRule := lifecycle.Rule{ + ID: rule.ID, + Expiration: lifecycle.Expiration{ + Days: lifecycle.ExpirationDays(rule.ExpirationDays), + }, + NoncurrentVersionExpiration: lifecycle.NoncurrentVersionExpiration{ + NoncurrentDays: lifecycle.ExpirationDays(rule.NoncurrentVersionExpirationDays), + }, + Status: "Enabled", + } + lifecycleConfiguration.Rules = append(lifecycleConfiguration.Rules, lifecycleRule) + + err = b.mc.SetBucketLifecycle(ctx, bucket.GetBucketName(), lifecycleConfiguration) + if err != nil { + return managed.ExternalCreation{}, err + } + } + } + b.setLock(bucket) return managed.ExternalCreation{}, b.emitCreationEvent(bucket) diff --git a/operator/bucket/observe.go b/operator/bucket/observe.go index e0340f6..95154b2 100644 --- a/operator/bucket/observe.go +++ b/operator/bucket/observe.go @@ -4,11 +4,13 @@ import ( "context" "fmt" "net/http" + "strings" xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" "github.com/crossplane/crossplane-runtime/pkg/resource" "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/lifecycle" "github.com/pkg/errors" miniov1 "github.com/vshn/provider-minio/apis/minio/v1" controllerruntime "sigs.k8s.io/controller-runtime" @@ -27,6 +29,16 @@ var bucketPolicyLatestFn = func(ctx context.Context, mc *minio.Client, bucketNam return current == policy, nil } +var bucketLifecycleLatestFn = func(ctx context.Context, mc *minio.Client, bucketName string, lifecycleRules *lifecycle.Configuration) (bool, error) { + current, err := mc.GetBucketLifecycle(ctx, bucketName) + // Continuing if error is not "The lifecycle configuration does not exist" as we want to report the resource as not up-to-date, if that is the case + if err != nil && !strings.Contains(err.Error(), "The lifecycle configuration does not exist") { + return false, err + } + + return current == lifecycleRules, nil +} + func (d *bucketClient) Observe(ctx context.Context, mg resource.Managed) (managed.ExternalObservation, error) { log := controllerruntime.LoggerFrom(ctx) log.V(1).Info("observing resource") @@ -65,6 +77,32 @@ func (d *bucketClient) Observe(ctx context.Context, mg resource.Managed) (manage isLatest = u } + if isLatest && bucket.Spec.ForProvider.LifecycleRules != nil { + lifecycleConfiguration := lifecycle.NewConfiguration() + for _, rule := range bucket.Spec.ForProvider.LifecycleRules { + lifecycleRule := lifecycle.Rule{ + ID: rule.ID, + Expiration: lifecycle.Expiration{ + Days: lifecycle.ExpirationDays(rule.ExpirationDays), + }, + NoncurrentVersionExpiration: lifecycle.NoncurrentVersionExpiration{ + NoncurrentDays: lifecycle.ExpirationDays(rule.NoncurrentVersionExpirationDays), + }, + Status: "Enabled", + } + lifecycleConfiguration.Rules = append(lifecycleConfiguration.Rules, lifecycleRule) + + upToDate, err := bucketLifecycleLatestFn(ctx, d.mc, bucketName, lifecycleConfiguration) + if err != nil { + return managed.ExternalObservation{}, errors.Wrap(err, "cannot determine whether a bucket lifecycle rule exists") + } + + if !upToDate { + isLatest = false + break + } + } + } return managed.ExternalObservation{ResourceExists: true, ResourceUpToDate: isLatest}, nil } else if exists { return managed.ExternalObservation{}, fmt.Errorf("bucket already exists, try changing bucket name: %s", bucketName) diff --git a/operator/bucket/observe_test.go b/operator/bucket/observe_test.go index f3517c7..8a5e7b7 100644 --- a/operator/bucket/observe_test.go +++ b/operator/bucket/observe_test.go @@ -9,6 +9,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" "github.com/go-logr/logr" "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/lifecycle" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" miniov1 "github.com/vshn/provider-minio/apis/minio/v1" @@ -17,11 +18,17 @@ import ( func TestProvisioningPipeline_Observe(t *testing.T) { policy := "policy-struct" + lifecycleRule := miniov1.LifecycleRules{ + ID: "rule-1", + ExpirationDays: 30, + NoncurrentVersionExpirationDays: 50, + } tests := map[string]struct { - givenBucket *miniov1.Bucket - bucketExists bool - returnError error - policyLatest bool + givenBucket *miniov1.Bucket + bucketExists bool + returnError error + policyLatest bool + lifecycleLatest bool expectedError string expectedResult managed.ExternalObservation @@ -40,6 +47,13 @@ func TestProvisioningPipeline_Observe(t *testing.T) { }, expectedResult: managed.ExternalObservation{}, }, + "NewBucketWithLifecycleDoesntYetExistOnMinio": { + givenBucket: &miniov1.Bucket{Spec: miniov1.BucketSpec{ForProvider: miniov1.BucketParameters{ + BucketName: "my-bucket-with-lifecycle", + LifecycleRules: []miniov1.LifecycleRules{lifecycleRule}}}, + }, + expectedResult: managed.ExternalObservation{}, + }, "BucketExistsAndAccessibleWithOurCredentials": { givenBucket: &miniov1.Bucket{ ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ @@ -89,6 +103,19 @@ func TestProvisioningPipeline_Observe(t *testing.T) { expectedResult: managed.ExternalObservation{}, expectedError: "mismatching endpointURL and zone, or bucket exists already in a different region, try changing bucket name: 301 Moved Permanently", }, + "BucketLifecycleChangeRequired": { + givenBucket: &miniov1.Bucket{ + ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ + lockAnnotation: "claimed", + }}, + Spec: miniov1.BucketSpec{ForProvider: miniov1.BucketParameters{ + BucketName: "my-bucket-with-lifecycle", + LifecycleRules: []miniov1.LifecycleRules{lifecycleRule}}}, + }, + bucketExists: true, + expectedResult: managed.ExternalObservation{ResourceExists: true, ResourceUpToDate: false}, + expectedBucketObservation: miniov1.BucketProviderStatus{BucketName: "my-bucket-with-lifecycle"}, + }, "BucketPolicyNoChangeRequired": { givenBucket: &miniov1.Bucket{ ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ @@ -103,6 +130,20 @@ func TestProvisioningPipeline_Observe(t *testing.T) { expectedResult: managed.ExternalObservation{ResourceExists: true, ResourceUpToDate: true}, expectedBucketObservation: miniov1.BucketProviderStatus{BucketName: "my-bucket"}, }, + "BucketLifecycleNoChangeRequired": { + givenBucket: &miniov1.Bucket{ + ObjectMeta: metav1.ObjectMeta{Annotations: map[string]string{ + lockAnnotation: "claimed", + }}, + Spec: miniov1.BucketSpec{ForProvider: miniov1.BucketParameters{ + BucketName: "my-bucket-with-lifecycle", + LifecycleRules: []miniov1.LifecycleRules{lifecycleRule}}}, + }, + bucketExists: true, + lifecycleLatest: true, + expectedResult: managed.ExternalObservation{ResourceExists: true, ResourceUpToDate: true}, + expectedBucketObservation: miniov1.BucketProviderStatus{BucketName: "my-bucket-with-lifecycle"}, + }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { @@ -115,6 +156,10 @@ func TestProvisioningPipeline_Observe(t *testing.T) { return tc.policyLatest, tc.returnError } + bucketLifecycleLatestFn = func(ctx context.Context, mc *minio.Client, bucketName string, lifecycleRules *lifecycle.Configuration) (bool, error) { + return tc.lifecycleLatest, tc.returnError + } + bucketExistsFn = func(ctx context.Context, mc *minio.Client, bucketName string) (bool, error) { return tc.bucketExists, tc.returnError } diff --git a/operator/bucket/update.go b/operator/bucket/update.go index 8d00367..9df1ad3 100644 --- a/operator/bucket/update.go +++ b/operator/bucket/update.go @@ -5,6 +5,7 @@ import ( "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" "github.com/crossplane/crossplane-runtime/pkg/resource" + "github.com/minio/minio-go/v7/pkg/lifecycle" miniov1 "github.com/vshn/provider-minio/apis/minio/v1" controllerruntime "sigs.k8s.io/controller-runtime" ) @@ -25,5 +26,27 @@ func (b *bucketClient) Update(ctx context.Context, mg resource.Managed) (managed } } + if bucket.Spec.ForProvider.LifecycleRules != nil { + lifecycleConfiguration := lifecycle.NewConfiguration() + for _, rule := range bucket.Spec.ForProvider.LifecycleRules { + lifecycleRule := lifecycle.Rule{ + ID: rule.ID, + Expiration: lifecycle.Expiration{ + Days: lifecycle.ExpirationDays(rule.ExpirationDays), + }, + NoncurrentVersionExpiration: lifecycle.NoncurrentVersionExpiration{ + NoncurrentDays: lifecycle.ExpirationDays(rule.NoncurrentVersionExpirationDays), + }, + Status: "Enabled", + } + lifecycleConfiguration.Rules = append(lifecycleConfiguration.Rules, lifecycleRule) + + err := b.mc.SetBucketLifecycle(ctx, bucket.GetBucketName(), lifecycleConfiguration) + if err != nil { + return managed.ExternalUpdate{}, err + } + } + } + return managed.ExternalUpdate{}, nil } diff --git a/operator/bucket/webhook.go b/operator/bucket/webhook.go index e02ccd5..d3091d2 100644 --- a/operator/bucket/webhook.go +++ b/operator/bucket/webhook.go @@ -30,6 +30,13 @@ func (v *Validator) ValidateCreate(_ context.Context, obj runtime.Object) (admis if providerConfigRef == nil || providerConfigRef.Name == "" { return nil, fmt.Errorf(".spec.providerConfigRef.name is required") } + if bucket.Spec.ForProvider.LifecycleRules != nil { + for _, rule := range bucket.Spec.ForProvider.LifecycleRules { + if rule.ExpirationDays <= 0 && rule.NoncurrentVersionExpirationDays <= 0 { + return nil, field.Invalid(field.NewPath("spec", "forProvider", "lifecycleRules"), rule, "Either ExpirationDays or NoncurrentVersionExpirationDays must be declared and both can't be 0") + } + } + } return nil, nil } @@ -51,6 +58,13 @@ func (v *Validator) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Obj if providerConfigRef == nil || providerConfigRef.Name == "" { return nil, field.Invalid(field.NewPath("spec", "providerConfigRef", "name"), "null", "Provider config is required") } + if newBucket.Spec.ForProvider.LifecycleRules != nil { + for _, rule := range newBucket.Spec.ForProvider.LifecycleRules { + if rule.ExpirationDays <= 0 && rule.NoncurrentVersionExpirationDays <= 0 { + return nil, field.Invalid(field.NewPath("spec", "forProvider", "lifecycleRules"), rule, "Either ExpirationDays or NoncurrentVersionExpirationDays must be declared and both can't be 0") + } + } + } return nil, nil } diff --git a/operator/bucket/webhook_test.go b/operator/bucket/webhook_test.go index 7bbbb8e..6baf023 100644 --- a/operator/bucket/webhook_test.go +++ b/operator/bucket/webhook_test.go @@ -48,6 +48,48 @@ func TestValidator_ValidateCreate_RequireProviderConfig(t *testing.T) { } } +func TestValidator_ValidateCreate_RequireLifecycleDaySpecification(t *testing.T) { + tests := map[string]struct { + expirationDays int + noncurrentVersionExpirationDays int + expectedError string + }{ + "GivenBothExpirationDays_ThenExpectNoError": { + expirationDays: 10, + noncurrentVersionExpirationDays: 10, + }, + "GivenOnlyExpirationDays_ThenExpectNoError": { + expirationDays: 10, + }, + "GivenOnlyVersionExpirationDays_ThenExpectNoError": { + noncurrentVersionExpirationDays: 10, + }, + "GivenNoExpirationDays_ThenExpectError": { + expirationDays: 0, + noncurrentVersionExpirationDays: 0, + expectedError: `spec.forProvider.lifecycleRules: Invalid value: v1.LifecycleRules{ID:"", ExpirationDays:0, NoncurrentVersionExpirationDays:0}: Either ExpirationDays or NoncurrentVersionExpirationDays must be declared and both can't be 0`, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + bucket := &miniov1.Bucket{ + ObjectMeta: metav1.ObjectMeta{Name: "bucket"}, + Spec: miniov1.BucketSpec{ + ForProvider: miniov1.BucketParameters{BucketName: "bucket", LifecycleRules: []miniov1.LifecycleRules{{ExpirationDays: tc.expirationDays, NoncurrentVersionExpirationDays: tc.noncurrentVersionExpirationDays}}}, + ResourceSpec: xpv1.ResourceSpec{ProviderConfigReference: &xpv1.Reference{Name: "provider-config"}}, + }, + } + v := &Validator{log: logr.Discard()} + _, err := v.ValidateCreate(context.TODO(), bucket) + if tc.expectedError != "" { + assert.EqualError(t, err, tc.expectedError) + } else { + require.NoError(t, err) + } + }) + } +} + func TestValidator_ValidateUpdate_PreventBucketNameChange(t *testing.T) { tests := map[string]struct { newBucketName string @@ -201,3 +243,52 @@ func TestValidator_ValidateUpdate_PreventZoneChange(t *testing.T) { }) } } + +func TestValidator_ValidateUpdate_RequireLifecycleDaySpecification(t *testing.T) { + tests := map[string]struct { + expirationDays int + noncurrentVersionExpirationDays int + expectedError string + }{ + "GivenBothExpirationDays_ThenExpectNoError": { + expirationDays: 10, + noncurrentVersionExpirationDays: 10, + }, + "GivenOnlyExpirationDays_ThenExpectNoError": { + expirationDays: 10, + }, + "GivenOnlyVersionExpirationDays_ThenExpectNoError": { + noncurrentVersionExpirationDays: 10, + }, + "GivenNoExpirationDays_ThenExpectError": { + expirationDays: 0, + noncurrentVersionExpirationDays: 0, + expectedError: `spec.forProvider.lifecycleRules: Invalid value: v1.LifecycleRules{ID:"", ExpirationDays:0, NoncurrentVersionExpirationDays:0}: Either ExpirationDays or NoncurrentVersionExpirationDays must be declared and both can't be 0`, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + oldBucket := &miniov1.Bucket{ + ObjectMeta: metav1.ObjectMeta{Name: "bucket"}, + Spec: miniov1.BucketSpec{ + ForProvider: miniov1.BucketParameters{BucketName: "bucket", LifecycleRules: []miniov1.LifecycleRules{{ExpirationDays: tc.expirationDays, NoncurrentVersionExpirationDays: tc.noncurrentVersionExpirationDays}}}, + ResourceSpec: xpv1.ResourceSpec{ProviderConfigReference: &xpv1.Reference{Name: "provider-config"}}, + }, + } + newBucket := &miniov1.Bucket{ + ObjectMeta: metav1.ObjectMeta{Name: "bucket"}, + Spec: miniov1.BucketSpec{ + ForProvider: miniov1.BucketParameters{BucketName: "bucket", LifecycleRules: []miniov1.LifecycleRules{{ExpirationDays: tc.expirationDays, NoncurrentVersionExpirationDays: tc.noncurrentVersionExpirationDays}}}, + ResourceSpec: xpv1.ResourceSpec{ProviderConfigReference: &xpv1.Reference{Name: "provider-config"}}, + }, + } + v := &Validator{log: logr.Discard()} + _, err := v.ValidateUpdate(context.TODO(), oldBucket, newBucket) + if tc.expectedError != "" { + assert.EqualError(t, err, tc.expectedError) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/package/crds/minio.crossplane.io_buckets.yaml b/package/crds/minio.crossplane.io_buckets.yaml index d27d7d8..7b8cd11 100644 --- a/package/crds/minio.crossplane.io_buckets.yaml +++ b/package/crds/minio.crossplane.io_buckets.yaml @@ -93,6 +93,25 @@ spec: Name must be acceptable by the S3 protocol, which follows RFC 1123. Be aware that S3 providers may require a unique name across the platform or zone. type: string + lifecycleRules: + description: |- + Bucket lifecycle rules. + Please consult https://min.io/docs/minio/linux/administration/object-management/object-lifecycle-management.html for more details about object lifecycle management. + items: + properties: + expirationDays: + description: ExpirationDays is the number of days after + which the object expires. + type: integer + id: + description: ID is the unique identifier for the rule. + type: string + noncurrentVersionExpirationDays: + description: NoncurrentVersionExpirationDays is the number + of days after which the noncurrent versions expire. + type: integer + type: object + type: array policy: description: |- Policy is a raw S3 bucket policy.