Repository: metallb/metallb
Pull request #1291 (2022-03-28 17:12:05)

Add validation webhooks for metallb crds
We add validating webhooks for the existing crds, we setup dev-env to deploy cert-manager and we validate the webhooks are working properly.

Based on https://github.com/metallb/metallb/pull/1273 started by @sabinaaledort 

oribon: LGTM

Patch:
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index 2f115b07..61b09154 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -121,7 +121,7 @@ jobs:
           inv build
           HELM_FLAGS=""
           if [ ${{ matrix.deployment }} = "helm" ]; then HELM_FLAGS="--helm-install"; fi
-          inv dev-env -i ${{ matrix.ip-family }} -b ${{ matrix.bgp-type }} -l all $HELM_FLAGS
+          inv dev-env -i ${{ matrix.ip-family }} -b ${{ matrix.bgp-type }} -l all $HELM_FLAGS --enable-webhooks
 
       - name: E2E
         run: |
diff --git a/api/v1beta1/addresspool_webhook.go b/api/v1beta1/addresspool_webhook.go
new file mode 100644
index 00000000..870ff6c6
--- /dev/null
+++ b/api/v1beta1/addresspool_webhook.go
@@ -0,0 +1,108 @@
+/*
+
+
+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 v1beta1
+
+import (
+	"context"
+
+	"github.com/go-kit/kit/log/level"
+	"github.com/pkg/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook"
+)
+
+func (addressPool *AddressPool) SetupWebhookWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewWebhookManagedBy(mgr).
+		For(addressPool).
+		Complete()
+}
+
+//+kubebuilder:webhook:verbs=create;update,path=/validate-metallb-io-v1beta1-addresspool,mutating=false,failurePolicy=fail,groups=metallb.io,resources=addresspools,versions=v1beta1,name=addresspoolvalidationwebhook.metallb.io,sideEffects=None,admissionReviewVersions=v1
+
+var _ webhook.Validator = &AddressPool{}
+
+// ValidateCreate implements webhook.Validator so a webhook will be registered for AddressPool.
+func (addressPool *AddressPool) ValidateCreate() error {
+	level.Debug(Logger).Log("webhook", "addressPool", "action", "create", "name", addressPool.Name, "namespace", addressPool.Namespace)
+
+	existingAddressPoolList, err := getExistingAddressPools()
+	if err != nil {
+		return err
+	}
+
+	existingIPPoolList, err := getExistingIPPools()
+	if err != nil {
+		return err
+	}
+	addressPoolList := listWithUpdate(existingAddressPoolList, addressPool)
+	err = Validator.Validate(addressPoolList, existingIPPoolList)
+	if err != nil {
+		level.Error(Logger).Log("webhook", "addressPool", "action", "create", "name", addressPool.Name, "namespace", addressPool.Namespace, "error", err)
+		return err
+	}
+	return nil
+}
+
+// ValidateUpdate implements webhook.Validator so a webhook will be registered for AddressPool.
+func (addressPool *AddressPool) ValidateUpdate(old runtime.Object) error {
+	level.Debug(Logger).Log("webhook", "addressPool", "action", "update", "name", addressPool.Name, "namespace", addressPool.Namespace)
+
+	existingAddressPoolList, err := getExistingAddressPools()
+	if err != nil {
+		return err
+	}
+
+	existingIPPoolList, err := getExistingIPPools()
+	if err != nil {
+		return err
+	}
+	addressPoolList := listWithUpdate(existingAddressPoolList, addressPool)
+	err = Validator.Validate(addressPoolList, existingIPPoolList)
+	if err != nil {
+		level.Error(Logger).Log("webhook", "addressPool", "action", "update", "name", addressPool.Name, "namespace", addressPool.Namespace, "error", err)
+		return err
+	}
+	return nil
+}
+
+// ValidateDelete implements webhook.Validator so a webhook will be registered for AddressPool.
+func (addressPool *AddressPool) ValidateDelete() error {
+	return nil
+}
+
+var getExistingAddressPools = func() (*AddressPoolList, error) {
+	existingAddressPoolList := &AddressPoolList{}
+	err := WebhookClient.List(context.Background(), existingAddressPoolList, &client.ListOptions{Namespace: MetalLBNamespace})
+	if err != nil {
+		return nil, errors.Wrapf(err, "Failed to get existing AddressPool objects")
+	}
+	return existingAddressPoolList, nil
+}
+
+func listWithUpdate(existing *AddressPoolList, toAdd *AddressPool) *AddressPoolList {
+	res := existing.DeepCopy()
+	for i, item := range res.Items { // We override the element with the fresh copy
+		if item.Name == toAdd.Name {
+			res.Items[i] = *toAdd.DeepCopy()
+			return res
+		}
+	}
+	res.Items = append(res.Items, *toAdd.DeepCopy())
+	return res
+}
diff --git a/api/v1beta1/bgpadvertisement_webhook.go b/api/v1beta1/bgpadvertisement_webhook.go
new file mode 100644
index 00000000..0fbaad06
--- /dev/null
+++ b/api/v1beta1/bgpadvertisement_webhook.go
@@ -0,0 +1,120 @@
+/*
+
+
+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 v1beta1
+
+import (
+	"context"
+
+	"github.com/go-kit/kit/log/level"
+	"github.com/pkg/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook"
+)
+
+func (bgpAdv *BGPAdvertisement) SetupWebhookWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewWebhookManagedBy(mgr).
+		For(bgpAdv).
+		Complete()
+}
+
+//+kubebuilder:webhook:verbs=create;update,path=/validate-metallb-io-v1beta1-bgpadvertisement,mutating=false,failurePolicy=fail,groups=metallb.io,resources=bgpadvertisements,versions=v1beta1,name=bgpadvertisementvalidationwebhook.metallb.io,sideEffects=None,admissionReviewVersions=v1
+
+var _ webhook.Validator = &BGPAdvertisement{}
+
+// ValidateCreate implements webhook.Validator so a webhook will be registered for BGPAdvertisement.
+func (bgpAdv *BGPAdvertisement) ValidateCreate() error {
+	level.Debug(Logger).Log("webhook", "bgpadvertisement", "action", "create", "name", bgpAdv.Name, "namespace", bgpAdv.Namespace)
+
+	existingBGPAdvList, err := getExistingBGPAdvs()
+	if err != nil {
+		return err
+	}
+
+	addressPools, err := getExistingAddressPools()
+	if err != nil {
+		return err
+	}
+
+	ipPools, err := getExistingIPPools()
+	if err != nil {
+		return err
+	}
+
+	toValidate := bgpAdvListWithUpdate(existingBGPAdvList, bgpAdv)
+	err = Validator.Validate(toValidate, addressPools, ipPools)
+	if err != nil {
+		level.Error(Logger).Log("webhook", "bgpadvertisement", "action", "create", "name", bgpAdv.Name, "namespace", bgpAdv.Namespace, "error", err)
+		return err
+	}
+	return nil
+}
+
+// ValidateUpdate implements webhook.Validator so a webhook will be registered for BGPAdvertisement.
+func (bgpAdv *BGPAdvertisement) ValidateUpdate(old runtime.Object) error {
+	level.Debug(Logger).Log("webhook", "bgpadvertisement", "action", "update", "name", bgpAdv.Name, "namespace", bgpAdv.Namespace)
+
+	bgpAdvs, err := getExistingBGPAdvs()
+	if err != nil {
+		return err
+	}
+
+	addressPools, err := getExistingAddressPools()
+	if err != nil {
+		return err
+	}
+
+	ipPools, err := getExistingIPPools()
+	if err != nil {
+		return err
+	}
+
+	toValidate := bgpAdvListWithUpdate(bgpAdvs, bgpAdv)
+	err = Validator.Validate(toValidate, addressPools, ipPools)
+	if err != nil {
+		level.Error(Logger).Log("webhook", "bgpadvertisement", "action", "create", "name", bgpAdv.Name, "namespace", bgpAdv.Namespace, "error", err)
+		return err
+	}
+	return nil
+}
+
+// ValidateDelete implements webhook.Validator so a webhook will be registered for BGPAdvertisement.
+func (bgpAdv *BGPAdvertisement) ValidateDelete() error {
+	return nil
+}
+
+var getExistingBGPAdvs = func() (*BGPAdvertisementList, error) {
+	existingBGPAdvList := &BGPAdvertisementList{}
+	err := WebhookClient.List(context.Background(), existingBGPAdvList, &client.ListOptions{Namespace: MetalLBNamespace})
+	if err != nil {
+		return nil, errors.Wrapf(err, "Failed to get existing BGPAdvertisement objects")
+	}
+	return existingBGPAdvList, nil
+}
+
+func bgpAdvListWithUpdate(existing *BGPAdvertisementList, toAdd *BGPAdvertisement) *BGPAdvertisementList {
+	res := existing.DeepCopy()
+	for i, item := range res.Items { // We override the element with the fresh copy
+		if item.Name == toAdd.Name {
+			res.Items[i] = *toAdd.DeepCopy()
+			return res
+		}
+	}
+	res.Items = append(res.Items, *toAdd.DeepCopy())
+	return res
+}
diff --git a/api/v1beta1/common.go b/api/v1beta1/common.go
new file mode 100644
index 00000000..665727f3
--- /dev/null
+++ b/api/v1beta1/common.go
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package v1beta1
+
+import (
+	"github.com/go-kit/kit/log"
+
+	"go.universe.tf/metallb/api/validate"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// log is for logging addresspool-webhook.
+var (
+	Logger           log.Logger
+	WebhookClient    client.Reader
+	Validator        validate.ClusterObjects
+	MetalLBNamespace string
+)
diff --git a/api/v1beta1/ippool_webhook.go b/api/v1beta1/ippool_webhook.go
new file mode 100644
index 00000000..4e2818aa
--- /dev/null
+++ b/api/v1beta1/ippool_webhook.go
@@ -0,0 +1,108 @@
+/*
+
+
+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 v1beta1
+
+import (
+	"context"
+
+	"github.com/go-kit/kit/log/level"
+	"github.com/pkg/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook"
+)
+
+func (ipPool *IPPool) SetupWebhookWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewWebhookManagedBy(mgr).
+		For(ipPool).
+		Complete()
+}
+
+//+kubebuilder:webhook:verbs=create;update,path=/validate-metallb-io-v1beta1-ippool,mutating=false,failurePolicy=fail,groups=metallb.io,resources=ippools,versions=v1beta1,name=ippoolvalidationwebhook.metallb.io,sideEffects=None,admissionReviewVersions=v1
+
+var _ webhook.Validator = &IPPool{}
+
+// ValidateCreate implements webhook.Validator so a webhook will be registered for IPPool.
+func (ipPool *IPPool) ValidateCreate() error {
+	level.Debug(Logger).Log("webhook", "ippool", "action", "create", "name", ipPool.Name, "namespace", ipPool.Namespace)
+
+	existingIPPoolList, err := getExistingIPPools()
+	if err != nil {
+		return err
+	}
+
+	existingAddressPoolList, err := getExistingAddressPools()
+	if err != nil {
+		return err
+	}
+	toValidate := ipPoolListWithUpdate(existingIPPoolList, ipPool)
+	err = Validator.Validate(existingAddressPoolList, toValidate)
+	if err != nil {
+		level.Error(Logger).Log("webhook", "ipPool", "action", "create", "name", ipPool.Name, "namespace", ipPool.Namespace, "error", err)
+		return err
+	}
+	return nil
+}
+
+// ValidateUpdate implements webhook.Validator so a webhook will be registered for IPPool.
+func (ipPool *IPPool) ValidateUpdate(old runtime.Object) error {
+	level.Debug(Logger).Log("webhook", "ippool", "action", "update", "name", ipPool.Name, "namespace", ipPool.Namespace)
+	existingAddressPoolList, err := getExistingAddressPools()
+	if err != nil {
+		return err
+	}
+
+	existingIPPoolList, err := getExistingIPPools()
+	if err != nil {
+		return err
+	}
+
+	toValidate := ipPoolListWithUpdate(existingIPPoolList, ipPool)
+	err = Validator.Validate(existingAddressPoolList, toValidate)
+	if err != nil {
+		level.Error(Logger).Log("webhook", "ipPool", "action", "update", "name", ipPool.Name, "namespace", ipPool.Namespace, "error", err)
+		return err
+	}
+	return nil
+}
+
+// ValidateDelete implements webhook.Validator so a webhook will be registered for IPPool.
+func (ipPool *IPPool) ValidateDelete() error {
+	return nil
+}
+
+var getExistingIPPools = func() (*IPPoolList, error) {
+	existingIPPoolList := &IPPoolList{}
+	err := WebhookClient.List(context.Background(), existingIPPoolList, &client.ListOptions{Namespace: MetalLBNamespace})
+	if err != nil {
+		return nil, errors.Wrapf(err, "Failed to get existing IPPool objects")
+	}
+	return existingIPPoolList, nil
+}
+
+func ipPoolListWithUpdate(existing *IPPoolList, toAdd *IPPool) *IPPoolList {
+	res := existing.DeepCopy()
+	for i, item := range res.Items { // We override the element with the fresh copy
+		if item.Name == toAdd.Name {
+			res.Items[i] = *toAdd.DeepCopy()
+			return res
+		}
+	}
+	res.Items = append(res.Items, *toAdd.DeepCopy())
+	return res
+}
diff --git a/api/v1beta2/bgppeer_webhook.go b/api/v1beta2/bgppeer_webhook.go
new file mode 100644
index 00000000..cd8dbb75
--- /dev/null
+++ b/api/v1beta2/bgppeer_webhook.go
@@ -0,0 +1,100 @@
+/*
+
+
+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 v1beta2
+
+import (
+	"context"
+
+	"github.com/go-kit/kit/log/level"
+	"github.com/pkg/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook"
+)
+
+func (bgpPeer *BGPPeer) SetupWebhookWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewWebhookManagedBy(mgr).
+		For(bgpPeer).
+		Complete()
+}
+
+//+kubebuilder:webhook:verbs=create;update,path=/validate-metallb-io-v1beta2-bgppeer,mutating=false,failurePolicy=fail,groups=metallb.io,resources=bgppeers,versions=v1beta2,name=bgppeersvalidationwebhook.metallb.io,sideEffects=None,admissionReviewVersions=v1
+
+var _ webhook.Validator = &BGPPeer{}
+
+// ValidateCreate implements webhook.Validator so a webhook will be registered for BGPPeer.
+func (bgpPeer *BGPPeer) ValidateCreate() error {
+	level.Debug(Logger).Log("webhook", "bgppeer", "action", "create", "name", bgpPeer.Name, "namespace", bgpPeer.Namespace)
+
+	existingBGPPeers, err := getExistingBGPPeers()
+	if err != nil {
+		return err
+	}
+
+	toValidate := bgpPeerListWithUpdate(existingBGPPeers, bgpPeer)
+	err = Validator.Validate(toValidate)
+	if err != nil {
+		level.Error(Logger).Log("webhook", "bgppeer", "action", "create", "name", bgpPeer.Name, "namespace", bgpPeer.Namespace, "error", err)
+		return err
+	}
+	return nil
+}
+
+// ValidateUpdate implements webhook.Validator so a webhook will be registered for AddressPool.
+func (bgpPeer *BGPPeer) ValidateUpdate(old runtime.Object) error {
+	level.Debug(Logger).Log("webhook", "bgppeer", "action", "update", "name", bgpPeer.Name, "namespace", bgpPeer.Namespace)
+
+	existingBGPPeers, err := getExistingBGPPeers()
+	if err != nil {
+		return err
+	}
+
+	toValidate := bgpPeerListWithUpdate(existingBGPPeers, bgpPeer)
+	err = Validator.Validate(toValidate)
+	if err != nil {
+		level.Error(Logger).Log("webhook", "bgppeer", "action", "update", "name", bgpPeer.Name, "namespace", bgpPeer.Namespace, "error", err)
+		return err
+	}
+	return nil
+}
+
+// ValidateDelete implements webhook.Validator so a webhook will be registered for AddressPool.
+func (bgpPeer *BGPPeer) ValidateDelete() error {
+	return nil
+}
+
+var getExistingBGPPeers = func() (*BGPPeerList, error) {
+	existingBGPPeerslList := &BGPPeerList{}
+	err := WebhookClient.List(context.Background(), existingBGPPeerslList, &client.ListOptions{Namespace: MetalLBNamespace})
+	if err != nil {
+		return nil, errors.Wrapf(err, "Failed to get existing BGPPeer objects")
+	}
+	return existingBGPPeerslList, nil
+}
+
+func bgpPeerListWithUpdate(existing *BGPPeerList, toAdd *BGPPeer) *BGPPeerList {
+	res := existing.DeepCopy()
+	for i, item := range res.Items { // We override the element with the fresh copy
+		if item.Name == toAdd.Name {
+			res.Items[i] = *toAdd.DeepCopy()
+			return res
+		}
+	}
+	res.Items = append(res.Items, *toAdd.DeepCopy())
+	return res
+}
diff --git a/api/v1beta2/common.go b/api/v1beta2/common.go
new file mode 100644
index 00000000..ad5dc216
--- /dev/null
+++ b/api/v1beta2/common.go
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package v1beta2
+
+import (
+	"github.com/go-kit/kit/log"
+
+	"go.universe.tf/metallb/api/validate"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// log is for logging addresspool-webhook.
+var (
+	Logger           log.Logger
+	WebhookClient    client.Reader
+	Validator        validate.ClusterObjects
+	MetalLBNamespace string
+)
diff --git a/api/validate/cluster_resources.go b/api/validate/cluster_resources.go
new file mode 100644
index 00000000..85b093a1
--- /dev/null
+++ b/api/validate/cluster_resources.go
@@ -0,0 +1,9 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package validate
+
+import "sigs.k8s.io/controller-runtime/pkg/client"
+
+type ClusterObjects interface {
+	Validate(lists ...client.ObjectList) error
+}
diff --git a/charts/metallb/README.md b/charts/metallb/README.md
index 348094fd..ed0cf592 100644
--- a/charts/metallb/README.md
+++ b/charts/metallb/README.md
@@ -42,6 +42,7 @@ A network load-balancer implementation for Kubernetes using standard routing pro
 | controller.serviceAccount.create | bool | `true` |  |
 | controller.serviceAccount.name | string | `""` |  |
 | controller.tolerations | list | `[]` |  |
+| webhooks.enable | bool | `false` |  |
 | existingConfigMap | string | `""` |  |
 | fullnameOverride | string | `""` |  |
 | imagePullSecrets | list | `[]` |  |
diff --git a/charts/metallb/templates/controller.yaml b/charts/metallb/templates/controller.yaml
index 01a11447..618eed02 100644
--- a/charts/metallb/templates/controller.yaml
+++ b/charts/metallb/templates/controller.yaml
@@ -48,6 +48,9 @@ spec:
         {{- with .Values.controller.logLevel }}
         - --log-level={{ . }}
         {{- end }}
+        {{- if .Values.webhooks.enable }}
+        - --enable-webhook
+        {{- end }}
         env:
         {{- if and .Values.speaker.enabled .Values.speaker.memberlist.enabled }}
         - name: METALLB_ML_SECRET_NAME
@@ -55,9 +58,22 @@ spec:
         - name: METALLB_DEPLOYMENT
           value: {{ template "metallb.fullname" . }}-controller
         {{- end }}
+        {{- if .Values.speaker.frr.enabled }}
+        - name: METALLB_BGP_TYPE
+          value: frr
+        {{- end }}
         ports:
         - name: monitoring
           containerPort: {{ .Values.prometheus.metricsPort }}
+        {{- if .Values.webhooks.enable }}
+        - containerPort: 9443
+          name: webhook-server
+          protocol: TCP
+        volumeMounts:
+        - mountPath: /tmp/k8s-webhook-server/serving-certs
+          name: cert
+          readOnly: true
+        {{- end }}
         {{- if .Values.controller.livenessProbe.enabled }}
         livenessProbe:
           httpGet:
@@ -106,4 +122,11 @@ spec:
       {{- with .Values.controller.priorityClassName }}
       priorityClassName: {{ . | quote }}
       {{- end }}
+      {{- if .Values.webhooks.enable }}
+      volumes:
+      - name: cert
+        secret:
+          defaultMode: 420
+          secretName: webhook-server-cert
+      {{- end }}
 {{- end }}
diff --git a/charts/metallb/templates/webhooks.yaml b/charts/metallb/templates/webhooks.yaml
new file mode 100644
index 00000000..9f96836b
--- /dev/null
+++ b/charts/metallb/templates/webhooks.yaml
@@ -0,0 +1,128 @@
+{{- if .Values.webhooks.enable -}}
+apiVersion: admissionregistration.k8s.io/v1
+kind: ValidatingWebhookConfiguration
+metadata:
+  name: {{ template "metallb.fullname" . }}-validating-webhook-configuration
+  labels:
+    {{- include "metallb.labels" . | nindent 4 }}
+  annotations:
+    cert-manager.io/inject-ca-from: {{ .Release.Namespace }}/{{ template "metallb.fullname" . }}-serving-cert
+webhooks:
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: {{ template "metallb.fullname" . }}-webhook-service
+      namespace: {{ .Release.Namespace }}
+      path: /validate-metallb-io-v1beta1-addresspool
+  failurePolicy: Fail
+  name: addresspoolvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - addresspools
+  sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: {{ template "metallb.fullname" . }}-webhook-service
+      namespace: {{ .Release.Namespace }}
+      path: /validate-metallb-io-v1beta2-bgppeer
+  failurePolicy: Fail
+  name: bgppeervalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta2
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - bgppeers
+  sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: {{ template "metallb.fullname" . }}-webhook-service
+      namespace: {{ .Release.Namespace }}
+      path: /validate-metallb-io-v1beta1-ippool
+  failurePolicy: Fail
+  name: ippoolvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - ippools
+  sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: {{ template "metallb.fullname" . }}-webhook-service
+      namespace: {{ .Release.Namespace }}
+      path: /validate-metallb-io-v1beta1-bgpadvertisement
+  failurePolicy: Fail
+  name: bgpadvertisementvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - bgpadvertisements
+  sideEffects: None
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: {{ template "metallb.fullname" . }}-webhook-service
+  labels:
+    {{- include "metallb.labels" . | nindent 4 }}
+spec:
+  ports:
+  - port: 443
+    targetPort: 9443
+  selector:
+    app.kubernetes.io/component: controller
+---
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+  name: {{ template "metallb.fullname" . }}-serving-cert
+  labels:
+    {{- include "metallb.labels" . | nindent 4 }}
+spec:
+  dnsNames:
+  - {{ template "metallb.fullname" . }}-webhook-service.{{ .Release.Namespace }}.svc
+  - {{ template "metallb.fullname" . }}-webhook-service.{{ .Release.Namespace }}.svc.cluster.local
+  issuerRef:
+    kind: Issuer
+    name: {{ template "metallb.fullname" . }}-selfsigned-issuer
+  secretName: webhook-server-cert
+---
+apiVersion: cert-manager.io/v1
+kind: Issuer
+metadata:
+  name: {{ template "metallb.fullname" . }}-selfsigned-issuer
+  labels:
+    {{- include "metallb.labels" . | nindent 4 }}
+spec:
+  selfSigned: {}
+{{- end -}}
diff --git a/charts/metallb/values.schema.json b/charts/metallb/values.schema.json
index 1ee48e45..92ff12f9 100644
--- a/charts/metallb/values.schema.json
+++ b/charts/metallb/values.schema.json
@@ -166,6 +166,16 @@
         }
       }
     },
+    "webhooks": {
+      "description": "webhooks configuration",
+      "type": "object",
+      "properties": {
+        "enable": {
+          "description": "Enables webhooks",
+          "type": "boolean"
+        }
+      }
+    },
     "psp": {
       "description": "PSP configuration",
       "type": "object",
@@ -267,12 +277,6 @@
       },
       "required": [ "podMonitor", "prometheusRule" ]
     },
-    "controller": { 
-      "allOf": [
-        { "$ref": "#/definitions/component" },
-        { "description": "MetalLB Controller" }
-      ]
-    },
     "speaker": { 
       "allOf": [
         { "$ref": "#/definitions/component" },
diff --git a/charts/metallb/values.yaml b/charts/metallb/values.yaml
index 73df435a..4f446264 100644
--- a/charts/metallb/values.yaml
+++ b/charts/metallb/values.yaml
@@ -17,6 +17,9 @@ psp:
   # create specifies whether to install and use Pod Security Policies.
   create: true
 
+webhooks:
+  enable: false
+
 prometheus:
   # scrape annotations specifies whether to add Prometheus metric
   # auto-collection annotations to pods. See
diff --git a/config/certmanager/certificate.yaml b/config/certmanager/certificate.yaml
new file mode 100644
index 00000000..52d86618
--- /dev/null
+++ b/config/certmanager/certificate.yaml
@@ -0,0 +1,25 @@
+# The following manifests contain a self-signed issuer CR and a certificate CR.
+# More document can be found at https://docs.cert-manager.io
+# WARNING: Targets CertManager v1.0. Check https://cert-manager.io/docs/installation/upgrading/ for breaking changes.
+apiVersion: cert-manager.io/v1
+kind: Issuer
+metadata:
+  name: selfsigned-issuer
+  namespace: system
+spec:
+  selfSigned: {}
+---
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+  name: serving-cert  # this name should match the one appeared in kustomizeconfig.yaml
+  namespace: system
+spec:
+  # $(SERVICE_NAME) and $(SERVICE_NAMESPACE) will be substituted by kustomize
+  dnsNames:
+  - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc
+  - $(SERVICE_NAME).$(SERVICE_NAMESPACE).svc.cluster.local
+  issuerRef:
+    kind: Issuer
+    name: selfsigned-issuer
+  secretName: webhook-server-cert # this secret will not be prefixed, since it's not managed by kustomize
diff --git a/config/certmanager/kustomization.yaml b/config/certmanager/kustomization.yaml
new file mode 100644
index 00000000..bebea5a5
--- /dev/null
+++ b/config/certmanager/kustomization.yaml
@@ -0,0 +1,5 @@
+resources:
+- certificate.yaml
+
+configurations:
+- kustomizeconfig.yaml
diff --git a/config/certmanager/kustomizeconfig.yaml b/config/certmanager/kustomizeconfig.yaml
new file mode 100644
index 00000000..90d7c313
--- /dev/null
+++ b/config/certmanager/kustomizeconfig.yaml
@@ -0,0 +1,16 @@
+# This configuration is for teaching kustomize how to update name ref and var substitution 
+nameReference:
+- kind: Issuer
+  group: cert-manager.io
+  fieldSpecs:
+  - kind: Certificate
+    group: cert-manager.io
+    path: spec/issuerRef/name
+
+varReference:
+- kind: Certificate
+  group: cert-manager.io
+  path: spec/commonName
+- kind: Certificate
+  group: cert-manager.io
+  path: spec/dnsNames
diff --git a/config/certmanager/self-signed-cert.yaml b/config/certmanager/self-signed-cert.yaml
new file mode 100644
index 00000000..3dd55224
--- /dev/null
+++ b/config/certmanager/self-signed-cert.yaml
@@ -0,0 +1,7 @@
+# The following Issuer can be used to verify cert-manager is deployed and ready.
+apiVersion: cert-manager.io/v1
+kind: Issuer
+metadata:
+  name: selfsigned-issuer-test
+spec:
+  selfSigned: {}
diff --git a/config/frr-with-webhook/controller-webhook-patch.yaml b/config/frr-with-webhook/controller-webhook-patch.yaml
new file mode 100644
index 00000000..4104475c
--- /dev/null
+++ b/config/frr-with-webhook/controller-webhook-patch.yaml
@@ -0,0 +1,27 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: controller
+  namespace: metallb-system
+spec:
+  template:
+    spec:
+      containers:
+      - name: controller
+        ports:
+        - containerPort: 9443
+          name: webhook-server
+          protocol: TCP
+        volumeMounts:
+        - mountPath: /tmp/k8s-webhook-server/serving-certs
+          name: cert
+          readOnly: true
+        args:
+        - --enable-webhook
+        - --port=7472
+        - --log-level=info
+      volumes:
+      - name: cert
+        secret:
+          defaultMode: 420
+          secretName: webhook-server-cert
diff --git a/config/frr-with-webhook/kustomization.yaml b/config/frr-with-webhook/kustomization.yaml
new file mode 100644
index 00000000..8ba1d96b
--- /dev/null
+++ b/config/frr-with-webhook/kustomization.yaml
@@ -0,0 +1,43 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- ../frr
+- ../webhook
+- ../certmanager
+
+patchesStrategicMerge:
+- controller-webhook-patch.yaml
+- webhookcainjection-patch.yaml
+
+# the following config is for teaching kustomize how to do var substitution
+# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
+vars:
+- fieldref:
+    fieldPath: metadata.namespace
+  name: CERTIFICATE_NAMESPACE
+  objref:
+    group: cert-manager.io
+    kind: Certificate
+    name: serving-cert
+    version: v1
+- fieldref: {}
+  name: CERTIFICATE_NAME
+  objref:
+    group: cert-manager.io
+    kind: Certificate
+    name: serving-cert
+    version: v1
+- fieldref:
+    fieldPath: metadata.namespace
+  name: SERVICE_NAMESPACE
+  objref:
+    kind: Service
+    name: webhook-service
+    version: v1
+- fieldref: {}
+  name: SERVICE_NAME
+  objref:
+    kind: Service
+    name: webhook-service
+    version: v1
+namespace: metallb-system
diff --git a/config/frr-with-webhook/webhookcainjection-patch.yaml b/config/frr-with-webhook/webhookcainjection-patch.yaml
new file mode 100644
index 00000000..47ef1d13
--- /dev/null
+++ b/config/frr-with-webhook/webhookcainjection-patch.yaml
@@ -0,0 +1,8 @@
+# This patch add annotation to admission webhook config and
+# the variables $(CERTIFICATE_NAMESPACE) and $(CERTIFICATE_NAME) will be substituted by kustomize.
+apiVersion: admissionregistration.k8s.io/v1
+kind: ValidatingWebhookConfiguration
+metadata:
+  name: validating-webhook-configuration
+  annotations:
+    cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME)
diff --git a/config/frr/controller-patch.yaml b/config/frr/controller-patch.yaml
new file mode 100644
index 00000000..50f8875d
--- /dev/null
+++ b/config/frr/controller-patch.yaml
@@ -0,0 +1,13 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  namespace: metallb-system
+  name: controller
+spec:
+  template:
+    spec:
+      containers:
+      - name: controller
+        env:
+        - name: METALLB_BGP_TYPE
+          value: frr
diff --git a/config/frr/kustomization.yaml b/config/frr/kustomization.yaml
index de0d01f7..d299045f 100644
--- a/config/frr/kustomization.yaml
+++ b/config/frr/kustomization.yaml
@@ -5,4 +5,5 @@ resources:
 - frr-cm.yaml
 patchesStrategicMerge:
 - speaker-patch.yaml
+- controller-patch.yaml
 namespace: metallb-system
diff --git a/config/manifests/metallb-frr-with-webhooks.yaml b/config/manifests/metallb-frr-with-webhooks.yaml
new file mode 100644
index 00000000..b36bc8dc
--- /dev/null
+++ b/config/manifests/metallb-frr-with-webhooks.yaml
@@ -0,0 +1,1662 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: metallb-system
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.7.0
+  creationTimestamp: null
+  name: addresspools.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: AddressPool
+    listKind: AddressPoolList
+    plural: addresspools
+    singular: addresspool
+  scope: Namespaced
+  versions:
+  - deprecated: true
+    deprecationWarning: metallbio.io v1alpha1 AddressPool is deprecated
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: AddressPool is the Schema for the addresspools API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: AddressPoolSpec defines the desired state of AddressPool.
+            properties:
+              addresses:
+                description: A list of IP address ranges over which MetalLB has authority.
+                  You can list multiple ranges in a single pool, they will all share
+                  the same settings. Each range can be either a CIDR prefix, or an
+                  explicit start-end range of IPs.
+                items:
+                  type: string
+                type: array
+              autoAssign:
+                default: true
+                description: AutoAssign flag used to prevent MetallB from automatic
+                  allocation for a pool.
+                type: boolean
+              bgpAdvertisements:
+                description: When an IP is allocated from this pool, how should it
+                  be translated into BGP announcements?
+                items:
+                  properties:
+                    aggregationLength:
+                      default: 32
+                      description: The aggregation-length advertisement option lets
+                        you “roll up” the /32s into a larger prefix.
+                      format: int32
+                      minimum: 1
+                      type: integer
+                    aggregationLengthV6:
+                      default: 128
+                      description: Optional, defaults to 128 (i.e. no aggregation)
+                        if not specified.
+                      format: int32
+                      type: integer
+                    communities:
+                      description: BGP communities
+                      items:
+                        type: string
+                      type: array
+                    localPref:
+                      description: BGP LOCAL_PREF attribute which is used by BGP best
+                        path algorithm, Path with higher localpref is preferred over
+                        one with lower localpref.
+                      format: int32
+                      type: integer
+                  type: object
+                type: array
+              protocol:
+                description: Protocol can be used to select how the announcement is
+                  done.
+                enum:
+                - layer2
+                - bgp
+                type: string
+            required:
+            - addresses
+            - protocol
+            type: object
+          status:
+            description: AddressPoolStatus defines the observed state of AddressPool.
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
+  - name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: AddressPool is the Schema for the addresspools API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: AddressPoolSpec defines the desired state of AddressPool.
+            properties:
+              addresses:
+                description: A list of IP address ranges over which MetalLB has authority.
+                  You can list multiple ranges in a single pool, they will all share
+                  the same settings. Each range can be either a CIDR prefix, or an
+                  explicit start-end range of IPs.
+                items:
+                  type: string
+                type: array
+              autoAssign:
+                default: true
+                description: AutoAssign flag used to prevent MetallB from automatic
+                  allocation for a pool.
+                type: boolean
+              avoidBuggyIPs:
+                default: false
+                description: AvoidBuggyIPs prevents addresses ending with .0 and .255
+                  to be used by a pool.
+                type: boolean
+              bgpAdvertisements:
+                description: When an IP is allocated from this pool, how should it
+                  be translated into BGP announcements?
+                items:
+                  properties:
+                    aggregationLength:
+                      default: 32
+                      description: The aggregation-length advertisement option lets
+                        you “roll up” the /32s into a larger prefix.
+                      format: int32
+                      minimum: 1
+                      type: integer
+                    aggregationLengthV6:
+                      default: 128
+                      description: Optional, defaults to 128 (i.e. no aggregation)
+                        if not specified.
+                      format: int32
+                      type: integer
+                    communities:
+                      description: BGP communities
+                      items:
+                        type: string
+                      type: array
+                    localPref:
+                      description: BGP LOCAL_PREF attribute which is used by BGP best
+                        path algorithm, Path with higher localpref is preferred over
+                        one with lower localpref.
+                      format: int32
+                      type: integer
+                  type: object
+                type: array
+              protocol:
+                description: Protocol can be used to select how the announcement is
+                  done.
+                enum:
+                - layer2
+                - bgp
+                type: string
+            required:
+            - addresses
+            - protocol
+            type: object
+          status:
+            description: AddressPoolStatus defines the observed state of AddressPool.
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.7.0
+  creationTimestamp: null
+  name: bfdprofiles.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: BFDProfile
+    listKind: BFDProfileList
+    plural: bfdprofiles
+    singular: bfdprofile
+  scope: Namespaced
+  versions:
+  - name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: BFDProfile is the Schema for the bfdprofiles API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: BFDProfileSpec defines the desired state of BFDProfile.
+            properties:
+              detectMultiplier:
+                format: int32
+                maximum: 255
+                minimum: 2
+                type: integer
+              echoInterval:
+                format: int32
+                maximum: 60000
+                minimum: 10
+                type: integer
+              echoMode:
+                type: boolean
+              minimumTtl:
+                format: int32
+                maximum: 254
+                minimum: 1
+                type: integer
+              passiveMode:
+                type: boolean
+              receiveInterval:
+                format: int32
+                maximum: 60000
+                minimum: 10
+                type: integer
+              transmitInterval:
+                format: int32
+                maximum: 60000
+                minimum: 10
+                type: integer
+            type: object
+          status:
+            description: BFDProfileStatus defines the observed state of BFDProfile.
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.7.0
+  creationTimestamp: null
+  name: bgpadvertisements.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: BGPAdvertisement
+    listKind: BGPAdvertisementList
+    plural: bgpadvertisements
+    singular: bgpadvertisement
+  scope: Namespaced
+  versions:
+  - name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: BGPAdvertisement is the Schema for the bgpadvertisements API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: BGPAdvertisementSpec defines the desired state of BGPAdvertisement.
+            properties:
+              aggregationLength:
+                default: 32
+                description: The aggregation-length advertisement option lets you
+                  “roll up” the /32s into a larger prefix.
+                format: int32
+                minimum: 1
+                type: integer
+              aggregationLengthV6:
+                default: 128
+                description: Optional, defaults to 128 (i.e. no aggregation) if not
+                  specified.
+                format: int32
+                type: integer
+              communities:
+                description: BGP communities
+                items:
+                  type: string
+                type: array
+              ipPools:
+                description: IPPools is the list of ippools to advertise via this
+                  advertisement.
+                items:
+                  type: string
+                type: array
+              localPref:
+                description: BGP LOCAL_PREF attribute which is used by BGP best path
+                  algorithm, Path with higher localpref is preferred over one with
+                  lower localpref.
+                format: int32
+                type: integer
+            type: object
+          status:
+            description: BGPAdvertisementStatus defines the observed state of BGPAdvertisement.
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.7.0
+  creationTimestamp: null
+  name: bgppeers.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: BGPPeer
+    listKind: BGPPeerList
+    plural: bgppeers
+    singular: bgppeer
+  scope: Namespaced
+  versions:
+  - name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: BGPPeer is the Schema for the peers API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: BGPPeerSpec defines the desired state of Peer.
+            properties:
+              bfdProfile:
+                type: string
+              ebgpMultiHop:
+                description: EBGP peer is multi-hops away
+                type: boolean
+              holdTime:
+                description: Requested BGP hold time, per RFC4271.
+                type: string
+              keepaliveTime:
+                description: Requested BGP keepalive time, per RFC4271.
+                type: string
+              myASN:
+                description: AS number to use for the local end of the session.
+                format: int32
+                maximum: 65535
+                minimum: 0
+                type: integer
+              nodeSelectors:
+                description: Only connect to this peer on nodes that match one of
+                  these selectors.
+                items:
+                  properties:
+                    matchExpressions:
+                      items:
+                        properties:
+                          key:
+                            type: string
+                          operator:
+                            type: string
+                          values:
+                            items:
+                              type: string
+                            minItems: 1
+                            type: array
+                        required:
+                        - key
+                        - operator
+                        - values
+                        type: object
+                      type: array
+                    matchLabels:
+                      additionalProperties:
+                        type: string
+                      type: object
+                  type: object
+                type: array
+              password:
+                description: Authentication password for routers enforcing TCP MD5
+                  authenticated sessions
+                type: string
+              peerASN:
+                description: AS number to expect from the remote end of the session.
+                format: int32
+                maximum: 65535
+                minimum: 0
+                type: integer
+              peerAddress:
+                description: Address to dial when establishing the session.
+                type: string
+              peerPort:
+                description: Port to dial when establishing the session.
+                maximum: 16384
+                minimum: 0
+                type: integer
+              routerID:
+                description: BGP router ID to advertise to the peer
+                type: string
+              sourceAddress:
+                description: Source address to use when establishing the session.
+                type: string
+            required:
+            - myASN
+            - peerASN
+            - peerAddress
+            type: object
+          status:
+            description: BGPPeerStatus defines the observed state of Peer.
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
+  - name: v1beta2
+    schema:
+      openAPIV3Schema:
+        description: BGPPeer is the Schema for the peers API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: BGPPeerSpec defines the desired state of Peer.
+            properties:
+              bfdProfile:
+                type: string
+              ebgpMultiHop:
+                description: EBGP peer is multi-hops away
+                type: boolean
+              holdTime:
+                description: Requested BGP hold time, per RFC4271.
+                type: string
+              keepaliveTime:
+                description: Requested BGP keepalive time, per RFC4271.
+                type: string
+              myASN:
+                description: AS number to use for the local end of the session.
+                format: int32
+                maximum: 65535
+                minimum: 0
+                type: integer
+              nodeSelectors:
+                description: Only connect to this peer on nodes that match one of
+                  these selectors.
+                items:
+                  description: A label selector is a label query over a set of resources.
+                    The result of matchLabels and matchExpressions are ANDed. An empty
+                    label selector matches all objects. A null label selector matches
+                    no objects.
+                  properties:
+                    matchExpressions:
+                      description: matchExpressions is a list of label selector requirements.
+                        The requirements are ANDed.
+                      items:
+                        description: A label selector requirement is a selector that
+                          contains values, a key, and an operator that relates the
+                          key and values.
+                        properties:
+                          key:
+                            description: key is the label key that the selector applies
+                              to.
+                            type: string
+                          operator:
+                            description: operator represents a key's relationship
+                              to a set of values. Valid operators are In, NotIn, Exists
+                              and DoesNotExist.
+                            type: string
+                          values:
+                            description: values is an array of string values. If the
+                              operator is In or NotIn, the values array must be non-empty.
+                              If the operator is Exists or DoesNotExist, the values
+                              array must be empty. This array is replaced during a
+                              strategic merge patch.
+                            items:
+                              type: string
+                            type: array
+                        required:
+                        - key
+                        - operator
+                        type: object
+                      type: array
+                    matchLabels:
+                      additionalProperties:
+                        type: string
+                      description: matchLabels is a map of {key,value} pairs. A single
+                        {key,value} in the matchLabels map is equivalent to an element
+                        of matchExpressions, whose key field is "key", the operator
+                        is "In", and the values array contains only "value". The requirements
+                        are ANDed.
+                      type: object
+                  type: object
+                type: array
+              password:
+                description: Authentication password for routers enforcing TCP MD5
+                  authenticated sessions
+                type: string
+              passwordSecret:
+                description: passwordSecret is name of the authentication secret for
+                  BGP Peer
+                properties:
+                  name:
+                    description: Name is unique within a namespace to reference a
+                      secret resource.
+                    type: string
+                  namespace:
+                    description: Namespace defines the space within which the secret
+                      name must be unique.
+                    type: string
+                type: object
+              peerASN:
+                description: AS number to expect from the remote end of the session.
+                format: int32
+                maximum: 65535
+                minimum: 0
+                type: integer
+              peerAddress:
+                description: Address to dial when establishing the session.
+                type: string
+              peerPort:
+                default: 179
+                maximum: 16384
+                minimum: 0
+                type: integer
+              routerID:
+                description: BGP router ID to advertise to the peer
+                type: string
+              sourceAddress:
+                description: Source address to use when establishing the session.
+                type: string
+            required:
+            - myASN
+            - peerASN
+            - peerAddress
+            type: object
+          status:
+            description: BGPPeerStatus defines the observed state of Peer.
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.7.0
+  creationTimestamp: null
+  name: ippools.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: IPPool
+    listKind: IPPoolList
+    plural: ippools
+    singular: ippool
+  scope: Namespaced
+  versions:
+  - name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: IPPool is the Schema for the IPPools API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: IPPoolSpec defines the desired state of IPPool.
+            properties:
+              addresses:
+                description: A list of IP address ranges over which MetalLB has authority.
+                  You can list multiple ranges in a single pool, they will all share
+                  the same settings. Each range can be either a CIDR prefix, or an
+                  explicit start-end range of IPs.
+                items:
+                  type: string
+                type: array
+              autoAssign:
+                default: true
+                description: AutoAssign flag used to prevent MetallB from automatic
+                  allocation for a pool.
+                type: boolean
+              avoidBuggyIPs:
+                default: false
+                description: AvoidBuggyIPs prevents addresses ending with .0 and .255
+                  to be used by a pool.
+                type: boolean
+            required:
+            - addresses
+            type: object
+          status:
+            description: IPPoolStatus defines the observed state of IPPool.
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.7.0
+  creationTimestamp: null
+  name: l2advertisements.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: L2Advertisement
+    listKind: L2AdvertisementList
+    plural: l2advertisements
+    singular: l2advertisement
+  scope: Namespaced
+  versions:
+  - name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: L2Advertisement is the Schema for the l2advertisements API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: L2AdvertisementSpec defines the desired state of L2Advertisement.
+            properties:
+              ipPools:
+                description: IPPools is the list of ippools to advertise via this
+                  advertisement.
+                items:
+                  type: string
+                type: array
+            type: object
+          status:
+            description: L2AdvertisementStatus defines the observed state of L2Advertisement.
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  labels:
+    app: metallb
+  name: controller
+  namespace: metallb-system
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  labels:
+    app: metallb
+  name: speaker
+  namespace: metallb-system
+---
+apiVersion: policy/v1beta1
+kind: PodSecurityPolicy
+metadata:
+  labels:
+    app: metallb
+  name: controller
+spec:
+  allowPrivilegeEscalation: false
+  allowedCapabilities: []
+  allowedHostPaths: []
+  defaultAddCapabilities: []
+  defaultAllowPrivilegeEscalation: false
+  fsGroup:
+    ranges:
+    - max: 65535
+      min: 1
+    rule: MustRunAs
+  hostIPC: false
+  hostNetwork: false
+  hostPID: false
+  privileged: false
+  readOnlyRootFilesystem: true
+  requiredDropCapabilities:
+  - ALL
+  runAsUser:
+    ranges:
+    - max: 65535
+      min: 1
+    rule: MustRunAs
+  seLinux:
+    rule: RunAsAny
+  supplementalGroups:
+    ranges:
+    - max: 65535
+      min: 1
+    rule: MustRunAs
+  volumes:
+  - configMap
+  - secret
+  - emptyDir
+---
+apiVersion: policy/v1beta1
+kind: PodSecurityPolicy
+metadata:
+  labels:
+    app: metallb
+  name: speaker
+spec:
+  allowPrivilegeEscalation: false
+  allowedCapabilities:
+  - NET_RAW
+  allowedHostPaths: []
+  defaultAddCapabilities: []
+  defaultAllowPrivilegeEscalation: false
+  fsGroup:
+    rule: RunAsAny
+  hostIPC: false
+  hostNetwork: true
+  hostPID: false
+  hostPorts:
+  - max: 7472
+    min: 7472
+  - max: 7946
+    min: 7946
+  privileged: true
+  readOnlyRootFilesystem: true
+  requiredDropCapabilities:
+  - ALL
+  runAsUser:
+    rule: RunAsAny
+  seLinux:
+    rule: RunAsAny
+  supplementalGroups:
+    rule: RunAsAny
+  volumes:
+  - configMap
+  - secret
+  - emptyDir
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  labels:
+    app: metallb
+  name: controller
+  namespace: metallb-system
+rules:
+- apiGroups:
+  - ""
+  resources:
+  - secrets
+  verbs:
+  - create
+  - get
+  - list
+  - watch
+- apiGroups:
+  - ""
+  resourceNames:
+  - memberlist
+  resources:
+  - secrets
+  verbs:
+  - list
+- apiGroups:
+  - apps
+  resourceNames:
+  - controller
+  resources:
+  - deployments
+  verbs:
+  - get
+- apiGroups:
+  - metallb.io
+  resources:
+  - addresspools
+  verbs:
+  - create
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - bfdprofiles
+  verbs:
+  - create
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - bgppeers
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - l2advertisements
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - bgpadvertisements
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - ippools
+  verbs:
+  - get
+  - list
+  - watch
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  labels:
+    app: metallb
+  name: pod-lister
+  namespace: metallb-system
+rules:
+- apiGroups:
+  - ""
+  resources:
+  - pods
+  verbs:
+  - list
+- apiGroups:
+  - ""
+  resources:
+  - secrets
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - addresspools
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - bfdprofiles
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - bgppeers
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - l2advertisements
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - bgpadvertisements
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - ippools
+  verbs:
+  - get
+  - list
+  - watch
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app: metallb
+  name: metallb-system:controller
+rules:
+- apiGroups:
+  - ""
+  resources:
+  - services
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - ""
+  resources:
+  - services/status
+  verbs:
+  - update
+- apiGroups:
+  - ""
+  resources:
+  - events
+  verbs:
+  - create
+  - patch
+- apiGroups:
+  - policy
+  resourceNames:
+  - controller
+  resources:
+  - podsecuritypolicies
+  verbs:
+  - use
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app: metallb
+  name: metallb-system:speaker
+rules:
+- apiGroups:
+  - ""
+  resources:
+  - services
+  - endpoints
+  - nodes
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - discovery.k8s.io
+  resources:
+  - endpointslices
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - ""
+  resources:
+  - events
+  verbs:
+  - create
+  - patch
+- apiGroups:
+  - policy
+  resourceNames:
+  - speaker
+  resources:
+  - podsecuritypolicies
+  verbs:
+  - use
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  labels:
+    app: metallb
+  name: controller
+  namespace: metallb-system
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: controller
+subjects:
+- kind: ServiceAccount
+  name: controller
+  namespace: metallb-system
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  labels:
+    app: metallb
+  name: pod-lister
+  namespace: metallb-system
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: pod-lister
+subjects:
+- kind: ServiceAccount
+  name: speaker
+  namespace: metallb-system
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  labels:
+    app: metallb
+  name: metallb-system:controller
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: metallb-system:controller
+subjects:
+- kind: ServiceAccount
+  name: controller
+  namespace: metallb-system
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  labels:
+    app: metallb
+  name: metallb-system:speaker
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: metallb-system:speaker
+subjects:
+- kind: ServiceAccount
+  name: speaker
+  namespace: metallb-system
+---
+apiVersion: v1
+data:
+  daemons: |
+    # This file tells the frr package which daemons to start.
+    #
+    # Sample configurations for these daemons can be found in
+    # /usr/share/doc/frr/examples/.
+    #
+    # ATTENTION:
+    #
+    # When activating a daemon for the first time, a config file, even if it is
+    # empty, has to be present *and* be owned by the user and group "frr", else
+    # the daemon will not be started by /etc/init.d/frr. The permissions should
+    # be u=rw,g=r,o=.
+    # When using "vtysh" such a config file is also needed. It should be owned by
+    # group "frrvty" and set to ug=rw,o= though. Check /etc/pam.d/frr, too.
+    #
+    # The watchfrr and zebra daemons are always started.
+    #
+    bgpd=yes
+    ospfd=no
+    ospf6d=no
+    ripd=no
+    ripngd=no
+    isisd=no
+    pimd=no
+    ldpd=no
+    nhrpd=no
+    eigrpd=no
+    babeld=no
+    sharpd=no
+    pbrd=no
+    bfdd=yes
+    fabricd=no
+    vrrpd=no
+
+    #
+    # If this option is set the /etc/init.d/frr script automatically loads
+    # the config via "vtysh -b" when the servers are started.
+    # Check /etc/pam.d/frr if you intend to use "vtysh"!
+    #
+    vtysh_enable=yes
+    zebra_options="  -A 127.0.0.1 -s 90000000"
+    bgpd_options="   -A 127.0.0.1 -p 0"
+    ospfd_options="  -A 127.0.0.1"
+    ospf6d_options=" -A ::1"
+    ripd_options="   -A 127.0.0.1"
+    ripngd_options=" -A ::1"
+    isisd_options="  -A 127.0.0.1"
+    pimd_options="   -A 127.0.0.1"
+    ldpd_options="   -A 127.0.0.1"
+    nhrpd_options="  -A 127.0.0.1"
+    eigrpd_options=" -A 127.0.0.1"
+    babeld_options=" -A 127.0.0.1"
+    sharpd_options=" -A 127.0.0.1"
+    pbrd_options="   -A 127.0.0.1"
+    staticd_options="-A 127.0.0.1"
+    bfdd_options="   -A 127.0.0.1"
+    fabricd_options="-A 127.0.0.1"
+    vrrpd_options="  -A 127.0.0.1"
+
+    # configuration profile
+    #
+    #frr_profile="traditional"
+    #frr_profile="datacenter"
+
+    #
+    # This is the maximum number of FD's that will be available.
+    # Upon startup this is read by the control files and ulimit
+    # is called. Uncomment and use a reasonable value for your
+    # setup if you are expecting a large number of peers in
+    # say BGP.
+    #MAX_FDS=1024
+
+    # The list of daemons to watch is automatically generated by the init script.
+    #watchfrr_options=""
+
+    # for debugging purposes, you can specify a "wrap" command to start instead
+    # of starting the daemon directly, e.g. to use valgrind on ospfd:
+    #   ospfd_wrap="/usr/bin/valgrind"
+    # or you can use "all_wrap" for all daemons, e.g. to use perf record:
+    #   all_wrap="/usr/bin/perf record --call-graph -"
+    # the normal daemon command is added to this at the end.
+  frr.conf: |
+    ! This file gets overriden the first time the speaker renders a config.
+    ! So anything configured here is only temporary.
+    frr version 7.5.1
+    frr defaults traditional
+    hostname Router
+    line vty
+    log file /etc/frr/frr.log informational
+  vtysh.conf: |
+    service integrated-vtysh-config
+kind: ConfigMap
+metadata:
+  name: frr-startup
+  namespace: metallb-system
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: webhook-service
+  namespace: metallb-system
+spec:
+  ports:
+  - port: 443
+    targetPort: 9443
+  selector:
+    component: controller
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    app: metallb
+    component: controller
+  name: controller
+  namespace: metallb-system
+spec:
+  revisionHistoryLimit: 3
+  selector:
+    matchLabels:
+      app: metallb
+      component: controller
+  template:
+    metadata:
+      annotations:
+        prometheus.io/port: "7472"
+        prometheus.io/scrape: "true"
+      labels:
+        app: metallb
+        component: controller
+    spec:
+      containers:
+      - args:
+        - --enable-webhook
+        - --port=7472
+        - --log-level=info
+        env:
+        - name: METALLB_BGP_TYPE
+          value: frr
+        - name: METALLB_ML_SECRET_NAME
+          value: memberlist
+        - name: METALLB_DEPLOYMENT
+          value: controller
+        image: quay.io/metallb/controller:main
+        livenessProbe:
+          failureThreshold: 3
+          httpGet:
+            path: /metrics
+            port: monitoring
+          initialDelaySeconds: 10
+          periodSeconds: 10
+          successThreshold: 1
+          timeoutSeconds: 1
+        name: controller
+        ports:
+        - containerPort: 9443
+          name: webhook-server
+          protocol: TCP
+        - containerPort: 7472
+          name: monitoring
+        readinessProbe:
+          failureThreshold: 3
+          httpGet:
+            path: /metrics
+            port: monitoring
+          initialDelaySeconds: 10
+          periodSeconds: 10
+          successThreshold: 1
+          timeoutSeconds: 1
+        securityContext:
+          allowPrivilegeEscalation: false
+          capabilities:
+            drop:
+            - all
+          readOnlyRootFilesystem: true
+        volumeMounts:
+        - mountPath: /tmp/k8s-webhook-server/serving-certs
+          name: cert
+          readOnly: true
+      nodeSelector:
+        kubernetes.io/os: linux
+      securityContext:
+        fsGroup: 65534
+        runAsNonRoot: true
+        runAsUser: 65534
+      serviceAccountName: controller
+      terminationGracePeriodSeconds: 0
+      volumes:
+      - name: cert
+        secret:
+          defaultMode: 420
+          secretName: webhook-server-cert
+---
+apiVersion: apps/v1
+kind: DaemonSet
+metadata:
+  labels:
+    app: metallb
+    component: speaker
+  name: speaker
+  namespace: metallb-system
+spec:
+  selector:
+    matchLabels:
+      app: metallb
+      component: speaker
+  template:
+    metadata:
+      annotations:
+        prometheus.io/port: "7472"
+        prometheus.io/scrape: "true"
+      labels:
+        app: metallb
+        component: speaker
+    spec:
+      containers:
+      - command:
+        - /bin/sh
+        - -c
+        - |
+          /sbin/tini -- /usr/lib/frr/docker-start &
+          attempts=0
+          until [[ -f /etc/frr/frr.log || $attempts -eq 60 ]]; do
+            sleep 1
+            attempts=$(( $attempts + 1 ))
+          done
+          tail -f /etc/frr/frr.log
+        env:
+        - name: TINI_SUBREAPER
+          value: "true"
+        image: quay.io/frrouting/frr:stable_7.5
+        name: frr
+        securityContext:
+          capabilities:
+            add:
+            - NET_ADMIN
+            - NET_RAW
+            - SYS_ADMIN
+            - NET_BIND_SERVICE
+        volumeMounts:
+        - mountPath: /var/run/frr
+          name: frr-sockets
+        - mountPath: /etc/frr
+          name: frr-conf
+      - command:
+        - /etc/frr_reloader/frr-reloader.sh
+        image: quay.io/frrouting/frr:stable_7.5
+        name: reloader
+        volumeMounts:
+        - mountPath: /var/run/frr
+          name: frr-sockets
+        - mountPath: /etc/frr
+          name: frr-conf
+        - mountPath: /etc/frr_reloader
+          name: reloader
+      - args:
+        - --metrics-port=7473
+        command:
+        - /etc/frr_metrics/frr-metrics
+        image: quay.io/frrouting/frr:stable_7.5
+        name: frr-metrics
+        ports:
+        - containerPort: 7473
+          name: monitoring
+        volumeMounts:
+        - mountPath: /var/run/frr
+          name: frr-sockets
+        - mountPath: /etc/frr
+          name: frr-conf
+        - mountPath: /etc/frr_metrics
+          name: metrics
+      - args:
+        - --port=7472
+        - --log-level=info
+        env:
+        - name: FRR_CONFIG_FILE
+          value: /etc/frr_reloader/frr.conf
+        - name: FRR_RELOADER_PID_FILE
+          value: /etc/frr_reloader/reloader.pid
+        - name: METALLB_BGP_TYPE
+          value: frr
+        - name: FRR_LOGGING_LEVEL
+          value: informational
+        - name: METALLB_NODE_NAME
+          valueFrom:
+            fieldRef:
+              fieldPath: spec.nodeName
+        - name: METALLB_HOST
+          valueFrom:
+            fieldRef:
+              fieldPath: status.hostIP
+        - name: METALLB_ML_BIND_ADDR
+          valueFrom:
+            fieldRef:
+              fieldPath: status.podIP
+        - name: METALLB_ML_LABELS
+          value: app=metallb,component=speaker
+        - name: METALLB_ML_SECRET_KEY
+          valueFrom:
+            secretKeyRef:
+              key: secretkey
+              name: memberlist
+        image: quay.io/metallb/speaker:main
+        livenessProbe:
+          failureThreshold: 3
+          httpGet:
+            path: /metrics
+            port: monitoring
+          initialDelaySeconds: 10
+          periodSeconds: 10
+          successThreshold: 1
+          timeoutSeconds: 1
+        name: speaker
+        ports:
+        - containerPort: 7472
+          name: monitoring
+        - containerPort: 7946
+          name: memberlist-tcp
+        - containerPort: 7946
+          name: memberlist-udp
+          protocol: UDP
+        readinessProbe:
+          failureThreshold: 3
+          httpGet:
+            path: /metrics
+            port: monitoring
+          initialDelaySeconds: 10
+          periodSeconds: 10
+          successThreshold: 1
+          timeoutSeconds: 1
+        securityContext:
+          allowPrivilegeEscalation: false
+          capabilities:
+            add:
+            - NET_RAW
+            drop:
+            - ALL
+          readOnlyRootFilesystem: true
+        volumeMounts:
+        - mountPath: /etc/frr_reloader
+          name: reloader
+      hostNetwork: true
+      initContainers:
+      - command:
+        - /bin/sh
+        - -c
+        - cp -rLf /tmp/frr/* /etc/frr/
+        image: quay.io/frrouting/frr:stable_7.5
+        name: cp-frr-files
+        securityContext:
+          runAsGroup: 101
+          runAsUser: 100
+        volumeMounts:
+        - mountPath: /tmp/frr
+          name: frr-startup
+        - mountPath: /etc/frr
+          name: frr-conf
+      - command:
+        - /bin/sh
+        - -c
+        - cp -f /frr-reloader.sh /etc/frr_reloader/
+        image: quay.io/metallb/speaker:main
+        name: cp-reloader
+        volumeMounts:
+        - mountPath: /etc/frr_reloader
+          name: reloader
+      - command:
+        - /bin/sh
+        - -c
+        - cp -f /frr-metrics /etc/frr_metrics/
+        image: quay.io/metallb/speaker:main
+        name: cp-metrics
+        volumeMounts:
+        - mountPath: /etc/frr_metrics
+          name: metrics
+      nodeSelector:
+        kubernetes.io/os: linux
+      serviceAccountName: speaker
+      shareProcessNamespace: true
+      terminationGracePeriodSeconds: 2
+      tolerations:
+      - effect: NoSchedule
+        key: node-role.kubernetes.io/master
+        operator: Exists
+      volumes:
+      - emptyDir: {}
+        name: frr-sockets
+      - configMap:
+          name: frr-startup
+        name: frr-startup
+      - emptyDir: {}
+        name: frr-conf
+      - emptyDir: {}
+        name: reloader
+      - emptyDir: {}
+        name: metrics
+---
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+  name: serving-cert
+  namespace: metallb-system
+spec:
+  dnsNames:
+  - webhook-service.metallb-system.svc
+  - webhook-service.metallb-system.svc.cluster.local
+  issuerRef:
+    kind: Issuer
+    name: selfsigned-issuer
+  secretName: webhook-server-cert
+---
+apiVersion: cert-manager.io/v1
+kind: Issuer
+metadata:
+  name: selfsigned-issuer
+  namespace: metallb-system
+spec:
+  selfSigned: {}
+---
+apiVersion: admissionregistration.k8s.io/v1
+kind: ValidatingWebhookConfiguration
+metadata:
+  annotations:
+    cert-manager.io/inject-ca-from: metallb-system/serving-cert
+  name: validating-webhook-configuration
+webhooks:
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: metallb-system
+      path: /validate-metallb-io-v1beta1-addresspool
+  failurePolicy: Fail
+  name: addresspoolvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - addresspools
+  sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: metallb-system
+      path: /validate-metallb-io-v1beta1-bgpadvertisement
+  failurePolicy: Fail
+  name: bgpadvertisementvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - bgpadvertisements
+  sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: metallb-system
+      path: /validate-metallb-io-v1beta1-ippool
+  failurePolicy: Fail
+  name: ippoolvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - ippools
+  sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: metallb-system
+      path: /validate-metallb-io-v1beta2-bgppeer
+  failurePolicy: Fail
+  name: bgppeersvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta2
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - bgppeers
+  sideEffects: None
diff --git a/config/manifests/metallb-frr.yaml b/config/manifests/metallb-frr.yaml
index 333fe9fa..a48755e9 100644
--- a/config/manifests/metallb-frr.yaml
+++ b/config/manifests/metallb-frr.yaml
@@ -1278,6 +1278,8 @@ spec:
         - --port=7472
         - --log-level=info
         env:
+        - name: METALLB_BGP_TYPE
+          value: frr
         - name: METALLB_ML_SECRET_NAME
           value: memberlist
         - name: METALLB_DEPLOYMENT
diff --git a/config/manifests/metallb-native-with-webhooks.yaml b/config/manifests/metallb-native-with-webhooks.yaml
new file mode 100644
index 00000000..cd52b04b
--- /dev/null
+++ b/config/manifests/metallb-native-with-webhooks.yaml
@@ -0,0 +1,1450 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: metallb-system
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.7.0
+  creationTimestamp: null
+  name: addresspools.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: AddressPool
+    listKind: AddressPoolList
+    plural: addresspools
+    singular: addresspool
+  scope: Namespaced
+  versions:
+  - deprecated: true
+    deprecationWarning: metallbio.io v1alpha1 AddressPool is deprecated
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: AddressPool is the Schema for the addresspools API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: AddressPoolSpec defines the desired state of AddressPool.
+            properties:
+              addresses:
+                description: A list of IP address ranges over which MetalLB has authority.
+                  You can list multiple ranges in a single pool, they will all share
+                  the same settings. Each range can be either a CIDR prefix, or an
+                  explicit start-end range of IPs.
+                items:
+                  type: string
+                type: array
+              autoAssign:
+                default: true
+                description: AutoAssign flag used to prevent MetallB from automatic
+                  allocation for a pool.
+                type: boolean
+              bgpAdvertisements:
+                description: When an IP is allocated from this pool, how should it
+                  be translated into BGP announcements?
+                items:
+                  properties:
+                    aggregationLength:
+                      default: 32
+                      description: The aggregation-length advertisement option lets
+                        you “roll up” the /32s into a larger prefix.
+                      format: int32
+                      minimum: 1
+                      type: integer
+                    aggregationLengthV6:
+                      default: 128
+                      description: Optional, defaults to 128 (i.e. no aggregation)
+                        if not specified.
+                      format: int32
+                      type: integer
+                    communities:
+                      description: BGP communities
+                      items:
+                        type: string
+                      type: array
+                    localPref:
+                      description: BGP LOCAL_PREF attribute which is used by BGP best
+                        path algorithm, Path with higher localpref is preferred over
+                        one with lower localpref.
+                      format: int32
+                      type: integer
+                  type: object
+                type: array
+              protocol:
+                description: Protocol can be used to select how the announcement is
+                  done.
+                enum:
+                - layer2
+                - bgp
+                type: string
+            required:
+            - addresses
+            - protocol
+            type: object
+          status:
+            description: AddressPoolStatus defines the observed state of AddressPool.
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
+  - name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: AddressPool is the Schema for the addresspools API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: AddressPoolSpec defines the desired state of AddressPool.
+            properties:
+              addresses:
+                description: A list of IP address ranges over which MetalLB has authority.
+                  You can list multiple ranges in a single pool, they will all share
+                  the same settings. Each range can be either a CIDR prefix, or an
+                  explicit start-end range of IPs.
+                items:
+                  type: string
+                type: array
+              autoAssign:
+                default: true
+                description: AutoAssign flag used to prevent MetallB from automatic
+                  allocation for a pool.
+                type: boolean
+              avoidBuggyIPs:
+                default: false
+                description: AvoidBuggyIPs prevents addresses ending with .0 and .255
+                  to be used by a pool.
+                type: boolean
+              bgpAdvertisements:
+                description: When an IP is allocated from this pool, how should it
+                  be translated into BGP announcements?
+                items:
+                  properties:
+                    aggregationLength:
+                      default: 32
+                      description: The aggregation-length advertisement option lets
+                        you “roll up” the /32s into a larger prefix.
+                      format: int32
+                      minimum: 1
+                      type: integer
+                    aggregationLengthV6:
+                      default: 128
+                      description: Optional, defaults to 128 (i.e. no aggregation)
+                        if not specified.
+                      format: int32
+                      type: integer
+                    communities:
+                      description: BGP communities
+                      items:
+                        type: string
+                      type: array
+                    localPref:
+                      description: BGP LOCAL_PREF attribute which is used by BGP best
+                        path algorithm, Path with higher localpref is preferred over
+                        one with lower localpref.
+                      format: int32
+                      type: integer
+                  type: object
+                type: array
+              protocol:
+                description: Protocol can be used to select how the announcement is
+                  done.
+                enum:
+                - layer2
+                - bgp
+                type: string
+            required:
+            - addresses
+            - protocol
+            type: object
+          status:
+            description: AddressPoolStatus defines the observed state of AddressPool.
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.7.0
+  creationTimestamp: null
+  name: bfdprofiles.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: BFDProfile
+    listKind: BFDProfileList
+    plural: bfdprofiles
+    singular: bfdprofile
+  scope: Namespaced
+  versions:
+  - name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: BFDProfile is the Schema for the bfdprofiles API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: BFDProfileSpec defines the desired state of BFDProfile.
+            properties:
+              detectMultiplier:
+                format: int32
+                maximum: 255
+                minimum: 2
+                type: integer
+              echoInterval:
+                format: int32
+                maximum: 60000
+                minimum: 10
+                type: integer
+              echoMode:
+                type: boolean
+              minimumTtl:
+                format: int32
+                maximum: 254
+                minimum: 1
+                type: integer
+              passiveMode:
+                type: boolean
+              receiveInterval:
+                format: int32
+                maximum: 60000
+                minimum: 10
+                type: integer
+              transmitInterval:
+                format: int32
+                maximum: 60000
+                minimum: 10
+                type: integer
+            type: object
+          status:
+            description: BFDProfileStatus defines the observed state of BFDProfile.
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.7.0
+  creationTimestamp: null
+  name: bgpadvertisements.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: BGPAdvertisement
+    listKind: BGPAdvertisementList
+    plural: bgpadvertisements
+    singular: bgpadvertisement
+  scope: Namespaced
+  versions:
+  - name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: BGPAdvertisement is the Schema for the bgpadvertisements API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: BGPAdvertisementSpec defines the desired state of BGPAdvertisement.
+            properties:
+              aggregationLength:
+                default: 32
+                description: The aggregation-length advertisement option lets you
+                  “roll up” the /32s into a larger prefix.
+                format: int32
+                minimum: 1
+                type: integer
+              aggregationLengthV6:
+                default: 128
+                description: Optional, defaults to 128 (i.e. no aggregation) if not
+                  specified.
+                format: int32
+                type: integer
+              communities:
+                description: BGP communities
+                items:
+                  type: string
+                type: array
+              ipPools:
+                description: IPPools is the list of ippools to advertise via this
+                  advertisement.
+                items:
+                  type: string
+                type: array
+              localPref:
+                description: BGP LOCAL_PREF attribute which is used by BGP best path
+                  algorithm, Path with higher localpref is preferred over one with
+                  lower localpref.
+                format: int32
+                type: integer
+            type: object
+          status:
+            description: BGPAdvertisementStatus defines the observed state of BGPAdvertisement.
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.7.0
+  creationTimestamp: null
+  name: bgppeers.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: BGPPeer
+    listKind: BGPPeerList
+    plural: bgppeers
+    singular: bgppeer
+  scope: Namespaced
+  versions:
+  - name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: BGPPeer is the Schema for the peers API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: BGPPeerSpec defines the desired state of Peer.
+            properties:
+              bfdProfile:
+                type: string
+              ebgpMultiHop:
+                description: EBGP peer is multi-hops away
+                type: boolean
+              holdTime:
+                description: Requested BGP hold time, per RFC4271.
+                type: string
+              keepaliveTime:
+                description: Requested BGP keepalive time, per RFC4271.
+                type: string
+              myASN:
+                description: AS number to use for the local end of the session.
+                format: int32
+                maximum: 65535
+                minimum: 0
+                type: integer
+              nodeSelectors:
+                description: Only connect to this peer on nodes that match one of
+                  these selectors.
+                items:
+                  properties:
+                    matchExpressions:
+                      items:
+                        properties:
+                          key:
+                            type: string
+                          operator:
+                            type: string
+                          values:
+                            items:
+                              type: string
+                            minItems: 1
+                            type: array
+                        required:
+                        - key
+                        - operator
+                        - values
+                        type: object
+                      type: array
+                    matchLabels:
+                      additionalProperties:
+                        type: string
+                      type: object
+                  type: object
+                type: array
+              password:
+                description: Authentication password for routers enforcing TCP MD5
+                  authenticated sessions
+                type: string
+              peerASN:
+                description: AS number to expect from the remote end of the session.
+                format: int32
+                maximum: 65535
+                minimum: 0
+                type: integer
+              peerAddress:
+                description: Address to dial when establishing the session.
+                type: string
+              peerPort:
+                description: Port to dial when establishing the session.
+                maximum: 16384
+                minimum: 0
+                type: integer
+              routerID:
+                description: BGP router ID to advertise to the peer
+                type: string
+              sourceAddress:
+                description: Source address to use when establishing the session.
+                type: string
+            required:
+            - myASN
+            - peerASN
+            - peerAddress
+            type: object
+          status:
+            description: BGPPeerStatus defines the observed state of Peer.
+            type: object
+        type: object
+    served: true
+    storage: false
+    subresources:
+      status: {}
+  - name: v1beta2
+    schema:
+      openAPIV3Schema:
+        description: BGPPeer is the Schema for the peers API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: BGPPeerSpec defines the desired state of Peer.
+            properties:
+              bfdProfile:
+                type: string
+              ebgpMultiHop:
+                description: EBGP peer is multi-hops away
+                type: boolean
+              holdTime:
+                description: Requested BGP hold time, per RFC4271.
+                type: string
+              keepaliveTime:
+                description: Requested BGP keepalive time, per RFC4271.
+                type: string
+              myASN:
+                description: AS number to use for the local end of the session.
+                format: int32
+                maximum: 65535
+                minimum: 0
+                type: integer
+              nodeSelectors:
+                description: Only connect to this peer on nodes that match one of
+                  these selectors.
+                items:
+                  description: A label selector is a label query over a set of resources.
+                    The result of matchLabels and matchExpressions are ANDed. An empty
+                    label selector matches all objects. A null label selector matches
+                    no objects.
+                  properties:
+                    matchExpressions:
+                      description: matchExpressions is a list of label selector requirements.
+                        The requirements are ANDed.
+                      items:
+                        description: A label selector requirement is a selector that
+                          contains values, a key, and an operator that relates the
+                          key and values.
+                        properties:
+                          key:
+                            description: key is the label key that the selector applies
+                              to.
+                            type: string
+                          operator:
+                            description: operator represents a key's relationship
+                              to a set of values. Valid operators are In, NotIn, Exists
+                              and DoesNotExist.
+                            type: string
+                          values:
+                            description: values is an array of string values. If the
+                              operator is In or NotIn, the values array must be non-empty.
+                              If the operator is Exists or DoesNotExist, the values
+                              array must be empty. This array is replaced during a
+                              strategic merge patch.
+                            items:
+                              type: string
+                            type: array
+                        required:
+                        - key
+                        - operator
+                        type: object
+                      type: array
+                    matchLabels:
+                      additionalProperties:
+                        type: string
+                      description: matchLabels is a map of {key,value} pairs. A single
+                        {key,value} in the matchLabels map is equivalent to an element
+                        of matchExpressions, whose key field is "key", the operator
+                        is "In", and the values array contains only "value". The requirements
+                        are ANDed.
+                      type: object
+                  type: object
+                type: array
+              password:
+                description: Authentication password for routers enforcing TCP MD5
+                  authenticated sessions
+                type: string
+              passwordSecret:
+                description: passwordSecret is name of the authentication secret for
+                  BGP Peer
+                properties:
+                  name:
+                    description: Name is unique within a namespace to reference a
+                      secret resource.
+                    type: string
+                  namespace:
+                    description: Namespace defines the space within which the secret
+                      name must be unique.
+                    type: string
+                type: object
+              peerASN:
+                description: AS number to expect from the remote end of the session.
+                format: int32
+                maximum: 65535
+                minimum: 0
+                type: integer
+              peerAddress:
+                description: Address to dial when establishing the session.
+                type: string
+              peerPort:
+                default: 179
+                maximum: 16384
+                minimum: 0
+                type: integer
+              routerID:
+                description: BGP router ID to advertise to the peer
+                type: string
+              sourceAddress:
+                description: Source address to use when establishing the session.
+                type: string
+            required:
+            - myASN
+            - peerASN
+            - peerAddress
+            type: object
+          status:
+            description: BGPPeerStatus defines the observed state of Peer.
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.7.0
+  creationTimestamp: null
+  name: ippools.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: IPPool
+    listKind: IPPoolList
+    plural: ippools
+    singular: ippool
+  scope: Namespaced
+  versions:
+  - name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: IPPool is the Schema for the IPPools API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: IPPoolSpec defines the desired state of IPPool.
+            properties:
+              addresses:
+                description: A list of IP address ranges over which MetalLB has authority.
+                  You can list multiple ranges in a single pool, they will all share
+                  the same settings. Each range can be either a CIDR prefix, or an
+                  explicit start-end range of IPs.
+                items:
+                  type: string
+                type: array
+              autoAssign:
+                default: true
+                description: AutoAssign flag used to prevent MetallB from automatic
+                  allocation for a pool.
+                type: boolean
+              avoidBuggyIPs:
+                default: false
+                description: AvoidBuggyIPs prevents addresses ending with .0 and .255
+                  to be used by a pool.
+                type: boolean
+            required:
+            - addresses
+            type: object
+          status:
+            description: IPPoolStatus defines the observed state of IPPool.
+            type: object
+        required:
+        - spec
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.7.0
+  creationTimestamp: null
+  name: l2advertisements.metallb.io
+spec:
+  group: metallb.io
+  names:
+    kind: L2Advertisement
+    listKind: L2AdvertisementList
+    plural: l2advertisements
+    singular: l2advertisement
+  scope: Namespaced
+  versions:
+  - name: v1beta1
+    schema:
+      openAPIV3Schema:
+        description: L2Advertisement is the Schema for the l2advertisements API.
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: L2AdvertisementSpec defines the desired state of L2Advertisement.
+            properties:
+              ipPools:
+                description: IPPools is the list of ippools to advertise via this
+                  advertisement.
+                items:
+                  type: string
+                type: array
+            type: object
+          status:
+            description: L2AdvertisementStatus defines the observed state of L2Advertisement.
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  labels:
+    app: metallb
+  name: controller
+  namespace: metallb-system
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+  labels:
+    app: metallb
+  name: speaker
+  namespace: metallb-system
+---
+apiVersion: policy/v1beta1
+kind: PodSecurityPolicy
+metadata:
+  labels:
+    app: metallb
+  name: controller
+spec:
+  allowPrivilegeEscalation: false
+  allowedCapabilities: []
+  allowedHostPaths: []
+  defaultAddCapabilities: []
+  defaultAllowPrivilegeEscalation: false
+  fsGroup:
+    ranges:
+    - max: 65535
+      min: 1
+    rule: MustRunAs
+  hostIPC: false
+  hostNetwork: false
+  hostPID: false
+  privileged: false
+  readOnlyRootFilesystem: true
+  requiredDropCapabilities:
+  - ALL
+  runAsUser:
+    ranges:
+    - max: 65535
+      min: 1
+    rule: MustRunAs
+  seLinux:
+    rule: RunAsAny
+  supplementalGroups:
+    ranges:
+    - max: 65535
+      min: 1
+    rule: MustRunAs
+  volumes:
+  - configMap
+  - secret
+  - emptyDir
+---
+apiVersion: policy/v1beta1
+kind: PodSecurityPolicy
+metadata:
+  labels:
+    app: metallb
+  name: speaker
+spec:
+  allowPrivilegeEscalation: false
+  allowedCapabilities:
+  - NET_RAW
+  allowedHostPaths: []
+  defaultAddCapabilities: []
+  defaultAllowPrivilegeEscalation: false
+  fsGroup:
+    rule: RunAsAny
+  hostIPC: false
+  hostNetwork: true
+  hostPID: false
+  hostPorts:
+  - max: 7472
+    min: 7472
+  - max: 7946
+    min: 7946
+  privileged: true
+  readOnlyRootFilesystem: true
+  requiredDropCapabilities:
+  - ALL
+  runAsUser:
+    rule: RunAsAny
+  seLinux:
+    rule: RunAsAny
+  supplementalGroups:
+    rule: RunAsAny
+  volumes:
+  - configMap
+  - secret
+  - emptyDir
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  labels:
+    app: metallb
+  name: controller
+  namespace: metallb-system
+rules:
+- apiGroups:
+  - ""
+  resources:
+  - secrets
+  verbs:
+  - create
+  - get
+  - list
+  - watch
+- apiGroups:
+  - ""
+  resourceNames:
+  - memberlist
+  resources:
+  - secrets
+  verbs:
+  - list
+- apiGroups:
+  - apps
+  resourceNames:
+  - controller
+  resources:
+  - deployments
+  verbs:
+  - get
+- apiGroups:
+  - metallb.io
+  resources:
+  - addresspools
+  verbs:
+  - create
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - bfdprofiles
+  verbs:
+  - create
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - bgppeers
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - l2advertisements
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - bgpadvertisements
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - ippools
+  verbs:
+  - get
+  - list
+  - watch
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+  labels:
+    app: metallb
+  name: pod-lister
+  namespace: metallb-system
+rules:
+- apiGroups:
+  - ""
+  resources:
+  - pods
+  verbs:
+  - list
+- apiGroups:
+  - ""
+  resources:
+  - secrets
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - addresspools
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - bfdprofiles
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - bgppeers
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - l2advertisements
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - bgpadvertisements
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metallb.io
+  resources:
+  - ippools
+  verbs:
+  - get
+  - list
+  - watch
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app: metallb
+  name: metallb-system:controller
+rules:
+- apiGroups:
+  - ""
+  resources:
+  - services
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - ""
+  resources:
+  - services/status
+  verbs:
+  - update
+- apiGroups:
+  - ""
+  resources:
+  - events
+  verbs:
+  - create
+  - patch
+- apiGroups:
+  - policy
+  resourceNames:
+  - controller
+  resources:
+  - podsecuritypolicies
+  verbs:
+  - use
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  labels:
+    app: metallb
+  name: metallb-system:speaker
+rules:
+- apiGroups:
+  - ""
+  resources:
+  - services
+  - endpoints
+  - nodes
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - discovery.k8s.io
+  resources:
+  - endpointslices
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - ""
+  resources:
+  - events
+  verbs:
+  - create
+  - patch
+- apiGroups:
+  - policy
+  resourceNames:
+  - speaker
+  resources:
+  - podsecuritypolicies
+  verbs:
+  - use
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  labels:
+    app: metallb
+  name: controller
+  namespace: metallb-system
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: controller
+subjects:
+- kind: ServiceAccount
+  name: controller
+  namespace: metallb-system
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+  labels:
+    app: metallb
+  name: pod-lister
+  namespace: metallb-system
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: Role
+  name: pod-lister
+subjects:
+- kind: ServiceAccount
+  name: speaker
+  namespace: metallb-system
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  labels:
+    app: metallb
+  name: metallb-system:controller
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: metallb-system:controller
+subjects:
+- kind: ServiceAccount
+  name: controller
+  namespace: metallb-system
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  labels:
+    app: metallb
+  name: metallb-system:speaker
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: metallb-system:speaker
+subjects:
+- kind: ServiceAccount
+  name: speaker
+  namespace: metallb-system
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: webhook-service
+  namespace: metallb-system
+spec:
+  ports:
+  - port: 443
+    targetPort: 9443
+  selector:
+    component: controller
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  labels:
+    app: metallb
+    component: controller
+  name: controller
+  namespace: metallb-system
+spec:
+  revisionHistoryLimit: 3
+  selector:
+    matchLabels:
+      app: metallb
+      component: controller
+  template:
+    metadata:
+      annotations:
+        prometheus.io/port: "7472"
+        prometheus.io/scrape: "true"
+      labels:
+        app: metallb
+        component: controller
+    spec:
+      containers:
+      - args:
+        - --enable-webhook
+        - --port=7472
+        - --log-level=info
+        env:
+        - name: METALLB_ML_SECRET_NAME
+          value: memberlist
+        - name: METALLB_DEPLOYMENT
+          value: controller
+        image: quay.io/metallb/controller:main
+        livenessProbe:
+          failureThreshold: 3
+          httpGet:
+            path: /metrics
+            port: monitoring
+          initialDelaySeconds: 10
+          periodSeconds: 10
+          successThreshold: 1
+          timeoutSeconds: 1
+        name: controller
+        ports:
+        - containerPort: 9443
+          name: webhook-server
+          protocol: TCP
+        - containerPort: 7472
+          name: monitoring
+        readinessProbe:
+          failureThreshold: 3
+          httpGet:
+            path: /metrics
+            port: monitoring
+          initialDelaySeconds: 10
+          periodSeconds: 10
+          successThreshold: 1
+          timeoutSeconds: 1
+        securityContext:
+          allowPrivilegeEscalation: false
+          capabilities:
+            drop:
+            - all
+          readOnlyRootFilesystem: true
+        volumeMounts:
+        - mountPath: /tmp/k8s-webhook-server/serving-certs
+          name: cert
+          readOnly: true
+      nodeSelector:
+        kubernetes.io/os: linux
+      securityContext:
+        fsGroup: 65534
+        runAsNonRoot: true
+        runAsUser: 65534
+      serviceAccountName: controller
+      terminationGracePeriodSeconds: 0
+      volumes:
+      - name: cert
+        secret:
+          defaultMode: 420
+          secretName: webhook-server-cert
+---
+apiVersion: apps/v1
+kind: DaemonSet
+metadata:
+  labels:
+    app: metallb
+    component: speaker
+  name: speaker
+  namespace: metallb-system
+spec:
+  selector:
+    matchLabels:
+      app: metallb
+      component: speaker
+  template:
+    metadata:
+      annotations:
+        prometheus.io/port: "7472"
+        prometheus.io/scrape: "true"
+      labels:
+        app: metallb
+        component: speaker
+    spec:
+      containers:
+      - args:
+        - --port=7472
+        - --log-level=info
+        env:
+        - name: METALLB_NODE_NAME
+          valueFrom:
+            fieldRef:
+              fieldPath: spec.nodeName
+        - name: METALLB_HOST
+          valueFrom:
+            fieldRef:
+              fieldPath: status.hostIP
+        - name: METALLB_ML_BIND_ADDR
+          valueFrom:
+            fieldRef:
+              fieldPath: status.podIP
+        - name: METALLB_ML_LABELS
+          value: app=metallb,component=speaker
+        - name: METALLB_ML_SECRET_KEY
+          valueFrom:
+            secretKeyRef:
+              key: secretkey
+              name: memberlist
+        image: quay.io/metallb/speaker:main
+        livenessProbe:
+          failureThreshold: 3
+          httpGet:
+            path: /metrics
+            port: monitoring
+          initialDelaySeconds: 10
+          periodSeconds: 10
+          successThreshold: 1
+          timeoutSeconds: 1
+        name: speaker
+        ports:
+        - containerPort: 7472
+          name: monitoring
+        - containerPort: 7946
+          name: memberlist-tcp
+        - containerPort: 7946
+          name: memberlist-udp
+          protocol: UDP
+        readinessProbe:
+          failureThreshold: 3
+          httpGet:
+            path: /metrics
+            port: monitoring
+          initialDelaySeconds: 10
+          periodSeconds: 10
+          successThreshold: 1
+          timeoutSeconds: 1
+        securityContext:
+          allowPrivilegeEscalation: false
+          capabilities:
+            add:
+            - NET_RAW
+            drop:
+            - ALL
+          readOnlyRootFilesystem: true
+      hostNetwork: true
+      nodeSelector:
+        kubernetes.io/os: linux
+      serviceAccountName: speaker
+      terminationGracePeriodSeconds: 2
+      tolerations:
+      - effect: NoSchedule
+        key: node-role.kubernetes.io/master
+        operator: Exists
+---
+apiVersion: cert-manager.io/v1
+kind: Certificate
+metadata:
+  name: serving-cert
+  namespace: metallb-system
+spec:
+  dnsNames:
+  - webhook-service.metallb-system.svc
+  - webhook-service.metallb-system.svc.cluster.local
+  issuerRef:
+    kind: Issuer
+    name: selfsigned-issuer
+  secretName: webhook-server-cert
+---
+apiVersion: cert-manager.io/v1
+kind: Issuer
+metadata:
+  name: selfsigned-issuer
+  namespace: metallb-system
+spec:
+  selfSigned: {}
+---
+apiVersion: admissionregistration.k8s.io/v1
+kind: ValidatingWebhookConfiguration
+metadata:
+  annotations:
+    cert-manager.io/inject-ca-from: metallb-system/serving-cert
+  name: validating-webhook-configuration
+webhooks:
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: metallb-system
+      path: /validate-metallb-io-v1beta1-addresspool
+  failurePolicy: Fail
+  name: addresspoolvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - addresspools
+  sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: metallb-system
+      path: /validate-metallb-io-v1beta1-bgpadvertisement
+  failurePolicy: Fail
+  name: bgpadvertisementvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - bgpadvertisements
+  sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: metallb-system
+      path: /validate-metallb-io-v1beta1-ippool
+  failurePolicy: Fail
+  name: ippoolvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - ippools
+  sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: metallb-system
+      path: /validate-metallb-io-v1beta2-bgppeer
+  failurePolicy: Fail
+  name: bgppeersvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta2
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - bgppeers
+  sideEffects: None
diff --git a/config/native-with-webhook/controller-webhook-patch.yaml b/config/native-with-webhook/controller-webhook-patch.yaml
new file mode 100644
index 00000000..4104475c
--- /dev/null
+++ b/config/native-with-webhook/controller-webhook-patch.yaml
@@ -0,0 +1,27 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: controller
+  namespace: metallb-system
+spec:
+  template:
+    spec:
+      containers:
+      - name: controller
+        ports:
+        - containerPort: 9443
+          name: webhook-server
+          protocol: TCP
+        volumeMounts:
+        - mountPath: /tmp/k8s-webhook-server/serving-certs
+          name: cert
+          readOnly: true
+        args:
+        - --enable-webhook
+        - --port=7472
+        - --log-level=info
+      volumes:
+      - name: cert
+        secret:
+          defaultMode: 420
+          secretName: webhook-server-cert
diff --git a/config/native-with-webhook/kustomization.yaml b/config/native-with-webhook/kustomization.yaml
new file mode 100644
index 00000000..835cb0a5
--- /dev/null
+++ b/config/native-with-webhook/kustomization.yaml
@@ -0,0 +1,43 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- ../native
+- ../webhook
+- ../certmanager
+
+patchesStrategicMerge:
+- controller-webhook-patch.yaml
+- webhookcainjection-patch.yaml
+
+# the following config is for teaching kustomize how to do var substitution
+# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
+vars:
+- fieldref:
+    fieldPath: metadata.namespace
+  name: CERTIFICATE_NAMESPACE
+  objref:
+    group: cert-manager.io
+    kind: Certificate
+    name: serving-cert
+    version: v1
+- fieldref: {}
+  name: CERTIFICATE_NAME
+  objref:
+    group: cert-manager.io
+    kind: Certificate
+    name: serving-cert
+    version: v1
+- fieldref:
+    fieldPath: metadata.namespace
+  name: SERVICE_NAMESPACE
+  objref:
+    kind: Service
+    name: webhook-service
+    version: v1
+- fieldref: {}
+  name: SERVICE_NAME
+  objref:
+    kind: Service
+    name: webhook-service
+    version: v1
+namespace: metallb-system
diff --git a/config/native-with-webhook/webhookcainjection-patch.yaml b/config/native-with-webhook/webhookcainjection-patch.yaml
new file mode 100644
index 00000000..47ef1d13
--- /dev/null
+++ b/config/native-with-webhook/webhookcainjection-patch.yaml
@@ -0,0 +1,8 @@
+# This patch add annotation to admission webhook config and
+# the variables $(CERTIFICATE_NAMESPACE) and $(CERTIFICATE_NAME) will be substituted by kustomize.
+apiVersion: admissionregistration.k8s.io/v1
+kind: ValidatingWebhookConfiguration
+metadata:
+  name: validating-webhook-configuration
+  annotations:
+    cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME)
diff --git a/config/webhook/kustomization.yaml b/config/webhook/kustomization.yaml
new file mode 100644
index 00000000..9cf26134
--- /dev/null
+++ b/config/webhook/kustomization.yaml
@@ -0,0 +1,6 @@
+resources:
+- manifests.yaml
+- service.yaml
+
+configurations:
+- kustomizeconfig.yaml
diff --git a/config/webhook/kustomizeconfig.yaml b/config/webhook/kustomizeconfig.yaml
new file mode 100644
index 00000000..e809f782
--- /dev/null
+++ b/config/webhook/kustomizeconfig.yaml
@@ -0,0 +1,18 @@
+# the following config is for teaching kustomize where to look at when substituting vars.
+# It requires kustomize v2.1.0 or newer to work properly.
+nameReference:
+- kind: Service
+  version: v1
+  fieldSpecs:
+  - kind: ValidatingWebhookConfiguration
+    group: admissionregistration.k8s.io
+    path: webhooks/clientConfig/service/name
+
+namespace:
+- kind: ValidatingWebhookConfiguration
+  group: admissionregistration.k8s.io
+  path: webhooks/clientConfig/service/namespace
+  create: true
+
+varReference:
+- path: metadata/annotations
diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml
new file mode 100644
index 00000000..8135b3c4
--- /dev/null
+++ b/config/webhook/manifests.yaml
@@ -0,0 +1,88 @@
+
+---
+apiVersion: admissionregistration.k8s.io/v1
+kind: ValidatingWebhookConfiguration
+metadata:
+  creationTimestamp: null
+  name: validating-webhook-configuration
+webhooks:
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: system
+      path: /validate-metallb-io-v1beta1-addresspool
+  failurePolicy: Fail
+  name: addresspoolvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - addresspools
+  sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: system
+      path: /validate-metallb-io-v1beta1-bgpadvertisement
+  failurePolicy: Fail
+  name: bgpadvertisementvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - bgpadvertisements
+  sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: system
+      path: /validate-metallb-io-v1beta1-ippool
+  failurePolicy: Fail
+  name: ippoolvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta1
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - ippools
+  sideEffects: None
+- admissionReviewVersions:
+  - v1
+  clientConfig:
+    service:
+      name: webhook-service
+      namespace: system
+      path: /validate-metallb-io-v1beta2-bgppeer
+  failurePolicy: Fail
+  name: bgppeersvalidationwebhook.metallb.io
+  rules:
+  - apiGroups:
+    - metallb.io
+    apiVersions:
+    - v1beta2
+    operations:
+    - CREATE
+    - UPDATE
+    resources:
+    - bgppeers
+  sideEffects: None
diff --git a/config/webhook/patches/webhook_in_addresspools.yaml b/config/webhook/patches/webhook_in_addresspools.yaml
new file mode 100644
index 00000000..026537d4
--- /dev/null
+++ b/config/webhook/patches/webhook_in_addresspools.yaml
@@ -0,0 +1,18 @@
+# The following patch enables conversion webhook for CRD
+# CRD conversion requires k8s 1.13 or later.
+apiVersion: apiextensions.k8s.io/v1beta1
+kind: CustomResourceDefinition
+metadata:
+  name: addresspools.metallb.io
+spec:
+  conversion:
+    strategy: Webhook
+    conversionReviewVersions: ["v1alpha1", "v1beta1"]
+    webhookClientConfig:
+      # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank,
+      # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager)
+      caBundle: Cg==
+      service:
+        namespace: system
+        name: webhook-service
+        path: /convert
diff --git a/config/webhook/service.yaml b/config/webhook/service.yaml
new file mode 100644
index 00000000..e4bd1774
--- /dev/null
+++ b/config/webhook/service.yaml
@@ -0,0 +1,11 @@
+apiVersion: v1
+kind: Service
+metadata:
+  name: webhook-service
+  namespace: system
+spec:
+  ports:
+    - port: 443
+      targetPort: 9443
+  selector:
+    component: controller
diff --git a/controller/main.go b/controller/main.go
index 3fdd0ed4..1b3547bb 100644
--- a/controller/main.go
+++ b/controller/main.go
@@ -23,7 +23,6 @@ import (
 
 	"go.universe.tf/metallb/internal/allocator"
 	"go.universe.tf/metallb/internal/config"
-	metallbcfg "go.universe.tf/metallb/internal/config"
 	"go.universe.tf/metallb/internal/k8s"
 	"go.universe.tf/metallb/internal/k8s/controllers"
 	"go.universe.tf/metallb/internal/k8s/epslices"
@@ -125,6 +124,7 @@ func main() {
 		logLevel        = flag.String("log-level", "info", fmt.Sprintf("log level. must be one of: [%s]", logging.Levels.String()))
 		disableEpSlices = flag.Bool("disable-epslices", false, "Disable the usage of EndpointSlices and default to Endpoints instead of relying on the autodiscovery mechanism")
 		enablePprof     = flag.Bool("enable-pprof", false, "Enable pprof profiling")
+		enableWebhook   = flag.Bool("enable-webhook", false, "Enable validation webhook")
 	)
 	flag.Parse()
 
@@ -149,6 +149,13 @@ func main() {
 		ips: allocator.New(),
 	}
 
+	bgpType, present := os.LookupEnv("METALLB_BGP_TYPE")
+	if !present {
+		bgpType = "native"
+	}
+
+	validation := config.ValidationFor(bgpType)
+
 	client, err := k8s.New(&k8s.Config{
 		ProcessName:     "metallb-controller",
 		MetricsPort:     *port,
@@ -161,7 +168,8 @@ func main() {
 			ServiceChanged: c.SetBalancer,
 			ConfigChanged:  c.SetConfig,
 		},
-		ValidateConfig: metallbcfg.DontValidate, // the controller is not aware of the mode, we defer the validation to the speaker
+		ValidateConfig: validation,
+		EnableWebhook:  *enableWebhook,
 	})
 	if err != nil {
 		level.Error(logger).Log("op", "startup", "error", err, "msg", "failed to create k8s client")
diff --git a/internal/config/config.go b/internal/config/config.go
index c940059a..86e401d4 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -34,13 +34,13 @@ import (
 )
 
 type ClusterResources struct {
-	Pools              []metallbv1beta1.IPPool
-	Peers              []metallbv1beta2.BGPPeer
-	BFDProfiles        []metallbv1beta1.BFDProfile
-	BGPAdvs            []metallbv1beta1.BGPAdvertisement
-	L2Advs             []metallbv1beta1.L2Advertisement
-	LegacyAddressPools []metallbv1beta1.AddressPool
-	PasswordSecrets    map[string]corev1.Secret
+	Pools              []metallbv1beta1.IPPool           `json:"ippools"`
+	Peers              []metallbv1beta2.BGPPeer          `json:"bgppeers"`
+	BFDProfiles        []metallbv1beta1.BFDProfile       `json:"bfdprofiles"`
+	BGPAdvs            []metallbv1beta1.BGPAdvertisement `json:"bgpadvertisements"`
+	L2Advs             []metallbv1beta1.L2Advertisement  `json:"l2advertisements"`
+	LegacyAddressPools []metallbv1beta1.AddressPool      `json:"legacyaddresspools"`
+	PasswordSecrets    map[string]corev1.Secret          `json:"passwordsecrets"`
 }
 
 // Config is a parsed MetalLB configuration.
@@ -175,14 +175,14 @@ func For(resources ClusterResources, validate Validate) (*Config, error) {
 		cfg.BFDProfiles[bfd.Name] = parsed
 	}
 
-	for i, p := range resources.Peers {
+	for _, p := range resources.Peers {
 		peer, err := peerFromCR(p, resources.PasswordSecrets)
 		if err != nil {
-			return nil, fmt.Errorf("parsing peer #%d: %s", i+1, err)
+			return nil, errors.Wrapf(err, "parsing peer %s", p.Name)
 		}
 		if peer.BFDProfile != "" {
 			if _, ok := cfg.BFDProfiles[peer.BFDProfile]; !ok {
-				return nil, fmt.Errorf("peer #%d referencing non existing bfd profile %s", i+1, peer.BFDProfile)
+				return nil, TransientError{fmt.Sprintf("peer %s referencing non existing bfd profile %s", p.Name, peer.BFDProfile)}
 			}
 		}
 		for _, ep := range cfg.Peers {
@@ -190,7 +190,7 @@ func For(resources ClusterResources, validate Validate) (*Config, error) {
 			// peers could have a different hold time but they'd still result
 			// in two BGP sessions between the speaker and the remote host.
 			if reflect.DeepEqual(peer, ep) {
-				return nil, fmt.Errorf("peer #%d already exists", i+1)
+				return nil, fmt.Errorf("peer %s already exists", p.Name)
 			}
 
 		}
@@ -210,10 +210,10 @@ func For(resources ClusterResources, validate Validate) (*Config, error) {
 	*/
 
 	var allCIDRs []*net.IPNet
