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

Add Endpoint webhook ensuring unique MAC addresses #187

Merged
merged 3 commits into from
Nov 28, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions PROJECT
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ resources:
kind: Endpoint
path: github.com/ironcore-dev/metal-operator/api/v1alpha1
version: v1alpha1
webhooks:
validation: true
webhookVersion: v1
- api:
crdVersion: v1
controller: true
Expand Down
9 changes: 9 additions & 0 deletions cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"os"
"time"

webhookmetalv1alpha1 "github.com/ironcore-dev/metal-operator/internal/webhook/v1alpha1"

// Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.)
// to ensure that exec-entrypoint and run can make use of them.
_ "k8s.io/client-go/plugin/pkg/client/auth"
Expand Down Expand Up @@ -252,6 +254,13 @@ func main() {
setupLog.Error(err, "unable to create controller", "controller", "ServerClaim")
os.Exit(1)
}
// nolint:goconst
if os.Getenv("ENABLE_WEBHOOKS") != "false" {
if err = webhookmetalv1alpha1.SetupEndpointWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create webhook", "webhook", "Endpoint")
os.Exit(1)
}
}
//+kubebuilder:scaffold:builder

if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil {
Expand Down
6 changes: 6 additions & 0 deletions config/webhook/kustomization.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
resources:
- manifests.yaml
- service.yaml

configurations:
- kustomizeconfig.yaml
22 changes: 22 additions & 0 deletions config/webhook/kustomizeconfig.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# the following config is for teaching kustomize where to look at when substituting nameReference.
# It requires kustomize v2.1.0 or newer to work properly.
nameReference:
- kind: Service
version: v1
fieldSpecs:
- kind: MutatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/name
- kind: ValidatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/name

namespace:
- kind: MutatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/namespace
create: true
- kind: ValidatingWebhookConfiguration
group: admissionregistration.k8s.io
path: webhooks/clientConfig/service/namespace
create: true
26 changes: 26 additions & 0 deletions config/webhook/manifests.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: validating-webhook-configuration
webhooks:
- admissionReviewVersions:
- v1
clientConfig:
service:
name: webhook-service
namespace: system
path: /validate-metal-ironcore-dev-v1alpha1-endpoint
failurePolicy: Fail
name: vendpoint-v1alpha1.kb.io
rules:
- apiGroups:
- metal.ironcore.dev
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- endpoints
sideEffects: None
15 changes: 15 additions & 0 deletions config/webhook/service.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
apiVersion: v1
kind: Service
metadata:
labels:
app.kubernetes.io/name: metal-operator
app.kubernetes.io/managed-by: kustomize
name: webhook-service
namespace: system
spec:
ports:
- port: 443
protocol: TCP
targetPort: 9443
selector:
control-plane: controller-manager
137 changes: 137 additions & 0 deletions internal/webhook/v1alpha1/endpoint_webhook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package v1alpha1

import (
"context"
"fmt"

apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/validation/field"
"sigs.k8s.io/controller-runtime/pkg/client"

"k8s.io/apimachinery/pkg/runtime"
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"

metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
)

// nolint:unused
// log is for logging in this package.
var endpointlog = logf.Log.WithName("endpoint-resource")

// SetupEndpointWebhookWithManager registers the webhook for Endpoint in the manager.
func SetupEndpointWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).For(&metalv1alpha1.Endpoint{}).
WithValidator(&EndpointCustomValidator{Client: mgr.GetClient()}).
Complete()
}

// NOTE: The 'path' attribute must follow a specific pattern and should not be modified directly here.
// Modifying the path for an invalid path can cause API server errors; failing to locate the webhook.
// +kubebuilder:webhook:path=/validate-metal-ironcore-dev-v1alpha1-endpoint,mutating=false,failurePolicy=fail,sideEffects=None,groups=metal.ironcore.dev,resources=endpoints,verbs=create;update,versions=v1alpha1,name=vendpoint-v1alpha1.kb.io,admissionReviewVersions=v1

// EndpointCustomValidator struct is responsible for validating the Endpoint resource
// when it is created, updated, or deleted.
//
// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods,
// as this struct is used only for temporary operations and does not need to be deeply copied.
type EndpointCustomValidator struct {
Client client.Client
}

var _ webhook.CustomValidator = &EndpointCustomValidator{}

// ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type Endpoint.
func (v *EndpointCustomValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
allErrs := field.ErrorList{}

endpoint, ok := obj.(*metalv1alpha1.Endpoint)
if !ok {
return nil, fmt.Errorf("expected a Endpoint object but got %T", obj)
damyan marked this conversation as resolved.
Show resolved Hide resolved
}
endpointlog.Info("Validation for Endpoint upon creation", "name", endpoint.GetName())

allErrs = append(allErrs, ValidateMACAddressCreate(ctx, v.Client, endpoint.Spec, field.NewPath("spec"))...)

if len(allErrs) != 0 {
return nil, apierrors.NewInvalid(
schema.GroupKind{Group: "metal.ironcore.dev", Kind: "Endpoint"},
endpoint.GetName(), allErrs)
}

return nil, nil
}

// ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type Endpoint.
func (v *EndpointCustomValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
allErrs := field.ErrorList{}

endpoint, ok := newObj.(*metalv1alpha1.Endpoint)
if !ok {
return nil, fmt.Errorf("expected a Endpoint object for the newObj but got %T", newObj)
damyan marked this conversation as resolved.
Show resolved Hide resolved
}
endpointlog.Info("Validation for Endpoint upon update", "name", endpoint.GetName())

allErrs = append(allErrs, ValidateMACAddressUpdate(ctx, v.Client, endpoint, field.NewPath("spec"))...)

if len(allErrs) != 0 {
return nil, apierrors.NewInvalid(
schema.GroupKind{Group: "metal.ironcore.dev", Kind: "Endpoint"},
endpoint.GetName(), allErrs)
}

return nil, nil
}

// ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type Endpoint.
func (v *EndpointCustomValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (admission.Warnings, error) {
endpoint, ok := obj.(*metalv1alpha1.Endpoint)
if !ok {
return nil, fmt.Errorf("expected a Endpoint object but got %T", obj)
damyan marked this conversation as resolved.
Show resolved Hide resolved
}
endpointlog.Info("Validation for Endpoint upon deletion", "name", endpoint.GetName())

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

return nil, nil
}

func ValidateMACAddressCreate(ctx context.Context, c client.Client, spec metalv1alpha1.EndpointSpec, path *field.Path) field.ErrorList {
allErrs := field.ErrorList{}

endpoints := &metalv1alpha1.EndpointList{}
if err := c.List(ctx, endpoints); err != nil {
allErrs = append(allErrs, field.InternalError(path, fmt.Errorf("failed to list Endpoints: %w", err)))
}

for _, e := range endpoints.Items {
if e.Spec.MACAddress == spec.MACAddress {
allErrs = append(allErrs, field.Duplicate(field.NewPath("spec").Child("MACAddress"), e.Spec.MACAddress))
}
}

return allErrs
}

func ValidateMACAddressUpdate(ctx context.Context, c client.Client, updatedEndpoint *metalv1alpha1.Endpoint, path *field.Path) field.ErrorList {
allErrs := field.ErrorList{}

endpoints := &metalv1alpha1.EndpointList{}
if err := c.List(ctx, endpoints); err != nil {
allErrs = append(allErrs, field.InternalError(path, fmt.Errorf("failed to list Endpoints: %w", err)))
}

for _, e := range endpoints.Items {
if e.Spec.MACAddress == updatedEndpoint.Spec.MACAddress && e.Name != updatedEndpoint.Name {
allErrs = append(allErrs, field.Duplicate(field.NewPath("spec").Child("MACAddress"), e.Spec.MACAddress))
}
}

return allErrs
}
141 changes: 141 additions & 0 deletions internal/webhook/v1alpha1/endpoint_webhook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and IronCore contributors
// SPDX-License-Identifier: Apache-2.0

package v1alpha1

import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

metalv1alpha1 "github.com/ironcore-dev/metal-operator/api/v1alpha1"
// TODO (user): Add any additional imports if needed
damyan marked this conversation as resolved.
Show resolved Hide resolved
)

