diff --git a/PROJECT b/PROJECT index 68a39cfeb..dae5207e4 100644 --- a/PROJECT +++ b/PROJECT @@ -69,4 +69,7 @@ resources: kind: LinodeObjectStorageBucket path: github.com/linode/cluster-api-provider-linode/api/v1alpha1 version: v1alpha1 + webhooks: + validation: true + webhookVersion: v1 version: "3" diff --git a/api/v1alpha1/linodeobjectstoragebucket_webhook.go b/api/v1alpha1/linodeobjectstoragebucket_webhook.go new file mode 100644 index 000000000..5bf1146f4 --- /dev/null +++ b/api/v1alpha1/linodeobjectstoragebucket_webhook.go @@ -0,0 +1,109 @@ +/* +Copyright 2023 Akamai Technologies, Inc. + +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" + "slices" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + ctrl "sigs.k8s.io/controller-runtime" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + . "github.com/linode/cluster-api-provider-linode/clients" +) + +var ( + // The capability string indicating a region supports Object Storage: [Object Storage Availability] + // + // [Object Storage Availability]: https://www.linode.com/docs/products/storage/object-storage/#availability + LinodeObjectStorageCapability = "Object Storage" +) + +// log is for logging in this package. +var linodeobjectstoragebucketlog = logf.Log.WithName("linodeobjectstoragebucket-resource") + +// SetupWebhookWithManager will setup the manager to manage the webhooks +func (r *LinodeObjectStorageBucket) SetupWebhookWithManager(mgr ctrl.Manager) error { + return ctrl.NewWebhookManagedBy(mgr). + For(r). + Complete() +} + +// TODO(user): change verbs to "verbs=create;update;delete" if you want to enable updation and deletion validation. +//+kubebuilder:webhook:path=/validate-infrastructure-cluster-x-k8s-io-v1alpha1-linodeobjectstoragebucket,mutating=false,failurePolicy=fail,sideEffects=None,groups=infrastructure.cluster.x-k8s.io,resources=linodeobjectstoragebuckets,verbs=create,versions=v1alpha1,name=vlinodeobjectstoragebucket.kb.io,admissionReviewVersions=v1 + +var _ webhook.Validator = &LinodeObjectStorageBucket{} + +// ValidateCreate implements webhook.Validator so a webhook will be registered for the type +func (r *LinodeObjectStorageBucket) ValidateCreate() (admission.Warnings, error) { + linodeobjectstoragebucketlog.Info("validate create", "name", r.Name) + + ctx, cancel := context.WithTimeout(context.Background(), defaultWebhookTimeout) + defer cancel() + + return nil, r.validateLinodeObjectStorageBucket(ctx, &defaultLinodeClient) +} + +// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type +func (r *LinodeObjectStorageBucket) ValidateUpdate(old runtime.Object) (admission.Warnings, error) { + linodeobjectstoragebucketlog.Info("validate update", "name", r.Name) + + // TODO(user): fill in your validation logic upon object update. + return nil, nil +} + +// ValidateDelete implements webhook.Validator so a webhook will be registered for the type +func (r *LinodeObjectStorageBucket) ValidateDelete() (admission.Warnings, error) { + linodeobjectstoragebucketlog.Info("validate delete", "name", r.Name) + + // TODO(user): fill in your validation logic upon object deletion. + return nil, nil +} + +func (r *LinodeObjectStorageBucket) validateLinodeObjectStorageBucket(ctx context.Context, client LinodeClient) error { + var errs field.ErrorList + + if err := r.validateLinodeObjectStorageBucketSpec(ctx, client); err != nil { + errs = slices.Concat(errs, err) + } + + if len(errs) == 0 { + return nil + } + return apierrors.NewInvalid( + schema.GroupKind{Group: "infrastructure.cluster.x-k8s.io", Kind: "LinodeObjectStorageBucket"}, + r.Name, errs) +} + +func (r *LinodeObjectStorageBucket) validateLinodeObjectStorageBucketSpec(ctx context.Context, client LinodeClient) field.ErrorList { + var errs field.ErrorList + + if err := validateObjectStorageCluster(ctx, client, r.Spec.Cluster, field.NewPath("spec").Child("cluster")); err != nil { + errs = append(errs, err) + } + + if len(errs) == 0 { + return nil + } + return errs +} diff --git a/api/v1alpha1/linodeobjectstoragebucket_webhook_test.go b/api/v1alpha1/linodeobjectstoragebucket_webhook_test.go new file mode 100644 index 000000000..93d28b234 --- /dev/null +++ b/api/v1alpha1/linodeobjectstoragebucket_webhook_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2023 Akamai Technologies, Inc. + +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" + "slices" + "testing" + + "github.com/linode/linodego" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/linode/cluster-api-provider-linode/mock" + + . "github.com/linode/cluster-api-provider-linode/mock/mocktest" +) + +func TestValidateLinodeObjectStorageBucket(t *testing.T) { + t.Parallel() + + var ( + bucket = LinodeObjectStorageBucket{ + ObjectMeta: metav1.ObjectMeta{ + Name: "example", + Namespace: "example", + }, + Spec: LinodeObjectStorageBucketSpec{ + Cluster: "example-1", + }, + } + region = linodego.Region{ID: "test"} + capabilities = []string{LinodeObjectStorageCapability} + capabilities_zero = []string{} + ) + + NewSuite(t, mock.MockLinodeClient{}).Run( + OneOf( + Path( + Call("valid", func(ctx context.Context, mck Mock) { + region := region + region.Capabilities = slices.Clone(capabilities) + mck.LinodeClient.EXPECT().GetRegion(gomock.Any(), gomock.Any()).Return(®ion, nil).AnyTimes() + }), + Result("success", func(ctx context.Context, mck Mock) { + assert.NoError(t, bucket.validateLinodeObjectStorageBucket(ctx, mck.LinodeClient)) + }), + ), + ), + OneOf( + Path( + Call("invalid cluster format", func(ctx context.Context, mck Mock) { + region := region + region.Capabilities = slices.Clone(capabilities) + mck.LinodeClient.EXPECT().GetRegion(gomock.Any(), gomock.Any()).Return(®ion, nil).AnyTimes() + }), + Result("error", func(ctx context.Context, mck Mock) { + bucket := bucket + bucket.Spec.Cluster = "invalid" + assert.Error(t, bucket.validateLinodeObjectStorageBucket(ctx, mck.LinodeClient)) + }), + ), + Path( + Call("region not supported", func(ctx context.Context, mck Mock) { + region := region + region.Capabilities = slices.Clone(capabilities_zero) + mck.LinodeClient.EXPECT().GetRegion(gomock.Any(), gomock.Any()).Return(®ion, nil).AnyTimes() + }), + Result("error", func(ctx context.Context, mck Mock) { + assert.Error(t, bucket.validateLinodeObjectStorageBucket(ctx, mck.LinodeClient)) + }), + ), + ), + ) +} diff --git a/api/v1alpha1/webhook_helpers.go b/api/v1alpha1/webhook_helpers.go index 1645bbb9e..4552bbe7a 100644 --- a/api/v1alpha1/webhook_helpers.go +++ b/api/v1alpha1/webhook_helpers.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "regexp" "slices" "time" @@ -48,3 +49,24 @@ func validateLinodeType(ctx context.Context, client LinodeClient, id string, pat return plan, nil } + +// validateObjectStorageCluster validates an Object Storage deployment's cluster ID via the following rules: +// - The cluster ID is in the form: REGION_ID-ORDINAL. +// - The region has Object Storage support. +// +// NOTE: This implementation intended to bypass the authentication requirement for the [Clusters List] and [Cluster +// View] endpoints in the Linode API, thereby reusing a [github.com/linode/linodego.Client] (and its caching if enabled) +// across many admission requests. +// +// [Clusters List]: https://www.linode.com/docs/api/object-storage/#clusters-list +// [Cluster View]: https://www.linode.com/docs/api/object-storage/#cluster-view +func validateObjectStorageCluster(ctx context.Context, client LinodeClient, id string, path *field.Path) *field.Error { + //nolint:gocritic // prefer no escapes + cexp := regexp.MustCompile("^(([[:lower:]]+-)*[[:lower:]]+)-[[:digit:]]+$") + if !cexp.MatchString(id) { + return field.Invalid(path, id, "must be in form: region_id-ordinal") + } + + region := cexp.FindStringSubmatch(id)[1] + return validateRegion(ctx, client, region, path, LinodeObjectStorageCapability) +} diff --git a/api/v1alpha1/webhook_suite_test.go b/api/v1alpha1/webhook_suite_test.go index a0b304c86..f234db59d 100644 --- a/api/v1alpha1/webhook_suite_test.go +++ b/api/v1alpha1/webhook_suite_test.go @@ -123,6 +123,9 @@ var _ = BeforeSuite(func() { err = (&LinodeVPC{}).SetupWebhookWithManager(mgr) Expect(err).NotTo(HaveOccurred()) + err = (&LinodeObjectStorageBucket{}).SetupWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + //+kubebuilder:scaffold:webhook go func() { diff --git a/cmd/main.go b/cmd/main.go index 6d564a9a3..f71b5a5f5 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -54,6 +54,7 @@ func init() { // +kubebuilder:scaffold:scheme } +//nolint:cyclop // main func main() { var ( // Environment variables @@ -162,6 +163,10 @@ func main() { setupLog.Error(err, "unable to create webhook", "webhook", "LinodeVPC") os.Exit(1) } + if err = (&infrastructurev1alpha1.LinodeObjectStorageBucket{}).SetupWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "LinodeObjectStorageBucket") + os.Exit(1) + } } // +kubebuilder:scaffold:builder diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index e3e76c28b..f99ee0a5c 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -21,12 +21,12 @@ resources: patches: # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. # patches here are for enabling the conversion webhook for each CRD -#- path: patches/webhook_in_linodeclusters.yaml +- path: patches/webhook_in_linodeclusters.yaml - path: patches/webhook_in_linodemachines.yaml #- path: patches/webhook_in_linodemachinetemplates.yaml #- path: patches/webhook_in_linodeclustertemplates.yaml - path: patches/webhook_in_linodevpcs.yaml -#- path: patches/webhook_in_linodeobjectstoragebuckets.yaml +- path: patches/webhook_in_linodeobjectstoragebuckets.yaml #+kubebuilder:scaffold:crdkustomizewebhookpatch # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. @@ -36,7 +36,7 @@ patches: #- path: patches/cainjection_in_linodemachinetemplates.yaml #- path: patches/cainjection_in_linodeclustertemplates.yaml - path: patches/cainjection_in_linodevpcs.yaml -#- path: patches/cainjection_in_linodeobjectstoragebuckets.yaml +- path: patches/cainjection_in_linodeobjectstoragebuckets.yaml #+kubebuilder:scaffold:crdkustomizecainjectionpatch # [VALIDATION] diff --git a/config/crd/patches/cainjection_in_linodeobjectstoragebuckets.yaml b/config/crd/patches/cainjection_in_linodeobjectstoragebuckets.yaml new file mode 100644 index 000000000..0b4789726 --- /dev/null +++ b/config/crd/patches/cainjection_in_linodeobjectstoragebuckets.yaml @@ -0,0 +1,7 @@ +# The following patch adds a directive for certmanager to inject CA into the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + cert-manager.io/inject-ca-from: CERTIFICATE_NAMESPACE/CERTIFICATE_NAME + name: linodeobjectstoragebuckets.infrastructure.cluster.x-k8s.io diff --git a/config/crd/patches/webhook_in_linodeobjectstoragebuckets.yaml b/config/crd/patches/webhook_in_linodeobjectstoragebuckets.yaml new file mode 100644 index 000000000..201f1f83d --- /dev/null +++ b/config/crd/patches/webhook_in_linodeobjectstoragebuckets.yaml @@ -0,0 +1,16 @@ +# The following patch enables a conversion webhook for the CRD +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: linodeobjectstoragebuckets.infrastructure.cluster.x-k8s.io +spec: + conversion: + strategy: Webhook + webhook: + clientConfig: + service: + namespace: system + name: webhook-service + path: /convert + conversionReviewVersions: + - v1 diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 4a46c23b1..39efa456a 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -42,6 +42,25 @@ webhooks: resources: - linodemachines sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /validate-infrastructure-cluster-x-k8s-io-v1alpha1-linodeobjectstoragebucket + failurePolicy: Fail + name: vlinodeobjectstoragebucket.kb.io + rules: + - apiGroups: + - infrastructure.cluster.x-k8s.io + apiVersions: + - v1alpha1 + operations: + - CREATE + resources: + - linodeobjectstoragebuckets + sideEffects: None - admissionReviewVersions: - v1 clientConfig: