feat(storage): prepare custom resource for storage API

This commit is contained in:
Peter 2025-01-21 21:54:53 +01:00
parent d02e2d4653
commit b55afea477
Signed by: prskr
GPG key ID: F56BED6903BC5E37
34 changed files with 1110 additions and 369 deletions

View file

@ -63,44 +63,32 @@ func (d *CoreCustomDefaulter) Default(ctx context.Context, obj runtime.Object) e
return fmt.Errorf("ensuring JWT secret: %w", err)
}
// TODO copy plain text DSN to secret if present
corelog.Info("Defaulting database roles")
if !core.Spec.Database.Roles.SelfManaged {
const roleCredsSecretNameTemplate = "db-roles-creds-%s"
if core.Spec.Database.Roles.Secrets.Admin == nil {
const roleCredsSecretNameTemplate = "%s-db-creds-%s"
if core.Spec.Database.Roles.Secrets.Admin == "" {
corelog.Info("Defaulting role", "role_name", supabase.DBRoleSupabaseAdmin)
core.Spec.Database.Roles.Secrets.Admin = &corev1.LocalObjectReference{
Name: fmt.Sprintf(roleCredsSecretNameTemplate, supabase.DBRoleSupabaseAdmin.K8sString()),
}
core.Spec.Database.Roles.Secrets.Admin = fmt.Sprintf(roleCredsSecretNameTemplate, core.Name, supabase.DBRoleSupabaseAdmin.K8sString())
}
if core.Spec.Database.Roles.Secrets.Authenticator == nil {
if core.Spec.Database.Roles.Secrets.Authenticator == "" {
corelog.Info("Defaulting role", "role_name", supabase.DBRoleAuthenticator)
core.Spec.Database.Roles.Secrets.Authenticator = &corev1.LocalObjectReference{
Name: fmt.Sprintf(roleCredsSecretNameTemplate, supabase.DBRoleAuthenticator.K8sString()),
}
core.Spec.Database.Roles.Secrets.Authenticator = fmt.Sprintf(roleCredsSecretNameTemplate, core.Name, supabase.DBRoleAuthenticator.K8sString())
}
if core.Spec.Database.Roles.Secrets.AuthAdmin == nil {
if core.Spec.Database.Roles.Secrets.AuthAdmin == "" {
corelog.Info("Defaulting role", "role_name", supabase.DBRoleAuthAdmin)
core.Spec.Database.Roles.Secrets.AuthAdmin = &corev1.LocalObjectReference{
Name: fmt.Sprintf(roleCredsSecretNameTemplate, supabase.DBRoleAuthAdmin.K8sString()),
}
core.Spec.Database.Roles.Secrets.AuthAdmin = fmt.Sprintf(roleCredsSecretNameTemplate, core.Name, supabase.DBRoleAuthAdmin.K8sString())
}
if core.Spec.Database.Roles.Secrets.FunctionsAdmin == nil {
if core.Spec.Database.Roles.Secrets.FunctionsAdmin == "" {
corelog.Info("Defaulting role", "role_name", supabase.DBRoleFunctionsAdmin)
core.Spec.Database.Roles.Secrets.FunctionsAdmin = &corev1.LocalObjectReference{
Name: fmt.Sprintf(roleCredsSecretNameTemplate, supabase.DBRoleFunctionsAdmin.K8sString()),
}
core.Spec.Database.Roles.Secrets.FunctionsAdmin = fmt.Sprintf(roleCredsSecretNameTemplate, core.Name, supabase.DBRoleFunctionsAdmin.K8sString())
}
if core.Spec.Database.Roles.Secrets.StorageAdmin == nil {
if core.Spec.Database.Roles.Secrets.StorageAdmin == "" {
corelog.Info("Defaulting role", "role_name", supabase.DBRoleStorageAdmin)
core.Spec.Database.Roles.Secrets.StorageAdmin = &corev1.LocalObjectReference{
Name: fmt.Sprintf(roleCredsSecretNameTemplate, supabase.DBRoleStorageAdmin.K8sString()),
}
core.Spec.Database.Roles.Secrets.StorageAdmin = fmt.Sprintf(roleCredsSecretNameTemplate, core.Name, supabase.DBRoleStorageAdmin.K8sString())
}
}
@ -114,10 +102,8 @@ func (d *CoreCustomDefaulter) defaultJWT(ctx context.Context, core *supabasev1al
core.Spec.JWT = new(supabasev1alpha1.CoreJwtSpec)
}
if core.Spec.JWT.SecretRef == nil {
core.Spec.JWT.SecretRef = &corev1.LocalObjectReference{
Name: supabase.ServiceConfig.JWT.ObjectName(core),
}
if core.Spec.JWT.SecretName == "" {
core.Spec.JWT.SecretName = supabase.ServiceConfig.JWT.ObjectName(core)
}
if core.Spec.JWT.SecretKey == "" {
@ -138,7 +124,7 @@ func (d *CoreCustomDefaulter) defaultJWT(ctx context.Context, core *supabasev1al
jwtSecret := &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: core.Spec.JWT.SecretRef.Name,
Name: core.Spec.JWT.SecretName,
Namespace: core.Namespace,
},
}

View file

@ -127,47 +127,47 @@ func (v *CoreCustomValidator) validateDb(
}
}
if authenticator := dbSpec.Roles.Secrets.Authenticator; authenticator == nil {
if authenticator := dbSpec.Roles.Secrets.Authenticator; authenticator == "" {
return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsNotSpecified, supabase.DBRoleAuthenticator)
} else {
exists, err := doesSecretExists(ctx, authenticator.Name)
exists, err := doesSecretExists(ctx, authenticator)
if err != nil {
return warnings, err
} else if !exists {
return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsSecretMissing, authenticator.Name)
return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsSecretMissing, authenticator)
}
}
if authAdmin := dbSpec.Roles.Secrets.AuthAdmin; authAdmin == nil {
if authAdmin := dbSpec.Roles.Secrets.AuthAdmin; authAdmin == "" {
return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsNotSpecified, supabase.DBRoleAuthAdmin)
} else {
exists, err := doesSecretExists(ctx, authAdmin.Name)
exists, err := doesSecretExists(ctx, authAdmin)
if err != nil {
return warnings, err
} else if !exists {
return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsSecretMissing, authAdmin.Name)
return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsSecretMissing, authAdmin)
}
}
if functionsAdmin := dbSpec.Roles.Secrets.FunctionsAdmin; functionsAdmin == nil {
if functionsAdmin := dbSpec.Roles.Secrets.FunctionsAdmin; functionsAdmin == "" {
return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsNotSpecified, supabase.DBRoleFunctionsAdmin)
} else {
exists, err := doesSecretExists(ctx, functionsAdmin.Name)
exists, err := doesSecretExists(ctx, functionsAdmin)
if err != nil {
return warnings, err
} else if !exists {
return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsSecretMissing, functionsAdmin.Name)
return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsSecretMissing, functionsAdmin)
}
}
if storageAdmin := dbSpec.Roles.Secrets.StorageAdmin; storageAdmin == nil {
if storageAdmin := dbSpec.Roles.Secrets.StorageAdmin; storageAdmin == "" {
return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsNotSpecified, supabase.DBRoleStorageAdmin)
} else {
exists, err := doesSecretExists(ctx, storageAdmin.Name)
exists, err := doesSecretExists(ctx, storageAdmin)
if err != nil {
return warnings, err
} else if !exists {
return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsSecretMissing, storageAdmin.Name)
return warnings, fmt.Errorf("%w: %s", ErrManagedCredentialsSecretMissing, storageAdmin)
}
}
}

