Skip to content

Commit db9eeda

Browse files
authored
feat: linodemachine: add validating admission webhook on create (#291)
* 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
1 parent c6faafa commit db9eeda

30 files changed

+942
-110
lines changed

Makefile

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ e2etest: generate local-release local-deploy chainsaw
152152

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

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

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

295-
KUBECTL ?= kubectl
297+
KUBECTL ?= $(LOCALBIN)/kubectl
296298
KUSTOMIZE ?= $(LOCALBIN)/kustomize
297299
CTLPTL ?= $(LOCALBIN)/ctlptl
298300
CLUSTERCTL ?= $(LOCALBIN)/clusterctl
301+
KUBEBUILDER ?= $(LOCALBIN)/kubebuilder
299302
CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen
300303
TILT ?= $(LOCALBIN)/tilt
301304
KIND ?= $(LOCALBIN)/kind
@@ -310,6 +313,7 @@ MOCKGEN ?= $(LOCALBIN)/mockgen
310313
KUSTOMIZE_VERSION ?= v5.1.1
311314
CTLPTL_VERSION ?= v0.8.25
312315
CLUSTERCTL_VERSION ?= v1.5.3
316+
KUBEBUILDER_VERSION ?= v3.14.1
313317
CONTROLLER_TOOLS_VERSION ?= v0.14.0
314318
TILT_VERSION ?= 0.33.6
315319
KIND_VERSION ?= 0.20.0
@@ -339,6 +343,12 @@ $(CLUSTERCTL): $(LOCALBIN)
339343
curl -fsSL https://github.com/kubernetes-sigs/cluster-api/releases/download/$(CLUSTERCTL_VERSION)/clusterctl-$(OS)-$(ARCH_SHORT) -o $(CLUSTERCTL)
340344
chmod +x $(CLUSTERCTL)
341345

346+
.PHONY: kubebuilder
347+
kubebuilder: $(KUBEBUILDER) ## Download kubebuilder locally if necessary.
348+
$(KUBEBUILDER): $(LOCALBIN)
349+
curl -L -o $(LOCALBIN)/kubebuilder https://github.com/kubernetes-sigs/kubebuilder/releases/download/$(KUBEBUILDER_VERSION)/kubebuilder_$(OS)_$(ARCH_SHORT)
350+
chmod +x $(LOCALBIN)/kubebuilder
351+
342352
.PHONY: controller-gen
343353
controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary.
344354
$(CONTROLLER_GEN): $(LOCALBIN)

PROJECT

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ resources:
2626
kind: LinodeMachine
2727
path: github.com/linode/cluster-api-provider-linode/api/v1alpha1
2828
version: v1alpha1
29+
webhooks:
30+
validation: true
31+
webhookVersion: v1
2932
- api:
3033
crdVersion: v1
3134
namespaced: true

Tiltfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,8 @@ for resource in manager_yaml:
9898
resource["stringData"]["apiToken"] = os.getenv("LINODE_TOKEN")
9999
if resource["kind"] == "CustomResourceDefinition" and resource["spec"]["group"] == "infrastructure.cluster.x-k8s.io":
100100
resource["metadata"]["labels"]["clusterctl.cluster.x-k8s.io"] = ""
101+
if resource["metadata"]["name"] == "capl-manager-config":
102+
resource["data"]["ENABLE_WEBHOOKS"] = os.getenv("ENABLE_WEBHOOKS", "true")
101103
k8s_yaml(encode_yaml_stream(manager_yaml))
102104

103105
if os.getenv("SKIP_DOCKER_BUILD", "false") != "true":
@@ -128,6 +130,10 @@ k8s_resource(
128130
"capl-manager-rolebinding:clusterrolebinding",
129131
"capl-proxy-rolebinding:clusterrolebinding",
130132
"capl-manager-credentials:secret",
133+
"capl-manager-config:configmap",
134+
"capl-serving-cert:certificate",
135+
"capl-selfsigned-issuer:issuer",
136+
"capl-validating-webhook-configuration:validatingwebhookconfiguration",
131137
],
132138
resource_deps=["capi-controller-manager"],
133139
labels=["CAPL"],

api/v1alpha1/linodemachine_webhook.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/*
2+
Copyright 2023 Akamai Technologies, Inc.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1alpha1
18+
19+
import (
20+
"context"
21+
"fmt"
22+
"slices"
23+
24+
"github.com/linode/linodego"
25+
apierrors "k8s.io/apimachinery/pkg/api/errors"
26+
"k8s.io/apimachinery/pkg/api/resource"
27+
"k8s.io/apimachinery/pkg/runtime"
28+
"k8s.io/apimachinery/pkg/runtime/schema"
29+
"k8s.io/apimachinery/pkg/util/validation/field"
30+
ctrl "sigs.k8s.io/controller-runtime"
31+
logf "sigs.k8s.io/controller-runtime/pkg/log"
32+
"sigs.k8s.io/controller-runtime/pkg/webhook"
33+
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
34+
35+
. "github.com/linode/cluster-api-provider-linode/clients"
36+
)
37+
38+
var (
39+
// The list of valid device slots that data device disks may attach to.
40+
// NOTE: sda is reserved for the OS device disk.
41+
LinodeMachineDevicePaths = []string{"sdb", "sdc", "sdd", "sde", "sdf", "sdg", "sdh"}
42+
43+
// The maximum number of device disks allowed per [Configuration Profile per Linode’s Instance].
44+
//
45+
// [Configuration Profile per Linode’s Instance]: https://www.linode.com/docs/api/linode-instances/#configuration-profile-view
46+
LinodeMachineMaxDisk = 8
47+
48+
// The maximum number of data device disks allowed in a Linode’s Instance's configuration profile.
49+
// NOTE: The first device disk is reserved for the OS disk
50+
LinodeMachineMaxDataDisk = LinodeMachineMaxDisk - 1
51+
)
52+
53+
// log is for logging in this package.
54+
var linodemachinelog = logf.Log.WithName("linodemachine-resource")
55+
56+
// SetupWebhookWithManager will setup the manager to manage the webhooks
57+
func (r *LinodeMachine) SetupWebhookWithManager(mgr ctrl.Manager) error {
58+
return ctrl.NewWebhookManagedBy(mgr).
59+
For(r).
60+
Complete()
61+
}
62+
63+
// TODO(user): EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
64+
65+
// TODO(user): change verbs to "verbs=create;update;delete" if you want to enable updation and deletion validation.
66+
//+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
67+
68+
var _ webhook.Validator = &LinodeMachine{}
69+
70+
// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
71+
func (r *LinodeMachine) ValidateCreate() (admission.Warnings, error) {
72+
linodemachinelog.Info("validate create", "name", r.Name)
73+
74+
ctx, cancel := context.WithTimeout(context.Background(), defaultWebhookTimeout)
75+
defer cancel()
76+
77+
return nil, r.validateLinodeMachine(ctx, &defaultLinodeClient)
78+
}
79+
80+
// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
81+
func (r *LinodeMachine) ValidateUpdate(old runtime.Object) (admission.Warnings, error) {
82+
linodemachinelog.Info("validate update", "name", r.Name)
83+
84+
// TODO(user): fill in your validation logic upon object update.
85+
return nil, nil
86+
}
87+
88+
// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
89+
func (r *LinodeMachine) ValidateDelete() (admission.Warnings, error) {
90+
linodemachinelog.Info("validate delete", "name", r.Name)
91+
92+
// TODO(user): fill in your validation logic upon object deletion.
93+
return nil, nil
94+
}
95+
96+
func (r *LinodeMachine) validateLinodeMachine(ctx context.Context, client LinodeClient) error {
97+
var errs field.ErrorList
98+
99+
if err := r.validateLinodeMachineSpec(ctx, client); err != nil {
100+
errs = slices.Concat(errs, err)
101+
}
102+
103+
if len(errs) == 0 {
104+
return nil
105+
}
106+
return apierrors.NewInvalid(
107+
schema.GroupKind{Group: "infrastructure.cluster.x-k8s.io", Kind: "LinodeMachine"},
108+
r.Name, errs)
109+
}
110+
111+
func (r *LinodeMachine) validateLinodeMachineSpec(ctx context.Context, client LinodeClient) field.ErrorList {
112+
var errs field.ErrorList
113+
114+
if err := validateRegion(ctx, client, r.Spec.Region, field.NewPath("spec").Child("region")); err != nil {
115+
errs = append(errs, err)
116+
}
117+
plan, err := validateLinodeType(ctx, client, r.Spec.Type, field.NewPath("spec").Child("type"))
118+
if err != nil {
119+
errs = append(errs, err)
120+
}
121+
if err := r.validateLinodeMachineDisks(plan); err != nil {
122+
errs = append(errs, err)
123+
}
124+
125+
if len(errs) == 0 {
126+
return nil
127+
}
128+
return errs
129+
}
130+
131+
func (r *LinodeMachine) validateLinodeMachineDisks(plan *linodego.LinodeType) *field.Error {
132+
// The Linode plan information is required to perform disk validation
133+
if plan == nil {
134+
return nil
135+
}
136+
137+
var (
138+
// The Linode API represents storage sizes in megabytes (MB)
139+
// https://www.linode.com/docs/api/linode-types/#type-view
140+
planSize = resource.MustParse(fmt.Sprintf("%d%s", plan.Disk, "M"))
141+
remainSize = &resource.Quantity{}
142+
err *field.Error
143+
)
144+
planSize.DeepCopyInto(remainSize)
145+
146+
if remainSize, err = validateDisk(r.Spec.OSDisk, field.NewPath("spec").Child("osDisk"), remainSize, &planSize); err != nil {
147+
return err
148+
}
149+
if _, err := validateDataDisks(r.Spec.DataDisks, field.NewPath("spec").Child("dataDisks"), remainSize, &planSize); err != nil {
150+
return err
151+
}
152+
153+
return nil
154+
}
155+
156+
func validateDataDisks(disks map[string]*InstanceDisk, path *field.Path, remainSize, planSize *resource.Quantity) (*resource.Quantity, *field.Error) {
157+
devs := []string{}
158+
159+
for dev, disk := range disks {
160+
if !slices.Contains(LinodeMachineDevicePaths, dev) {
161+
return nil, field.Forbidden(path.Child(dev), fmt.Sprintf("allowed device paths: %v", LinodeMachineDevicePaths))
162+
}
163+
if slices.Contains(devs, dev) {
164+
return nil, field.Duplicate(path.Child(dev), "duplicate device path")
165+
}
166+
devs = append(devs, dev)
167+
if len(devs) > LinodeMachineMaxDataDisk {
168+
return nil, field.TooMany(path, len(devs), LinodeMachineMaxDataDisk)
169+
}
170+
171+
var err *field.Error
172+
if remainSize, err = validateDisk(disk, path.Child(dev), remainSize, planSize); err != nil {
173+
return nil, err
174+
}
175+
}
176+
return remainSize, nil
177+
}
178+
179+
func validateDisk(disk *InstanceDisk, path *field.Path, remainSize, planSize *resource.Quantity) (*resource.Quantity, *field.Error) {
180+
if disk == nil {
181+
return remainSize, nil
182+
}
183+
184+
if disk.Size.Sign() < 1 {
185+
return nil, field.Invalid(path, disk.Size.String(), "invalid size")
186+
}
187+
if remainSize.Cmp(disk.Size) == -1 {
188+
return nil, field.Invalid(path, disk.Size.String(), fmt.Sprintf("sum disk sizes exceeds plan storage: %s", planSize.String()))
189+
}
190+
191+
// Decrement the remaining amount of space available
192+
remainSize.Sub(disk.Size)
193+
return remainSize, nil
194+
}

0 commit comments

Comments
 (0)