Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: linodemachine: add validating admission webhook on create #291

Merged
merged 9 commits into from
May 15, 2024
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ e2etest: generate local-release local-deploy chainsaw

local-deploy: kind ctlptl tilt kustomize clusterctl
@echo -n "LINODE_TOKEN=$(LINODE_TOKEN)" > config/default/.env.linode
@echo -n "ENABLE_WEBHOOKS=$(ENABLE_WEBHOOKS)" > config/default/.env.manager
$(CTLPTL) apply -f .tilt/ctlptl-config.yaml
$(TILT) ci -f Tiltfile

Expand Down Expand Up @@ -204,6 +205,7 @@ endif
.PHONY: tilt-cluster
tilt-cluster: ctlptl tilt kind clusterctl
@echo -n "LINODE_TOKEN=$(LINODE_TOKEN)" > config/default/.env.linode
@echo -n "ENABLE_WEBHOOKS=$(ENABLE_WEBHOOKS)" > config/default/.env.manager
$(CTLPTL) apply -f .tilt/ctlptl-config.yaml
$(TILT) up --stream

Expand Down Expand Up @@ -292,10 +294,11 @@ $(LOCALBIN):
##@ Tooling Binaries:
# setup-envtest does not have devbox support so always use CACHE_BIN

KUBECTL ?= kubectl
KUBECTL ?= $(LOCALBIN)/kubectl
AshleyDumaine marked this conversation as resolved.
Show resolved Hide resolved
KUSTOMIZE ?= $(LOCALBIN)/kustomize
CTLPTL ?= $(LOCALBIN)/ctlptl
CLUSTERCTL ?= $(LOCALBIN)/clusterctl
KUBEBUILDER ?= $(LOCALBIN)/kubebuilder
CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
TILT ?= $(LOCALBIN)/tilt
KIND ?= $(LOCALBIN)/kind
Expand All @@ -310,6 +313,7 @@ MOCKGEN ?= $(LOCALBIN)/mockgen
KUSTOMIZE_VERSION ?= v5.1.1
CTLPTL_VERSION ?= v0.8.25
CLUSTERCTL_VERSION ?= v1.5.3
KUBEBUILDER_VERSION ?= v3.14.1
CONTROLLER_TOOLS_VERSION ?= v0.14.0
TILT_VERSION ?= 0.33.6
KIND_VERSION ?= 0.20.0
Expand Down Expand Up @@ -339,6 +343,12 @@ $(CLUSTERCTL): $(LOCALBIN)
curl -fsSL https://github.com/kubernetes-sigs/cluster-api/releases/download/$(CLUSTERCTL_VERSION)/clusterctl-$(OS)-$(ARCH_SHORT) -o $(CLUSTERCTL)
chmod +x $(CLUSTERCTL)

.PHONY: kubebuilder
kubebuilder: $(KUBEBUILDER) ## Download kubebuilder locally if necessary.
$(KUBEBUILDER): $(LOCALBIN)
curl -L -o $(LOCALBIN)/kubebuilder https://github.com/kubernetes-sigs/kubebuilder/releases/download/$(KUBEBUILDER_VERSION)/kubebuilder_$(OS)_$(ARCH_SHORT)
chmod +x $(LOCALBIN)/kubebuilder

.PHONY: controller-gen
controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary.
$(CONTROLLER_GEN): $(LOCALBIN)
Expand Down
3 changes: 3 additions & 0 deletions PROJECT
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ resources:
kind: LinodeMachine
path: github.com/linode/cluster-api-provider-linode/api/v1alpha1
version: v1alpha1
webhooks:
validation: true
webhookVersion: v1
- api:
crdVersion: v1
namespaced: true
Expand Down
6 changes: 6 additions & 0 deletions Tiltfile
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ for resource in manager_yaml:
resource["stringData"]["apiToken"] = os.getenv("LINODE_TOKEN")
if resource["kind"] == "CustomResourceDefinition" and resource["spec"]["group"] == "infrastructure.cluster.x-k8s.io":
resource["metadata"]["labels"]["clusterctl.cluster.x-k8s.io"] = ""
if resource["metadata"]["name"] == "capl-manager-config":
resource["data"]["ENABLE_WEBHOOKS"] = os.getenv("ENABLE_WEBHOOKS", "true")
k8s_yaml(encode_yaml_stream(manager_yaml))

