Skip to content

Commit

Permalink
feat: linodemachine: add validating admission webhook on create (#291)
Browse files Browse the repository at this point in the history
* fixup! Rename csi-driver to better short-name. (#158)

* chore: add kubebuilder

* fixup! shorten cluster-api-provider-linode to capl, add release process (#124)

* feat: linodemachine: kubebuilder create webhook

Scaffold a validating admission webhook for the LinodeMachine resource
with Kubebuider via the command:
kubebuilder create webhook --group infrastructure --version v1alpha1 --kind LinodeMachine --programmatic-validation

* fixup! feat: linodemachine: kubebuilder create webhook

* tiltfile: add validation webhook

* chore: e2e: use 6th Generation Linode plans

Use the latest 6th Generation Linode plans (announced in May 2018). The 5th
Generation plans are publicly deprecated.

See: https://www.linode.com/blog/linode/updated-linode-plans-new-larger-linodes/

* chore: add webhook toggle

Webhooks can be enabled or disabled by setting the `ENABLE_WEBHOOKS` environment
variable during deployment.

* api: linodemachine: add create validation
  • Loading branch information
cbzzz authored May 15, 2024
1 parent c6faafa commit db9eeda
Show file tree
Hide file tree
Showing 30 changed files with 942 additions and 110 deletions.
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
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()
}

// 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)

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

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

// 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)

// 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 *LinodeMachine) ValidateDelete() (admission.Warnings, error) {
linodemachinelog.Info("validate delete", "name", r.Name)

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

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")
}
devs = append(devs, dev)
if len(devs) > LinodeMachineMaxDataDisk {
return nil, field.TooMany(path, len(devs), LinodeMachineMaxDataDisk)
}

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

0 comments on commit db9eeda

Please sign in to comment.