Repository: metal3-io/baremetal-operator
Pull request #2390 (2025-04-07 07:59:30)

:warning: Bump Kubernetes api, controller-runtime and decouple webhooks from api
<!-- please add a icon to the title of this PR and delete this line and similar ones -->
<!-- the icon will be either ⚠️ (:warning:, major or breaking changes), ✨ (:sparkles:, feature additions), 🐛 (:bug:, patch and bugfixes), 📖 (:book:, documentation or proposals), or 🌱 (:seedling:, minor or other) -->

**What this PR does / why we need it**: Bump CAPI 1.10.0-beta.0, Kubernetes api 0.32.3 and controller-runtime v0.20.4

Bump done in the same fashion as 
- https://github.com/metal3-io/cluster-api-provider-metal3/pull/2432
- https://github.com/metal3-io/ip-address-manager/pull/941 and https://github.com/metal3-io/ip-address-manager/pull/944

metal3-io-bot: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
metal3-io-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
**Once this PR has been reviewed and has the lgtm label**, please assign [dtantsur](https://github.com/dtantsur) for approval. For more information see [the Code Review Process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process).

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=metal3-io%2Fbaremetal-operator).

<details open>
Needs approval from an approver in each of these files:

- **[OWNERS](https://github.com/metal3-io/baremetal-operator/blob/main/OWNERS)**

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":["dtantsur"]} -->
metal3-io-bot: @peppi-lotta: The following tests **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
gomod | 217d66efb2732821ea6dd4780a04689d5868e29d | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/2390/gomod/1909154064206139392) | true | `/test gomod`
test | 217d66efb2732821ea6dd4780a04689d5868e29d | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/2390/test/1909154064243888128) | true | `/test test`
manifestlint | 217d66efb2732821ea6dd4780a04689d5868e29d | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/2390/manifestlint/1909154064277442560) | true | `/test manifestlint`
generate | 217d66efb2732821ea6dd4780a04689d5868e29d | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/2390/generate/1909154064231305216) | true | `/test generate`

[Full PR test history](https://prow.apps.test.metal3.io/pr-history?org=metal3-io&repo=baremetal-operator&pr=2390). [Your PR dashboard](https://prow.apps.test.metal3.io/pr?query=is:pr+state:open+author:peppi-lotta).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
metal3-io-bot: @peppi-lotta: The following tests **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
gomod | 217d66efb2732821ea6dd4780a04689d5868e29d | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/2390/gomod/1909154064206139392) | true | `/test gomod`
generate | 217d66efb2732821ea6dd4780a04689d5868e29d | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/2390/generate/1909154064231305216) | true | `/test generate`
test | 93e5e47b946ce807a4439bfe7535a72118602e42 | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/2390/test/1909154647386361856) | true | `/test test`

[Full PR test history](https://prow.apps.test.metal3.io/pr-history?org=metal3-io&repo=baremetal-operator&pr=2390). [Your PR dashboard](https://prow.apps.test.metal3.io/pr?query=is:pr+state:open+author:peppi-lotta).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
metal3-io-bot: @peppi-lotta: The following tests **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
gomod | 217d66efb2732821ea6dd4780a04689d5868e29d | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/2390/gomod/1909154064206139392) | true | `/test gomod`
test | 93e5e47b946ce807a4439bfe7535a72118602e42 | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/2390/test/1909154647386361856) | true | `/test test`
generate | 93e5e47b946ce807a4439bfe7535a72118602e42 | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/2390/generate/1909154647352807424) | true | `/test generate`

[Full PR test history](https://prow.apps.test.metal3.io/pr-history?org=metal3-io&repo=baremetal-operator&pr=2390). [Your PR dashboard](https://prow.apps.test.metal3.io/pr?query=is:pr+state:open+author:peppi-lotta).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
tuminoid: Why :warning:  ? Usually this is :sparkles: .
dtantsur: > Bump CAPI 1.10.0-beta.0 and Kubernetes api 0.32.3

There is no dependency on CAPI here, but it's useful to mention the bump of controller-runtime, which is a source of the actual changes.
peppi-lotta: > Why ⚠️ ? Usually this is ✨ .

I'm decoupling webhooks from api so this is a breaking change same as in `CAPM3` and `IPAM`. I linked the PRs I modeled these changes after in the desrcription. 
tuminoid: > > Why ⚠️ ? Usually this is ✨ .
> 
> I'm decoupling webhooks from api so this is a breaking change same as in `CAPM3` and `IPAM`. I linked the PRs I modeled these changes after in the desrcription.

OK. Can we have it in separate PR?  This is large enough as-is, doing two big changes in one PR is not great.
adilGhaffarDev: > OK. Can we have it in separate PR? This is large enough as-is, doing two big changes in one PR is not great.

I think it's fine to include this in the same PR, since the new files are mostly copy-pasted from the api folder's webhooks. This is also part of the controller-runtime v0.20.4 uplift. As part of that upgrade, it's recommended to separate the webhooks and apis into their own folders. 
metal3-io-bot: @peppi-lotta: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
test | 48d4d93fe77cb9ddfec07d267949547f0ee8d9b7 | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/2390/test/1909500609472499712) | true | `/test test`