var _ = Describe("Endpoint Webhook", func() {
var (
obj *metalv1alpha1.Endpoint
oldObj *metalv1alpha1.Endpoint
validator EndpointCustomValidator
)

BeforeEach(func() {
obj = &metalv1alpha1.Endpoint{}
oldObj = &metalv1alpha1.Endpoint{}
validator = EndpointCustomValidator{
Client: k8sClient,
}
Expect(validator).NotTo(BeNil(), "Expected validator to be initialized")
Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized")
Expect(obj).NotTo(BeNil(), "Expected obj to be initialized")
})

Context("When creating or updating Endpoint under Validating Webhook", func() {
damyan marked this conversation as resolved.
Show resolved Hide resolved
It("Should deny creation if an Endpoint has a duplicate MAC address", func(ctx SpecContext) {
By("creating an Endpoint")
damyan marked this conversation as resolved.
Show resolved Hide resolved
endpoint := &metalv1alpha1.Endpoint{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "test-",
},
Spec: metalv1alpha1.EndpointSpec{
IP: metalv1alpha1.MustParseIP("1.1.1.1"),
MACAddress: "foo",
},
}
Expect(k8sClient.Create(ctx, endpoint)).To(Succeed())
DeferCleanup(k8sClient.Delete, endpoint)

By("creating an Endpoint with existing MAC address")
existingEndpoint := &metalv1alpha1.Endpoint{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "test-",
},
Spec: metalv1alpha1.EndpointSpec{
IP: metalv1alpha1.MustParseIP("2.2.2.2"),
MACAddress: "foo",
},
}
Expect(validator.ValidateCreate(ctx, existingEndpoint)).Error().To(HaveOccurred())
})

It("Should allow creation if an Endpoint has an unique MAC address", func(ctx SpecContext) {
damyan marked this conversation as resolved.
Show resolved Hide resolved
By("creating an Endpoint")
endpoint := &metalv1alpha1.Endpoint{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "test-",
},
Spec: metalv1alpha1.EndpointSpec{
IP: metalv1alpha1.MustParseIP("1.1.1.1"),
MACAddress: "foo",
},
}
Expect(k8sClient.Create(ctx, endpoint)).ToNot(HaveOccurred())
DeferCleanup(k8sClient.Delete, endpoint)

By("creating an Endpoint with non-existing MAC address")
existingEndpoint := &metalv1alpha1.Endpoint{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "test-",
},
Spec: metalv1alpha1.EndpointSpec{
IP: metalv1alpha1.MustParseIP("2.2.2.2"),
MACAddress: "bar",
},
}
Expect(validator.ValidateCreate(ctx, existingEndpoint)).Error().ToNot(HaveOccurred())
})

It("Should deny update of an Endpoint with existing MAC address", func() {
By("creating an Endpoint")
endpoint := &metalv1alpha1.Endpoint{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "test-",
},
Spec: metalv1alpha1.EndpointSpec{
IP: metalv1alpha1.MustParseIP("1.1.1.1"),
MACAddress: "foo",
},
}
Expect(k8sClient.Create(ctx, endpoint)).To(Succeed())
DeferCleanup(k8sClient.Delete, endpoint)

By("creating an Endpoint with different MAC address")
existingEndpoint := &metalv1alpha1.Endpoint{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "test-",
},
Spec: metalv1alpha1.EndpointSpec{
IP: metalv1alpha1.MustParseIP("2.2.2.2"),
MACAddress: "bar",
},
}
Expect(k8sClient.Create(ctx, existingEndpoint)).To(Succeed())
DeferCleanup(k8sClient.Delete, existingEndpoint)

By("Updating an Endpoint to conflicting MAC address")
updatedEndpoint := endpoint.DeepCopy()
updatedEndpoint.Spec.MACAddress = "bar"
Expect(validator.ValidateUpdate(ctx, endpoint, updatedEndpoint)).Error().To(HaveOccurred())
})

It("Should allow update an IP address of the same Endpoint", func() {
By("creating an Endpoint")
existingEndpoint := &metalv1alpha1.Endpoint{
ObjectMeta: metav1.ObjectMeta{
GenerateName: "test-",
},
Spec: metalv1alpha1.EndpointSpec{
IP: metalv1alpha1.MustParseIP("1.1.1.1"),
MACAddress: "foo",
},
}
Expect(k8sClient.Create(ctx, existingEndpoint)).To(Succeed())
DeferCleanup(k8sClient.Delete, existingEndpoint)

By("Updating an Endpoint IP address")
updatedEndpoint := existingEndpoint.DeepCopy()
updatedEndpoint.Spec.IP = metalv1alpha1.MustParseIP("2.2.2.2")
Expect(validator.ValidateUpdate(ctx, existingEndpoint, updatedEndpoint)).Error().ToNot(HaveOccurred())
})
})
})
Loading
Loading