-	for i, p := range resources.Pools {
+	for _, p := range resources.Pools {
 		pool, err := addressPoolFromCR(p, communities)
 		if err != nil {
-			return nil, fmt.Errorf("parsing address pool #%d: %s", i+1, err)
+			return nil, fmt.Errorf("parsing address pool %s: %s", p.Name, err)
 		}
 
 		// Check that the pool isn't already defined
@@ -286,10 +286,10 @@ func For(resources ClusterResources, validate Validate) (*Config, error) {
 		}
 	}
 
-	for i, p := range resources.LegacyAddressPools {
+	for _, p := range resources.LegacyAddressPools {
 		pool, err := addressPoolFromLegacyCR(p, communities)
 		if err != nil {
-			return nil, fmt.Errorf("parsing address pool #%d: %s", i+1, err)
+			return nil, fmt.Errorf("parsing address pool %s: %s", p.Name, err)
 		}
 
 		// Check that the pool isn't already defined
@@ -325,7 +325,7 @@ func peerFromCR(p metallbv1beta2.BGPPeer, passwordSecrets map[string]corev1.Secr
 	}
 	ip := net.ParseIP(p.Spec.Address)
 	if ip == nil {
-		return nil, fmt.Errorf("invalid peer IP %q", p.Spec.Address)
+		return nil, fmt.Errorf("invalid BGPPeer address %q", p.Spec.Address)
 	}
 	holdTime := p.Spec.HoldTime.Duration
 	if holdTime == 0 {
@@ -404,7 +404,7 @@ func passwordForPeer(p metallbv1beta2.BGPPeer, passwordSecrets map[string]corev1
 	} else if p.Spec.PasswordSecret.Name != "" {
 		secret, ok := passwordSecrets[p.Spec.PasswordSecret.Name]
 		if !ok {
-			return "", fmt.Errorf("secret ref not found for peer config %q/%q", p.Namespace, p.Name)
+			return "", TransientError{Message: fmt.Sprintf("secret ref not found for peer config %q/%q", p.Namespace, p.Name)}
 		}
 		if secret.Type != corev1.SecretTypeBasicAuth {
 			return "", fmt.Errorf("secret type mismatch on %q/%q, type %q is expected ", secret.Namespace,
@@ -636,7 +636,7 @@ func bgpAdvertisementsFromLegacyCR(ads []metallbv1beta1.LegacyBgpAdvertisement,
 			// We reject if none of the cidrs are compatible with the aggregation length.
 			lowest := lowestMask(cidrs)
 			if maxLength < lowest {
-				return nil, fmt.Errorf("invalid aggregation length %d: prefix %q in "+
+				return nil, fmt.Errorf("invalid aggregation length %d: prefix %d in "+
 					"this pool is more specific than the aggregation length for addresses %s", ad.AggregationLength, lowest, addr)
 			}
 		}
@@ -683,7 +683,7 @@ func validateBGPAdvPerPool(adv *BGPAdvertisement, pool *Pool) error {
 		// We reject if none of the cidrs are compatible with the aggregation length.
 		lowest := lowestMask(cidrs)
 		if maxLength < lowest {
-			return fmt.Errorf("invalid aggregation length %d: prefix %q in "+
+			return fmt.Errorf("invalid aggregation length %d: prefix %d in "+
 				"this pool is more specific than the aggregation length for addresses %s", adv.AggregationLength, lowest, addr)
 		}
 	}
diff --git a/internal/config/validate.go b/internal/config/validation.go
similarity index 93%
rename from internal/config/validate.go
rename to internal/config/validation.go
index 13b160ee..e874b086 100644
--- a/internal/config/validate.go
+++ b/internal/config/validation.go
@@ -10,6 +10,16 @@ import (
 
 type Validate func(ClusterResources) error
 
+func ValidationFor(bgpImpl string) Validate {
+	switch bgpImpl {
+	case "frr":
+		return DiscardNativeOnly
+	case "native":
+		return DiscardFRROnly
+	}
+	return DontValidate
+}
+
 // DiscardFRROnly returns an error if the current configFile contains
 // any options that are available only in the FRR implementation.
 func DiscardFRROnly(c ClusterResources) error {
diff --git a/internal/config/validate_test.go b/internal/config/validation_test.go
similarity index 100%
rename from internal/config/validate_test.go
rename to internal/config/validation_test.go
diff --git a/internal/config/validator.go b/internal/config/validator.go
new file mode 100644
index 00000000..8b9af2df
--- /dev/null
+++ b/internal/config/validator.go
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package config
+
+import (
+	"errors"
+
+	metallbv1beta1 "go.universe.tf/metallb/api/v1beta1"
+	metallbv1beta2 "go.universe.tf/metallb/api/v1beta2"
+	apivalidate "go.universe.tf/metallb/api/validate"
+
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+// TransientError is an error that happens due to interdependencies
+// between crds, such as referencing non-existing bfd profile.
+type TransientError struct {
+	Message string
+}
+
+func (e TransientError) Error() string { return e.Message }
+
+// validator is an implementation of the resources validator
+// that tries to parse the configuration and fails if the returned
+// error is non transitient.
+type validator struct {
+	validate Validate
+}
+
+func (v *validator) Validate(resources ...client.ObjectList) error {
+	clusterResources := ClusterResources{
+		Pools:              make([]metallbv1beta1.IPPool, 0),
+		Peers:              make([]metallbv1beta2.BGPPeer, 0),
+		BFDProfiles:        make([]metallbv1beta1.BFDProfile, 0),
+		BGPAdvs:            make([]metallbv1beta1.BGPAdvertisement, 0),
+		L2Advs:             make([]metallbv1beta1.L2Advertisement, 0),
+		LegacyAddressPools: make([]metallbv1beta1.AddressPool, 0),
+	}
+	for _, list := range resources {
+		switch list := list.(type) {
+		case *metallbv1beta1.IPPoolList:
+			clusterResources.Pools = append(clusterResources.Pools, list.Items...)
+		case *metallbv1beta2.BGPPeerList:
+			clusterResources.Peers = append(clusterResources.Peers, list.Items...)
+		case *metallbv1beta1.BFDProfileList:
+			clusterResources.BFDProfiles = append(clusterResources.BFDProfiles, list.Items...)
+		case *metallbv1beta1.BGPAdvertisementList:
+			clusterResources.BGPAdvs = append(clusterResources.BGPAdvs, list.Items...)
+		case *metallbv1beta1.L2AdvertisementList:
+			clusterResources.L2Advs = append(clusterResources.L2Advs, list.Items...)
+		case *metallbv1beta1.AddressPoolList:
+			clusterResources.LegacyAddressPools = append(clusterResources.LegacyAddressPools, list.Items...)
+		}
+	}
+	_, err := For(clusterResources, v.validate)
+	if errors.As(err, &TransientError{}) { // we do not want to make assumption on ordering in webhooks.
+		return nil
+	}
+	return err
+}
+
+func NewValidator(validate Validate) apivalidate.ClusterObjects {
+	return &validator{validate: validate}
+}
diff --git a/internal/k8s/controllers/config_controller.go b/internal/k8s/controllers/config_controller.go
index 215c46e5..bf52a775 100644
--- a/internal/k8s/controllers/config_controller.go
+++ b/internal/k8s/controllers/config_controller.go
@@ -41,6 +41,7 @@ type ConfigReconciler struct {
 	Handler        func(log.Logger, *config.Config) SyncState
 	ValidateConfig config.Validate
 	ForceReload    func()
+	BGPType        string
 }
 
 func (r *ConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
@@ -98,7 +99,7 @@ func (r *ConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 		PasswordSecrets:    secrets,
 	}
 
-	level.Debug(r.Logger).Log("controller", "ConfigReconciler", "metallb CRs and Secrets", spew.Sdump(resources))
+	level.Debug(r.Logger).Log("controller", "ConfigReconciler", "metallb CRs and Secrets", dumpClusterResources(&resources))
 
 	cfg, err := config.For(resources, r.ValidateConfig)
 	if err != nil {
@@ -111,13 +112,13 @@ func (r *ConfigReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
 	res := r.Handler(r.Logger, cfg)
 	switch res {
 	case SyncStateError:
-		level.Error(r.Logger).Log("controller", "ConfigReconciler", "metallb CRs and Secrets", spew.Sdump(resources), "event", "reload failed, retry")
+		level.Error(r.Logger).Log("controller", "ConfigReconciler", "metallb CRs and Secrets", dumpClusterResources(&resources), "event", "reload failed, retry")
 		return ctrl.Result{}, retryError
 	case SyncStateReprocessAll:
 		level.Info(r.Logger).Log("controller", "ConfigReconciler", "event", "force service reload")
 		r.ForceReload()
 	case SyncStateErrorNoRetry:
-		level.Error(r.Logger).Log("controller", "ConfigReconciler", "metallb CRs and Secrets", spew.Sdump(resources), "event", "reload failed, no retry")
+		level.Error(r.Logger).Log("controller", "ConfigReconciler", "metallb CRs and Secrets", dumpClusterResources(&resources), "event", "reload failed, no retry")
 		return ctrl.Result{}, nil
 	}
 
diff --git a/internal/k8s/controllers/dump.go b/internal/k8s/controllers/dump.go
new file mode 100644
index 00000000..630c22e3
--- /dev/null
+++ b/internal/k8s/controllers/dump.go
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package controllers
+
+import (
+	"encoding/json"
+
+	"github.com/davecgh/go-spew/spew"
+	"go.universe.tf/metallb/internal/config"
+	corev1 "k8s.io/api/core/v1"
+)
+
+func dumpClusterResources(c *config.ClusterResources) string {
+	withNoSecret := config.ClusterResources{
+		Pools:              c.Pools,
+		Peers:              c.Peers,
+		BFDProfiles:        c.BFDProfiles,
+		L2Advs:             c.L2Advs,
+		BGPAdvs:            c.BGPAdvs,
+		LegacyAddressPools: c.LegacyAddressPools,
+	}
+	withNoSecret.PasswordSecrets = make(map[string]corev1.Secret)
+	for k, s := range c.PasswordSecrets {
+		secretToDump := *s.DeepCopy()
+		secretToDump.Data = nil
+		withNoSecret.PasswordSecrets[k] = secretToDump
+	}
+	return dumpResource(withNoSecret)
+}
+
+func dumpResource(i interface{}) string {
+	toDump, err := json.Marshal(i)
+	if err != nil {
+		return spew.Sdump(i)
+	}
+	return string(toDump)
+}
diff --git a/internal/k8s/controllers/service_controller.go b/internal/k8s/controllers/service_controller.go
index be7786d7..808a9bd5 100644
--- a/internal/k8s/controllers/service_controller.go
+++ b/internal/k8s/controllers/service_controller.go
@@ -19,7 +19,6 @@ package controllers
 import (
 	"context"
 
-	"github.com/davecgh/go-spew/spew"
 	"github.com/go-kit/kit/log"
 	"github.com/go-kit/kit/log/level"
 
@@ -65,7 +64,7 @@ func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 		return ctrl.Result{}, err
 	}
 	if service != nil {
-		level.Debug(r.Logger).Log("controller", "ServiceReconciler", "processing service", spew.Sdump(service))
+		level.Debug(r.Logger).Log("controller", "ServiceReconciler", "processing service", dumpResource(service))
 	} else {
 		level.Debug(r.Logger).Log("controller", "ServiceReconciler", "processing deletion on service", req.NamespacedName.String())
 	}
@@ -73,7 +72,7 @@ func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
 	res := r.Handler(r.Logger, req.NamespacedName.String(), service, epSlices)
 	switch res {
 	case SyncStateError:
-		level.Error(r.Logger).Log("controller", "ServiceReconciler", "name", req.NamespacedName.String(), "service", spew.Sdump(service), "endpoints", spew.Sdump(epSlices), "event", "failed to handle service")
+		level.Error(r.Logger).Log("controller", "ServiceReconciler", "name", req.NamespacedName.String(), "service", dumpResource(service), "endpoints", dumpResource(epSlices), "event", "failed to handle service")
 		return ctrl.Result{}, nil
 	case SyncStateReprocessAll:
 		level.Info(r.Logger).Log("controller", "ServiceReconciler", "event", "force service reload")
@@ -101,6 +100,7 @@ func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
 						level.Error(r.Logger).Log("controller", "ServiceReconciler", "error", "failed to get serviceName for slice", "error", err, "epslice", epSlice.Name)
 						return []reconcile.Request{}
 					}
+					level.Debug(r.Logger).Log("controller", "ServiceReconciler", "enqueueing", serviceName, "epslice", dumpResource(epSlice))
 					return []reconcile.Request{{NamespacedName: serviceName}}
 				})).
 			Complete(r)
@@ -115,7 +115,9 @@ func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
 						level.Error(r.Logger).Log("controller", "ServiceReconciler", "error", "received an object that is not an endpoint")
 						return []reconcile.Request{}
 					}
-					return []reconcile.Request{{NamespacedName: types.NamespacedName{Name: endpoints.Name, Namespace: endpoints.Namespace}}}
+					name := types.NamespacedName{Name: endpoints.Name, Namespace: endpoints.Namespace}
+					level.Debug(r.Logger).Log("controller", "ServiceReconciler", "enqueueing", name, "endpoints", dumpResource(endpoints))
+					return []reconcile.Request{{NamespacedName: name}}
 				})).
 			Complete(r)
 
diff --git a/internal/k8s/controllers/servicereload_controller.go b/internal/k8s/controllers/servicereload_controller.go
index 821cecdf..92dc0b84 100644
--- a/internal/k8s/controllers/servicereload_controller.go
+++ b/internal/k8s/controllers/servicereload_controller.go
@@ -19,7 +19,6 @@ package controllers
 import (
 	"context"
 
-	"github.com/davecgh/go-spew/spew"
 	"github.com/go-kit/kit/log"
 	"github.com/go-kit/kit/log/level"
 
@@ -88,23 +87,23 @@ func (r *ServiceReloadReconciler) reprocessAllServices(ctx context.Context) (ctr
 			return ctrl.Result{}, err
 		}
 
-		level.Debug(r.Log).Log("controller", "ServiceReloadReconciler", "reprocessing service", spew.Sdump(service))
+		level.Debug(r.Log).Log("controller", "ServiceReloadReconciler", "reprocessing service", dumpResource(service))
 
 		res := r.Handler(r.Log, serviceName.String(), &service, eps)
 		switch res {
 		case SyncStateError:
-			level.Error(r.Log).Log("controller", "ServiceReloadReconciler", "name", serviceName, "service", spew.Sdump(service), "endpoints", spew.Sdump(eps), "event", "failed to handle service, retry")
+			level.Error(r.Log).Log("controller", "ServiceReloadReconciler", "name", serviceName, "service", dumpResource(service), "endpoints", dumpResource(eps), "event", "failed to handle service, retry")
 			retry = true
 		case SyncStateReprocessAll:
 			retry = true
 		case SyncStateErrorNoRetry:
-			level.Error(r.Log).Log("controller", "ServiceReloadReconciler", "name", serviceName, "service", spew.Sdump(service), "endpoints", spew.Sdump(eps), "event", "failed to handle service, no retry")
+			level.Error(r.Log).Log("controller", "ServiceReloadReconciler", "name", serviceName, "service", dumpResource(service), "endpoints", dumpResource(eps), "event", "failed to handle service, no retry")
 		}
 	}
 	if retry {
 		// in case we want to retry, we return an error to trigger the exponential backoff mechanism so that
 		// this controller won't loop at full speed
-		level.Info(r.Log).Log("controller", "ConfigReconciler", "event", "force service reload")
+		level.Info(r.Log).Log("controller", "ServiceReloadReconciler", "event", "force service reload")
 		return ctrl.Result{}, retryError
 	}
 	return ctrl.Result{}, nil
diff --git a/internal/k8s/k8s.go b/internal/k8s/k8s.go
index 450e9512..5408074d 100644
--- a/internal/k8s/k8s.go
+++ b/internal/k8s/k8s.go
@@ -97,7 +97,7 @@ type Config struct {
 	DisableEpSlices bool
 	Namespace       string
 	ValidateConfig  config.Validate
-
+	EnableWebhook   bool
 	Listener
 }
 
@@ -239,6 +239,14 @@ func New(cfg *Config) (*Client, error) {
 		Reload:    reloadChan,
 	}
 
+	if cfg.EnableWebhook {
+		err := enableWebhook(mgr, cfg.ValidateConfig, cfg.Namespace, cfg.Logger)
+		if err != nil {
+			level.Error(c.logger).Log("error", err, "unable to create", "webhooks")
+			return nil, err
+		}
+	}
+
 	mux := http.NewServeMux()
 	mux.Handle("/metrics", promhttp.Handler())
 
@@ -260,6 +268,42 @@ func New(cfg *Config) (*Client, error) {
 	return c, nil
 }
 
+func enableWebhook(mgr manager.Manager, validate config.Validate, namespace string, logger log.Logger) error {
+	level.Info(logger).Log("op", "startup", "action", "webhooks enabled")
+
+	// Used by all the webhooks
+	metallbv1beta1.MetalLBNamespace = namespace
+	metallbv1beta2.MetalLBNamespace = namespace
+	metallbv1beta1.Logger = logger
+	metallbv1beta2.Logger = logger
+	metallbv1beta1.WebhookClient = mgr.GetAPIReader()
+	metallbv1beta2.WebhookClient = mgr.GetAPIReader()
+	metallbv1beta1.Validator = config.NewValidator(validate)
+	metallbv1beta2.Validator = config.NewValidator(validate)
+
+	if err := (&metallbv1beta1.AddressPool{}).SetupWebhookWithManager(mgr); err != nil {
+		level.Error(logger).Log("op", "startup", "error", err, "msg", "unable to create webhook", "webhook", "AddressPool")
+		return err
+	}
+
+	if err := (&metallbv1beta1.IPPool{}).SetupWebhookWithManager(mgr); err != nil {
+		level.Error(logger).Log("op", "startup", "error", err, "msg", "unable to create webhook", "webhook", "IPPool")
+		return err
+	}
+
+	if err := (&metallbv1beta2.BGPPeer{}).SetupWebhookWithManager(mgr); err != nil {
+		level.Error(logger).Log("op", "startup", "error", err, "msg", "unable to create webhook", "webhook", "BGPPeer v1beta2")
+		return err
+	}
+
+	if err := (&metallbv1beta1.BGPAdvertisement{}).SetupWebhookWithManager(mgr); err != nil {
+		level.Error(logger).Log("op", "startup", "error", err, "msg", "unable to create webhook", "webhook", "BGPAdvertisement")
+		return err
+	}
+
+	return nil
+}
+
 // CreateMlSecret create the memberlist secret.
 func (c *Client) CreateMlSecret(namespace, controllerDeploymentName, secretName string) error {
 	// Use List instead of Get to differentiate between API errors and non existing secret.
diff --git a/speaker/bgp_controller.go b/speaker/bgp_controller.go
index 1e32331f..235b9381 100644
--- a/speaker/bgp_controller.go
+++ b/speaker/bgp_controller.go
@@ -85,11 +85,13 @@ newPeers:
 			continue
 		}
 		level.Info(l).Log("event", "peerRemoved", "peer", p.cfg.Addr, "reason", "removedFromConfig", "msg", "peer deconfigured, closing BGP session")
+
 		if p.session != nil {
 			if err := p.session.Close(); err != nil {
 				level.Error(l).Log("op", "setConfig", "error", err, "peer", p.cfg.Addr, "msg", "failed to shut down BGP session")
 			}
 		}
+		level.Debug(l).Log("event", "peerRemoved", "peer", p.cfg.Addr, "reason", "removedFromConfig", "msg", "peer deconfigured, BGP session closed")
 	}
 
 	err := c.syncBFDProfiles(cfg.BFDProfiles)
diff --git a/speaker/layer2_controller.go b/speaker/layer2_controller.go
index 83914875..a07c4f66 100644
--- a/speaker/layer2_controller.go
+++ b/speaker/layer2_controller.go
@@ -21,6 +21,7 @@ import (
 	"sort"
 
 	"github.com/go-kit/kit/log"
+	"github.com/go-kit/kit/log/level"
 	"go.universe.tf/metallb/internal/config"
 	"go.universe.tf/metallb/internal/k8s/epslices"
 	"go.universe.tf/metallb/internal/layer2"
@@ -94,6 +95,7 @@ func usableNodes(eps epslices.EpsOrSlices, speakers map[string]bool) []string {
 
 func (c *layer2Controller) ShouldAnnounce(l log.Logger, name string, toAnnounce []net.IP, svc *v1.Service, eps epslices.EpsOrSlices) string {
 	if !activeEndpointExists(eps) { // no active endpoints, just return
+		level.Debug(l).Log("event", "shouldannounce", "protocol", "l2", "message", "failed no active endpoints", "service", name)
 		return "notOwner"
 	}
 	var nodes []string
diff --git a/speaker/main.go b/speaker/main.go
index f98002d5..d2877347 100644
--- a/speaker/main.go
+++ b/speaker/main.go
@@ -23,22 +23,20 @@ import (
 	"os/signal"
 	"syscall"
 
+	"github.com/go-kit/kit/log"
+	"github.com/go-kit/kit/log/level"
+	"github.com/prometheus/client_golang/prometheus"
 	"go.universe.tf/metallb/internal/bgp"
 	"go.universe.tf/metallb/internal/config"
+	metallbcfg "go.universe.tf/metallb/internal/config"
 	"go.universe.tf/metallb/internal/k8s"
 	"go.universe.tf/metallb/internal/k8s/controllers"
-	v1 "k8s.io/api/core/v1"
-
-	metallbcfg "go.universe.tf/metallb/internal/config"
 	"go.universe.tf/metallb/internal/k8s/epslices"
 	"go.universe.tf/metallb/internal/layer2"
 	"go.universe.tf/metallb/internal/logging"
 	"go.universe.tf/metallb/internal/speakerlist"
 	"go.universe.tf/metallb/internal/version"
-
-	"github.com/go-kit/kit/log"
-	"github.com/go-kit/kit/log/level"
-	"github.com/prometheus/client_golang/prometheus"
+	v1 "k8s.io/api/core/v1"
 )
 
 var announcing = prometheus.NewGaugeVec(prometheus.GaugeOpts{
diff --git a/tasks.py b/tasks.py
index 78b2f48a..91dcdbe6 100644
--- a/tasks.py
+++ b/tasks.py
@@ -6,6 +6,7 @@ import shutil
 import sys
 import yaml
 import tempfile
+import time
 try:
     from urllib.request import urlopen
 except ImportError:
@@ -252,14 +253,44 @@ def validate_kind_version():
     if delta < 0:
         raise Exit(message="kind version >= {} required".format(min_version))
 
+@task(help={
+    "version": "version of cert-manager to install."
+                "Default: v1.5.4",
+})
+def install_cert_manager(ctx, version="v1.5.4"):
+    res = run("kubectl apply -f https://github.com/jetstack/cert-manager/releases/download/{}/cert-manager.yaml".format(version))
+    if not res.ok:
+        raise Exit(message="Failed to install cert-manager")
+    
+    # wait for cert-manager to be ready
+    attempts = 0
+    max_attempts = 60
+    cert_manager_ready = False
+    while not cert_manager_ready and attempts != max_attempts:
+        print("Waiting for cert-manager to be ready attempt: {}".format(attempts))
+        try:
+            res = run("kubectl apply -f config/certmanager/self-signed-cert.yaml")
+        except Exception as e:
+            print("Failed, retrying")
+            time.sleep(5)
+        else:
+            print("cert-manager is ready")
+            cert_manager_ready = True
+            res = run("kubectl delete -f config/certmanager/self-signed-cert.yaml")
+        attempts += 1
+    if not cert_manager_ready:
+        raise Exit(message="Timed out waiting for cert-manage to be ready")
+
 def generate_manifest(ctx, controller_gen="controller-gen", crd_options="crd:crdVersions=v1",
-        kustomize_cli="kustomize", bgp_type="native", output=None):
+        kustomize_cli="kustomize", bgp_type="native", output=None, enable_webhooks=False):
     res = run("{} {} rbac:roleName=manager-role webhook paths=\"./api/...\" output:crd:artifacts:config=config/crd/bases".format(controller_gen, crd_options))
     if not res.ok:
         raise Exit(message="Failed to generate manifests")
 
+    webhook_flag = "-with-webhook" if enable_webhooks else ""
+
     if output:
-        res = run("kubectl kustomize config/{} > {}".format(bgp_type, output))
+        res = run("kubectl kustomize config/{}{} > {}".format(bgp_type, webhook_flag, output))
         if not res.ok:
             raise Exit(message="Failed to kustomize manifests")
 
@@ -279,11 +310,13 @@ def generate_manifest(ctx, controller_gen="controller-gen", crd_options="crd:crd
     "log_level": "Log level for the controller and the speaker."
                 "Default: info, Supported: 'all', 'debug', 'info', 'warn', 'error' or 'none'",
     "helm_install": "Optional install MetalLB via helm chart instead of manifests."
-                "Default: False."
+                "Default: False.",
+    "enable_webhooks": "Optional enable MetalLB webhooks."
+                "Default: False.",
 })
 def dev_env(ctx, architecture="amd64", name="kind", protocol=None, frr_volume_dir="",
         node_img=None, ip_family="ipv4", bgp_type="native", log_level="info",
-        helm_install=False):
+        helm_install=False, enable_webhooks=False):
     """Build and run MetalLB in a local Kind cluster.
 
     If the cluster specified by --name (default "kind") doesn't exist,
@@ -329,18 +362,21 @@ def dev_env(ctx, architecture="amd64", name="kind", protocol=None, frr_volume_di
     run("kind load docker-image --name={} quay.io/metallb/speaker:dev-{}".format(name, architecture), echo=True)
     run("kind load docker-image --name={} quay.io/metallb/mirror-server:dev-{}".format(name, architecture), echo=True)
 
+    if enable_webhooks:
+        install_cert_manager(ctx)
+
     if helm_install:
         run("helm install metallb charts/metallb/ --set controller.image.tag=dev-{} "
                 "--set speaker.image.tag=dev-{} --set speaker.frr.enabled={} --set speaker.logLevel=debug "
-                "--set controller.logLevel=debug".format(architecture,
-                architecture, "true" if bgp_type == "frr" else "false"), echo=True)
+                "--set controller.logLevel=debug --set webhooks.enable={}".format(architecture,
+                architecture, "true" if bgp_type == "frr" else "false", "true" if enable_webhooks else "false"), echo=True)
     else:
         run("kubectl delete po -nmetallb-system --all", echo=True)
 
         with tempfile.TemporaryDirectory() as tmpdir:
             manifest_file = tmpdir + "/metallb.yaml"
 
-            generate_manifest(ctx, bgp_type=bgp_type, output=manifest_file)
+            generate_manifest(ctx, bgp_type=bgp_type, output=manifest_file, enable_webhooks=enable_webhooks)
 
             # open file and replace the images with the newely built MetalLB docker images
             with open(manifest_file) as f:
@@ -757,7 +793,8 @@ def generatemanifests(ctx, controller_gen="controller-gen", kustomize_cli="kusto
     """ Re-generates the all-in-one manifests under config/manifests"""
     generate_manifest(ctx, controller_gen=controller_gen, kustomize_cli=kustomize_cli, bgp_type="frr", output="config/manifests/metallb-frr.yaml")
     generate_manifest(ctx, controller_gen=controller_gen, kustomize_cli=kustomize_cli, bgp_type="native", output="config/manifests/metallb-native.yaml")
-
+    generate_manifest(ctx, controller_gen=controller_gen, kustomize_cli=kustomize_cli, bgp_type="frr", enable_webhooks=True, output="config/manifests/metallb-frr-with-webhooks.yaml")
+    generate_manifest(ctx, controller_gen=controller_gen, kustomize_cli=kustomize_cli, bgp_type="native", enable_webhooks=True, output="config/manifests/metallb-native-with-webhooks.yaml")
 
 @task(help={
     "action": "The action to take to fix the uncommitted changes",


Test patch:
diff --git a/api/v1beta1/addresspool_webhook_test.go b/api/v1beta1/addresspool_webhook_test.go
new file mode 100644
index 00000000..c13d3733
--- /dev/null
+++ b/api/v1beta1/addresspool_webhook_test.go
@@ -0,0 +1,138 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package v1beta1
+
+import (
+	"testing"
+
+	"github.com/go-kit/log"
+	"github.com/google/go-cmp/cmp"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+const (
+	MetalLBTestNameSpace = "metallb-test-namespace"
+)
+
+func TestValidateAddressPool(t *testing.T) {
+	addressPool := AddressPool{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "test-addresspool",
+			Namespace: MetalLBTestNameSpace,
+		},
+	}
+	Logger = log.NewNopLogger()
+
+	toRestoreAddresspools := getExistingAddressPools
+	getExistingAddressPools = func() (*AddressPoolList, error) {
+		return &AddressPoolList{
+			Items: []AddressPool{
+				addressPool,
+			},
+		}, nil
+	}
+	toRestoreIPpools := getExistingIPPools
+	getExistingIPPools = func() (*IPPoolList, error) {
+		return &IPPoolList{}, nil
+	}
+
+	defer func() {
+		getExistingAddressPools = toRestoreAddresspools
+		getExistingIPPools = toRestoreIPpools
+	}()
+
+	tests := []struct {
+		desc             string
+		addressPool      *AddressPool
+		isNewAddressPool bool
+		failValidate     bool
+		expected         *AddressPoolList
+	}{
+		{
+			desc: "Second AddressPool",
+			addressPool: &AddressPool{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-addresspool1",
+					Namespace: MetalLBTestNameSpace,
+				},
+			},
+			isNewAddressPool: true,
+			expected: &AddressPoolList{
+				Items: []AddressPool{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-addresspool",
+							Namespace: MetalLBTestNameSpace,
+						},
+					},
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-addresspool1",
+							Namespace: MetalLBTestNameSpace,
+						},
+					},
+				},
+			},
+		},
+		{
+			desc: "Same AddressPool, update",
+			addressPool: &AddressPool{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-addresspool",
+					Namespace: MetalLBTestNameSpace,
+				},
+			},
+			isNewAddressPool: false,
+			expected: &AddressPoolList{
+				Items: []AddressPool{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-addresspool",
+							Namespace: MetalLBTestNameSpace,
+						},
+					},
+				},
+			},
+		},
+		{
+			desc: "Validation Fails",
+			addressPool: &AddressPool{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-addresspool",
+					Namespace: MetalLBTestNameSpace,
+				},
+			},
+			isNewAddressPool: false,
+			expected: &AddressPoolList{
+				Items: []AddressPool{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-addresspool",
+							Namespace: MetalLBTestNameSpace,
+						},
+					},
+				},
+			},
+			failValidate: true,
+		},
+	}
+
+	for _, test := range tests {
+		var err error
+		mock := &mockValidator{}
+		Validator = mock
+		mock.forceError = test.failValidate
+
+		if test.isNewAddressPool {
+			err = test.addressPool.ValidateCreate()
+		} else {
+			err = test.addressPool.ValidateUpdate(nil)
+		}
+		if test.failValidate && err == nil {
+			t.Fatalf("test %s failed, expecting error", test.desc)
+		}
+		if !cmp.Equal(test.expected, mock.pools) {
+			t.Fatalf("test %s failed, %s", test.desc, cmp.Diff(test.expected, mock.pools))
+		}
+	}
+}
diff --git a/api/v1beta1/bgpadvertisement_webhook_test.go b/api/v1beta1/bgpadvertisement_webhook_test.go
new file mode 100644
index 00000000..16bafd0e
--- /dev/null
+++ b/api/v1beta1/bgpadvertisement_webhook_test.go
@@ -0,0 +1,139 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package v1beta1
+
+import (
+	"testing"
+
+	"github.com/go-kit/log"
+	"github.com/google/go-cmp/cmp"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestValidateBGPAdvertisement(t *testing.T) {
+	bgpAdv := BGPAdvertisement{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "test-bgpadv",
+			Namespace: MetalLBTestNameSpace,
+		},
+	}
+
+	Logger = log.NewNopLogger()
+
+	toRestore := getExistingBGPAdvs
+	getExistingBGPAdvs = func() (*BGPAdvertisementList, error) {
+		return &BGPAdvertisementList{
+			Items: []BGPAdvertisement{
+				bgpAdv,
+			},
+		}, nil
+	}
+	toRestoreAddresspools := getExistingAddressPools
+	getExistingAddressPools = func() (*AddressPoolList, error) {
+		return &AddressPoolList{}, nil
+	}
+	toRestoreIPpools := getExistingIPPools
+	getExistingIPPools = func() (*IPPoolList, error) {
+		return &IPPoolList{}, nil
+	}
+
+	defer func() {
+		getExistingBGPAdvs = toRestore
+		getExistingAddressPools = toRestoreAddresspools
+		getExistingIPPools = toRestoreIPpools
+	}()
+
+	tests := []struct {
+		desc         string
+		bgpAdv       *BGPAdvertisement
+		isNew        bool
+		failValidate bool
+		expected     *BGPAdvertisementList
+	}{
+		{
+			desc: "Second Adv",
+			bgpAdv: &BGPAdvertisement{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: MetalLBTestNameSpace,
+				},
+			},
+			isNew: true,
+			expected: &BGPAdvertisementList{
+				Items: []BGPAdvertisement{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-bgpadv",
+							Namespace: MetalLBTestNameSpace,
+						},
+					},
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test",
+							Namespace: MetalLBTestNameSpace,
+						},
+					},
+				},
+			},
+		},
+		{
+			desc: "Same, update",
+			bgpAdv: &BGPAdvertisement{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-bgpadv",
+					Namespace: MetalLBTestNameSpace,
+				},
+			},
+			isNew: false,
+			expected: &BGPAdvertisementList{
+				Items: []BGPAdvertisement{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-bgpadv",
+							Namespace: MetalLBTestNameSpace,
+						},
+					},
+				},
+			},
+		},
+		{
+			desc: "Same, update",
+			bgpAdv: &BGPAdvertisement{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-bgpadv",
+					Namespace: MetalLBTestNameSpace,
+				},
+			},
+			isNew: true,
+			expected: &BGPAdvertisementList{
+				Items: []BGPAdvertisement{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-bgpadv",
+							Namespace: MetalLBTestNameSpace,
+						},
+					},
+				},
+			},
+			failValidate: true,
+		},
+	}
+	for _, test := range tests {
+		var err error
+		mock := &mockValidator{}
+		Validator = mock
+		mock.forceError = test.failValidate
+
+		if test.isNew {
+			err = test.bgpAdv.ValidateCreate()
+		} else {
+			err = test.bgpAdv.ValidateUpdate(nil)
+		}
+		if test.failValidate && err == nil {
+			t.Fatalf("test %s failed, expecting error", test.desc)
+		}
+		if !cmp.Equal(test.expected, mock.bgpAdvs) {
+			t.Fatalf("test %s failed, %s", test.desc, cmp.Diff(test.expected, mock.bgpAdvs))
+		}
+	}
+}
diff --git a/api/v1beta1/ippool_webhook_test.go b/api/v1beta1/ippool_webhook_test.go
new file mode 100644
index 00000000..4c93a9b1
--- /dev/null
+++ b/api/v1beta1/ippool_webhook_test.go
@@ -0,0 +1,134 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package v1beta1
+
+import (
+	"testing"
+
+	"github.com/go-kit/log"
+	"github.com/google/go-cmp/cmp"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestValidateIPPool(t *testing.T) {
+	ipPool := IPPool{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "test-ippool",
+			Namespace: MetalLBTestNameSpace,
+		},
+	}
+	Logger = log.NewNopLogger()
+
+	toRestoreAddresspools := getExistingAddressPools
+	getExistingAddressPools = func() (*AddressPoolList, error) {
+		return &AddressPoolList{}, nil
+	}
+	toRestoreIPpools := getExistingIPPools
+	getExistingIPPools = func() (*IPPoolList, error) {
+		return &IPPoolList{
+			Items: []IPPool{
+				ipPool,
+			},
+		}, nil
+	}
+
+	defer func() {
+		getExistingAddressPools = toRestoreAddresspools
+		getExistingIPPools = toRestoreIPpools
+	}()
+
+	tests := []struct {
+		desc         string
+		ipPool       *IPPool
+		isNew        bool
+		failValidate bool
+		expected     *IPPoolList
+	}{
+		{
+			desc: "Second IPPool",
+			ipPool: &IPPool{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-ippool1",
+					Namespace: MetalLBTestNameSpace,
+				},
+			},
+			isNew: true,
+			expected: &IPPoolList{
+				Items: []IPPool{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-ippool",
+							Namespace: MetalLBTestNameSpace,
+						},
+					},
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-ippool1",
+							Namespace: MetalLBTestNameSpace,
+						},
+					},
+				},
+			},
+		},
+		{
+			desc: "Same IPPool, update",
+			ipPool: &IPPool{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-ippool",
+					Namespace: MetalLBTestNameSpace,
+				},
+			},
+			isNew: false,
+			expected: &IPPoolList{
+				Items: []IPPool{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-ippool",
+							Namespace: MetalLBTestNameSpace,
+						},
+					},
+				},
+			},
+		},
+		{
+			desc: "Validation fails",
+			ipPool: &IPPool{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-ippool",
+					Namespace: MetalLBTestNameSpace,
+				},
+			},
+			isNew: false,
+			expected: &IPPoolList{
+				Items: []IPPool{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-ippool",
+							Namespace: MetalLBTestNameSpace,
+						},
+					},
+				},
+			},
+			failValidate: true,
+		},
+	}
+
+	for _, test := range tests {
+		var err error
+		mock := &mockValidator{}
+		Validator = mock
+		mock.forceError = test.failValidate
+
+		if test.isNew {
+			err = test.ipPool.ValidateCreate()
+		} else {
+			err = test.ipPool.ValidateUpdate(nil)
+		}
+		if test.failValidate && err == nil {
+			t.Fatalf("test %s failed, expecting error", test.desc)
+		}
+		if !cmp.Equal(test.expected, mock.ipPools) {
+			t.Fatalf("test %s failed, %s", test.desc, cmp.Diff(test.expected, mock.ipPools))
+		}
+	}
+}
diff --git a/api/v1beta1/mock_validator_test.go b/api/v1beta1/mock_validator_test.go
new file mode 100644
index 00000000..a8d84398
--- /dev/null
+++ b/api/v1beta1/mock_validator_test.go
@@ -0,0 +1,35 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package v1beta1
+
+import (
+	"github.com/pkg/errors"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+type mockValidator struct {
+	pools      *AddressPoolList
+	ipPools    *IPPoolList
+	bgpAdvs    *BGPAdvertisementList
+	forceError bool
+}
+
+func (m *mockValidator) Validate(objects ...client.ObjectList) error {
+	for _, obj := range objects { // assuming one object per type
+		switch list := obj.(type) {
+		case *AddressPoolList:
+			m.pools = list
+		case *BGPAdvertisementList:
+			m.bgpAdvs = list
+		case *IPPoolList:
+			m.ipPools = list
+		default:
+			panic("unexpected type")
+		}
+	}
+
+	if m.forceError {
+		return errors.New("Error!")
+	}
+	return nil
+}
diff --git a/api/v1beta2/bgppeer_webhook_test.go b/api/v1beta2/bgppeer_webhook_test.go
new file mode 100644
index 00000000..163454b0
--- /dev/null
+++ b/api/v1beta2/bgppeer_webhook_test.go
@@ -0,0 +1,131 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package v1beta2
+
+import (
+	"testing"
+
+	"github.com/go-kit/log"
+	"github.com/google/go-cmp/cmp"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+const testNamespace = "namespace"
+
+func TestValidateBGPPeer(t *testing.T) {
+	bgpPeer := BGPPeer{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      "test-peer",
+			Namespace: testNamespace,
+		},
+	}
+
+	Logger = log.NewNopLogger()
+
+	toRestore := getExistingBGPPeers
+	getExistingBGPPeers = func() (*BGPPeerList, error) {
+		return &BGPPeerList{
+			Items: []BGPPeer{
+				bgpPeer,
+			},
+		}, nil
+	}
+
+	defer func() {
+		getExistingBGPPeers = toRestore
+	}()
+
+	tests := []struct {
+		desc         string
+		bgpPeer      *BGPPeer
+		isNew        bool
+		failValidate bool
+		expected     *BGPPeerList
+	}{
+		{
+			desc: "Second Peer",
+			bgpPeer: &BGPPeer{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: testNamespace,
+				},
+			},
+			isNew: true,
+			expected: &BGPPeerList{
+				Items: []BGPPeer{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-peer",
+							Namespace: testNamespace,
+						},
+					},
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test",
+							Namespace: testNamespace,
+						},
+					},
+				},
+			},
+		},
+		{
+			desc: "Same, update",
+			bgpPeer: &BGPPeer{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-peer",
+					Namespace: testNamespace,
+				},
+			},
+			isNew: false,
+			expected: &BGPPeerList{
+				Items: []BGPPeer{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-peer",
+							Namespace: testNamespace,
+						},
+					},
+				},
+			},
+		},
+		{
+			desc: "Validation failed",
+			bgpPeer: &BGPPeer{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test-peer",
+					Namespace: testNamespace,
+				},
+			},
+			isNew: false,
+			expected: &BGPPeerList{
+				Items: []BGPPeer{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name:      "test-peer",
+							Namespace: testNamespace,
+						},
+					},
+				},
+			},
+			failValidate: true,
+		},
+	}
+	for _, test := range tests {
+		var err error
+		mock := &mockValidator{}
+		Validator = mock
+		mock.forceError = test.failValidate
+
+		if test.isNew {
+			err = test.bgpPeer.ValidateCreate()
+		} else {
+			err = test.bgpPeer.ValidateUpdate(nil)
+		}
+		if test.failValidate && err == nil {
+			t.Fatalf("test %s failed, expecting error", test.desc)
+		}
+		if !cmp.Equal(test.expected, mock.bgpPeers) {
+			t.Fatalf("test %s failed, %s", test.desc, cmp.Diff(test.expected, mock.bgpPeers))
+		}
+	}
+}
diff --git a/api/v1beta2/mock_validator_test.go b/api/v1beta2/mock_validator_test.go
new file mode 100644
index 00000000..3498d3bc
--- /dev/null
+++ b/api/v1beta2/mock_validator_test.go
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package v1beta2
+
+import (
+	"github.com/pkg/errors"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+type mockValidator struct {
+	bgpPeers   *BGPPeerList
+	forceError bool
+}
+
+func (m *mockValidator) Validate(objects ...client.ObjectList) error {
+	for _, obj := range objects { // assuming one object per type
+		switch list := obj.(type) {
+		case *BGPPeerList:
+			m.bgpPeers = list
+		default:
+			panic("unexpected type")
+		}
+	}
+
+	if m.forceError {
+		return errors.New("Error!")
+	}
+	return nil
+}
diff --git a/e2etest/bgptests/bgp.go b/e2etest/bgptests/bgp.go
index df5cce6e..831ecbae 100644
--- a/e2etest/bgptests/bgp.go
+++ b/e2etest/bgptests/bgp.go
@@ -504,6 +504,12 @@ var _ = ginkgo.Describe("BGP", func() {
 		}
 		err := ConfigUpdater.Update(resources)
 		framework.ExpectNoError(err)
+		defer func() {
+			for _, s := range resources.PasswordSecrets {
+				err := cs.CoreV1().Secrets(metallb.Namespace).Delete(context.Background(), s.Name, metav1.DeleteOptions{})
+				framework.ExpectNoError(err)
+			}
+		}()
 
 		for _, c := range FRRContainers {
 			err = frrcontainer.PairWithNodes(cs, c, ipFamily)
diff --git a/e2etest/e2etest_suite_test.go b/e2etest/e2etest_suite_test.go
index 5f04b07d..fde1818a 100644
--- a/e2etest/e2etest_suite_test.go
+++ b/e2etest/e2etest_suite_test.go
@@ -35,6 +35,7 @@ import (
 	testsconfig "go.universe.tf/metallb/e2etest/pkg/config"
 	"go.universe.tf/metallb/e2etest/pkg/metallb"
 	"go.universe.tf/metallb/e2etest/pkg/service"
+	"go.universe.tf/metallb/e2etest/webhookstests"
 	internalconfig "go.universe.tf/metallb/internal/config"
 	"k8s.io/client-go/tools/clientcmd"
 	"k8s.io/klog"
@@ -53,7 +54,15 @@ var (
 func handleFlags() {
 	e2econfig.CopyFlags(e2econfig.Flags, flag.CommandLine)
 	framework.RegisterCommonFlags(flag.CommandLine)
-	framework.RegisterClusterFlags(flag.CommandLine)
+	/*
+		Using framework.RegisterClusterFlags(flag.CommandLine) results in a panic:
+		"flag redefined: kubeconfig".
+		This happens because controller-runtime registers the kubeconfig flag as well.
+		To solve this we set the framework's kubeconfig directly via the KUBECONFIG env var
+		instead of letting it call the flag. Since we also use the provider flag it is handled manually.
+	*/
+	flag.StringVar(&framework.TestContext.Provider, "provider", "", "The name of the Kubernetes provider (gce, gke, local, skeleton (the fallback if not set), etc.)")
+	framework.TestContext.KubeConfig = os.Getenv(clientcmd.RecommendedConfigPathEnvVar)
 
 	flag.IntVar(&service.TestServicePort, "service-pod-port", 80, "port number that pod opens, default: 80")
 	flag.BoolVar(&skipDockerCmd, "skip-docker", false, "set this to true if the BGP daemon is running on the host instead of in a container")
@@ -124,6 +133,7 @@ var _ = ginkgo.BeforeSuite(func() {
 
 	bgptests.ConfigUpdater = updater
 	l2tests.ConfigUpdater = updater
+	webhookstests.ConfigUpdater = updater
 })
 
 var _ = ginkgo.AfterSuite(func() {
diff --git a/e2etest/l2tests/l2.go b/e2etest/l2tests/l2.go
index 447c8d5f..7cb61493 100644
--- a/e2etest/l2tests/l2.go
+++ b/e2etest/l2tests/l2.go
@@ -442,8 +442,9 @@ var _ = ginkgo.Describe("L2", func() {
 			ingressIP := e2eservice.GetIngressPoint(
 				&svc.Status.LoadBalancer.Ingress[0])
 
-			err = mac.RequestAddressResolution(ingressIP, executor.Host)
-			framework.ExpectNoError(err)
+			gomega.Eventually(func() error {
+				return mac.RequestAddressResolution(ingressIP, executor.Host)
+			}, 2*time.Minute, 1*time.Second).Should(gomega.Not(gomega.HaveOccurred()))
 
 			ginkgo.By("checking connectivity to its external VIP")
 			hostport := net.JoinHostPort(ingressIP, port)
diff --git a/e2etest/pkg/config/update.go b/e2etest/pkg/config/update.go
index 1634382c..5c4e4fd7 100644
--- a/e2etest/pkg/config/update.go
+++ b/e2etest/pkg/config/update.go
@@ -61,46 +61,79 @@ func (o beta1Updater) Update(r config.ClusterResources) error {
 	// find corner cases that we would not find by adding them always in the same
 	// order.
 	objects := map[int]client.Object{}
+	oldValues := map[int]client.Object{}
 	key := 0
 	for _, pool := range r.Pools {
 		objects[key] = pool.DeepCopy()
+		oldValues[key] = pool.DeepCopy()
 		key = key + 1
 	}
 
 	for _, secret := range r.PasswordSecrets {
 		objects[key] = secret.DeepCopy()
+		oldValues[key] = secret.DeepCopy()
 		key = key + 1
 	}
 
 	for _, peer := range r.Peers {
 		objects[key] = peer.DeepCopy()
+		oldValues[key] = peer.DeepCopy()
 		key = key + 1
 	}
 
 	for _, bfdProfile := range r.BFDProfiles {
 		objects[key] = bfdProfile.DeepCopy()
+		oldValues[key] = bfdProfile.DeepCopy()
 		key = key + 1
 	}
 
 	for _, bgpAdv := range r.BGPAdvs {
 		objects[key] = bgpAdv.DeepCopy()
+		oldValues[key] = bgpAdv.DeepCopy()
 		key = key + 1
 	}
 
 	for _, l2Adv := range r.L2Advs {
 		objects[key] = l2Adv.DeepCopy()
+		oldValues[key] = l2Adv.DeepCopy()
 		key = key + 1
 	}
 
 	for _, legacyPool := range r.LegacyAddressPools {
 		objects[key] = legacyPool.DeepCopy()
+		oldValues[key] = legacyPool.DeepCopy()
 		key = key + 1
 	}
 
 	// Iterating over the map will return the items in a random order.
-	for _, obj := range objects {
+	for i, obj := range objects {
 		obj.SetNamespace(o.namespace)
-		_, err := controllerutil.CreateOrUpdate(context.Background(), o.cli, obj, func() error { return nil })
+		_, err := controllerutil.CreateOrUpdate(context.Background(), o.cli, obj, func() error {
+			// the mutate function is expected to change the object when updating.
+			// we always override with the old version, and we change only the spec part.
+			switch toChange := obj.(type) {
+			case *metallbv1beta1.IPPool:
+				old := oldValues[i].(*metallbv1beta1.IPPool)
+				toChange.Spec = *old.Spec.DeepCopy()
+			case *metallbv1beta1.AddressPool:
+				old := oldValues[i].(*metallbv1beta1.AddressPool)
+				toChange.Spec = *old.Spec.DeepCopy()
+			case *metallbv1beta1.BFDProfile:
+				old := oldValues[i].(*metallbv1beta1.BFDProfile)
+				toChange.Spec = *old.Spec.DeepCopy()
+			case *metallbv1beta2.BGPPeer:
+				old := oldValues[i].(*metallbv1beta2.BGPPeer)
+				toChange.Spec = *old.Spec.DeepCopy()
+			case *metallbv1beta1.BGPAdvertisement:
+				old := oldValues[i].(*metallbv1beta1.BGPAdvertisement)
+				toChange.Spec = *old.Spec.DeepCopy()
+			case *metallbv1beta1.L2Advertisement:
+				old := oldValues[i].(*metallbv1beta1.L2Advertisement)
+				toChange.Spec = *old.Spec.DeepCopy()
+			}
+
+			return nil
+		})
 		if err != nil {
 			return err
 		}
diff --git a/e2etest/pkg/metallb/metallb.go b/e2etest/pkg/metallb/metallb.go
index da666bb6..b877c251 100644
--- a/e2etest/pkg/metallb/metallb.go
+++ b/e2etest/pkg/metallb/metallb.go
@@ -14,13 +14,13 @@ import (
 )
 
 var (
-	controllerLabelSelector = "component=controller"
+	ControllerLabelSelector = "component=controller"
 	speakerLabelgSelector   = "component=speaker"
 )
 
 func init() {
 	if v, ok := os.LookupEnv("CONTROLLER_SELECTOR"); ok {
-		controllerLabelSelector = v
+		ControllerLabelSelector = v
 	}
 
 	if v, ok := os.LookupEnv("SPEAKER_SELECTOR"); ok {
@@ -50,10 +50,10 @@ func SpeakerPods(cs clientset.Interface) ([]*corev1.Pod, error) {
 // ControllerPod returns the metallb controller pod.
 func ControllerPod(cs clientset.Interface) (*corev1.Pod, error) {
 	pods, err := cs.CoreV1().Pods(Namespace).List(context.Background(), metav1.ListOptions{
-		LabelSelector: controllerLabelSelector,
+		LabelSelector: ControllerLabelSelector,
 	})
 	if err != nil {
-		return nil, errors.Wrap(err, "Failed to fetch speaker pods")
+		return nil, errors.Wrap(err, "Failed to fetch controller pods")
 	}
 	if len(pods.Items) != 1 {
 		return nil, fmt.Errorf("Expected one controller pod, found %d", len(pods.Items))
diff --git a/e2etest/webhookstests/webhooks.go b/e2etest/webhookstests/webhooks.go
new file mode 100644
index 00000000..a36c2384
--- /dev/null
+++ b/e2etest/webhookstests/webhooks.go
@@ -0,0 +1,230 @@
+/*
+Copyright 2016 The Kubernetes Authors.
+Copyright 2021 The MetalLB Authors.
+
+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.
+*/
+// https://github.com/kubernetes/kubernetes/blob/92aff21558831b829fbc8cbca3d52edc80c01aa3/test/e2e/network/loadbalancer.go#L878
+
+package webhookstests
+
+import (
+	"go.universe.tf/metallb/e2etest/pkg/config"
+
+	"github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+
+	metallbv1beta1 "go.universe.tf/metallb/api/v1beta1"
+	metallbv1beta2 "go.universe.tf/metallb/api/v1beta2"
+	metallbconfig "go.universe.tf/metallb/internal/config"
+	"go.universe.tf/metallb/internal/pointer"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/kubernetes/test/e2e/framework"
+)
+
+var ConfigUpdater config.Updater
+
+var _ = ginkgo.Describe("Webhooks", func() {
+	ginkgo.BeforeEach(func() {
+		ginkgo.By("Clearing any previous configuration")
+		err := ConfigUpdater.Clean()
+		framework.ExpectNoError(err)
+	})
+
+	ginkgo.Context("For IPPool", func() {
+		ginkgo.AfterEach(func() {
+			// Clean previous configuration.
+			err := ConfigUpdater.Clean()
+			framework.ExpectNoError(err)
+		})
+
+		ginkgo.It("Should recognize overlapping addresses in two AddressPools", func() {
+			ginkgo.By("Creating first IPPool")
+			resources := metallbconfig.ClusterResources{
+				Pools: []metallbv1beta1.IPPool{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "webhooks-test1",
+						},
+						Spec: metallbv1beta1.IPPoolSpec{
+							Addresses: []string{
+								"1.1.1.1-1.1.1.100",
+							},
+						},
+					},
+				},
+			}
+			err := ConfigUpdater.Update(resources)
+			framework.ExpectNoError(err)
+
+			ginkgo.By("Creating second IPPool with overlapping addresses defined by address range")
+			resources.Pools = append(resources.Pools, metallbv1beta1.IPPool{
+				ObjectMeta: metav1.ObjectMeta{
+					Name: "webhooks-test2",
+				},
+				Spec: metallbv1beta1.IPPoolSpec{
+					Addresses: []string{
+						"1.1.1.15-1.1.1.20",
+					},
+				},
+			})
+			err = ConfigUpdater.Update(resources)
+			framework.ExpectError(err)
+			Expect(err.Error()).To(ContainSubstring("overlaps with already defined CIDR"))
+
+			ginkgo.By("Creating second valid IPPool")
+			resources.Pools[1].Spec.Addresses = []string{"1.1.1.101-1.1.1.200"}
+			err = ConfigUpdater.Update(resources)
+			framework.ExpectNoError(err)
+
+			ginkgo.By("Updating second IPPool addresses to overlapping addresses defined by network prefix")
+			resources.Pools[1].Spec.Addresses = []string{"1.1.1.0/24"}
+			err = ConfigUpdater.Update(resources)
+			framework.ExpectError(err)
+			Expect(err.Error()).To(ContainSubstring("overlaps with already defined CIDR"))
+		})
+	})
+
+	ginkgo.Context("for Legacy AddressPool", func() {
+		ginkgo.AfterEach(func() {
+			// Clean previous configuration.
+			err := ConfigUpdater.Clean()
+			framework.ExpectNoError(err)
+		})
+
+		ginkgo.It("Should recognize overlapping addresses in two AddressPools", func() {
+			ginkgo.By("Creating first AddrssPool")
+			resources := metallbconfig.ClusterResources{
+				LegacyAddressPools: []metallbv1beta1.AddressPool{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "webhooks-test1",
+						},
+						Spec: metallbv1beta1.AddressPoolSpec{
+							Addresses: []string{
+								"1.1.1.1-1.1.1.100",
+							},
+							Protocol: string(metallbconfig.Layer2),
+						},
+					},
+				},
+			}
+			err := ConfigUpdater.Update(resources)
+			framework.ExpectNoError(err)
+
+			ginkgo.By("Creating second AddressPool with overlapping addresses defined by address range")
+			resources.LegacyAddressPools = append(resources.LegacyAddressPools,
+				metallbv1beta1.AddressPool{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "webhooks-test2",
+					},
+					Spec: metallbv1beta1.AddressPoolSpec{
+						Addresses: []string{
+							"1.1.1.15-1.1.1.20",
+						},
+						Protocol: string(metallbconfig.Layer2),
+					},
+				},
+			)
+			err = ConfigUpdater.Update(resources)
+			framework.ExpectError(err)
+			Expect(err.Error()).To(ContainSubstring("overlaps with already defined CIDR"))
+
+			ginkgo.By("Creating second valid AddressPool")
+			resources.LegacyAddressPools[1].Spec.Addresses = []string{"1.1.1.101-1.1.1.200"}
+			err = ConfigUpdater.Update(resources)
+			framework.ExpectNoError(err)
+
+			ginkgo.By("Updating second AddressPool addresses to overlapping addresses defined by network prefix")
+			resources.LegacyAddressPools[1].Spec.Addresses = []string{"1.1.1.0/24"}
+			err = ConfigUpdater.Update(resources)
+			framework.ExpectError(err)
+			Expect(err.Error()).To(ContainSubstring("overlaps with already defined CIDR"))
+		})
+	})
+
+	ginkgo.Context("for BGPAdvertisement", func() {
+		ginkgo.AfterEach(func() {
+			ginkgo.By("Clearing any previous configuration")
+
+			err := ConfigUpdater.Clean()
+			framework.ExpectNoError(err)
+		})
+
+		ginkgo.It("Should recognize invalid AggregationLength", func() {
+			ginkgo.By("Creating AddressPool")
+			resources := metallbconfig.ClusterResources{
+				Pools: []metallbv1beta1.IPPool{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "pool-webhooks-test",
+						},
+						Spec: metallbv1beta1.IPPoolSpec{
+							Addresses: []string{
+								"1.1.1.0/28",
+							},
+						},
+					},
+				},
+			}
+			err := ConfigUpdater.Update(resources)
+			framework.ExpectNoError(err)
+
+			ginkgo.By("Creating BGPAdvertisement")
+			resources.BGPAdvs = []metallbv1beta1.BGPAdvertisement{
+				{
+					ObjectMeta: metav1.ObjectMeta{
+						Name: "adv-webhooks-test",
+					},
+					Spec: metallbv1beta1.BGPAdvertisementSpec{
+						AggregationLength: pointer.Int32Ptr(26),
+						IPPools:           []string{"pool-webhooks-test"},
+					},
+				},
+			}
+			err = ConfigUpdater.Update(resources)
+			framework.ExpectError(err)
+			Expect(err.Error()).To(ContainSubstring("invalid aggregation length 26: prefix 28 in this pool is more specific than the aggregation length for addresses 1.1.1.0/28"))
+		})
+	})
+
+	ginkgo.Context("For BGPPeer", func() {
+		ginkgo.AfterEach(func() {
+			ginkgo.By("Clearing any previous configuration")
+
+			err := ConfigUpdater.Clean()
+			framework.ExpectNoError(err)
+		})
+
+		ginkgo.It("Should reject invalid BGPPeer IP address", func() {
+			ginkgo.By("Creating BGPPeer")
+			resources := metallbconfig.ClusterResources{
+				Peers: []metallbv1beta2.BGPPeer{
+					{
+						ObjectMeta: metav1.ObjectMeta{
+							Name: "webhooks-test",
+						},
+						Spec: metallbv1beta2.BGPPeerSpec{
+							Address: "1.1.1",
+							ASN:     64500,
+							MyASN:   1000,
+						},
+					},
+				},
+			}
+			err := ConfigUpdater.Update(resources)
+			framework.ExpectError(err)
+			Expect(err.Error()).To(ContainSubstring("invalid BGPPeer address"))
+		})
+	})
+})
diff --git a/internal/config/validator_test.go b/internal/config/validator_test.go
new file mode 100644
index 00000000..4b618714
--- /dev/null
+++ b/internal/config/validator_test.go
@@ -0,0 +1,30 @@
+// SPDX-License-Identifier:Apache-2.0
+
+package config
+
+import (
+	"testing"
+
+	"go.universe.tf/metallb/api/v1beta2"
+)
+
+func TestValidator(t *testing.T) {
+	v := validator{DontValidate}
+
+	bgpPeerList := v1beta2.BGPPeerList{
+		Items: []v1beta2.BGPPeer{
+			{
+				Spec: v1beta2.BGPPeerSpec{
+					MyASN:      42,
+					ASN:        42,
+					Address:    "1.2.3.4",
+					BFDProfile: "default",
+				},
+			},
+		},
+	}
+	err := v.Validate(&bgpPeerList)
+	if err != nil {
+		t.Error("The validator should not fail for non existing bfd profile")
+	}
+}