if os.getenv("SKIP_DOCKER_BUILD", "false") != "true":
Expand Down Expand Up @@ -128,6 +130,10 @@ k8s_resource(
"capl-manager-rolebinding:clusterrolebinding",
"capl-proxy-rolebinding:clusterrolebinding",
"capl-manager-credentials:secret",
"capl-manager-config:configmap",
"capl-serving-cert:certificate",
"capl-selfsigned-issuer:issuer",
"capl-validating-webhook-configuration:validatingwebhookconfiguration",
],
resource_deps=["capi-controller-manager"],
labels=["CAPL"],
Expand Down
194 changes: 194 additions & 0 deletions api/v1alpha1/linodemachine_webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/*
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"
"fmt"
"slices"

"github.com/linode/linodego"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/api/resource"
"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 list of valid device slots that data device disks may attach to.
// NOTE: sda is reserved for the OS device disk.
LinodeMachineDevicePaths = []string{"sdb", "sdc", "sdd", "sde", "sdf", "sdg", "sdh"}

// The maximum number of device disks allowed per [Configuration Profile per Linode’s Instance].
//
// [Configuration Profile per Linode’s Instance]: https://www.linode.com/docs/api/linode-instances/#configuration-profile-view
LinodeMachineMaxDisk = 8

// The maximum number of data device disks allowed in a Linode’s Instance's configuration profile.
// NOTE: The first device disk is reserved for the OS disk
LinodeMachineMaxDataDisk = LinodeMachineMaxDisk - 1
)

// log is for logging in this package.
var linodemachinelog = logf.Log.WithName("linodemachine-resource")

// SetupWebhookWithManager will setup the manager to manage the webhooks
func (r *LinodeMachine) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).
For(r).
Complete()

Check warning on line 60 in api/v1alpha1/linodemachine_webhook.go

View check run for this annotation

Codecov / codecov/patch

api/v1alpha1/linodemachine_webhook.go#L57-L60

Added lines #L57 - L60 were not covered by tests
}

// TODO(user): EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!

// 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-linodemachine,mutating=false,failurePolicy=fail,sideEffects=None,groups=infrastructure.cluster.x-k8s.io,resources=linodemachines,verbs=create,versions=v1alpha1,name=vlinodemachine.kb.io,admissionReviewVersions=v1

var _ webhook.Validator = &LinodeMachine{}

// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
func (r *LinodeMachine) ValidateCreate() (admission.Warnings, error) {
linodemachinelog.Info("validate create", "name", r.Name)

Check warning on line 72 in api/v1alpha1/linodemachine_webhook.go

View check run for this annotation

Codecov / codecov/patch

api/v1alpha1/linodemachine_webhook.go#L71-L72

Added lines #L71 - L72 were not covered by tests

ctx, cancel := context.WithTimeout(context.Background(), defaultWebhookTimeout)
defer cancel()

Check warning on line 75 in api/v1alpha1/linodemachine_webhook.go

View check run for this annotation

Codecov / codecov/patch

api/v1alpha1/linodemachine_webhook.go#L74-L75

Added lines #L74 - L75 were not covered by tests

return nil, r.validateLinodeMachine(ctx, &defaultLinodeClient)

Check warning on line 77 in api/v1alpha1/linodemachine_webhook.go

View check run for this annotation

Codecov / codecov/patch

api/v1alpha1/linodemachine_webhook.go#L77

Added line #L77 was not covered by tests
}

// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
func (r *LinodeMachine) ValidateUpdate(old runtime.Object) (admission.Warnings, error) {
linodemachinelog.Info("validate update", "name", r.Name)

Check warning on line 82 in api/v1alpha1/linodemachine_webhook.go

View check run for this annotation

Codecov / codecov/patch

api/v1alpha1/linodemachine_webhook.go#L81-L82

Added lines #L81 - L82 were not covered by tests

// TODO(user): fill in your validation logic upon object update.
return nil, nil

Check warning on line 85 in api/v1alpha1/linodemachine_webhook.go

View check run for this annotation

Codecov / codecov/patch

api/v1alpha1/linodemachine_webhook.go#L85

Added line #L85 was not covered by tests
}

// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
func (r *LinodeMachine) ValidateDelete() (admission.Warnings, error) {
linodemachinelog.Info("validate delete", "name", r.Name)

Check warning on line 90 in api/v1alpha1/linodemachine_webhook.go

View check run for this annotation

Codecov / codecov/patch

api/v1alpha1/linodemachine_webhook.go#L89-L90

Added lines #L89 - L90 were not covered by tests

// TODO(user): fill in your validation logic upon object deletion.
return nil, nil

Check warning on line 93 in api/v1alpha1/linodemachine_webhook.go

View check run for this annotation

Codecov / codecov/patch

api/v1alpha1/linodemachine_webhook.go#L93

Added line #L93 was not covered by tests
}

func (r *LinodeMachine) validateLinodeMachine(ctx context.Context, client LinodeClient) error {
var errs field.ErrorList

if err := r.validateLinodeMachineSpec(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: "LinodeMachine"},
r.Name, errs)
}

func (r *LinodeMachine) validateLinodeMachineSpec(ctx context.Context, client LinodeClient) field.ErrorList {
var errs field.ErrorList

if err := validateRegion(ctx, client, r.Spec.Region, field.NewPath("spec").Child("region")); err != nil {
errs = append(errs, err)
}
plan, err := validateLinodeType(ctx, client, r.Spec.Type, field.NewPath("spec").Child("type"))
if err != nil {
errs = append(errs, err)
}
if err := r.validateLinodeMachineDisks(plan); err != nil {
errs = append(errs, err)
}

if len(errs) == 0 {
return nil
}
return errs
}

func (r *LinodeMachine) validateLinodeMachineDisks(plan *linodego.LinodeType) *field.Error {
// The Linode plan information is required to perform disk validation
if plan == nil {
return nil
}

var (
// The Linode API represents storage sizes in megabytes (MB)
// https://www.linode.com/docs/api/linode-types/#type-view
planSize = resource.MustParse(fmt.Sprintf("%d%s", plan.Disk, "M"))
remainSize = &resource.Quantity{}
err *field.Error
)
planSize.DeepCopyInto(remainSize)

if remainSize, err = validateDisk(r.Spec.OSDisk, field.NewPath("spec").Child("osDisk"), remainSize, &planSize); err != nil {
return err
}
if _, err := validateDataDisks(r.Spec.DataDisks, field.NewPath("spec").Child("dataDisks"), remainSize, &planSize); err != nil {
return err
}

return nil
}

func validateDataDisks(disks map[string]*InstanceDisk, path *field.Path, remainSize, planSize *resource.Quantity) (*resource.Quantity, *field.Error) {
devs := []string{}

for dev, disk := range disks {
if !slices.Contains(LinodeMachineDevicePaths, dev) {
return nil, field.Forbidden(path.Child(dev), fmt.Sprintf("allowed device paths: %v", LinodeMachineDevicePaths))
}
if slices.Contains(devs, dev) {
return nil, field.Duplicate(path.Child(dev), "duplicate device path")

Check warning on line 164 in api/v1alpha1/linodemachine_webhook.go

View check run for this annotation

Codecov / codecov/patch

api/v1alpha1/linodemachine_webhook.go#L164

Added line #L164 was not covered by tests
}
devs = append(devs, dev)
if len(devs) > LinodeMachineMaxDataDisk {
return nil, field.TooMany(path, len(devs), LinodeMachineMaxDataDisk)

Check warning on line 168 in api/v1alpha1/linodemachine_webhook.go

View check run for this annotation

Codecov / codecov/patch

api/v1alpha1/linodemachine_webhook.go#L168

Added line #L168 was not covered by tests
}

var err *field.Error
if remainSize, err = validateDisk(disk, path.Child(dev), remainSize, planSize); err != nil {
return nil, err
}
}
return remainSize, nil
}

func validateDisk(disk *InstanceDisk, path *field.Path, remainSize, planSize *resource.Quantity) (*resource.Quantity, *field.Error) {
if disk == nil {
return remainSize, nil
}

if disk.Size.Sign() < 1 {
return nil, field.Invalid(path, disk.Size.String(), "invalid size")
}
if remainSize.Cmp(disk.Size) == -1 {
return nil, field.Invalid(path, disk.Size.String(), fmt.Sprintf("sum disk sizes exceeds plan storage: %s", planSize.String()))
}

// Decrement the remaining amount of space available
remainSize.Sub(disk.Size)
return remainSize, nil
}
Loading
Loading