View file

@ -53,3 +53,11 @@ func SetupDashboardWebhookWithManager(mgr ctrl.Manager) error {
WithDefaulter(&DashboardCustomDefaulter{}).
Complete()
}
// SetupStorageWebhookWithManager registers the webhook for Storage in the manager.
func SetupStorageWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).For(&supabasev1alpha1.Storage{}).
WithValidator(&StorageCustomValidator{}).
WithDefaulter(&StorageCustomDefaulter{Client: mgr.GetClient()}).
Complete()
}

View file

@ -0,0 +1,109 @@
/*
Copyright 2025 Peter Kurfer.
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 v1alpha1
import (
"context"
"fmt"
"maps"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/webhook"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
"code.icb4dc0.de/prskr/supabase-operator/internal/meta"
"code.icb4dc0.de/prskr/supabase-operator/internal/pw"
)
// +kubebuilder:webhook:path=/mutate-supabase-k8s-icb4dc0-de-v1alpha1-storage,mutating=true,failurePolicy=fail,sideEffects=None,groups=supabase.k8s.icb4dc0.de,resources=storages,verbs=create;update,versions=v1alpha1,name=mstorage-v1alpha1.kb.io,admissionReviewVersions=v1
// StorageCustomDefaulter struct is responsible for setting default values on the custom resource of the
// Kind Storage when those are created or updated.
//
// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods,
// as it is used only for temporary operations and does not need to be deeply copied.
type StorageCustomDefaulter struct {
client.Client
}
var _ webhook.CustomDefaulter = &StorageCustomDefaulter{}
// Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind Storage.
func (d *StorageCustomDefaulter) Default(ctx context.Context, obj runtime.Object) error {
storage, ok := obj.(*supabasev1alpha1.Storage)
if !ok {
return fmt.Errorf("expected an Storage object but got %T", obj)
}
storagelog.Info("Defaulting for Storage", "name", storage.GetName())
if err := d.defaultS3Protocol(ctx, storage); err != nil {
return err
}
return nil
}
func (d *StorageCustomDefaulter) defaultS3Protocol(ctx context.Context, storage *supabasev1alpha1.Storage) error {
if storage.Spec.S3 == nil {
storage.Spec.S3 = new(supabasev1alpha1.S3ProtocolSpec)
}
if storage.Spec.S3.CredentialsSecretRef == nil {
storage.Spec.S3.CredentialsSecretRef = &supabasev1alpha1.S3CredentialsRef{
AccessKeyIdKey: "accessKeyId",
AccessSecretKeyKey: "secretAccessKey",
SecretName: fmt.Sprintf("%s-storage-protocol-s3-credentials", storage.Name),
}
}
credentialsSecret := corev1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: storage.Spec.S3.CredentialsSecretRef.SecretName,
Namespace: storage.Namespace,
},
}
_, err := controllerutil.CreateOrUpdate(ctx, d.Client, &credentialsSecret, func() error {
credentialsSecret.Labels = maps.Clone(storage.Labels)
if credentialsSecret.Labels == nil {
credentialsSecret.Labels = make(map[string]string)
}
credentialsSecret.Labels[meta.SupabaseLabel.Reload] = ""
if credentialsSecret.Data == nil {
credentialsSecret.Data = make(map[string][]byte, 2)
}
if _, ok := credentialsSecret.Data[storage.Spec.S3.CredentialsSecretRef.AccessKeyIdKey]; !ok {
credentialsSecret.Data[storage.Spec.S3.CredentialsSecretRef.AccessKeyIdKey] = pw.GeneratePW(32, nil)
}
if _, ok := credentialsSecret.Data[storage.Spec.S3.CredentialsSecretRef.AccessSecretKeyKey]; !ok {
credentialsSecret.Data[storage.Spec.S3.CredentialsSecretRef.AccessSecretKeyKey] = pw.GeneratePW(64, nil)
}
return nil
})
return err
}

View file

@ -0,0 +1,86 @@
/*
Copyright 2025 Peter Kurfer.
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 v1alpha1
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
// TODO (user): Add any additional imports if needed
)
var _ = Describe("Storage Webhook", func() {
var (
obj *supabasev1alpha1.Storage
oldObj *supabasev1alpha1.Storage
validator StorageCustomValidator
defaulter StorageCustomDefaulter
)
BeforeEach(func() {
obj = &supabasev1alpha1.Storage{}
oldObj = &supabasev1alpha1.Storage{}
validator = StorageCustomValidator{}
Expect(validator).NotTo(BeNil(), "Expected validator to be initialized")
defaulter = StorageCustomDefaulter{}
Expect(defaulter).NotTo(BeNil(), "Expected defaulter to be initialized")
Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized")
Expect(obj).NotTo(BeNil(), "Expected obj to be initialized")
// TODO (user): Add any setup logic common to all tests
})
AfterEach(func() {
// TODO (user): Add any teardown logic common to all tests
})
Context("When creating Storage under Defaulting Webhook", func() {
// TODO (user): Add logic for defaulting webhooks
// Example:
// It("Should apply defaults when a required field is empty", func() {
// By("simulating a scenario where defaults should be applied")
// obj.SomeFieldWithDefault = ""
// By("calling the Default method to apply defaults")
// defaulter.Default(ctx, obj)
// By("checking that the default values are set")
// Expect(obj.SomeFieldWithDefault).To(Equal("default_value"))
// })
})
Context("When creating or updating Storage under Validating Webhook", func() {
// TODO (user): Add logic for validating webhooks
// Example:
// It("Should deny creation if a required field is missing", func() {
// By("simulating an invalid creation scenario")
// obj.SomeRequiredField = ""
// Expect(validator.ValidateCreate(ctx, obj)).Error().To(HaveOccurred())
// })
//
// It("Should admit creation if all required fields are present", func() {
// By("simulating an invalid creation scenario")
// obj.SomeRequiredField = "valid_value"
// Expect(validator.ValidateCreate(ctx, obj)).To(BeNil())
// })
//
// It("Should validate updates correctly", func() {
// By("simulating a valid update scenario")
// oldObj.SomeRequiredField = "updated_value"
// obj.SomeRequiredField = "updated_value"
// Expect(validator.ValidateUpdate(ctx, oldObj, obj)).To(BeNil())
// })
})
})

View file

@ -0,0 +1,87 @@
/*
Copyright 2025 Peter Kurfer.
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 v1alpha1
import (
"context"
"fmt"
"k8s.io/apimachinery/pkg/runtime"
logf "sigs.k8s.io/controller-runtime/pkg/log"
"sigs.k8s.io/controller-runtime/pkg/webhook"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
supabasev1alpha1 "code.icb4dc0.de/prskr/supabase-operator/api/v1alpha1"
)
// nolint:unused
// log is for logging in this package.
var storagelog = logf.Log.WithName("storage-resource")
// NOTE: The 'path' attribute must follow a specific pattern and should not be modified directly here.
// Modifying the path for an invalid path can cause API server errors; failing to locate the webhook.
// +kubebuilder:webhook:path=/validate-supabase-k8s-icb4dc0-de-v1alpha1-storage,mutating=false,failurePolicy=fail,sideEffects=None,groups=supabase.k8s.icb4dc0.de,resources=storages,verbs=create;update,versions=v1alpha1,name=vstorage-v1alpha1.kb.io,admissionReviewVersions=v1
// StorageCustomValidator struct is responsible for validating the Storage resource
// when it is created, updated, or deleted.
//
// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods,
// as this struct is used only for temporary operations and does not need to be deeply copied.
type StorageCustomValidator struct {
// TODO(user): Add more fields as needed for validation
}
var _ webhook.CustomValidator = &StorageCustomValidator{}
// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type Storage.
func (v *StorageCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
storage, ok := obj.(*supabasev1alpha1.Storage)
if !ok {
return nil, fmt.Errorf("expected a Storage object but got %T", obj)
}
storagelog.Info("Validation for Storage upon creation", "name", storage.GetName())
// TODO(user): fill in your validation logic upon object creation.
return nil, nil
}
// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type Storage.
func (v *StorageCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
storage, ok := newObj.(*supabasev1alpha1.Storage)
if !ok {
return nil, fmt.Errorf("expected a Storage object for the newObj but got %T", newObj)
}
storagelog.Info("Validation for Storage upon update", "name", storage.GetName())
// TODO(user): fill in your validation logic upon object update.
return nil, nil
}
// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type Storage.
func (v *StorageCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
storage, ok := obj.(*supabasev1alpha1.Storage)
if !ok {
return nil, fmt.Errorf("expected a Storage object but got %T", obj)
}
storagelog.Info("Validation for Storage upon deletion", "name", storage.GetName())
// TODO(user): fill in your validation logic upon object deletion.
return nil, nil
}

View file

@ -127,6 +127,9 @@ var _ = BeforeSuite(func() {
err = SetupDashboardWebhookWithManager(mgr)
Expect(err).NotTo(HaveOccurred())
err = SetupStorageWebhookWithManager(mgr)
Expect(err).NotTo(HaveOccurred())
// +kubebuilder:scaffold:webhook
go func() {