diff --git a/api/v1alpha1/datastore_secret_webhook.go b/api/v1alpha1/datastore_secret_webhook.go deleted file mode 100644 index da05919..0000000 --- a/api/v1alpha1/datastore_secret_webhook.go +++ /dev/null @@ -1,57 +0,0 @@ -// Copyright 2022 Clastix Labs -// SPDX-License-Identifier: Apache-2.0 - -package v1alpha1 - -import ( - "context" - "fmt" - "strings" - - "github.com/go-logr/logr" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/fields" - "k8s.io/apimachinery/pkg/runtime" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -//+kubebuilder:webhook:path=/validate--v1-secret,mutating=false,failurePolicy=ignore,sideEffects=None,groups="",resources=secrets,verbs=delete,versions=v1,name=vdatastoresecrets.kb.io,admissionReviewVersions=v1 - -type dataStoreSecretValidator struct { - log logr.Logger - client client.Client -} - -func (d *dataStoreSecretValidator) ValidateCreate(context.Context, runtime.Object) error { - return nil -} - -func (d *dataStoreSecretValidator) ValidateUpdate(context.Context, runtime.Object, runtime.Object) error { - return nil -} - -func (d *dataStoreSecretValidator) ValidateDelete(ctx context.Context, obj runtime.Object) error { - secret := obj.(*corev1.Secret) //nolint:forcetypeassert - - dsList := &DataStoreList{} - - if err := d.client.List(ctx, dsList, client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(DatastoreUsedSecretNamespacedNameKey, fmt.Sprintf("%s/%s", secret.GetNamespace(), secret.GetName()))}); err != nil { - return err - } - - if len(dsList.Items) > 0 { - var res []string - - for _, ds := range dsList.Items { - res = append(res, ds.GetName()) - } - - return fmt.Errorf("the Secret is used by the following kamajiv1alpha1.DataStores and cannot be deleted (%s)", strings.Join(res, ", ")) - } - - return nil -} - -func (d *dataStoreSecretValidator) Default(context.Context, runtime.Object) error { - return nil -} diff --git a/api/v1alpha1/datastore_webhook.go b/api/v1alpha1/datastore_webhook.go deleted file mode 100644 index 835da52..0000000 --- a/api/v1alpha1/datastore_webhook.go +++ /dev/null @@ -1,185 +0,0 @@ -// Copyright 2022 Clastix Labs -// SPDX-License-Identifier: Apache-2.0 - -package v1alpha1 - -import ( - "context" - "fmt" - - "github.com/go-logr/logr" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/fields" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -//+kubebuilder:webhook:path=/mutate-kamaji-clastix-io-v1alpha1-datastore,mutating=true,failurePolicy=fail,sideEffects=None,groups=kamaji.clastix.io,resources=datastores,verbs=create;update,versions=v1alpha1,name=mdatastore.kb.io,admissionReviewVersions=v1 -//+kubebuilder:webhook:path=/validate-kamaji-clastix-io-v1alpha1-datastore,mutating=false,failurePolicy=fail,sideEffects=None,groups=kamaji.clastix.io,resources=datastores,verbs=create;update;delete,versions=v1alpha1,name=vdatastore.kb.io,admissionReviewVersions=v1 - -func (in *DataStore) SetupWebhookWithManager(mgr ctrl.Manager) error { - secretValidator := &dataStoreSecretValidator{ - log: mgr.GetLogger().WithName("datastore-secret-webhook"), - client: mgr.GetClient(), - } - - if err := ctrl.NewWebhookManagedBy(mgr).For(&corev1.Secret{}).WithValidator(secretValidator).Complete(); err != nil { - return err - } - - dsValidator := &dataStoreValidator{ - log: mgr.GetLogger().WithName("datastore-webhook"), - client: mgr.GetClient(), - } - - return ctrl.NewWebhookManagedBy(mgr). - For(in). - WithValidator(dsValidator). - WithDefaulter(dsValidator). - Complete() -} - -type dataStoreValidator struct { - log logr.Logger - client client.Client -} - -func (d *dataStoreValidator) ValidateCreate(ctx context.Context, obj runtime.Object) error { - ds, ok := obj.(*DataStore) - if !ok { - return fmt.Errorf("expected *kamajiv1alpha1.DataStore") - } - - if err := d.validate(ctx, ds); err != nil { - return err - } - - return nil -} - -func (d *dataStoreValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) error { - old, ok := oldObj.(*DataStore) - if !ok { - return fmt.Errorf("expected *kamajiv1alpha1.DataStore") - } - - ds, ok := newObj.(*DataStore) - if !ok { - return fmt.Errorf("expected *kamajiv1alpha1.DataStore") - } - - d.log.Info("validate update", "name", ds.GetName()) - - if ds.Spec.Driver != old.Spec.Driver { - return fmt.Errorf("driver of a DataStore cannot be changed") - } - - if err := d.validate(ctx, ds); err != nil { - return err - } - - return nil -} - -func (d *dataStoreValidator) ValidateDelete(ctx context.Context, obj runtime.Object) error { - ds, ok := obj.(*DataStore) - if !ok { - return fmt.Errorf("expected *kamajiv1alpha1.DataStore") - } - - tcpList := &TenantControlPlaneList{} - - if err := d.client.List(ctx, tcpList, client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(TenantControlPlaneUsedDataStoreKey, ds.GetName())}); err != nil { - return err - } - - if len(tcpList.Items) > 0 { - return fmt.Errorf("the DataStore is used by multiple TenantControlPlanes and cannot be removed") - } - - return nil -} - -func (d *dataStoreValidator) Default(context.Context, runtime.Object) error { - return nil -} - -func (d *dataStoreValidator) validate(ctx context.Context, ds *DataStore) error { - if ds.Spec.BasicAuth != nil { - if err := d.validateBasicAuth(ctx, ds); err != nil { - return err - } - } - - if err := d.validateTLSConfig(ctx, ds); err != nil { - return err - } - - return nil -} - -func (d *dataStoreValidator) validateBasicAuth(ctx context.Context, ds *DataStore) error { - if err := d.validateContentReference(ctx, ds.Spec.BasicAuth.Password); err != nil { - return fmt.Errorf("basic-auth password is not valid, %w", err) - } - - if err := d.validateContentReference(ctx, ds.Spec.BasicAuth.Username); err != nil { - return fmt.Errorf("basic-auth username is not valid, %w", err) - } - - return nil -} - -func (d *dataStoreValidator) validateTLSConfig(ctx context.Context, ds *DataStore) error { - if err := d.validateContentReference(ctx, ds.Spec.TLSConfig.CertificateAuthority.Certificate); err != nil { - return fmt.Errorf("CA certificate is not valid, %w", err) - } - - if ds.Spec.Driver == EtcdDriver { - if ds.Spec.TLSConfig.CertificateAuthority.PrivateKey == nil { - return fmt.Errorf("CA private key is required when using the etcd driver") - } - } - - if ds.Spec.TLSConfig.CertificateAuthority.PrivateKey != nil { - if err := d.validateContentReference(ctx, *ds.Spec.TLSConfig.CertificateAuthority.PrivateKey); err != nil { - return fmt.Errorf("CA private key is not valid, %w", err) - } - } - - if err := d.validateContentReference(ctx, ds.Spec.TLSConfig.ClientCertificate.Certificate); err != nil { - return fmt.Errorf("client certificate is not valid, %w", err) - } - - if err := d.validateContentReference(ctx, ds.Spec.TLSConfig.ClientCertificate.PrivateKey); err != nil { - return fmt.Errorf("client private key is not valid, %w", err) - } - - return nil -} - -func (d *dataStoreValidator) validateContentReference(ctx context.Context, ref ContentRef) error { - switch { - case len(ref.Content) > 0: - return nil - case ref.SecretRef == nil: - return fmt.Errorf("the Secret reference is mandatory when bare content is not specified") - case len(ref.SecretRef.SecretReference.Name) == 0: - return fmt.Errorf("the Secret reference name is mandatory") - case len(ref.SecretRef.SecretReference.Namespace) == 0: - return fmt.Errorf("the Secret reference namespace is mandatory") - } - - if err := d.client.Get(ctx, types.NamespacedName{Name: ref.SecretRef.SecretReference.Name, Namespace: ref.SecretRef.SecretReference.Namespace}, &corev1.Secret{}); err != nil { - if errors.IsNotFound(err) { - return fmt.Errorf("secret %s/%s is not found", ref.SecretRef.SecretReference.Namespace, ref.SecretRef.SecretReference.Name) - } - - return err - } - - return nil -} diff --git a/api/v1alpha1/tenantcontrolplane_webhook.go b/api/v1alpha1/tenantcontrolplane_webhook.go deleted file mode 100644 index d39b4b6..0000000 --- a/api/v1alpha1/tenantcontrolplane_webhook.go +++ /dev/null @@ -1,188 +0,0 @@ -// Copyright 2022 Clastix Labs -// SPDX-License-Identifier: Apache-2.0 - -package v1alpha1 - -import ( - "context" - "fmt" - "strings" - - "github.com/blang/semver" - "github.com/go-logr/logr" - "github.com/pkg/errors" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "k8s.io/apimachinery/pkg/util/sets" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - - "github.com/clastix/kamaji/internal/upgrade" -) - -//+kubebuilder:webhook:path=/mutate-kamaji-clastix-io-v1alpha1-tenantcontrolplane,mutating=true,failurePolicy=fail,sideEffects=None,groups=kamaji.clastix.io,resources=tenantcontrolplanes,verbs=create;update,versions=v1alpha1,name=mtenantcontrolplane.kb.io,admissionReviewVersions=v1 -//+kubebuilder:webhook:path=/validate-kamaji-clastix-io-v1alpha1-tenantcontrolplane,mutating=false,failurePolicy=fail,sideEffects=None,groups=kamaji.clastix.io,resources=tenantcontrolplanes,verbs=create;update,versions=v1alpha1,name=vtenantcontrolplane.kb.io,admissionReviewVersions=v1 - -func (in *TenantControlPlane) SetupWebhookWithManager(mgr ctrl.Manager, datastore string) error { - validator := &tenantControlPlaneValidator{ - client: mgr.GetClient(), - defaultDatastore: datastore, - log: mgr.GetLogger().WithName("tenantcontrolplane-webhook"), - } - - return ctrl.NewWebhookManagedBy(mgr). - For(in). - WithValidator(validator). - WithDefaulter(validator). - Complete() -} - -type tenantControlPlaneValidator struct { - client client.Client - defaultDatastore string - log logr.Logger -} - -func (t *tenantControlPlaneValidator) Default(_ context.Context, obj runtime.Object) error { - tcp, ok := obj.(*TenantControlPlane) - if !ok { - return fmt.Errorf("expected *kamajiv1alpha1.TenantControlPlane") - } - - if len(tcp.Spec.DataStore) == 0 { - tcp.Spec.DataStore = t.defaultDatastore - } - - return nil -} - -func (t *tenantControlPlaneValidator) ValidateCreate(_ context.Context, obj runtime.Object) error { - tcp, ok := obj.(*TenantControlPlane) - if !ok { - return fmt.Errorf("expected *kamajiv1alpha1.TenantControlPlane") - } - - t.log.Info("validate create", "name", tcp.Name, "namespace", tcp.Namespace) - - ver, err := semver.New(t.normalizeKubernetesVersion(tcp.Spec.Kubernetes.Version)) - if err != nil { - return errors.Wrap(err, "unable to parse the desired Kubernetes version") - } - - supportedVer, supportedErr := semver.Make(t.normalizeKubernetesVersion(upgrade.KubeadmVersion)) - if supportedErr != nil { - return errors.Wrap(supportedErr, "unable to parse the Kamaji supported Kubernetes version") - } - - if ver.GT(supportedVer) { - return fmt.Errorf("unable to create a TenantControlPlane with a Kubernetes version greater than the supported one, actually %s", supportedVer.String()) - } - - if err = t.validatePreferredKubeletAddressTypes(tcp.Spec.Kubernetes.Kubelet.PreferredAddressTypes); err != nil { - return err - } - - return nil -} - -func (t *tenantControlPlaneValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) error { - old, ok := oldObj.(*TenantControlPlane) - if !ok { - return fmt.Errorf("expected *kamajiv1alpha1.TenantControlPlane") - } - - tcp, ok := newObj.(*TenantControlPlane) - if !ok { - return fmt.Errorf("expected *kamajiv1alpha1.TenantControlPlane") - } - - t.log.Info("validate update", "name", tcp.Name, "namespace", tcp.Namespace) - - if err := t.validateVersionUpdate(old, tcp); err != nil { - return err - } - if err := t.validateDataStore(ctx, old, tcp); err != nil { - return err - } - if err := t.validatePreferredKubeletAddressTypes(tcp.Spec.Kubernetes.Kubelet.PreferredAddressTypes); err != nil { - return err - } - - return nil -} - -func (t *tenantControlPlaneValidator) ValidateDelete(context.Context, runtime.Object) error { - return nil -} - -func (t *tenantControlPlaneValidator) validatePreferredKubeletAddressTypes(addressTypes []KubeletPreferredAddressType) error { - s := sets.NewString() - - for _, at := range addressTypes { - if s.Has(string(at)) { - return fmt.Errorf("preferred kubelet address types is stated multiple times: %s", at) - } - - s.Insert(string(at)) - } - - return nil -} - -func (t *tenantControlPlaneValidator) validateVersionUpdate(oldObj, newObj *TenantControlPlane) error { - oldVer, oldErr := semver.Make(t.normalizeKubernetesVersion(oldObj.Spec.Kubernetes.Version)) - if oldErr != nil { - return errors.Wrap(oldErr, "unable to parse the previous Kubernetes version") - } - - newVer, newErr := semver.New(t.normalizeKubernetesVersion(newObj.Spec.Kubernetes.Version)) - if newErr != nil { - return errors.Wrap(newErr, "unable to parse the desired Kubernetes version") - } - - supportedVer, supportedErr := semver.Make(t.normalizeKubernetesVersion(upgrade.KubeadmVersion)) - if supportedErr != nil { - return errors.Wrap(supportedErr, "unable to parse the Kamaji supported Kubernetes version") - } - - switch { - case newVer.GT(supportedVer): - return fmt.Errorf("unable to upgrade to a version greater than the supported one, actually %s", supportedVer.String()) - case newVer.LT(oldVer): - return fmt.Errorf("unable to downgrade a TenantControlPlane from %s to %s", oldVer.String(), newVer.String()) - case newVer.Minor-oldVer.Minor > 1: - return fmt.Errorf("unable to upgrade to a minor version in a non-sequential mode") - } - - return nil -} - -func (t *tenantControlPlaneValidator) validateDataStore(ctx context.Context, oldObj, tcp *TenantControlPlane) error { - if oldObj.Spec.DataStore == tcp.Spec.DataStore { - return nil - } - - previousDatastore, desiredDatastore := &DataStore{}, &DataStore{} - - if err := t.client.Get(ctx, types.NamespacedName{Name: oldObj.Spec.DataStore}, previousDatastore); err != nil { - return fmt.Errorf("unable to retrieve old DataStore for validation: %w", err) - } - - if err := t.client.Get(ctx, types.NamespacedName{Name: tcp.Spec.DataStore}, desiredDatastore); err != nil { - return fmt.Errorf("unable to retrieve old DataStore for validation: %w", err) - } - - if previousDatastore.Spec.Driver != desiredDatastore.Spec.Driver { - return fmt.Errorf("migration between different Datastore drivers is not supported") - } - - return nil -} - -func (t *tenantControlPlaneValidator) normalizeKubernetesVersion(input string) string { - if strings.HasPrefix(input, "v") { - return strings.Replace(input, "v", "", 1) - } - - return input -} diff --git a/api/v1alpha1/webhook_suite_test.go b/api/v1alpha1/webhook_suite_test.go deleted file mode 100644 index 0fc4fd3..0000000 --- a/api/v1alpha1/webhook_suite_test.go +++ /dev/null @@ -1,123 +0,0 @@ -// Copyright 2022 Clastix Labs -// SPDX-License-Identifier: Apache-2.0 - -package v1alpha1 - -import ( - "context" - "crypto/tls" - "fmt" - "net" - "path/filepath" - "testing" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - admissionv1beta1 "k8s.io/api/admission/v1beta1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/rest" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - //+kubebuilder:scaffold:imports -) - -// These tests use Ginkgo (BDD-style Go testing framework). Refer to -// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. - -var ( - cfg *rest.Config - k8sClient client.Client - testEnv *envtest.Environment - ctx context.Context - cancel context.CancelFunc -) - -func TestAPIs(t *testing.T) { - RegisterFailHandler(Fail) - - RunSpecs(t, "Webhook Suite") -} - -var _ = BeforeSuite(func() { - logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) - - ctx, cancel = context.WithCancel(context.TODO()) - - By("bootstrapping test environment") - testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, - ErrorIfCRDPathMissing: false, - WebhookInstallOptions: envtest.WebhookInstallOptions{ - Paths: []string{filepath.Join("..", "..", "config", "webhook")}, - }, - } - - var err error - // cfg is defined in this file globally. - cfg, err = testEnv.Start() - Expect(err).NotTo(HaveOccurred()) - Expect(cfg).NotTo(BeNil()) - - scheme := runtime.NewScheme() - err = AddToScheme(scheme) - Expect(err).NotTo(HaveOccurred()) - - err = admissionv1beta1.AddToScheme(scheme) - Expect(err).NotTo(HaveOccurred()) - - //+kubebuilder:scaffold:scheme - - k8sClient, err = client.New(cfg, client.Options{Scheme: scheme}) - Expect(err).NotTo(HaveOccurred()) - Expect(k8sClient).NotTo(BeNil()) - - // start webhook server using Manager - webhookInstallOptions := &testEnv.WebhookInstallOptions - mgr, err := ctrl.NewManager(cfg, ctrl.Options{ - Scheme: scheme, - Host: webhookInstallOptions.LocalServingHost, - Port: webhookInstallOptions.LocalServingPort, - CertDir: webhookInstallOptions.LocalServingCertDir, - LeaderElection: false, - MetricsBindAddress: "0", - }) - Expect(err).NotTo(HaveOccurred()) - - err = (&TenantControlPlane{}).SetupWebhookWithManager(mgr, "") - Expect(err).NotTo(HaveOccurred()) - - err = (&DataStore{}).SetupWebhookWithManager(mgr) - Expect(err).NotTo(HaveOccurred()) - - //+kubebuilder:scaffold:webhook - - go func() { - defer GinkgoRecover() - err = mgr.Start(ctx) - Expect(err).NotTo(HaveOccurred()) - }() - - // wait for the webhook server to get ready - dialer := &net.Dialer{Timeout: time.Second} - addrPort := fmt.Sprintf("%s:%d", webhookInstallOptions.LocalServingHost, webhookInstallOptions.LocalServingPort) - Eventually(func() error { - conn, err := tls.DialWithDialer(dialer, "tcp", addrPort, &tls.Config{InsecureSkipVerify: true}) - if err != nil { - return err - } - conn.Close() - - return nil - }).Should(Succeed()) -}) - -var _ = AfterSuite(func() { - cancel() - By("tearing down the test environment") - err := testEnv.Stop() - Expect(err).NotTo(HaveOccurred()) -}) diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 42e0f00..dcf4a20 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -10,7 +10,7 @@ package v1alpha1 import ( "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" + runtime "k8s.io/apimachinery/pkg/runtime" ) // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. diff --git a/cmd/manager/cmd.go b/cmd/manager/cmd.go index e06e90b..7952ee6 100644 --- a/cmd/manager/cmd.go +++ b/cmd/manager/cmd.go @@ -25,6 +25,8 @@ import ( "github.com/clastix/kamaji/internal" datastoreutils "github.com/clastix/kamaji/internal/datastore/utils" "github.com/clastix/kamaji/internal/webhook" + "github.com/clastix/kamaji/internal/webhook/handlers" + "github.com/clastix/kamaji/internal/webhook/routes" ) func NewCmd(scheme *runtime.Scheme) *cobra.Command { @@ -126,12 +128,6 @@ func NewCmd(scheme *runtime.Scheme) *cobra.Command { return err } - if err = (&webhook.Freeze{}).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to register webhook", "webhook", "Freeze") - - return err - } - if err = (&kamajiv1alpha1.DatastoreUsedSecret{}).SetupWithManager(ctx, mgr); err != nil { setupLog.Error(err, "unable to create indexer", "indexer", "DatastoreUsedSecret") @@ -144,13 +140,27 @@ func NewCmd(scheme *runtime.Scheme) *cobra.Command { return err } - if err = (&kamajiv1alpha1.TenantControlPlane{}).SetupWebhookWithManager(mgr, datastore); err != nil { - setupLog.Error(err, "unable to create webhook", "webhook", "TenantControlPlane") - - return err - } - if err = (&kamajiv1alpha1.DataStore{}).SetupWebhookWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create webhook", "webhook", "DataStore") + err = webhook.Register(mgr, map[routes.Route][]handlers.Handler{ + routes.TenantControlPlaneMigrate{}: { + handlers.Freeze{}, + }, + routes.TenantControlPlaneDefaults{}: { + handlers.TenantControlPlaneDefaults{DefaultDatastore: datastore}, + }, + routes.TenantControlPlaneValidate{}: { + handlers.TenantControlPlaneVersion{}, + handlers.TenantControlPlaneKubeletAddresses{}, + handlers.TenantControlPlaneDataStore{Client: mgr.GetClient()}, + }, + routes.DataStoreValidate{}: { + handlers.DataStoreValidation{Client: mgr.GetClient()}, + }, + routes.DataStoreSecrets{}: { + handlers.DataStoreSecretValidation{Client: mgr.GetClient()}, + }, + }) + if err != nil { + setupLog.Error(err, "unable to create webhook") return err } @@ -187,6 +197,7 @@ func NewCmd(scheme *runtime.Scheme) *cobra.Command { return nil }, } + // Setting zap logger zapfs := flag.NewFlagSet("zap", flag.ExitOnError) opts := zap.Options{ diff --git a/internal/webhook/chainer.go b/internal/webhook/chainer.go new file mode 100644 index 0000000..caa3567 --- /dev/null +++ b/internal/webhook/chainer.go @@ -0,0 +1,91 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package webhook + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/pkg/errors" + "gomodules.xyz/jsonpatch/v2" + admissionv1 "k8s.io/api/admission/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/clastix/kamaji/internal/webhook/handlers" +) + +type handlersChainer struct { + decoder *admission.Decoder +} + +//nolint:gocognit +func (h handlersChainer) Handler(object runtime.Object, routeHandlers ...handlers.Handler) admission.HandlerFunc { + return func(ctx context.Context, req admission.Request) admission.Response { + decodedObj, oldDecodedObj := object.DeepCopyObject(), object.DeepCopyObject() + + if err := h.decoder.Decode(req, decodedObj); err != nil { + return admission.Errored(http.StatusInternalServerError, errors.Wrap(err, fmt.Sprintf("unable to decode into %T", object))) + } + + fnInvoker := func(fn func(runtime.Object) handlers.AdmissionResponse) (patches []jsonpatch.JsonPatchOperation, err error) { + patch, err := fn(decodedObj)(ctx, req) + if err != nil { + return nil, err + } + + if patch != nil { + patches = append(patches, patch...) + } + + return patches, nil + } + + var patches []jsonpatch.JsonPatchOperation + + switch req.Operation { + case admissionv1.Create: + for _, routeHandler := range routeHandlers { + handlerPatches, err := fnInvoker(routeHandler.OnCreate) + if err != nil { + return admission.Denied(err.Error()) + } + + patches = append(patches, handlerPatches...) + } + case admissionv1.Update: + if err := h.decoder.DecodeRaw(req.OldObject, oldDecodedObj); err != nil { + return admission.Errored(http.StatusInternalServerError, errors.Wrap(err, fmt.Sprintf("unable to decode old object into %T", object))) + } + + for _, routeHandler := range routeHandlers { + handlerPatches, err := routeHandler.OnUpdate(decodedObj, oldDecodedObj)(ctx, req) + if err != nil { + return admission.Denied(err.Error()) + } + + patches = append(patches, handlerPatches...) + } + case admissionv1.Delete: + for _, routeHandler := range routeHandlers { + handlerPatches, err := fnInvoker(routeHandler.OnDelete) + if err != nil { + return admission.Denied(err.Error()) + } + + patches = append(patches, handlerPatches...) + } + case admissionv1.Connect: + break + } + + if len(patches) > 0 { + return admission.Patched("patching required", patches...) + } + + return admission.Allowed(fmt.Sprintf("%s operation allowed", strings.ToLower(string(req.Operation)))) + } +} diff --git a/internal/webhook/freeze.go b/internal/webhook/freeze.go deleted file mode 100644 index e57c98e..0000000 --- a/internal/webhook/freeze.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2022 Clastix Labs -// SPDX-License-Identifier: Apache-2.0 - -package webhook - -import ( - "context" - - controllerruntime "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/webhook" - "sigs.k8s.io/controller-runtime/pkg/webhook/admission" -) - -const ( - deniedMessage = "the current Control Plane is in freezing mode due to a maintenance mode, all the changes are blocked: " + - "removing the webhook may lead to an inconsistent state upon its completion" -) - -type Freeze struct{} - -func (f *Freeze) Handle(context.Context, admission.Request) admission.Response { - return admission.Denied(deniedMessage) -} - -func (f *Freeze) SetupWithManager(mgr controllerruntime.Manager) error { - mgr.GetWebhookServer().Register("/migrate", &webhook.Admission{Handler: f}) - - return nil -} diff --git a/internal/webhook/handlers/ds_secrets.go b/internal/webhook/handlers/ds_secrets.go new file mode 100644 index 0000000..a446fe8 --- /dev/null +++ b/internal/webhook/handlers/ds_secrets.go @@ -0,0 +1,57 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "context" + "fmt" + "strings" + + "github.com/pkg/errors" + "gomodules.xyz/jsonpatch/v2" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1" + "github.com/clastix/kamaji/internal/webhook/utils" +) + +type DataStoreSecretValidation struct { + Client client.Client +} + +func (d DataStoreSecretValidation) OnCreate(runtime.Object) AdmissionResponse { + return utils.NilOp() +} + +func (d DataStoreSecretValidation) OnDelete(runtime.Object) AdmissionResponse { + return utils.NilOp() +} + +func (d DataStoreSecretValidation) OnUpdate(object runtime.Object, _ runtime.Object) AdmissionResponse { + return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + secret := object.(*corev1.Secret) //nolint:forcetypeassert + + dsList := &kamajiv1alpha1.DataStoreList{} + + if err := d.Client.List(ctx, dsList, client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(kamajiv1alpha1.DatastoreUsedSecretNamespacedNameKey, fmt.Sprintf("%s/%s", secret.GetNamespace(), secret.GetName()))}); err != nil { + return nil, errors.Wrap(err, "cannot list Tenant Control Plane using the provided Secret") + } + + if len(dsList.Items) > 0 { + var res []string + + for _, ds := range dsList.Items { + res = append(res, ds.GetName()) + } + + return nil, fmt.Errorf("the Secret is used by the following kamajiv1alpha1.DataStores and cannot be deleted (%s)", strings.Join(res, ", ")) + } + + return nil, nil + } +} diff --git a/internal/webhook/handlers/ds_validate.go b/internal/webhook/handlers/ds_validate.go new file mode 100644 index 0000000..d1cef8e --- /dev/null +++ b/internal/webhook/handlers/ds_validate.go @@ -0,0 +1,139 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + "gomodules.xyz/jsonpatch/v2" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1" +) + +type DataStoreValidation struct { + Client client.Client +} + +func (d DataStoreValidation) OnCreate(object runtime.Object) AdmissionResponse { + return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + ds := object.(*kamajiv1alpha1.DataStore) //nolint:forcetypeassert + + return nil, d.validate(ctx, *ds) + } +} + +func (d DataStoreValidation) OnDelete(object runtime.Object) AdmissionResponse { + return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + ds := object.(*kamajiv1alpha1.DataStore) //nolint:forcetypeassert + + tcpList := &kamajiv1alpha1.TenantControlPlaneList{} + if err := d.Client.List(ctx, tcpList, client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(kamajiv1alpha1.TenantControlPlaneUsedDataStoreKey, ds.GetName())}); err != nil { + return nil, errors.Wrap(err, "cannot retrieve TenantControlPlane list used by the DataStore") + } + + if len(tcpList.Items) > 0 { + return nil, fmt.Errorf("the DataStore is used by multiple TenantControlPlanes and cannot be removed") + } + + return nil, nil + } +} + +func (d DataStoreValidation) OnUpdate(object runtime.Object, oldObj runtime.Object) AdmissionResponse { + return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + newDs, oldDs := object.(*kamajiv1alpha1.DataStore), oldObj.(*kamajiv1alpha1.DataStore) //nolint:forcetypeassert + + if oldDs.Spec.Driver != newDs.Spec.Driver { + return nil, fmt.Errorf("driver of a DataStore cannot be changed") + } + + return nil, d.validate(ctx, *newDs) + } +} + +func (d DataStoreValidation) validate(ctx context.Context, ds kamajiv1alpha1.DataStore) error { + if ds.Spec.BasicAuth != nil { + if err := d.validateBasicAuth(ctx, ds); err != nil { + return err + } + } + + if err := d.validateTLSConfig(ctx, ds); err != nil { + return err + } + + return nil +} + +func (d DataStoreValidation) validateBasicAuth(ctx context.Context, ds kamajiv1alpha1.DataStore) error { + if err := d.validateContentReference(ctx, ds.Spec.BasicAuth.Password); err != nil { + return fmt.Errorf("basic-auth password is not valid, %w", err) + } + + if err := d.validateContentReference(ctx, ds.Spec.BasicAuth.Username); err != nil { + return fmt.Errorf("basic-auth username is not valid, %w", err) + } + + return nil +} + +func (d DataStoreValidation) validateTLSConfig(ctx context.Context, ds kamajiv1alpha1.DataStore) error { + if err := d.validateContentReference(ctx, ds.Spec.TLSConfig.CertificateAuthority.Certificate); err != nil { + return fmt.Errorf("CA certificate is not valid, %w", err) + } + + if ds.Spec.Driver == kamajiv1alpha1.EtcdDriver { + if ds.Spec.TLSConfig.CertificateAuthority.PrivateKey == nil { + return fmt.Errorf("CA private key is required when using the etcd driver") + } + } + + if ds.Spec.TLSConfig.CertificateAuthority.PrivateKey != nil { + if err := d.validateContentReference(ctx, *ds.Spec.TLSConfig.CertificateAuthority.PrivateKey); err != nil { + return fmt.Errorf("CA private key is not valid, %w", err) + } + } + + if err := d.validateContentReference(ctx, ds.Spec.TLSConfig.ClientCertificate.Certificate); err != nil { + return fmt.Errorf("client certificate is not valid, %w", err) + } + + if err := d.validateContentReference(ctx, ds.Spec.TLSConfig.ClientCertificate.PrivateKey); err != nil { + return fmt.Errorf("client private key is not valid, %w", err) + } + + return nil +} + +func (d DataStoreValidation) validateContentReference(ctx context.Context, ref kamajiv1alpha1.ContentRef) error { + switch { + case len(ref.Content) > 0: + return nil + case ref.SecretRef == nil: + return fmt.Errorf("the Secret reference is mandatory when bare content is not specified") + case len(ref.SecretRef.SecretReference.Name) == 0: + return fmt.Errorf("the Secret reference name is mandatory") + case len(ref.SecretRef.SecretReference.Namespace) == 0: + return fmt.Errorf("the Secret reference namespace is mandatory") + } + + if err := d.Client.Get(ctx, types.NamespacedName{Name: ref.SecretRef.SecretReference.Name, Namespace: ref.SecretRef.SecretReference.Namespace}, &corev1.Secret{}); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("secret %s/%s is not found", ref.SecretRef.SecretReference.Namespace, ref.SecretRef.SecretReference.Name) + } + + return err + } + + return nil +} diff --git a/internal/webhook/handlers/freeze.go b/internal/webhook/handlers/freeze.go new file mode 100644 index 0000000..a6221ed --- /dev/null +++ b/internal/webhook/handlers/freeze.go @@ -0,0 +1,32 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "context" + "fmt" + + "gomodules.xyz/jsonpatch/v2" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type Freeze struct{} + +func (f Freeze) OnCreate(runtime.Object) AdmissionResponse { + return f.response +} + +func (f Freeze) OnDelete(runtime.Object) AdmissionResponse { + return f.response +} + +func (f Freeze) OnUpdate(runtime.Object, runtime.Object) AdmissionResponse { + return f.response +} + +func (f Freeze) response(context.Context, admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + return nil, fmt.Errorf("the current Control Plane is in freezing mode due to a maintenance mode, all the changes are blocked: " + + "removing the webhook may lead to an inconsistent state upon its completion") +} diff --git a/internal/webhook/handlers/handler.go b/internal/webhook/handlers/handler.go new file mode 100644 index 0000000..c1238e9 --- /dev/null +++ b/internal/webhook/handlers/handler.go @@ -0,0 +1,20 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "context" + + "gomodules.xyz/jsonpatch/v2" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +type AdmissionResponse func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) + +type Handler interface { + OnCreate(runtime.Object) AdmissionResponse + OnDelete(runtime.Object) AdmissionResponse + OnUpdate(newObject runtime.Object, prevObject runtime.Object) AdmissionResponse +} diff --git a/internal/webhook/handlers/tcp_datastore.go b/internal/webhook/handlers/tcp_datastore.go new file mode 100644 index 0000000..d5cce7a --- /dev/null +++ b/internal/webhook/handlers/tcp_datastore.go @@ -0,0 +1,55 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "context" + "fmt" + + "gomodules.xyz/jsonpatch/v2" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1" + "github.com/clastix/kamaji/internal/webhook/utils" +) + +type TenantControlPlaneDataStore struct { + Client client.Client +} + +func (t TenantControlPlaneDataStore) OnCreate(object runtime.Object) AdmissionResponse { + return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + tcp := object.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert + + return nil, t.check(ctx, tcp.Spec.DataStore) + } +} + +func (t TenantControlPlaneDataStore) OnDelete(runtime.Object) AdmissionResponse { + return utils.NilOp() +} + +func (t TenantControlPlaneDataStore) OnUpdate(object runtime.Object, _ runtime.Object) AdmissionResponse { + return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + tcp := object.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert + + return nil, t.check(ctx, tcp.Spec.DataStore) + } +} + +func (t TenantControlPlaneDataStore) check(ctx context.Context, dataStoreName string) error { + if err := t.Client.Get(ctx, types.NamespacedName{Name: dataStoreName}, &kamajiv1alpha1.DataStore{}); err != nil { + if k8serrors.IsNotFound(err) { + return fmt.Errorf("%s DataStore does not exist", dataStoreName) + } + + return fmt.Errorf("an unexpected error occurred upon Tenant Control Plane DataStore check, %w", err) + } + + return nil +} diff --git a/internal/webhook/handlers/tcp_defaults.go b/internal/webhook/handlers/tcp_defaults.go new file mode 100644 index 0000000..d3af59e --- /dev/null +++ b/internal/webhook/handlers/tcp_defaults.go @@ -0,0 +1,60 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "context" + "fmt" + + "github.com/pkg/errors" + "gomodules.xyz/jsonpatch/v2" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1" + "github.com/clastix/kamaji/internal/webhook/utils" +) + +type TenantControlPlaneDefaults struct { + DefaultDatastore string +} + +func (t TenantControlPlaneDefaults) OnCreate(object runtime.Object) AdmissionResponse { + return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + tcp := object.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert + + if len(tcp.Spec.DataStore) == 0 { + operations, err := utils.JSONPatch(tcp, func() { + tcp.Spec.DataStore = t.DefaultDatastore + }) + if err != nil { + return nil, errors.Wrap(err, "cannot create patch responses upon Tenant Control Plane creation") + } + + return operations, nil + } + + return nil, nil + } +} + +func (t TenantControlPlaneDefaults) OnDelete(runtime.Object) AdmissionResponse { + return utils.NilOp() +} + +func (t TenantControlPlaneDefaults) OnUpdate(object runtime.Object, oldObject runtime.Object) AdmissionResponse { + return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + newTCP, oldTCP := object.(*kamajiv1alpha1.TenantControlPlane), oldObject.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert + + if oldTCP.Spec.DataStore == newTCP.Spec.DataStore { + return nil, nil + } + + if len(newTCP.Spec.DataStore) == 0 { + return nil, fmt.Errorf("DataStore is a required field") + } + + return nil, nil + } +} diff --git a/internal/webhook/handlers/tcp_kubeletaddresses.go b/internal/webhook/handlers/tcp_kubeletaddresses.go new file mode 100644 index 0000000..4445d12 --- /dev/null +++ b/internal/webhook/handlers/tcp_kubeletaddresses.go @@ -0,0 +1,53 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "context" + "fmt" + + "gomodules.xyz/jsonpatch/v2" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1" + "github.com/clastix/kamaji/internal/webhook/utils" +) + +type TenantControlPlaneKubeletAddresses struct{} + +func (t TenantControlPlaneKubeletAddresses) OnCreate(object runtime.Object) AdmissionResponse { + return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + tcp := object.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert + + return nil, t.validatePreferredKubeletAddressTypes(tcp.Spec.Kubernetes.Kubelet.PreferredAddressTypes) + } +} + +func (t TenantControlPlaneKubeletAddresses) OnDelete(runtime.Object) AdmissionResponse { + return utils.NilOp() +} + +func (t TenantControlPlaneKubeletAddresses) OnUpdate(object runtime.Object, _ runtime.Object) AdmissionResponse { + return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + tcp := object.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert + + return nil, t.validatePreferredKubeletAddressTypes(tcp.Spec.Kubernetes.Kubelet.PreferredAddressTypes) + } +} + +func (t TenantControlPlaneKubeletAddresses) validatePreferredKubeletAddressTypes(addressTypes []kamajiv1alpha1.KubeletPreferredAddressType) error { + s := sets.New[string]() + + for _, at := range addressTypes { + if s.Has(string(at)) { + return fmt.Errorf("preferred kubelet address types is stated multiple times: %s", at) + } + + s.Insert(string(at)) + } + + return nil +} diff --git a/internal/webhook/handlers/tcp_version.go b/internal/webhook/handlers/tcp_version.go new file mode 100644 index 0000000..11e6164 --- /dev/null +++ b/internal/webhook/handlers/tcp_version.go @@ -0,0 +1,88 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package handlers + +import ( + "context" + "fmt" + "strings" + + "github.com/blang/semver" + "github.com/pkg/errors" + "gomodules.xyz/jsonpatch/v2" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1" + "github.com/clastix/kamaji/internal/upgrade" + "github.com/clastix/kamaji/internal/webhook/utils" +) + +type TenantControlPlaneVersion struct{} + +func (t TenantControlPlaneVersion) OnCreate(object runtime.Object) AdmissionResponse { + return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + tcp := object.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert + + ver, err := semver.New(t.normalizeKubernetesVersion(tcp.Spec.Kubernetes.Version)) + if err != nil { + return nil, errors.Wrap(err, "unable to parse the desired Kubernetes version") + } + + supportedVer, supportedErr := semver.Make(t.normalizeKubernetesVersion(upgrade.KubeadmVersion)) + if supportedErr != nil { + return nil, errors.Wrap(supportedErr, "unable to parse the Kamaji supported Kubernetes version") + } + + if ver.GT(supportedVer) { + return nil, fmt.Errorf("unable to create a TenantControlPlane with a Kubernetes version greater than the supported one, actually %s", supportedVer.String()) + } + + return nil, nil + } +} + +func (t TenantControlPlaneVersion) normalizeKubernetesVersion(input string) string { + if strings.HasPrefix(input, "v") { + return strings.Replace(input, "v", "", 1) + } + + return input +} + +func (t TenantControlPlaneVersion) OnDelete(runtime.Object) AdmissionResponse { + return utils.NilOp() +} + +func (t TenantControlPlaneVersion) OnUpdate(object runtime.Object, oldObject runtime.Object) AdmissionResponse { + return func(ctx context.Context, req admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + newTCP, oldTCP := object.(*kamajiv1alpha1.TenantControlPlane), oldObject.(*kamajiv1alpha1.TenantControlPlane) //nolint:forcetypeassert + + oldVer, oldErr := semver.Make(t.normalizeKubernetesVersion(oldTCP.Spec.Kubernetes.Version)) + if oldErr != nil { + return nil, errors.Wrap(oldErr, "unable to parse the previous Kubernetes version") + } + + newVer, newErr := semver.New(t.normalizeKubernetesVersion(newTCP.Spec.Kubernetes.Version)) + if newErr != nil { + return nil, errors.Wrap(newErr, "unable to parse the desired Kubernetes version") + } + + supportedVer, supportedErr := semver.Make(t.normalizeKubernetesVersion(upgrade.KubeadmVersion)) + if supportedErr != nil { + return nil, errors.Wrap(supportedErr, "unable to parse the Kamaji supported Kubernetes version") + } + + switch { + case newVer.GT(supportedVer): + return nil, fmt.Errorf("unable to upgrade to a version greater than the supported one, actually %s", supportedVer.String()) + case newVer.LT(oldVer): + return nil, fmt.Errorf("unable to downgrade a TenantControlPlane from %s to %s", oldVer.String(), newVer.String()) + case newVer.Minor-oldVer.Minor > 1: + return nil, fmt.Errorf("unable to upgrade to a minor version in a non-sequential mode") + } + + return nil, nil + } +} diff --git a/internal/webhook/register.go b/internal/webhook/register.go new file mode 100644 index 0000000..cefd4a2 --- /dev/null +++ b/internal/webhook/register.go @@ -0,0 +1,36 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package webhook + +import ( + "github.com/pkg/errors" + controllerruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + webhookhandlers "github.com/clastix/kamaji/internal/webhook/handlers" + webhookroutes "github.com/clastix/kamaji/internal/webhook/routes" +) + +func Register(mgr controllerruntime.Manager, routes map[webhookroutes.Route][]webhookhandlers.Handler) error { + srv := mgr.GetWebhookServer() + + decoder, err := admission.NewDecoder(mgr.GetScheme()) + if err != nil { + return errors.Wrap(err, "unable to create NewDecoder for webhook registration") + } + + chainer := handlersChainer{ + decoder: decoder, + } + + for route, handlers := range routes { + srv.Register(route.GetPath(), &webhook.Admission{ + Handler: chainer.Handler(route.GetObject(), handlers...), + RecoverPanic: true, + }) + } + + return nil +} diff --git a/internal/webhook/routes/ds_secrets.go b/internal/webhook/routes/ds_secrets.go new file mode 100644 index 0000000..4e33821 --- /dev/null +++ b/internal/webhook/routes/ds_secrets.go @@ -0,0 +1,21 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package routes + +import ( + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +//+kubebuilder:webhook:path=/validate--v1-secret,mutating=false,failurePolicy=ignore,sideEffects=None,groups="",resources=secrets,verbs=delete,versions=v1,name=vdatastoresecrets.kb.io,admissionReviewVersions=v1 + +type DataStoreSecrets struct{} + +func (d DataStoreSecrets) GetPath() string { + return "validate--v1-secret" +} + +func (d DataStoreSecrets) GetObject() runtime.Object { + return &corev1.Secret{} +} diff --git a/internal/webhook/routes/ds_validate.go b/internal/webhook/routes/ds_validate.go new file mode 100644 index 0000000..3905244 --- /dev/null +++ b/internal/webhook/routes/ds_validate.go @@ -0,0 +1,22 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package routes + +import ( + "k8s.io/apimachinery/pkg/runtime" + + kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1" +) + +//+kubebuilder:webhook:path=/validate-kamaji-clastix-io-v1alpha1-datastore,mutating=false,failurePolicy=fail,sideEffects=None,groups=kamaji.clastix.io,resources=datastores,verbs=create;update;delete,versions=v1alpha1,name=vdatastore.kb.io,admissionReviewVersions=v1 + +type DataStoreValidate struct{} + +func (d DataStoreValidate) GetPath() string { + return "/validate-kamaji-clastix-io-v1alpha1-datastore" +} + +func (d DataStoreValidate) GetObject() runtime.Object { + return &kamajiv1alpha1.DataStore{} +} diff --git a/internal/webhook/routes/route.go b/internal/webhook/routes/route.go new file mode 100644 index 0000000..6544078 --- /dev/null +++ b/internal/webhook/routes/route.go @@ -0,0 +1,13 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package routes + +import ( + "k8s.io/apimachinery/pkg/runtime" +) + +type Route interface { + GetPath() string + GetObject() runtime.Object +} diff --git a/internal/webhook/routes/tcp_defaults.go b/internal/webhook/routes/tcp_defaults.go new file mode 100644 index 0000000..a39fa92 --- /dev/null +++ b/internal/webhook/routes/tcp_defaults.go @@ -0,0 +1,22 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package routes + +import ( + "k8s.io/apimachinery/pkg/runtime" + + kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1" +) + +//+kubebuilder:webhook:path=/mutate-kamaji-clastix-io-v1alpha1-tenantcontrolplane,mutating=true,failurePolicy=fail,sideEffects=None,groups=kamaji.clastix.io,resources=tenantcontrolplanes,verbs=create;update,versions=v1alpha1,name=mtenantcontrolplane.kb.io,admissionReviewVersions=v1 + +type TenantControlPlaneDefaults struct{} + +func (t TenantControlPlaneDefaults) GetObject() runtime.Object { + return &kamajiv1alpha1.TenantControlPlane{} +} + +func (t TenantControlPlaneDefaults) GetPath() string { + return "/mutate-kamaji-clastix-io-v1alpha1-tenantcontrolplane" +} diff --git a/internal/webhook/routes/tcp_freeze.go b/internal/webhook/routes/tcp_freeze.go new file mode 100644 index 0000000..1e8680a --- /dev/null +++ b/internal/webhook/routes/tcp_freeze.go @@ -0,0 +1,20 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package routes + +import ( + "k8s.io/apimachinery/pkg/runtime" + + kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1" +) + +type TenantControlPlaneMigrate struct{} + +func (t TenantControlPlaneMigrate) GetPath() string { + return "/migrate" +} + +func (t TenantControlPlaneMigrate) GetObject() runtime.Object { + return &kamajiv1alpha1.TenantControlPlane{} +} diff --git a/internal/webhook/routes/tcp_validate.go b/internal/webhook/routes/tcp_validate.go new file mode 100644 index 0000000..852a328 --- /dev/null +++ b/internal/webhook/routes/tcp_validate.go @@ -0,0 +1,22 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package routes + +import ( + "k8s.io/apimachinery/pkg/runtime" + + kamajiv1alpha1 "github.com/clastix/kamaji/api/v1alpha1" +) + +//+kubebuilder:webhook:path=/validate-kamaji-clastix-io-v1alpha1-tenantcontrolplane,mutating=false,failurePolicy=fail,sideEffects=None,groups=kamaji.clastix.io,resources=tenantcontrolplanes,verbs=create;update,versions=v1alpha1,name=vtenantcontrolplane.kb.io,admissionReviewVersions=v1 + +type TenantControlPlaneValidate struct{} + +func (t TenantControlPlaneValidate) GetPath() string { + return "/validate-kamaji-clastix-io-v1alpha1-tenantcontrolplane" +} + +func (t TenantControlPlaneValidate) GetObject() runtime.Object { + return &kamajiv1alpha1.TenantControlPlane{} +} diff --git a/internal/webhook/utils/jsonpatch.go b/internal/webhook/utils/jsonpatch.go new file mode 100644 index 0000000..17927ca --- /dev/null +++ b/internal/webhook/utils/jsonpatch.go @@ -0,0 +1,27 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + json "github.com/json-iterator/go" + "github.com/pkg/errors" + "gomodules.xyz/jsonpatch/v2" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +func JSONPatch(obj client.Object, modifierFunc func()) ([]jsonpatch.Operation, error) { + original, err := json.Marshal(obj) + if err != nil { + return nil, errors.Wrap(err, "cannot marshal input object") + } + + modifierFunc() + + patched, err := json.Marshal(obj) + if err != nil { + return nil, errors.Wrap(err, "cannot marshal patched object") + } + + return jsonpatch.CreatePatch(original, patched) +} diff --git a/internal/webhook/utils/nil_op.go b/internal/webhook/utils/nil_op.go new file mode 100644 index 0000000..3dec0eb --- /dev/null +++ b/internal/webhook/utils/nil_op.go @@ -0,0 +1,17 @@ +// Copyright 2022 Clastix Labs +// SPDX-License-Identifier: Apache-2.0 + +package utils + +import ( + "context" + + "gomodules.xyz/jsonpatch/v2" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" +) + +func NilOp() func(context.Context, admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + return func(context.Context, admission.Request) ([]jsonpatch.JsonPatchOperation, error) { + return nil, nil + } +}