[Full PR test history](https://prow.apps.test.metal3.io/pr-history?org=metal3-io&repo=baremetal-operator&pr=2390). [Your PR dashboard](https://prow.apps.test.metal3.io/pr?query=is:pr+state:open+author:peppi-lotta).

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
peppi-lotta: I'm trying to make a beta release for BMO and that requires a CAPI Bump. When I bump CAPI, `go mod tidy` automatically bumps the controller-runtime in `/test` directory. And this is why I'm making these changes. I see these changes all one change and not two separate changes but I made a [PR](https://github.com/metal3-io/baremetal-operator/pull/2391) that implements part of the changes if it will be easier to get in and get these changes merged
tuminoid: Needs rebase after #2391 was merged.
metal3-io-bot: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>

Patch:
diff --git a/Makefile b/Makefile
index 1f893b3f..a684bb17 100644
--- a/Makefile
+++ b/Makefile
@@ -98,6 +98,7 @@ unit: ## Run unit tests
 	go test ./... $(GO_TEST_FLAGS) -coverprofile $(COVER_PROFILE)
 	cd apis/ && go test ./... $(GO_TEST_FLAGS) -coverprofile $(COVER_PROFILE)
 	cd pkg/hardwareutils && go test ./... $(GO_TEST_FLAGS) -coverprofile $(COVER_PROFILE)
+	cd internal/ && go test ./... $(GO_TEST_FLAGS) -coverprofile $(COVER_PROFILE)
 
 .PHONY: unit-cover
 unit-cover: ## Run unit tests with code coverage
@@ -107,6 +108,8 @@ unit-cover: ## Run unit tests with code coverage
 	cd apis/ && go tool cover -func=$(COVER_PROFILE)
 	cd pkg/hardwareutils/ && go test -coverprofile=$(COVER_PROFILE) $(GO_TEST_FLAGS) ./...
 	cd pkg/hardwareutils/ && go tool cover -func=$(COVER_PROFILE)
+	cd internal/ && go test -coverprofile=$(COVER_PROFILE) $(GO_TEST_FLAGS) ./...
+	cd internal/ && go tool cover -func=$(COVER_PROFILE)
 
 .PHONY: unit-verbose
 unit-verbose: ## Run unit tests with verbose output
@@ -144,6 +147,7 @@ lint: $(GOLANGCI_LINT)
 	cd test; $(GOLANGCI_LINT) run -v $(GOLANGCI_LINT_EXTRA_ARGS) ./... --timeout=10m
 	cd pkg/hardwareutils; $(GOLANGCI_LINT) run -v $(GOLANGCI_LINT_EXTRA_ARGS) ./... --timeout=10m
 	cd hack/tools; $(GOLANGCI_LINT) run -v $(GOLANGCI_LINT_EXTRA_ARGS) ./... --timeout=10m
+	cd internal; $(GOLANGCI_LINT) run -v $(GOLANGCI_LINT_EXTRA_ARGS) ./... --timeout=10m
 
 .PHONY: lint-fix
 lint-fix: $(GOLANGCI_LINT) ## Lint the codebase and run auto-fixers if supported by the linter
@@ -247,6 +251,7 @@ set-manifest-image-ipa-downloader: $(KUSTOMIZE) manifests
 .PHONY: generate
 generate: $(CONTROLLER_GEN) ## Generate code
 	cd apis; $(abspath $<) object:headerFile="../hack/boilerplate.go.txt" paths="./..."
+	cd internal; $(abspath $<) object:headerFile="../hack/boilerplate.go.txt" paths="./..."
 	$< object:headerFile="hack/boilerplate.go.txt" paths="./..."
 
 ## --------------------------------------
diff --git a/apis/metal3.io/v1alpha1/baremetalhost_validation.go b/apis/metal3.io/v1alpha1/baremetalhost_validation.go
index dc3c2f98..765cf5c9 100644
--- a/apis/metal3.io/v1alpha1/baremetalhost_validation.go
+++ b/apis/metal3.io/v1alpha1/baremetalhost_validation.go
@@ -25,6 +25,8 @@ var (
 )
 
 // validateHost validates BareMetalHost resource for creation.
+//
+// Deprecated: This method is going to be removed in a next release.
 func (webhook *BareMetalHost) validateHost(host *BareMetalHost) []error {
 	var errs []error
 	var bmcAccess bmc.AccessDetails
@@ -76,6 +78,8 @@ func (webhook *BareMetalHost) validateHost(host *BareMetalHost) []error {
 
 // validateChanges validates BareMetalHost resource on changes
 // but also covers the validations of creation.
+//
+// Deprecated: This method is going to be removed in a next release.
 func (webhook *BareMetalHost) validateChanges(oldObj *BareMetalHost, newObj *BareMetalHost) []error {
 	var errs []error
 
@@ -97,6 +101,7 @@ func (webhook *BareMetalHost) validateChanges(oldObj *BareMetalHost, newObj *Bar
 	return errs
 }
 
+// Deprecated: This method is going to be removed in a next release.
 func validateBMCAccess(s BareMetalHostSpec, bmcAccess bmc.AccessDetails) []error {
 	var errs []error
 
@@ -134,6 +139,7 @@ func validateBMCAccess(s BareMetalHostSpec, bmcAccess bmc.AccessDetails) []error
 	return errs
 }
 
+// Deprecated: This method is going to be removed in a next release.
 func validateRAID(r *RAIDConfig) []error {
 	var errs []error
 
@@ -164,6 +170,7 @@ func validateRAID(r *RAIDConfig) []error {
 	return errs
 }
 
+// Deprecated: This method is going to be removed in a next release.
 func validateBMHName(bmhname string) error {
 	invalidname, _ := regexp.MatchString(`[^A-Za-z0-9\.\-\_]`, bmhname)
 	if invalidname {
@@ -178,6 +185,7 @@ func validateBMHName(bmhname string) error {
 	return nil
 }
 
+// Deprecated: This method is going to be removed in a next release.
 func validateDNSName(hostaddress string) error {
 	if hostaddress == "" {
 		return nil
@@ -187,6 +195,7 @@ func validateDNSName(hostaddress string) error {
 	return err // the error has enough context already
 }
 
+// Deprecated: This method is going to be removed in a next release.
 func validateAnnotations(host *BareMetalHost) []error {
 	var errs []error
 	var err error
@@ -213,6 +222,7 @@ func validateAnnotations(host *BareMetalHost) []error {
 	return errs
 }
 
+// Deprecated: This method is going to be removed in a next release.
 func validateStatusAnnotation(statusAnnotation string) error {
 	if statusAnnotation != "" {
 		objBMHStatus := &BareMetalHostStatus{}
@@ -231,6 +241,7 @@ func validateStatusAnnotation(statusAnnotation string) error {
 	return nil
 }
 
+// Deprecated: This method is going to be removed in a next release.
 func validateImageURL(imageURL string) error {
 	_, err := url.ParseRequestURI(imageURL)
 	if err != nil {
@@ -240,6 +251,7 @@ func validateImageURL(imageURL string) error {
 	return nil
 }
 
+// Deprecated: This method is going to be removed in a next release.
 func validateRootDeviceHints(rdh *RootDeviceHints) error {
 	if rdh == nil || rdh.DeviceName == "" {
 		return nil
@@ -261,6 +273,8 @@ func validateRootDeviceHints(rdh *RootDeviceHints) error {
 // also make the corresponding changes in the OperationalStatus and
 // ErrorType fields in the struct definition of BareMetalHostStatus in
 // the file baremetalhost_types.go.
+//
+// Deprecated: This method is going to be removed in a next release.
 func checkStatusAnnotation(bmhStatus *BareMetalHostStatus) error {
 	if !slices.Contains(OperationalStatusAllowed, string(bmhStatus.OperationalStatus)) {
 		return fmt.Errorf("invalid operationalStatus '%s' in the %s annotation", string(bmhStatus.OperationalStatus), StatusAnnotation)
@@ -273,6 +287,7 @@ func checkStatusAnnotation(bmhStatus *BareMetalHostStatus) error {
 	return nil
 }
 
+// Deprecated: This method is going to be removed in a next release.
 func validateHwdDetailsAnnotation(hwdDetAnnotation string, inspect string) error {
 	if hwdDetAnnotation == "" {
 		return nil
@@ -293,6 +308,7 @@ func validateHwdDetailsAnnotation(hwdDetAnnotation string, inspect string) error
 	return nil
 }
 
+// Deprecated: This method is going to be removed in a next release.
 func validateInspectAnnotation(inspectAnnotation string) error {
 	if !slices.Contains(inspectAnnotationAllowed, inspectAnnotation) {
 		return fmt.Errorf("invalid value for the %s annotation, allowed are %v", InspectAnnotationPrefix, inspectAnnotationAllowedString)
@@ -301,6 +317,7 @@ func validateInspectAnnotation(inspectAnnotation string) error {
 	return nil
 }
 
+// Deprecated: This method is going to be removed in a next release.
 func validateRebootAnnotation(rebootAnnotation string) error {
 	if rebootAnnotation == "" {
 		return nil
@@ -321,6 +338,8 @@ func validateRebootAnnotation(rebootAnnotation string) error {
 
 // validateCrossNamespaceSecretReferences validates that a SecretReference does not refer to a Secret
 // in a different namespace than the host resource.
+//
+// Deprecated: This method is going to be removed in a next release.
 func validateCrossNamespaceSecretReferences(hostNamespace, hostName, fieldName string, ref *corev1.SecretReference) error {
 	if ref != nil &&
 		ref.Namespace != "" &&
@@ -340,6 +359,8 @@ func validateCrossNamespaceSecretReferences(hostNamespace, hostName, fieldName s
 // validateCrossNamespaceSecretReferences checks all Secret references in the BareMetalHost spec
 // to ensure they do not reference Secrets from other namespaces. This includes userData,
 // networkData, and metaData Secret references.
+//
+// Deprecated: This method is going to be removed in a next release.
 func (webhook *BareMetalHost) validateCrossNamespaceSecretReferences(host *BareMetalHost) []error {
 	secretRefs := map[*corev1.SecretReference]string{
 		host.Spec.UserData:    "userData",
@@ -355,6 +376,7 @@ func (webhook *BareMetalHost) validateCrossNamespaceSecretReferences(host *BareM
 	return errs
 }
 
+// Deprecated: This method is going to be removed in a next release.
 func validatePowerStatus(host *BareMetalHost) error {
 	if host.Spec.DisablePowerOff && !host.Spec.Online {
 		return fmt.Errorf("node can't simultaneously have online set to false and have power off disabled")
diff --git a/apis/metal3.io/v1alpha1/baremetalhost_webhook.go b/apis/metal3.io/v1alpha1/baremetalhost_webhook.go
index 15eca66a..b53d20af 100644
--- a/apis/metal3.io/v1alpha1/baremetalhost_webhook.go
+++ b/apis/metal3.io/v1alpha1/baremetalhost_webhook.go
@@ -28,7 +28,7 @@ import (
 // log is for logging in this package.
 var baremetalhostlog = logf.Log.WithName("webhooks").WithName("BareMetalHost")
 
-//+kubebuilder:webhook:verbs=create;update,path=/validate-metal3-io-v1alpha1-baremetalhost,mutating=false,failurePolicy=fail,sideEffects=none,admissionReviewVersions=v1;v1beta,groups=metal3.io,resources=baremetalhosts,versions=v1alpha1,name=baremetalhost.metal3.io
+var _ webhook.CustomValidator = &BareMetalHost{}
 
 func (webhook *BareMetalHost) SetupWebhookWithManager(mgr ctrl.Manager) error {
 	return ctrl.NewWebhookManagedBy(mgr).
@@ -40,6 +40,8 @@ func (webhook *BareMetalHost) SetupWebhookWithManager(mgr ctrl.Manager) error {
 var _ webhook.CustomValidator = &BareMetalHost{}
 
 // ValidateCreate implements webhook.Validator so a webhook will be registered for the type.
+//
+// Deprecated: This method is going to be removed in a next release.
 func (webhook *BareMetalHost) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) {
 	bmh, ok := obj.(*BareMetalHost)
 	baremetalhostlog.Info("validate create", "namespace", bmh.Namespace, "name", bmh.Name)
@@ -50,6 +52,8 @@ func (webhook *BareMetalHost) ValidateCreate(_ context.Context, obj runtime.Obje
 }
 
 // ValidateUpdate implements webhook.Validator so a webhook will be registered for the type.
+//
+// Deprecated: This method is going to be removed in a next release.
 func (webhook *BareMetalHost) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
 	oldBmh, casted := oldObj.(*BareMetalHost)
 	if !casted {
@@ -65,6 +69,8 @@ func (webhook *BareMetalHost) ValidateUpdate(_ context.Context, oldObj, newObj r
 }
 
 // ValidateDelete implements webhook.Validator so a webhook will be registered for the type.
+//
+// Deprecated: This method is going to be removed in a next release.
 func (webhook *BareMetalHost) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) {
 	return nil, nil
 }
diff --git a/apis/metal3.io/v1alpha1/bmceventsubscription_validation.go b/apis/metal3.io/v1alpha1/bmceventsubscription_validation.go
index a3cd6835..59257477 100644
--- a/apis/metal3.io/v1alpha1/bmceventsubscription_validation.go
+++ b/apis/metal3.io/v1alpha1/bmceventsubscription_validation.go
@@ -22,6 +22,8 @@ import (
 )
 
 // validateSubscription validates BMCEventSubscription resource for creation.
+//
+// Deprecated: This method is going to be removed in a next release.
 func (webhook *BMCEventSubscription) validateSubscription(bmces *BMCEventSubscription) []error {
 	var errs []error
 
diff --git a/apis/metal3.io/v1alpha1/bmceventsubscription_webhook.go b/apis/metal3.io/v1alpha1/bmceventsubscription_webhook.go
index 7718b0b1..42df052f 100644
--- a/apis/metal3.io/v1alpha1/bmceventsubscription_webhook.go
+++ b/apis/metal3.io/v1alpha1/bmceventsubscription_webhook.go
@@ -28,8 +28,6 @@ import (
 // bmcsubscriptionlog is for logging in this package.
 var bmcsubscriptionlog = logf.Log.WithName("webhooks").WithName("BMCEventSubscription")
 
-//+kubebuilder:webhook:verbs=create;update,path=/validate-metal3-io-v1alpha1-bmceventsubscription,mutating=false,failurePolicy=fail,sideEffects=none,admissionReviewVersions=v1;v1beta,groups=metal3.io,resources=bmceventsubscriptions,versions=v1alpha1,name=bmceventsubscription.metal3.io
-
 func (webhook *BMCEventSubscription) SetupWebhookWithManager(mgr ctrl.Manager) error {
 	return ctrl.NewWebhookManagedBy(mgr).
 		For(&BMCEventSubscription{}).
@@ -40,6 +38,8 @@ func (webhook *BMCEventSubscription) SetupWebhookWithManager(mgr ctrl.Manager) e
 var _ webhook.CustomValidator = &BMCEventSubscription{}
 
 // ValidateCreate implements webhook.Validator so a webhook will be registered for the type.
+//
+// Deprecated: This method is going to be removed in a next release.
 func (webhook *BMCEventSubscription) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) {
 	bmces, ok := obj.(*BMCEventSubscription)
 	if !ok {
@@ -53,12 +53,14 @@ func (webhook *BMCEventSubscription) ValidateCreate(_ context.Context, obj runti
 // ValidateUpdate implements webhook.Validator so a webhook will be registered for the type.
 //
 // We prevent updates to the spec.  All other updates (e.g. status, finalizers) are allowed.
+//
+// Deprecated: This method is going to be removed in a next release.
 func (webhook *BMCEventSubscription) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
-	newBmces, ok := newObj.(*BMCEventSubscription)
+	newBMCES, ok := newObj.(*BMCEventSubscription)
 	if !ok {
 		return nil, k8serrors.NewBadRequest(fmt.Sprintf("expected a BMCEventSubscription but got a %T", newObj))
 	}
-	bmcsubscriptionlog.Info("validate update", "namespace", newBmces.Namespace, "name", newBmces.Name)
+	bmcsubscriptionlog.Info("validate update", "namespace", newBMCES.Namespace, "name", newBMCES.Name)
 
 	oldBmces, casted := oldObj.(*BMCEventSubscription)
 	if !casted {
@@ -66,7 +68,7 @@ func (webhook *BMCEventSubscription) ValidateUpdate(_ context.Context, oldObj, n
 		return nil, nil
 	}
 
-	if newBmces.Spec != oldBmces.Spec {
+	if newBMCES.Spec != oldBmces.Spec {
 		return nil, fmt.Errorf("subscriptions cannot be updated, please recreate it")
 	}
 
@@ -74,6 +76,8 @@ func (webhook *BMCEventSubscription) ValidateUpdate(_ context.Context, oldObj, n
 }
 
 // ValidateDelete implements webhook.Validator so a webhook will be registered for the type.
+//
+// Deprecated: This method is going to be removed in a next release.
 func (webhook *BMCEventSubscription) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) {
 	return nil, nil
 }
diff --git a/go.mod b/go.mod
index a888f936..17d008fe 100644
--- a/go.mod
+++ b/go.mod
@@ -5,6 +5,7 @@ go 1.23.7
 require (
 	github.com/go-logr/logr v1.4.2
 	github.com/google/safetext v0.0.0-20230106111101-7156a760e523
+	github.com/google/uuid v1.6.0
 	github.com/gophercloud/gophercloud/v2 v2.6.0
 	github.com/metal3-io/baremetal-operator/apis v0.5.1
 	github.com/metal3-io/baremetal-operator/pkg/hardwareutils v0.5.1
@@ -51,7 +52,6 @@ require (
 	github.com/google/gnostic-models v0.6.8 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
 	github.com/google/gofuzz v1.2.0 // indirect
-	github.com/google/uuid v1.6.0 // indirect
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.20.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/josharian/intern v1.0.0 // indirect
diff --git a/hack/tools/go.mod b/hack/tools/go.mod
index d62ea8aa..400fdefc 100644
--- a/hack/tools/go.mod
+++ b/hack/tools/go.mod
@@ -41,28 +41,28 @@ require (
 	github.com/modern-go/reflect2 v1.0.2 // indirect
 	github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect
 	github.com/sergi/go-diff v1.2.0 // indirect
-	github.com/spf13/cobra v1.8.1 // indirect
-	github.com/spf13/pflag v1.0.5 // indirect
+	github.com/spf13/cobra v1.9.1 // indirect
+	github.com/spf13/pflag v1.0.6 // indirect
 	github.com/x448/float16 v0.8.4 // indirect
 	github.com/xlab/treeprint v1.2.0 // indirect
-	golang.org/x/mod v0.21.0 // indirect
+	golang.org/x/mod v0.23.0 // indirect
 	golang.org/x/net v0.38.0 // indirect
 	golang.org/x/sync v0.12.0 // indirect
 	golang.org/x/sys v0.31.0 // indirect
 	golang.org/x/text v0.23.0 // indirect
-	golang.org/x/tools v0.26.0 // indirect
-	google.golang.org/protobuf v1.35.1 // indirect
+	golang.org/x/tools v0.30.0 // indirect
+	google.golang.org/protobuf v1.36.5 // indirect
 	gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
 	gopkg.in/inf.v0 v0.9.1 // indirect
 	gopkg.in/yaml.v2 v2.4.0 // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
-	k8s.io/api v0.31.7 // indirect
-	k8s.io/apiextensions-apiserver v0.31.7 // indirect
-	k8s.io/apimachinery v0.31.7 // indirect
+	k8s.io/api v0.32.3 // indirect
+	k8s.io/apiextensions-apiserver v0.32.3 // indirect
+	k8s.io/apimachinery v0.32.3 // indirect
 	k8s.io/klog/v2 v2.130.1 // indirect
 	k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect
-	k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 // indirect
-	sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
+	k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
+	sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
 	sigs.k8s.io/kustomize/cmd/config v0.19.0 // indirect
 	sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
 	sigs.k8s.io/yaml v1.4.0 // indirect
diff --git a/hack/tools/go.sum b/hack/tools/go.sum
index 9fe2034d..38eea52b 100644
--- a/hack/tools/go.sum
+++ b/hack/tools/go.sum
@@ -2,7 +2,7 @@ github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdn
 github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
 github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
 github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
-github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
+github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -91,10 +91,10 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
 github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
-github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
-github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
-github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
-github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
+github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
+github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
+github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -123,8 +123,8 @@ golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
 golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0=
-golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
+golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
+golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -153,14 +153,14 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.26.0 h1:v/60pFQmzmT9ExmjDv2gGIfi3OqfKoEP6I5+umXlbnQ=
-golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0=
+golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
+golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
-google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
+google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
+google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
@@ -178,24 +178,24 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-k8s.io/api v0.31.7 h1:wSo59nXpVXmaB6hgNVJCrdnKtyYoutIgpNNBbROBd2U=
-k8s.io/api v0.31.7/go.mod h1:vLUha4nXRUGtQdayzsmjur0lQApK/sJSxyR/fwuujcU=
-k8s.io/apiextensions-apiserver v0.31.7 h1:FujQQl6iKuCF5nX4GIQy3ClvftU8MqadAyi9oQ6ZeAw=
-k8s.io/apiextensions-apiserver v0.31.7/go.mod h1:YmNzYECWFYy8n9R0oxtVAD9JYILZnZCNziYrpUQhKeI=
-k8s.io/apimachinery v0.31.7 h1:fpV8yLerIZFAkj0of66+i1ArPv/Btf9KO6Aulng7RRw=
-k8s.io/apimachinery v0.31.7/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo=
+k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls=
+k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k=
+k8s.io/apiextensions-apiserver v0.32.3 h1:4D8vy+9GWerlErCwVIbcQjsWunF9SUGNu7O7hiQTyPY=
+k8s.io/apiextensions-apiserver v0.32.3/go.mod h1:8YwcvVRMVzw0r1Stc7XfGAzB/SIVLunqApySV5V7Dss=
+k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U=
+k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
 k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
 k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
 k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg=
 k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas=
-k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 h1:pUdcCO1Lk/tbT5ztQWOBi5HBgbBP1J8+AsQnQCKsi8A=
-k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
+k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
+k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
 sigs.k8s.io/cluster-api/test v1.9.5 h1:DtiiDWCIGaWhp4cA7xzGPWeM796XIRQwOptLATe0yFg=
 sigs.k8s.io/cluster-api/test v1.9.5/go.mod h1:YL2wANe8TFWFBka9CDkxjPj7KALqUtK+PtKa4ChNIok=
 sigs.k8s.io/controller-tools v0.16.5 h1:5k9FNRqziBPwqr17AMEPPV/En39ZBplLAdOwwQHruP4=
 sigs.k8s.io/controller-tools v0.16.5/go.mod h1:8vztuRVzs8IuuJqKqbXCSlXcw+lkAv/M2sTpg55qjMY=
-sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo=
-sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0=
+sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
+sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
 sigs.k8s.io/kustomize/api v0.19.0 h1:F+2HB2mU1MSiR9Hp1NEgoU2q9ItNOaBJl0I4Dlus5SQ=
 sigs.k8s.io/kustomize/api v0.19.0/go.mod h1:/BbwnivGVcBh1r+8m3tH1VNxJmHSk1PzP5fkP6lbL1o=
 sigs.k8s.io/kustomize/cmd/config v0.19.0 h1:D3uASwjHWHmNiEHu3pPJBJMBIsb+auFvHrHql3HAarU=
diff --git a/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation.go b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation.go
new file mode 100644
index 00000000..a3e2afe2
--- /dev/null
+++ b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation.go
@@ -0,0 +1,378 @@
+/*
+Copyright 2025 The Metal3 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.
+*/
+
+package webhooks
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net"
+	"net/url"
+	"regexp"
+	"slices"
+	"strings"
+
+	"github.com/google/uuid"
+	metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
+	"github.com/metal3-io/baremetal-operator/pkg/hardwareutils/bmc"
+	corev1 "k8s.io/api/core/v1"
+	k8serrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/runtime/schema"
+)
+
+var (
+	supportedRebootModes           = []string{"hard", "soft", ""}
+	supportedRebootModesString     = "\"hard\", \"soft\" or \"\""
+	inspectAnnotationAllowed       = []string{"disabled", ""}
+	inspectAnnotationAllowedString = "\"disabled\" or \"\""
+)
+
+// validateHost validates BareMetalHost resource for creation.
+func (webhook *BareMetalHost) validateHost(host *metal3api.BareMetalHost) []error {
+	var errs []error
+	var bmcAccess bmc.AccessDetails
+
+	if host.Spec.BMC.Address != "" {
+		var err error
+		bmcAccess, err = bmc.NewAccessDetails(host.Spec.BMC.Address, host.Spec.BMC.DisableCertificateVerification)
+		if err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	errs = append(errs, webhook.validateCrossNamespaceSecretReferences(host)...)
+
+	if raidErrors := validateRAID(host.Spec.RAID); raidErrors != nil {
+		errs = append(errs, raidErrors...)
+	}
+
+	errs = append(errs, validateBMCAccess(host.Spec, bmcAccess)...)
+
+	if err := validateBMHName(host.Name); err != nil {
+		errs = append(errs, err)
+	}
+
+	if err := validateDNSName(host.Spec.BMC.Address); err != nil {
+		errs = append(errs, err)
+	}
+
+	if err := validateRootDeviceHints(host.Spec.RootDeviceHints); err != nil {
+		errs = append(errs, err)
+	}
+
+	if host.Spec.Image != nil {
+		if err := validateImageURL(host.Spec.Image.URL); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	if annotationErrors := validateAnnotations(host); annotationErrors != nil {
+		errs = append(errs, annotationErrors...)
+	}
+
+	if err := validatePowerStatus(host); err != nil {
+		errs = append(errs, err)
+	}
+
+	return errs
+}
+
+// validateChanges validates BareMetalHost resource on changes
+// but also covers the validations of creation.
+func (webhook *BareMetalHost) validateChanges(oldObj *metal3api.BareMetalHost, newObj *metal3api.BareMetalHost) []error {
+	var errs []error
+
+	if err := webhook.validateHost(newObj); err != nil {
+		errs = append(errs, err...)
+	}
+
+	if oldObj.Spec.BMC.Address != "" &&
+		newObj.Spec.BMC.Address != oldObj.Spec.BMC.Address &&
+		newObj.Status.OperationalStatus != metal3api.OperationalStatusDetached &&
+		newObj.Status.Provisioning.State != metal3api.StateRegistering {
+		errs = append(errs, errors.New("BMC address can not be changed if the BMH is not in the Registering state, or if the BMH is not detached"))
+	}
+
+	if oldObj.Spec.BootMACAddress != "" && newObj.Spec.BootMACAddress != oldObj.Spec.BootMACAddress {
+		errs = append(errs, errors.New("bootMACAddress can not be changed once it is set"))
+	}
+
+	return errs
+}
+
+func validateBMCAccess(s metal3api.BareMetalHostSpec, bmcAccess bmc.AccessDetails) []error {
+	var errs []error
+
+	if bmcAccess == nil {
+		return errs
+	}
+
+	if s.RAID != nil && len(s.RAID.HardwareRAIDVolumes) > 0 {
+		if bmcAccess.RAIDInterface() == "no-raid" {
+			errs = append(errs, fmt.Errorf("BMC driver %s does not support configuring RAID", bmcAccess.Type()))
+		}
+	}
+
+	if s.Firmware != nil {
+		if _, err := bmcAccess.BuildBIOSSettings((*bmc.FirmwareConfig)(s.Firmware)); err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	if bmcAccess.NeedsMAC() && s.BootMACAddress == "" {
+		errs = append(errs, fmt.Errorf("BMC driver %s requires a BootMACAddress value", bmcAccess.Type()))
+	}
+
+	if s.BootMACAddress != "" {
+		_, err := net.ParseMAC(s.BootMACAddress)
+		if err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	if s.BootMode == metal3api.UEFISecureBoot && !bmcAccess.SupportsSecureBoot() {
+		errs = append(errs, fmt.Errorf("BMC driver %s does not support secure boot", bmcAccess.Type()))
+	}
+
+	return errs
+}
+
+func validateRAID(r *metal3api.RAIDConfig) []error {
+	var errs []error
+
+	if r == nil {
+		return nil
+	}
+
+	// check if both hardware and software RAID are specified
+	if len(r.HardwareRAIDVolumes) > 0 && len(r.SoftwareRAIDVolumes) > 0 {
+		errs = append(errs, errors.New("hardwareRAIDVolumes and softwareRAIDVolumes can not be set at the same time"))
+	}
+
+	for index, volume := range r.HardwareRAIDVolumes {
+		// check if physicalDisks are specified without a controller
+		if len(volume.PhysicalDisks) != 0 {
+			if volume.Controller == "" {
+				errs = append(errs, fmt.Errorf("'physicalDisks' specified without 'controller' in hardware RAID volume %d", index))
+			}
+		}
+		// check if numberOfPhysicalDisks is not same as len(physicalDisks)
+		if volume.NumberOfPhysicalDisks != nil && len(volume.PhysicalDisks) != 0 {
+			if *volume.NumberOfPhysicalDisks != len(volume.PhysicalDisks) {
+				errs = append(errs, fmt.Errorf("the 'numberOfPhysicalDisks'[%d] and number of 'physicalDisks'[%d] is not same for volume %d", *volume.NumberOfPhysicalDisks, len(volume.PhysicalDisks), index))
+			}
+		}
+	}
+
+	return errs
+}
+
+func validateBMHName(bmhname string) error {
+	invalidname, _ := regexp.MatchString(`[^A-Za-z0-9\.\-\_]`, bmhname)
+	if invalidname {
+		return errors.New("BareMetalHost resource name cannot contain characters other than [A-Za-z0-9._-]")
+	}
+
+	_, err := uuid.Parse(bmhname)
+	if err == nil {
+		return errors.New("BareMetalHost resource name cannot be a UUID")
+	}
+
+	return nil
+}
+
+func validateDNSName(hostaddress string) error {
+	if hostaddress == "" {
+		return nil
+	}
+
+	_, err := bmc.GetParsedURL(hostaddress)
+	return err // the error has enough context already
+}
+
+func validateAnnotations(host *metal3api.BareMetalHost) []error {
+	var errs []error
+	var err error
+
+	for annotation, value := range host.Annotations {
+		switch {
+		case annotation == metal3api.StatusAnnotation:
+			err = validateStatusAnnotation(value)
+		case strings.HasPrefix(annotation, metal3api.RebootAnnotationPrefix+"/") || annotation == metal3api.RebootAnnotationPrefix:
+			err = validateRebootAnnotation(value)
+		case annotation == metal3api.InspectAnnotationPrefix:
+			err = validateInspectAnnotation(value)
+		case annotation == metal3api.HardwareDetailsAnnotation:
+			inspect := host.Annotations[metal3api.InspectAnnotationPrefix]
+			err = validateHwdDetailsAnnotation(value, inspect)
+		default:
+			err = nil
+		}
+		if err != nil {
+			errs = append(errs, err)
+		}
+	}
+
+	return errs
+}
+
+func validateStatusAnnotation(statusAnnotation string) error {
+	if statusAnnotation != "" {
+		objBMHStatus := &metal3api.BareMetalHostStatus{}
+
+		deco := json.NewDecoder(strings.NewReader(statusAnnotation))
+		deco.DisallowUnknownFields()
+		if err := deco.Decode(objBMHStatus); err != nil {
+			return fmt.Errorf("error decoding status annotation: %w", err)
+		}
+
+		if err := checkStatusAnnotation(objBMHStatus); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func validateImageURL(imageURL string) error {
+	_, err := url.ParseRequestURI(imageURL)
+	if err != nil {
+		return fmt.Errorf("image URL %s is invalid: %w", imageURL, err)
+	}
+
+	return nil
+}
+
+func validateRootDeviceHints(rdh *metal3api.RootDeviceHints) error {
+	if rdh == nil || rdh.DeviceName == "" {
+		return nil
+	}
+
+	subpath := strings.TrimPrefix(rdh.DeviceName, "/dev/")
+	if rdh.DeviceName == subpath {
+		return fmt.Errorf("device name of root device hint must be a /dev/ path, not \"%s\"", rdh.DeviceName)
+	}
+
+	subpath = strings.TrimPrefix(subpath, "disk/by-path/")
+	if strings.Contains(subpath, "/") {
+		return fmt.Errorf("device name of root device hint must be path in /dev/ or /dev/disk/by-path/, not \"%s\"", rdh.DeviceName)
+	}
+	return nil
+}
+
+// When making changes to this function for operationalstatus and errortype,
+// also make the corresponding changes in the OperationalStatus and
+// ErrorType fields in the struct definition of BareMetalHostStatus in
+// the file baremetalhost_types.go.
+func checkStatusAnnotation(bmhStatus *metal3api.BareMetalHostStatus) error {
+	if !slices.Contains(metal3api.OperationalStatusAllowed, string(bmhStatus.OperationalStatus)) {
+		return fmt.Errorf("invalid operationalStatus '%s' in the %s annotation", string(bmhStatus.OperationalStatus), metal3api.StatusAnnotation)
+	}
+
+	if !slices.Contains(metal3api.ErrorTypeAllowed, string(bmhStatus.ErrorType)) {
+		return fmt.Errorf("invalid errorType '%s' in the %s annotation", string(bmhStatus.ErrorType), metal3api.StatusAnnotation)
+	}
+
+	return nil
+}
+
+func validateHwdDetailsAnnotation(hwdDetAnnotation string, inspect string) error {
+	if hwdDetAnnotation == "" {
+		return nil
+	}
+
+	if inspect != "disabled" {
+		return errors.New("when hardware details are provided, the inspect.metal3.io annotation must be set to disabled")
+	}
+
+	objHwdDet := &metal3api.HardwareDetails{}
+
+	deco := json.NewDecoder(strings.NewReader(hwdDetAnnotation))
+	deco.DisallowUnknownFields()
+	if err := deco.Decode(objHwdDet); err != nil {
+		return fmt.Errorf("error decoding the %s annotation: %w", metal3api.HardwareDetailsAnnotation, err)
+	}
+
+	return nil
+}
+
+func validateInspectAnnotation(inspectAnnotation string) error {
+	if !slices.Contains(inspectAnnotationAllowed, inspectAnnotation) {
+		return fmt.Errorf("invalid value for the %s annotation, allowed are %v", metal3api.InspectAnnotationPrefix, inspectAnnotationAllowedString)
+	}
+
+	return nil
+}
+
+func validateRebootAnnotation(rebootAnnotation string) error {
+	if rebootAnnotation == "" {
+		return nil
+	}
+
+	objStatus := &metal3api.RebootAnnotationArguments{}
+	err := json.Unmarshal([]byte(rebootAnnotation), objStatus)
+	if err != nil {
+		return fmt.Errorf("failed to unmarshal the data from the %s annotation: %w", metal3api.RebootAnnotationPrefix, err)
+	}
+
+	if !slices.Contains(supportedRebootModes, string(objStatus.Mode)) {
+		return fmt.Errorf("invalid mode in the %s annotation, allowed are %v", metal3api.RebootAnnotationPrefix, supportedRebootModesString)
+	}
+
+	return nil
+}
+
+// validateCrossNamespaceSecretReferences validates that a SecretReference does not refer to a Secret
+// in a different namespace than the host resource.
+func validateCrossNamespaceSecretReferences(hostNamespace, hostName, fieldName string, ref *corev1.SecretReference) error {
+	if ref != nil &&
+		ref.Namespace != "" &&
+		ref.Namespace != hostNamespace {
+		return k8serrors.NewForbidden(
+			schema.GroupResource{
+				Group:    "metal3.io",
+				Resource: "baremetalhosts",
+			},
+			hostName,
+			fmt.Errorf("%s: cross-namespace Secret references are not allowed", fieldName),
+		)
+	}
+	return nil
+}
+
+// validateCrossNamespaceSecretReferences checks all Secret references in the BareMetalHost spec
+// to ensure they do not reference Secrets from other namespaces. This includes userData,
+// networkData, and metaData Secret references.
+func (webhook *BareMetalHost) validateCrossNamespaceSecretReferences(host *metal3api.BareMetalHost) []error {
+	secretRefs := map[*corev1.SecretReference]string{
+		host.Spec.UserData:    "userData",
+		host.Spec.NetworkData: "networkData",
+		host.Spec.MetaData:    "metaData",
+	}
+	errs := []error{}
+	for ref, fieldName := range secretRefs {
+		if err := validateCrossNamespaceSecretReferences(host.Namespace, host.Name, fieldName, ref); err != nil {
+			errs = append(errs, err)
+		}
+	}
+	return errs
+}
+
+func validatePowerStatus(host *metal3api.BareMetalHost) error {
+	if host.Spec.DisablePowerOff && !host.Spec.Online {
+		return fmt.Errorf("node can't simultaneously have online set to false and have power off disabled")
+	}
+	return nil
+}
diff --git a/internal/webhooks/metal3.io/v1alpha1/baremetalhost_webhook.go b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_webhook.go
new file mode 100644
index 00000000..75352a1d
--- /dev/null
+++ b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_webhook.go
@@ -0,0 +1,76 @@
+/*
+Copyright 2025 The Metal3 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.
+*/
+
+package webhooks
+
+import (
+	"context"
+	"fmt"
+
+	metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
+	k8serrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+	kerrors "k8s.io/apimachinery/pkg/util/errors"
+	ctrl "sigs.k8s.io/controller-runtime"
+	logf "sigs.k8s.io/controller-runtime/pkg/log"
+	"sigs.k8s.io/controller-runtime/pkg/webhook"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+)
+
+// log is for logging in this package.
+var baremetalhostlog = logf.Log.WithName("webhooks").WithName("BareMetalHost")
+
+func (webhook *BareMetalHost) SetupWebhookWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewWebhookManagedBy(mgr).
+		For(&metal3api.BareMetalHost{}).
+		WithValidator(webhook).
+		Complete()
+}
+
+//+kubebuilder:webhook:verbs=create;update,path=/validate-metal3-io-v1alpha1-baremetalhost,mutating=false,failurePolicy=fail,sideEffects=none,admissionReviewVersions=v1;v1beta,groups=metal3.io,resources=baremetalhosts,versions=v1alpha1,name=baremetalhost.metal3.io
+
+// BareMetalHost implements a validation and defaulting webhook for BareMetalHost.
+type BareMetalHost struct{}
+
+var _ webhook.CustomValidator = &BareMetalHost{}
+
+// ValidateCreate implements webhook.Validator so a webhook will be registered for the type.
+func (webhook *BareMetalHost) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) {
+	bmh, ok := obj.(*metal3api.BareMetalHost)
+	baremetalhostlog.Info("validate create", "namespace", bmh.Namespace, "name", bmh.Name)
+	if !ok {
+		return nil, k8serrors.NewBadRequest(fmt.Sprintf("expected a BareMetalHost but got a %T", obj))
+	}
+	return nil, kerrors.NewAggregate(webhook.validateHost(bmh))
+}
+
+// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type.
+func (webhook *BareMetalHost) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
+	oldBmh, casted := oldObj.(*metal3api.BareMetalHost)
+	if !casted {
+		baremetalhostlog.Error(fmt.Errorf("old object conversion error for %s/%s", oldBmh.Namespace, oldBmh.Name), "validate update error")
+		return nil, nil
+	}
+
+	newBmh, ok := newObj.(*metal3api.BareMetalHost)
+	if !ok {
+		return nil, k8serrors.NewBadRequest(fmt.Sprintf("expected a BareMetalHost but got a %T", newObj))
+	}
+	return nil, kerrors.NewAggregate(webhook.validateChanges(oldBmh, newBmh))
+}
+
+// ValidateDelete implements webhook.Validator so a webhook will be registered for the type.
+func (webhook *BareMetalHost) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) {
+	return nil, nil
+}
diff --git a/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_validation.go b/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_validation.go
new file mode 100644
index 00000000..7fa78671
--- /dev/null
+++ b/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_validation.go
@@ -0,0 +1,51 @@
+/*
+Copyright 2025 The Metal3 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.
+*/
+
+package webhooks
+
+import (
+	"errors"
+	"fmt"
+	"net/url"
+
+	metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
+)
+
+// validateSubscription validates BMCEventSubscription resource for creation.
+func (webhook *BMCEventSubscription) validateSubscription(bmces *metal3api.BMCEventSubscription) []error {
+	var errs []error
+
+	if bmces.Spec.HostName == "" {
+		errs = append(errs, errors.New("hostName cannot be empty"))
+	}
+
+	if bmces.Spec.HTTPHeadersRef != nil {
+		if bmces.Spec.HTTPHeadersRef.Namespace != bmces.Namespace {
+			errs = append(errs, errors.New("httpHeadersRef secret must be in the same namespace as the BMCEventSubscription"))
+		}
+	}
+
+	if bmces.Spec.Destination == "" {
+		errs = append(errs, errors.New("destination cannot be empty"))
+	} else {
+		destinationURL, err := url.ParseRequestURI(bmces.Spec.Destination)
+
+		if err != nil {
+			errs = append(errs, fmt.Errorf("destination is invalid: %w", err))
+		} else if destinationURL.Path == "" {
+			errs = append(errs, errors.New("hostname-only destination must have a trailing slash"))
+		}
+	}
+	return errs
+}
diff --git a/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_webhook.go b/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_webhook.go
new file mode 100644
index 00000000..4788b358
--- /dev/null
+++ b/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_webhook.go
@@ -0,0 +1,83 @@
+/*
+Copyright 2025 The Metal3 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.
+*/
+
+package webhooks
+
+import (
+	"context"
+	"fmt"
+
+	metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
+	k8serrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/runtime"
+	kerrors "k8s.io/apimachinery/pkg/util/errors"
+	ctrl "sigs.k8s.io/controller-runtime"
+	logf "sigs.k8s.io/controller-runtime/pkg/log"
+	"sigs.k8s.io/controller-runtime/pkg/webhook"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+)
+
+// bmcsubscriptionlog is for logging in this package.
+var bmcsubscriptionlog = logf.Log.WithName("webhooks").WithName("BMCEventSubscription")
+
+func (webhook *BMCEventSubscription) SetupWebhookWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewWebhookManagedBy(mgr).
+		For(&metal3api.BMCEventSubscription{}).
+		WithValidator(webhook).
+		Complete()
+}
+
+//+kubebuilder:webhook:verbs=create;update,path=/validate-metal3-io-v1alpha1-bmceventsubscription,mutating=false,failurePolicy=fail,sideEffects=none,admissionReviewVersions=v1;v1beta,groups=metal3.io,resources=bmceventsubscriptions,versions=v1alpha1,name=bmceventsubscription.metal3.io
+
+// BMCEventSubscription implements a validation and defaulting webhook for BMCEventSubscription.
+type BMCEventSubscription struct{}
+
+var _ webhook.CustomValidator = &BMCEventSubscription{}
+
+// ValidateCreate implements webhook.Validator so a webhook will be registered for the type.
+func (webhook *BMCEventSubscription) ValidateCreate(_ context.Context, obj runtime.Object) (admission.Warnings, error) {
+	bmces, ok := obj.(*metal3api.BMCEventSubscription)
+	if !ok {
+		return nil, k8serrors.NewBadRequest(fmt.Sprintf("expected a BMCEventSubscription but got a %T", obj))
+	}
+
+	bmcsubscriptionlog.Info("validate create", "namespace", bmces.Namespace, "name", bmces.Name)
+	return nil, kerrors.NewAggregate(webhook.validateSubscription(bmces))
+}
+
+// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type.
+func (webhook *BMCEventSubscription) ValidateUpdate(_ context.Context, oldObj, newObj runtime.Object) (admission.Warnings, error) {
+	newBMCES, ok := newObj.(*metal3api.BMCEventSubscription)
+	if !ok {
+		return nil, k8serrors.NewBadRequest(fmt.Sprintf("expected a BMCEventSubscription but got a %T", newObj))
+	}
+	bmcsubscriptionlog.Info("validate update", "namespace", newBMCES.Namespace, "name", newBMCES.Name)
+
+	oldBMCES, casted := oldObj.(*metal3api.BMCEventSubscription)
+	if !casted {
+		bmcsubscriptionlog.Error(fmt.Errorf("old object conversion error for %s/%s", oldBMCES.Namespace, oldBMCES.Name), "validate update error")
+		return nil, nil
+	}
+
+	if newBMCES.Spec != oldBMCES.Spec {
+		return nil, fmt.Errorf("subscriptions cannot be updated, please recreate it")
+	}
+
+	return nil, nil
+}
+
+// ValidateDelete implements webhook.Validator so a webhook will be registered for the type.
+func (webhook *BMCEventSubscription) ValidateDelete(_ context.Context, _ runtime.Object) (admission.Warnings, error) {
+	return nil, nil
+}
diff --git a/internal/webhooks/metal3.io/v1alpha1/doc.go b/internal/webhooks/metal3.io/v1alpha1/doc.go
new file mode 100644
index 00000000..6bdc9a6b
--- /dev/null
+++ b/internal/webhooks/metal3.io/v1alpha1/doc.go
@@ -0,0 +1,19 @@
+/*
+Copyright 2021 The Kubernetes Authors.
+Copyright 2025 The Metal3 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.
+*/
+
+// Package webhooks contains external webhook implementations for some of our API types.
+package webhooks
diff --git a/main.go b/main.go
index 1733a3e2..cae5346d 100644
--- a/main.go
+++ b/main.go
@@ -27,6 +27,7 @@ import (
 
 	metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
 	metal3iocontroller "github.com/metal3-io/baremetal-operator/controllers/metal3.io"
+	webhooks "github.com/metal3-io/baremetal-operator/internal/webhooks/metal3.io/v1alpha1"
 	"github.com/metal3-io/baremetal-operator/pkg/imageprovider"
 	"github.com/metal3-io/baremetal-operator/pkg/provisioner"
 	"github.com/metal3-io/baremetal-operator/pkg/provisioner/demo"
@@ -98,12 +99,12 @@ func setupChecks(mgr ctrl.Manager) {
 }
 
 func setupWebhooks(mgr ctrl.Manager) {
-	if err := (&metal3api.BareMetalHost{}).SetupWebhookWithManager(mgr); err != nil {
+	if err := (&webhooks.BareMetalHost{}).SetupWebhookWithManager(mgr); err != nil {
 		setupLog.Error(err, "unable to create webhook", "webhook", "BareMetalHost")
 		os.Exit(1)
 	}
 
-	if err := (&metal3api.BMCEventSubscription{}).SetupWebhookWithManager(mgr); err != nil {
+	if err := (&webhooks.BMCEventSubscription{}).SetupWebhookWithManager(mgr); err != nil {
 		setupLog.Error(err, "unable to create webhook", "webhook", "BareMetalHost")
 		os.Exit(1)
 	}


Test patch:
diff --git a/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation_test.go b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation_test.go
new file mode 100644
index 00000000..5d6ab808
--- /dev/null
+++ b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation_test.go
@@ -0,0 +1,908 @@
+/*
+Copyright 2025 The Metal3 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.
+*/
+
+package webhooks
+
+import (
+	"fmt"
+	"testing"
+
+	metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func errorArrContains(out []error, want string) bool {
+	if out == nil {
+		return want == ""
+	}
+	if want == "" {
+		return false
+	}
+	for _, err := range out {
+		if err.Error() == want {
+			return true
+		}
+	}
+	return false
+}
+
+func TestValidateCreate(t *testing.T) {
+	tm := metav1.TypeMeta{
+		Kind:       "BareMetalHost",
+		APIVersion: "metal3.io/v1alpha1",
+	}
+
+	om := metav1.ObjectMeta{
+		Name:      "test",
+		Namespace: "test-namespace",
+	}
+
+	inom := metav1.ObjectMeta{
+		Name:      "test~",
+		Namespace: "test-namespace",
+	}
+
+	inom2 := metav1.ObjectMeta{
+		Name:      "07564256-96ae-4315-ab03-8d34ece60fbb",
+		Namespace: "test-namespace",
+	}
+
+	enable := true
+
+	// for RAID validation test cases
+	numberOfPhysicalDisks := 3
+
+	tests := []struct {
+		name      string
+		newBMH    *metal3api.BareMetalHost
+		oldBMH    *metal3api.BareMetalHost
+		wantedErr string
+	}{
+		{
+			name:      "valid",
+			newBMH:    &metal3api.BareMetalHost{TypeMeta: tm, ObjectMeta: om, Spec: metal3api.BareMetalHostSpec{}},
+			oldBMH:    nil,
+			wantedErr: "",
+		},
+		{
+			name:      "invalidName",
+			newBMH:    &metal3api.BareMetalHost{TypeMeta: tm, ObjectMeta: inom, Spec: metal3api.BareMetalHostSpec{}},
+			oldBMH:    nil,
+			wantedErr: "BareMetalHost resource name cannot contain characters other than [A-Za-z0-9._-]",
+		},
+		{
+			name:      "invalidName2",
+			newBMH:    &metal3api.BareMetalHost{TypeMeta: tm, ObjectMeta: inom2, Spec: metal3api.BareMetalHostSpec{}},
+			oldBMH:    nil,
+			wantedErr: "BareMetalHost resource name cannot be a UUID",
+		},
+		{
+			name: "invalidRAID",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BootMACAddress: "01:02:03:04:05:06",
+					BMC: metal3api.BMCDetails{
+						Address:         "irmc:127.0.1.1",
+						CredentialsName: "test1",
+					},
+					RAID: &metal3api.RAIDConfig{
+						HardwareRAIDVolumes: []metal3api.HardwareRAIDVolume{
+							{
+								SizeGibibytes:         nil,
+								Level:                 "",
+								Name:                  "",
+								Rotational:            nil,
+								NumberOfPhysicalDisks: nil,
+							},
+						},
+						SoftwareRAIDVolumes: []metal3api.SoftwareRAIDVolume{
+							{
+								SizeGibibytes: nil,
+								Level:         "",
+								PhysicalDisks: nil,
+							},
+						},
+					}}},
+			oldBMH:    nil,
+			wantedErr: "hardwareRAIDVolumes and softwareRAIDVolumes can not be set at the same time",
+		},
+		{
+			name: "supportBMCType",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BootMACAddress: "01:02:03:04:05:06",
+					BMC: metal3api.BMCDetails{
+						Address:         "irmc:127.0.1.1",
+						CredentialsName: "test1",
+					},
+				}},
+			oldBMH:    nil,
+			wantedErr: "",
+		},
+		{
+			name: "unsupportBMCType",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address:         "test:127.0.1.1",
+						CredentialsName: "test1",
+					},
+				}},
+			oldBMH:    nil,
+			wantedErr: "Unknown BMC type 'test' for address test:127.0.1.1",
+		},
+		{
+			name: "RAIDWithSupportBMC",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					RAID: &metal3api.RAIDConfig{
+						HardwareRAIDVolumes: []metal3api.HardwareRAIDVolume{
+							{
+								SizeGibibytes:         nil,
+								Level:                 "",
+								Name:                  "",
+								Rotational:            nil,
+								NumberOfPhysicalDisks: nil,
+							},
+						},
+					},
+					BootMACAddress: "01:02:03:04:05:06",
+					BMC: metal3api.BMCDetails{
+						Address:         "irmc://127.0.1.1",
+						CredentialsName: "test1",
+					},
+				}},
+			oldBMH:    nil,
+			wantedErr: "",
+		},
+		{
+			name: "RAIDWithUnsupportBMC",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					RAID: &metal3api.RAIDConfig{
+						HardwareRAIDVolumes: []metal3api.HardwareRAIDVolume{
+							{
+								SizeGibibytes:         nil,
+								Level:                 "",
+								Name:                  "",
+								Rotational:            nil,
+								NumberOfPhysicalDisks: nil,
+							},
+						},
+					},
+					BMC: metal3api.BMCDetails{
+						Address:         "ipmi://127.0.1.1",
+						CredentialsName: "test1",
+					},
+				}},
+			oldBMH:    nil,
+			wantedErr: "BMC driver ipmi does not support configuring RAID",
+		},
+		{
+			name: "FirmwareWithSupportBMC",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					Firmware: &metal3api.FirmwareConfig{
+						VirtualizationEnabled: &enable,
+					},
+					BootMACAddress: "01:02:03:04:05:06",
+					BMC: metal3api.BMCDetails{
+						Address:         "irmc://127.0.1.1",
+						CredentialsName: "test1",
+					},
+				}},
+			oldBMH:    nil,
+			wantedErr: "",
+		},
+		{
+			name: "FirmwareWithUnsupportBMC",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					Firmware: &metal3api.FirmwareConfig{
+						VirtualizationEnabled: &enable,
+					},
+					BMC: metal3api.BMCDetails{
+						Address:         "ipmi://127.0.1.1",
+						CredentialsName: "test1",
+					},
+				}},
+			oldBMH:    nil,
+			wantedErr: "firmware settings for ipmi are not supported",
+		},
+		{
+			name: "BootMACAddressRequiredWithoutBootMACAddress",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address:         "libvirt://127.0.1.1",
+						CredentialsName: "test1",
+					},
+				}},
+			oldBMH:    nil,
+			wantedErr: "BMC driver libvirt requires a BootMACAddress value",
+		},
+		{
+			name: "BootMACAddressRequiredWithBootMACAddress",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address:         "libvirt://127.0.1.1",
+						CredentialsName: "test1",
+					},
+					BootMACAddress: "00:00:00:00:00:00",
+				}},
+			oldBMH:    nil,
+			wantedErr: "",
+		},
+		{
+			name: "BootMACAddressRequired",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address:         "libvirt://127.0.1.1",
+						CredentialsName: "test1",
+					},
+					BootMACAddress: "00:00:00:00:00:00",
+					BootMode:       metal3api.UEFISecureBoot,
+				}},
+			oldBMH:    nil,
+			wantedErr: "BMC driver libvirt does not support secure boot",
+		},
+		{
+			name: "InvalidBootMACAddress",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address:         "irmc://127.0.1.1",
+						CredentialsName: "test1",
+					},
+					BootMACAddress: "00:00:00:00:00",
+					BootMode:       metal3api.UEFISecureBoot,
+				}},
+			oldBMH:    nil,
+			wantedErr: "address 00:00:00:00:00: invalid MAC address",
+		},
+		{
+			name: "UEFISecureBootWithSupportBMC",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address:         "irmc://127.0.1.1",
+						CredentialsName: "test1",
+					},
+					BootMACAddress: "00:00:00:00:00:00",
+					BootMode:       metal3api.UEFISecureBoot,
+				}},
+			oldBMH:    nil,
+			wantedErr: "",
+		},
+		{
+			name: "'physicalDisks' in HardwareRAID without 'controller'.",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address:         "redfish://127.0.0.1",
+						CredentialsName: "test1",
+					},
+					RAID: &metal3api.RAIDConfig{
+						HardwareRAIDVolumes: []metal3api.HardwareRAIDVolume{
+							{
+								SizeGibibytes: nil,
+								Level:         "",
+								Name:          "",
+								Rotational:    nil,
+								PhysicalDisks: []string{"Disk-1", "Disk-2"},
+							}, // end of RAID volume
+						}, // end of RAID volume slice
+					}, // end of RAID config
+				}, // end of BMH spec
+			},
+			oldBMH:    nil,
+			wantedErr: "'physicalDisks' specified without 'controller' in hardware RAID volume 0",
+		},
+		{
+			name: "'numberOfPhysicalDisks' not same as length of 'physicalDisks'",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address:         "redfish://127.0.0.1",
+						CredentialsName: "test1",
+					},
+					RAID: &metal3api.RAIDConfig{
+						HardwareRAIDVolumes: []metal3api.HardwareRAIDVolume{
+							{
+								SizeGibibytes:         nil,
+								Level:                 "",
+								Name:                  "",
+								Rotational:            nil,
+								Controller:            "Controller-1",
+								PhysicalDisks:         []string{"Disk-1", "Disk-2"},
+								NumberOfPhysicalDisks: &numberOfPhysicalDisks, // defined as 3 above
+							}, // end of RAID volume
+						}, // end of RAID volume slice
+					}, // end of RAID config
+				}, // end of BMH spec
+			},
+			oldBMH:    nil,
+			wantedErr: fmt.Sprintf("the 'numberOfPhysicalDisks'[%d] and number of 'physicalDisks'[2] is not same for volume 0", numberOfPhysicalDisks),
+		},
+		{
+			name: "validDNSName",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "ipmi://host-0.example.com.org:6223"}}},
+			oldBMH:    nil,
+			wantedErr: "",
+		},
+		{
+			name: "validDNSName2",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "ipmi://baremetalhost"}}},
+			oldBMH:    nil,
+			wantedErr: "",
+		},
+		{
+			name: "validDNSName3",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "ipmi://[fe80::fc33:62ff:fe83:8a76]:6233"}}},
+			oldBMH:    nil,
+			wantedErr: "",
+		},
+		{
+			name: "invalidDNSNameinvalidhyphenuse",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "ipmi://-host.example.com.org"}}},
+			oldBMH:    nil,
+			wantedErr: "failed to parse BMC address information: BMC address hostname/IP : [-host.example.com.org] is invalid",
+		},
+		{
+			name: "invalidDNSNameinvalidcharacter",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "ipmi://host+1.example.com.org"}}},
+			oldBMH:    nil,
+			wantedErr: "failed to parse BMC address information: BMC address hostname/IP : [host+1.example.com.org] is invalid",
+		},
+		{
+			name: "invalidDNSNameinvalidformat",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "[@]host.example.com"}}},
+			oldBMH:    nil,
+			wantedErr: "failed to parse BMC address information: parse \"ipmi://[@]host.example.com\": net/url: invalid userinfo",
+		},
+		{
+			name: "invalidDNSNameinvalidbmc",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "ipm:host.example.com:6223"}}},
+			oldBMH:    nil,
+			wantedErr: "Unknown BMC type 'ipm' for address ipm:host.example.com:6223",
+		},
+		{
+			name: "invalidDNSNameinvalidipv6",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "ipmi://[fe80::fc33:62ff:fe33:8xff]:6223"}}},
+			oldBMH:    nil,
+			wantedErr: "failed to parse BMC address information: BMC address hostname/IP : [fe80::fc33:62ff:fe33:8xff] is invalid",
+		},
+		{
+			name: "validRootDeviceHint",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					RootDeviceHints: &metal3api.RootDeviceHints{
+						DeviceName: "/dev/sda",
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "",
+		},
+		{
+			name: "validRootDeviceHintByPath",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					RootDeviceHints: &metal3api.RootDeviceHints{
+						DeviceName: "/dev/disk/by-path/pci-0000:01:00.0-scsi-0:2:0:0",
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "",
+		},
+		{
+			name: "invalidRootDeviceHintByUUID",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					RootDeviceHints: &metal3api.RootDeviceHints{
+						DeviceName: "/dev/disk/by-uuid/cdaacd50-3a4c-421c-91c0-fe9ba7b8b2f1",
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "device name of root device hint must be path in /dev/ or /dev/disk/by-path/, not \"/dev/disk/by-uuid/cdaacd50-3a4c-421c-91c0-fe9ba7b8b2f1\"",
+		},
+		{
+			name: "invalidRootDeviceHintNoPath",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					RootDeviceHints: &metal3api.RootDeviceHints{
+						DeviceName: "sda",
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "device name of root device hint must be a /dev/ path, not \"sda\"",
+		},
+		{
+			name: "invalidImageURL",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address:         "redfish://127.0.0.1",
+						CredentialsName: "test1",
+					},
+					Image: &metal3api.Image{
+						URL: "test1",
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "image URL test1 is invalid: parse \"test1\": invalid URI for request",
+		},
+		{
+			name: "validStatusAnnotation",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm,
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						metal3api.StatusAnnotation: `{"operationalStatus": "OK"}`,
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "",
+		},
+		{
+			name: "invalidFieldStatusAnnotation",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm,
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						metal3api.StatusAnnotation: `{"InvalidField":"NotOK"}`,
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "error decoding status annotation: json: unknown field \"InvalidField\"",
+		},
+		{
+			name: "invalidOpstatusStatusAnnotation",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm,
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						metal3api.StatusAnnotation: `{"operationalStatus":"NotOK"}`,
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "invalid operationalStatus 'NotOK' in the baremetalhost.metal3.io/status annotation",
+		},
+		{
+			name: "invalidErrtypeStatusAnnotation",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm,
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						metal3api.StatusAnnotation: `{"errorType":"No Error"}`,
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "invalid errorType 'No Error' in the baremetalhost.metal3.io/status annotation",
+		},
+		{
+			name: "invalidFormatStatusAnnotation",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm,
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						metal3api.StatusAnnotation: `{"operationalStatus":"OK"`,
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "error decoding status annotation: unexpected EOF",
+		},
+		{
+			name: "invalidValueRebootAnnotationPrefix",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm,
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						metal3api.RebootAnnotationPrefix: `{"mode":"medium"}`,
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "invalid mode in the reboot.metal3.io annotation, allowed are \"hard\", \"soft\" or \"\"",
+		},
+		{
+			name: "invalidValueRebootAnnotationWithKey",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm,
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						metal3api.RebootAnnotationPrefix + "/my-key": `{"mode":"medium"}`,
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "invalid mode in the reboot.metal3.io annotation, allowed are \"hard\", \"soft\" or \"\"",
+		},
+		{
+			name: "inspectionNotDisabledHardwareDetailsAnnotation",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm,
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						metal3api.HardwareDetailsAnnotation: `{"systemVendor":{"manufacturer":"QEMU","productName":"Standard PC (Q35 + ICH9, 2009)","serialNumber":""},"firmware":{"bios":{"date":"","vendor":"","version":""}},"ramMebibytes":4096,"nics":[{"name":"eth0","model":"0x1af4 0x0001","mac":"00:b7:8b:bb:3d:f6","ip":"172.22.0.64","speedGbps":0,"vlanId":0,"pxe":true}],"storage":[{"name":"/dev/sda","rotational":true,"sizeBytes":53687091200,"vendor":"QEMU","model":"QEMU HARDDISK","serialNumber":"drive-scsi0-0-0-0","hctl":"6:0:0:0"}],"cpu":{"arch":"x86_64","model":"Intel Xeon E3-12xx v2 (IvyBridge)","clockMegahertz":2494.224,"flags":["foo"],"count":4},"hostname":"hwdAnnotation-0"}`,
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "when hardware details are provided, the inspect.metal3.io annotation must be set to disabled",
+		},
+		{
+			name: "invalidFieldHardwareDetailsAnnotation",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm,
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						metal3api.InspectAnnotationPrefix:   "disabled",
+						metal3api.HardwareDetailsAnnotation: `{"INVALIDField":{"manufacturer":"QEMU","productName":"Standard PC (Q35 + ICH9, 2009)","serialNumber":""},"firmware":{"bios":{"date":"","vendor":"","version":""}},"ramMebibytes":4096,"nics":[{"name":"eth0","model":"0x1af4 0x0001","mac":"00:b7:8b:bb:3d:f6","ip":"172.22.0.64","speedGbps":0,"vlanId":0,"pxe":true}],"storage":[{"name":"/dev/sda","rotational":true,"sizeBytes":53687091200,"vendor":"QEMU","model":"QEMU HARDDISK","serialNumber":"drive-scsi0-0-0-0","hctl":"6:0:0:0"}],"cpu":{"arch":"x86_64","model":"Intel Xeon E3-12xx v2 (IvyBridge)","clockMegahertz":2494.224,"flags":["foo"],"count":4},"hostname":"hwdAnnotation-0"}`,
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "error decoding the inspect.metal3.io/hardwaredetails annotation: json: unknown field \"INVALIDField\"",
+		},
+		{
+			name: "invalidJsonHardwareDetailsAnnotation",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm, ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						metal3api.InspectAnnotationPrefix:   "disabled",
+						metal3api.HardwareDetailsAnnotation: `{"INVALIDField":{"manufacturer":"QEMU","productName":"Standard PC (Q35 + ICH9, 2009)","serialNumber":""},"firmware":{"bios":{"date":"","vendor":"","version":""}},"ramMebibytes":4096,"nics":[{"name":"eth0","model":"0x1af4 0x0001","mac":"00:b7:8b:bb:3d:f6","ip":"172.22.0.64","speedGbps":0,"vlanId":0,"pxe":true}],"storage":[{"name":"/dev/sda","rotational":true,"sizeBytes":53687091200,"vendor":"QEMU","model":"QEMU HARDDISK","serialNumber":"drive-scsi0-0-0-0","hctl":"6:0:0:0"}],"cpu":{"arch":"x86_64","model":"Intel Xeon E3-12xx v2 (IvyBridge)","clockMegahertz":2494.224,"flags":["foo"],"count":4},"hostname":"hwdAnnotation-0"`,
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "error decoding the inspect.metal3.io/hardwaredetails annotation: unexpected EOF",
+		},
+		{
+			name: "invalidValueInspectAnnotation",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm,
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+					Annotations: map[string]string{
+						metal3api.InspectAnnotationPrefix: "enabled",
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "invalid value for the inspect.metal3.io annotation, allowed are \"disabled\" or \"\"",
+		},
+		{
+			name: "crossNamespaceUserData",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					UserData: &corev1.SecretReference{
+						Name:      "test-secret",
+						Namespace: "different-namespace", // Different from host's namespace
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "baremetalhosts.metal3.io \"test\" is forbidden: userData: cross-namespace Secret references are not allowed",
+		},
+		{
+			name: "crossNamespaceNetworkData",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					NetworkData: &corev1.SecretReference{
+						Name:      "test-secret",
+						Namespace: "different-namespace", // Different from host's namespace
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "baremetalhosts.metal3.io \"test\" is forbidden: networkData: cross-namespace Secret references are not allowed",
+		},
+		{
+			name: "crossNamespaceMetaData",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					MetaData: &corev1.SecretReference{
+						Name:      "test-secret",
+						Namespace: "different-namespace", // Different from host's namespace
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "baremetalhosts.metal3.io \"test\" is forbidden: metaData: cross-namespace Secret references are not allowed",
+		},
+		{
+			name: "multipleSecretsCrossNamespace",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					UserData: &corev1.SecretReference{
+						Name:      "test-secret1",
+						Namespace: "different-namespace1",
+					},
+					NetworkData: &corev1.SecretReference{
+						Name:      "test-secret2",
+						Namespace: "different-namespace2",
+					},
+					MetaData: &corev1.SecretReference{
+						Name:      "test-secret3",
+						Namespace: "different-namespace3",
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "baremetalhosts.metal3.io \"test\" is forbidden: userData: cross-namespace Secret references are not allowed", // Should catch at least one error
+		},
+		{
+			name: "sameNamespaceSecrets",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om, // namespace is "test-namespace"
+				Spec: metal3api.BareMetalHostSpec{
+					UserData: &corev1.SecretReference{
+						Name:      "test-secret1",
+						Namespace: "test-namespace", // Same as host's namespace
+					},
+					NetworkData: &corev1.SecretReference{
+						Name:      "test-secret2",
+						Namespace: "test-namespace", // Same as host's namespace
+					},
+					MetaData: &corev1.SecretReference{
+						Name:      "test-secret3",
+						Namespace: "test-namespace", // Same as host's namespace
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "", // Should be valid
+		},
+		{
+			name: "disablePowerOff",
+			newBMH: &metal3api.BareMetalHost{
+				Spec: metal3api.BareMetalHostSpec{
+					DisablePowerOff: true,
+					Online:          true,
+				},
+			},
+			wantedErr: "",
+		},
+		{
+			name: "disablePowerOffErr",
+			newBMH: &metal3api.BareMetalHost{
+				Spec: metal3api.BareMetalHostSpec{
+					DisablePowerOff: true,
+					Online:          false,
+				},
+			},
+			wantedErr: "node can't simultaneously have online set to false and have power off disabled",
+		},
+	}
+
+	for _, tt := range tests {
+		webhook := &BareMetalHost{}
+		t.Run(tt.name, func(t *testing.T) {
+			if err := webhook.validateHost(tt.newBMH); !errorArrContains(err, tt.wantedErr) {
+				t.Errorf("metal3api.BareMetalHost.Validatemetal3api.BareMetalHost() error = %v, wantErr %v", err, tt.wantedErr)
+			}
+		})
+	}
+}
+
+func TestValidateUpdate(t *testing.T) {
+	tm := metav1.TypeMeta{
+		Kind:       "BareMetalHost",
+		APIVersion: "metal3.io/v1alpha1",
+	}
+
+	om := metav1.ObjectMeta{
+		Name:      "test",
+		Namespace: "test-namespace",
+	}
+
+	tests := []struct {
+		name      string
+		newBMH    *metal3api.BareMetalHost
+		oldBMH    *metal3api.BareMetalHost
+		wantedErr string
+	}{
+		{
+			name: "updateAddress",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "test-address-changed"}}},
+			oldBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "test-address"}}},
+			wantedErr: "BMC address can not be changed if the BMH is not in the Registering state, or if the BMH is not detached",
+		},
+		{
+			name: "updateAddressBMHRegistering",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "test-address-changed"}},
+				Status: metal3api.BareMetalHostStatus{
+					Provisioning: metal3api.ProvisionStatus{
+						State: metal3api.StateRegistering}}},
+			oldBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "test-address"}}},
+			wantedErr: "",
+		},
+		{
+			name: "updateAddressBMHDetached",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "test-address-changed"}},
+				Status: metal3api.BareMetalHostStatus{
+					OperationalStatus: metal3api.OperationalStatusDetached}},
+			oldBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					BMC: metal3api.BMCDetails{
+						Address: "test-address"}}},
+			wantedErr: "",
+		},
+		{
+			name: "updateBootMAC",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm, ObjectMeta: om, Spec: metal3api.BareMetalHostSpec{BootMACAddress: "test-mac-changed"}},
+			oldBMH: &metal3api.BareMetalHost{
+				TypeMeta: tm, ObjectMeta: om, Spec: metal3api.BareMetalHostSpec{BootMACAddress: "test-mac"}},
+			wantedErr: "bootMACAddress can not be changed once it is set",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			webhook := &BareMetalHost{}
+			if err := webhook.validateChanges(tt.oldBMH, tt.newBMH); !errorArrContains(err, tt.wantedErr) {
+				t.Errorf("metal3api.BareMetalHost.Validatemetal3api.BareMetalHost() error = %v, wantErr %v", err, tt.wantedErr)
+			}
+		})
+	}
+}
diff --git a/internal/webhooks/metal3.io/v1alpha1/baremetalhost_webhook_test.go b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_webhook_test.go
new file mode 100644
index 00000000..89a31885
--- /dev/null
+++ b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_webhook_test.go
@@ -0,0 +1,105 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+Copyright 2025 The Metal3 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.
+*/
+
+package webhooks
+
+import (
+	"context"
+	"strings"
+	"testing"
+
+	metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func errorContains(out error, want string) bool {
+	if out == nil {
+		return want == ""
+	}
+	if want == "" {
+		return false
+	}
+	return strings.Contains(out.Error(), want)
+}
+
+func TestBareMetalHostCreate(t *testing.T) {
+	tests := []struct {
+		name      string
+		bmh       *metal3api.BareMetalHost
+		wantedErr string
+	}{
+		{
+			name: "valid",
+			bmh: &metal3api.BareMetalHost{TypeMeta: metav1.TypeMeta{
+				Kind:       "BareMetalHost",
+				APIVersion: "metal3.io/v1alpha1",
+			}, ObjectMeta: metav1.ObjectMeta{
+				Name:      "test",
+				Namespace: "test-namespace",
+			}, Spec: metal3api.BareMetalHostSpec{}},
+			wantedErr: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			webhook := &BareMetalHost{}
+			ctx := context.Background()
+			if _, err := webhook.ValidateCreate(ctx, tt.bmh); !errorContains(err, tt.wantedErr) {
+				t.Errorf("BareMetalHost.ValidateCreate() error = %v, wantErr %v", err, tt.wantedErr)
+			}
+		})
+	}
+}
+
+func TestBareMetalHostUpdate(t *testing.T) {
+	tests := []struct {
+		name      string
+		bmh       *metal3api.BareMetalHost
+		old       *metal3api.BareMetalHost
+		wantedErr string
+	}{
+		{
+			name: "valid",
+			bmh: &metal3api.BareMetalHost{TypeMeta: metav1.TypeMeta{
+				Kind:       "BareMetalHost",
+				APIVersion: "metal3.io/v1alpha1",
+			}, ObjectMeta: metav1.ObjectMeta{
+				Name:      "test",
+				Namespace: "test-namespace",
+			}, Spec: metal3api.BareMetalHostSpec{}},
+			old: &metal3api.BareMetalHost{TypeMeta: metav1.TypeMeta{
+				Kind:       "BareMetalHost",
+				APIVersion: "metal3.io/v1alpha1",
+			}, ObjectMeta: metav1.ObjectMeta{
+				Name:      "test",
+				Namespace: "test-namespace",
+			}, Spec: metal3api.BareMetalHostSpec{}},
+			wantedErr: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			webhook := &BareMetalHost{}
+			ctx := context.Background()
+			if _, err := webhook.ValidateUpdate(ctx, tt.old, tt.bmh); !errorContains(err, tt.wantedErr) {
+				t.Errorf("BareMetalHost.ValidateUpdate() error = %v, wantErr %v", err, tt.wantedErr)
+			}
+		})
+	}
+}
diff --git a/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_validation_test.go b/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_validation_test.go
new file mode 100644
index 00000000..0312532c
--- /dev/null
+++ b/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_validation_test.go
@@ -0,0 +1,120 @@
+/*
+Copyright 2025 The Metal3 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.
+*/
+
+package webhooks
+
+import (
+	"testing"
+
+	metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestBMCEventSubscriptionValidateCreate(t *testing.T) {
+	tm := metav1.TypeMeta{
+		Kind:       "BMCEventSubscription",
+		APIVersion: "metal3.io/v1alpha1",
+	}
+
+	om := metav1.ObjectMeta{
+		Name:      "test",
+		Namespace: "test-namespace",
+	}
+
+	tests := []struct {
+		name      string
+		newS      *metal3api.BMCEventSubscription
+		oldS      *metal3api.BMCEventSubscription
+		wantedErr string
+	}{
+		{
+			name:      "valid",
+			newS:      &metal3api.BMCEventSubscription{TypeMeta: tm, ObjectMeta: om, Spec: metal3api.BMCEventSubscriptionSpec{HostName: "worker-01", Destination: "http://localhost/abc/abc.php"}},
+			oldS:      nil,
+			wantedErr: "",
+		},
+		{
+			name: "missingHostName",
+			newS: &metal3api.BMCEventSubscription{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec:       metal3api.BMCEventSubscriptionSpec{Destination: "http://localhost/abc/abc"},
+			},
+			oldS:      nil,
+			wantedErr: "hostName cannot be empty",
+		},
+		{
+			name: "missingDestination",
+			newS: &metal3api.BMCEventSubscription{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec:       metal3api.BMCEventSubscriptionSpec{HostName: "worker-01"},
+			},
+			oldS:      nil,
+			wantedErr: "destination cannot be empty",
+		},
+		{
+			name: "destinationNotUrl",
+			newS: &metal3api.BMCEventSubscription{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec:       metal3api.BMCEventSubscriptionSpec{HostName: "worker-01", Destination: "abc"},
+			},
+			oldS:      nil,
+			wantedErr: "destination is invalid: parse \"abc\": invalid URI for request",
+		},
+		{
+			name: "destinationMissingTrailingSlash",
+			newS: &metal3api.BMCEventSubscription{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec:       metal3api.BMCEventSubscriptionSpec{HostName: "worker-01", Destination: "http://localhost"},
+			},
+			oldS:      nil,
+			wantedErr: "hostname-only destination must have a trailing slash",
+		},
+		{
+			name: "httpHeadersRef valid",
+			newS: &metal3api.BMCEventSubscription{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BMCEventSubscriptionSpec{HostName: "worker-01", Destination: "http://localhost/abc/abc.php",
+					HTTPHeadersRef: &corev1.SecretReference{Namespace: om.Namespace, Name: "headers"}},
+			},
+			oldS:      nil,
+			wantedErr: "",
+		},
+		{
+			name: "httpHeadersRef in different namespace",
+			newS: &metal3api.BMCEventSubscription{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BMCEventSubscriptionSpec{HostName: "worker-01", Destination: "http://localhost/abc/abc.php",
+					HTTPHeadersRef: &corev1.SecretReference{Namespace: "different", Name: "headers"}},
+			},
+			oldS:      nil,
+			wantedErr: "httpHeadersRef secret must be in the same namespace as the BMCEventSubscription",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			webhook := &BMCEventSubscription{}
+			if err := webhook.validateSubscription(tt.newS); !errorArrContains(err, tt.wantedErr) {
+				t.Errorf("BareMetalHost.validateSubscription() error = %v, wantErr %v", err, tt.wantedErr)
+			}
+		})
+	}
+}
diff --git a/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_webhook_test.go b/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_webhook_test.go
new file mode 100644
index 00000000..ca4380d7
--- /dev/null
+++ b/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_webhook_test.go
@@ -0,0 +1,159 @@
+/*
+Copyright 2019 The Kubernetes Authors.
+Copyright 2025 The Metal3 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.
+*/
+
+package webhooks
+
+import (
+	"context"
+	"testing"
+
+	metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestBMCEventSubscriptionUpdate(t *testing.T) {
+	tests := []struct {
+		name      string
+		bes       *metal3api.BMCEventSubscription
+		old       *metal3api.BMCEventSubscription
+		wantedErr string
+	}{
+		{
+			// This is valid because the spec wasn't updated.
+			name: "valid",
+			bes: &metal3api.BMCEventSubscription{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "metal3api.BMCEventSubscription",
+					APIVersion: "metal3.io/v1alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+				},
+				Spec: metal3api.BMCEventSubscriptionSpec{},
+			},
+			old: &metal3api.BMCEventSubscription{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "metal3api.BMCEventSubscription",
+					APIVersion: "metal3.io/v1alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+				},
+				Spec: metal3api.BMCEventSubscriptionSpec{},
+			},
+			wantedErr: "",
+		},
+		{
+			name: "invalid",
+			bes: &metal3api.BMCEventSubscription{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "metal3api.BMCEventSubscription",
+					APIVersion: "metal3.io/v1alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+				},
+				Spec: metal3api.BMCEventSubscriptionSpec{},
+			},
+			old: &metal3api.BMCEventSubscription{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "metal3api.BMCEventSubscription",
+					APIVersion: "metal3.io/v1alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+				},
+				Spec: metal3api.BMCEventSubscriptionSpec{Context: "abc"},
+			},
+			wantedErr: "subscriptions cannot be updated, please recreate it",
+		},
+		{
+			// Status updates are valid
+			name: "status updated",
+			bes: &metal3api.BMCEventSubscription{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "metal3api.BMCEventSubscription",
+					APIVersion: "metal3.io/v1alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+				},
+				Spec: metal3api.BMCEventSubscriptionSpec{},
+				Status: metal3api.BMCEventSubscriptionStatus{
+					SubscriptionID: "some-id",
+				},
+			},
+			old: &metal3api.BMCEventSubscription{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "metal3api.BMCEventSubscription",
+					APIVersion: "metal3.io/v1alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "test",
+					Namespace: "test-namespace",
+				},
+				Spec:   metal3api.BMCEventSubscriptionSpec{},
+				Status: metal3api.BMCEventSubscriptionStatus{},
+			},
+			wantedErr: "",
+		},
+		{
+			// Finalizer updates are valid
+			name: "finalizers updated",
+			bes: &metal3api.BMCEventSubscription{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "metal3api.BMCEventSubscription",
+					APIVersion: "metal3.io/v1alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name:       "test",
+					Namespace:  "test-namespace",
+					Finalizers: []string{metal3api.BMCEventSubscriptionFinalizer},
+				},
+				Spec:   metal3api.BMCEventSubscriptionSpec{},
+				Status: metal3api.BMCEventSubscriptionStatus{},
+			},
+			old: &metal3api.BMCEventSubscription{
+				TypeMeta: metav1.TypeMeta{
+					Kind:       "metal3api.BMCEventSubscription",
+					APIVersion: "metal3.io/v1alpha1",
+				},
+				ObjectMeta: metav1.ObjectMeta{
+					Name:       "test",
+					Namespace:  "test-namespace",
+					Finalizers: []string{},
+				},
+				Spec:   metal3api.BMCEventSubscriptionSpec{},
+				Status: metal3api.BMCEventSubscriptionStatus{},
+			},
+			wantedErr: "",
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			webhook := &BMCEventSubscription{}
+			context := context.Background()
+			if _, err := webhook.ValidateUpdate(context, tt.old, tt.bes); !errorContains(err, tt.wantedErr) {
+				t.Errorf("metal3api.BMCEventSubscription.ValidateUpdate() error = %v, wantErr %v", err, tt.wantedErr)
+			}
+		})
+	}
+}