diff --git a/api/v1alpha1/bucket_types.go b/api/v1alpha1/bucket_types.go index d292eb6..8fd3776 100644 --- a/api/v1alpha1/bucket_types.go +++ b/api/v1alpha1/bucket_types.go @@ -48,6 +48,29 @@ type BucketSpec struct { // Quota to apply to the bucket // +kubebuilder:validation:Required Quota Quota `json:"quota"` + + // Enable object locking on the bucket. Must be set at creation time. + // Enables versioning automatically. + // +kubebuilder:validation:Optional + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="objectLocking is immutable" + ObjectLocking bool `json:"objectLocking,omitempty"` + + // Default retention configuration for the bucket. Requires objectLocking to be true. + // +kubebuilder:validation:Optional + Retention *RetentionSpec `json:"retention,omitempty"` +} + +// RetentionSpec defines the default retention policy for a locked bucket. +type RetentionSpec struct { + // Retention mode: "governance" or "compliance" + // +kubebuilder:validation:Enum=governance;compliance + // +kubebuilder:validation:Required + Mode string `json:"mode"` + + // Retention period in days + // +kubebuilder:validation:Minimum=1 + // +kubebuilder:validation:Required + Days uint `json:"days"` } // BucketStatus defines the observed state of Bucket diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index a41dc1f..6aa59bc 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -93,6 +93,11 @@ func (in *BucketSpec) DeepCopyInto(out *BucketSpec) { copy(*out, *in) } in.Quota.DeepCopyInto(&out.Quota) + if in.Retention != nil { + in, out := &in.Retention, &out.Retention + *out = new(RetentionSpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BucketSpec. @@ -341,6 +346,21 @@ func (in *Quota) DeepCopy() *Quota { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RetentionSpec) DeepCopyInto(out *RetentionSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RetentionSpec. +func (in *RetentionSpec) DeepCopy() *RetentionSpec { + if in == nil { + return nil + } + out := new(RetentionSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *S3Instance) DeepCopyInto(out *S3Instance) { *out = *in diff --git a/config/crd/bases/s3.onyxia.sh_buckets.yaml b/config/crd/bases/s3.onyxia.sh_buckets.yaml index 030bd58..d4c6813 100644 --- a/config/crd/bases/s3.onyxia.sh_buckets.yaml +++ b/config/crd/bases/s3.onyxia.sh_buckets.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.17.1 + controller-gen.kubebuilder.io/version: v0.20.1 name: buckets.s3.onyxia.sh spec: group: s3.onyxia.sh @@ -42,6 +42,14 @@ spec: name: description: Name of the bucket type: string + objectLocking: + description: |- + Enable object locking on the bucket. Must be set at creation time. + Enables versioning automatically. + type: boolean + x-kubernetes-validations: + - message: objectLocking is immutable + rule: self == oldSelf paths: description: Paths (folders) to create inside the bucket items: @@ -67,6 +75,24 @@ spec: required: - default type: object + retention: + description: Default retention configuration for the bucket. Requires + objectLocking to be true. + properties: + days: + description: Retention period in days + minimum: 1 + type: integer + mode: + description: 'Retention mode: "governance" or "compliance"' + enum: + - governance + - compliance + type: string + required: + - days + - mode + type: object s3InstanceRef: default: s3-operator/default description: s3InstanceRef where create the bucket diff --git a/config/crd/bases/s3.onyxia.sh_paths.yaml b/config/crd/bases/s3.onyxia.sh_paths.yaml index 67fd1c5..a02a516 100644 --- a/config/crd/bases/s3.onyxia.sh_paths.yaml +++ b/config/crd/bases/s3.onyxia.sh_paths.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.17.1 + controller-gen.kubebuilder.io/version: v0.20.1 name: paths.s3.onyxia.sh spec: group: s3.onyxia.sh diff --git a/config/crd/bases/s3.onyxia.sh_policies.yaml b/config/crd/bases/s3.onyxia.sh_policies.yaml index ce5f62e..fee8b43 100644 --- a/config/crd/bases/s3.onyxia.sh_policies.yaml +++ b/config/crd/bases/s3.onyxia.sh_policies.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.17.1 + controller-gen.kubebuilder.io/version: v0.20.1 name: policies.s3.onyxia.sh spec: group: s3.onyxia.sh diff --git a/config/crd/bases/s3.onyxia.sh_s3instances.yaml b/config/crd/bases/s3.onyxia.sh_s3instances.yaml index 8cf429d..ffbb813 100644 --- a/config/crd/bases/s3.onyxia.sh_s3instances.yaml +++ b/config/crd/bases/s3.onyxia.sh_s3instances.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.17.1 + controller-gen.kubebuilder.io/version: v0.20.1 name: s3instances.s3.onyxia.sh spec: group: s3.onyxia.sh diff --git a/config/crd/bases/s3.onyxia.sh_s3users.yaml b/config/crd/bases/s3.onyxia.sh_s3users.yaml index 5dffc6d..40ac6e9 100644 --- a/config/crd/bases/s3.onyxia.sh_s3users.yaml +++ b/config/crd/bases/s3.onyxia.sh_s3users.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.17.1 + controller-gen.kubebuilder.io/version: v0.20.1 name: s3users.s3.onyxia.sh spec: group: s3.onyxia.sh diff --git a/deploy/charts/s3-operator/templates/crds/buckets.yaml b/deploy/charts/s3-operator/templates/crds/buckets.yaml index a825fd6..d4c6813 100644 --- a/deploy/charts/s3-operator/templates/crds/buckets.yaml +++ b/deploy/charts/s3-operator/templates/crds/buckets.yaml @@ -1,14 +1,9 @@ -{{- if .Values.crds.install }} +--- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - {{- if .Values.crds.keep }} - helm.sh/resource-policy: keep - {{- end }} - controller-gen.kubebuilder.io/version: v0.17.1 - labels: - {{- include "s3-operator.labels" . | nindent 4 }} + controller-gen.kubebuilder.io/version: v0.20.1 name: buckets.s3.onyxia.sh spec: group: s3.onyxia.sh @@ -47,6 +42,14 @@ spec: name: description: Name of the bucket type: string + objectLocking: + description: |- + Enable object locking on the bucket. Must be set at creation time. + Enables versioning automatically. + type: boolean + x-kubernetes-validations: + - message: objectLocking is immutable + rule: self == oldSelf paths: description: Paths (folders) to create inside the bucket items: @@ -72,6 +75,24 @@ spec: required: - default type: object + retention: + description: Default retention configuration for the bucket. Requires + objectLocking to be true. + properties: + days: + description: Retention period in days + minimum: 1 + type: integer + mode: + description: 'Retention mode: "governance" or "compliance"' + enum: + - governance + - compliance + type: string + required: + - days + - mode + type: object s3InstanceRef: default: s3-operator/default description: s3InstanceRef where create the bucket @@ -155,4 +176,3 @@ spec: storage: true subresources: status: {} -{{- end }} \ No newline at end of file diff --git a/internal/controller/bucket/reconcile.go b/internal/controller/bucket/reconcile.go index 5ae258e..217d4cb 100644 --- a/internal/controller/bucket/reconcile.go +++ b/internal/controller/bucket/reconcile.go @@ -441,7 +441,7 @@ func (r *BucketReconciler) handleCreation( } // Bucket creation - err = s3Client.CreateBucket(bucketResource.Spec.Name) + err = s3Client.CreateBucket(bucketResource.Spec.Name, bucketResource.Spec.ObjectLocking) if err != nil { logger.Error( err, @@ -505,6 +505,33 @@ func (r *BucketReconciler) handleCreation( ) } + // Retention configuration (requires objectLocking) + if bucketResource.Spec.Retention != nil && bucketResource.Spec.ObjectLocking { + err = s3Client.SetBucketRetention( + bucketResource.Spec.Name, + bucketResource.Spec.Retention.Mode, + bucketResource.Spec.Retention.Days, + ) + if err != nil { + logger.Error( + err, + "An error occurred while setting retention for bucket", + "bucketName", + bucketResource.Spec.Name, + "NamespacedName", + req.Namespace, + ) + return r.SetReconciledCondition( + ctx, + req, + bucketResource, + s3v1alpha1.CreationFailure, + "An error occurred while setting retention on bucket", + err, + ) + } + } + // Path creation for _, pathInCr := range bucketResource.Spec.Paths { err = s3Client.CreatePath(bucketResource.Spec.Name, pathInCr) diff --git a/internal/s3/client/impl/minioS3Client.go b/internal/s3/client/impl/minioS3Client.go index a27f833..002e40d 100644 --- a/internal/s3/client/impl/minioS3Client.go +++ b/internal/s3/client/impl/minioS3Client.go @@ -187,13 +187,35 @@ func (minioS3Client *MinioS3Client) BucketExists(name string) (bool, error) { return minioS3Client.client.BucketExists(context.Background(), name) } -func (minioS3Client *MinioS3Client) CreateBucket(name string) error { +func (minioS3Client *MinioS3Client) CreateBucket(name string, objectLocking bool) error { s3Logger := ctrl.Log.WithValues("logger", "s3clientimplminio") - s3Logger.Info("creating bucket", "bucket", name) + s3Logger.Info("creating bucket", "bucket", name, "objectLocking", objectLocking) return minioS3Client.client.MakeBucket( context.Background(), name, - minio.MakeBucketOptions{Region: minioS3Client.s3Config.Region}, + minio.MakeBucketOptions{Region: minioS3Client.s3Config.Region, ObjectLocking: objectLocking}, + ) +} + +func (minioS3Client *MinioS3Client) SetBucketRetention(name string, mode string, days uint) error { + s3Logger := ctrl.Log.WithValues("logger", "s3clientimplminio") + s3Logger.Info("setting bucket retention", "bucket", name, "mode", mode, "days", days) + var retentionMode minio.RetentionMode + switch mode { + case "governance": + retentionMode = minio.Governance + case "compliance": + retentionMode = minio.Compliance + default: + retentionMode = minio.Governance + } + unit := minio.Days + return minioS3Client.client.SetBucketObjectLockConfig( + context.Background(), + name, + &retentionMode, + &days, + &unit, ) } diff --git a/internal/s3/client/impl/mockedS3Client.go b/internal/s3/client/impl/mockedS3Client.go index 31adb0f..bab9a38 100644 --- a/internal/s3/client/impl/mockedS3Client.go +++ b/internal/s3/client/impl/mockedS3Client.go @@ -34,9 +34,15 @@ func (mockedS3Provider *MockedS3Client) BucketExists(name string) (bool, error) return false, nil } -func (mockedS3Provider *MockedS3Client) CreateBucket(name string) error { +func (mockedS3Provider *MockedS3Client) CreateBucket(name string, objectLocking bool) error { s3Logger := ctrl.Log.WithValues("logger", "s3ClientImplMocked") - s3Logger.Info("checking a bucket", "bucket", name) + s3Logger.Info("creating a bucket", "bucket", name, "objectLocking", objectLocking) + return nil +} + +func (mockedS3Provider *MockedS3Client) SetBucketRetention(name string, mode string, days uint) error { + s3Logger := ctrl.Log.WithValues("logger", "s3ClientImplMocked") + s3Logger.Info("setting bucket retention", "bucket", name, "mode", mode, "days", days) return nil } diff --git a/internal/s3/client/s3client.go b/internal/s3/client/s3client.go index 6b9f258..1dfb7a6 100644 --- a/internal/s3/client/s3client.go +++ b/internal/s3/client/s3client.go @@ -41,7 +41,8 @@ type S3Config struct { type S3Client interface { BucketExists(name string) (bool, error) - CreateBucket(name string) error + CreateBucket(name string, objectLocking bool) error + SetBucketRetention(name string, mode string, days uint) error DeleteBucket(name string) error CreatePath(bucketname string, path string) error PathExists(bucketname string, path string) (bool, error) diff --git a/test/mocks/mockedS3Client.go b/test/mocks/mockedS3Client.go index 79f6780..743541b 100644 --- a/test/mocks/mockedS3Client.go +++ b/test/mocks/mockedS3Client.go @@ -37,10 +37,17 @@ func (mockedS3Provider *MockedS3Client) BucketExists(name string) (bool, error) return args.Bool(0), args.Error(1) } -func (mockedS3Provider *MockedS3Client) CreateBucket(name string) error { +func (mockedS3Provider *MockedS3Client) CreateBucket(name string, objectLocking bool) error { s3Logger := ctrl.Log.WithValues("logger", "mockedS3Client") - s3Logger.Info("checking a bucket", "bucket", name) - args := mockedS3Provider.Called(name) + s3Logger.Info("creating a bucket", "bucket", name, "objectLocking", objectLocking) + args := mockedS3Provider.Called(name, objectLocking) + return args.Error(0) +} + +func (mockedS3Provider *MockedS3Client) SetBucketRetention(name string, mode string, days uint) error { + s3Logger := ctrl.Log.WithValues("logger", "mockedS3Client") + s3Logger.Info("setting bucket retention", "bucket", name, "mode", mode, "days", days) + args := mockedS3Provider.Called(name, mode, days) return args.Error(0) } diff --git a/test/utils/testUtils.go b/test/utils/testUtils.go index 571d127..df884a4 100644 --- a/test/utils/testUtils.go +++ b/test/utils/testUtils.go @@ -53,7 +53,7 @@ func (t *TestUtils) SetupMockedS3FactoryAndClient() { }) mockedS3Client.On("BucketExists", "test-bucket").Return(false, nil) mockedS3Client.On("BucketExists", "existing-bucket").Return(true, nil) - mockedS3Client.On("CreateBucket", "test-bucket").Return(nil) + mockedS3Client.On("CreateBucket", "test-bucket", false).Return(nil) mockedS3Client.On("SetQuota", "test-bucket", int64(10)).Return(nil) mockedS3Client.On("ListBuckets").Return([]string{}, nil) mockedS3Client.On("GetPolicyInfo", "example-policy").Return(nil, nil) @@ -127,7 +127,7 @@ func (t *TestUtils) SetupMockedS3FactoryAndClient() { mockedInvalidS3Client := mocks.NewMockedS3Client(s3client.S3Config{}) mockedInvalidS3Client.On("BucketExists", "test-bucket").Return(false, nil) - mockedInvalidS3Client.On("CreateBucket", "test-bucket").Return(nil) + mockedInvalidS3Client.On("CreateBucket", "test-bucket", false).Return(nil) mockedInvalidS3Client.On("SetQuota", "test-bucket", int64(10)).Return(nil) mockedInvalidS3Client.On("ListBuckets").Return([]string{}, fmt.Errorf("random error"))