Repository: syself/cluster-api-provider-hetzner
Pull request #1429 (2024-08-09 09:16:39)

:seedling: WipeDisk annotation
Introduced annotation "wipedisk.hetznerbaremetalhost.infrastructure.cluster.x-k8s.io"

Set it to a space seperated list of WWNs or "all".

The given disks will be erased with `wipefs -af` before provisioning.

Once wipefs was executed the annotation gets removed.



Patch:
diff --git a/api/v1beta1/conditions_const.go b/api/v1beta1/conditions_const.go
index bcc05436..ff7f9e3b 100644
--- a/api/v1beta1/conditions_const.go
+++ b/api/v1beta1/conditions_const.go
@@ -183,6 +183,8 @@ const (
 	ServerNotFoundReason = "ServerNotFound"
 	// LinuxOnOtherDiskFoundReason indicates that the server can't be provisioned on the given WWN, since the reboot would fail.
 	LinuxOnOtherDiskFoundReason = "LinuxOnOtherDiskFound"
+	// WipeDiskFailedReason indicates that erasing the disks before provisioning failed.
+	WipeDiskFailedReason = "WipeDiskFailed"
 	// SSHToRescueSystemFailedReason indicates that the rescue system can't be reached via ssh.
 	SSHToRescueSystemFailedReason = "SSHToRescueSystemFailed"
 	// RebootTimedOutReason indicates that the reboot timed out.
diff --git a/api/v1beta1/hetznerbaremetalhost_types.go b/api/v1beta1/hetznerbaremetalhost_types.go
index 47be9682..5e84afa0 100644
--- a/api/v1beta1/hetznerbaremetalhost_types.go
+++ b/api/v1beta1/hetznerbaremetalhost_types.go
@@ -22,10 +22,12 @@ import (
 	"encoding/json"
 	"fmt"
 	"strings"
+	"time"
 
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
+	"sigs.k8s.io/cluster-api/util/record"
 )
 
 const (
@@ -37,6 +39,10 @@ const (
 	// HostAnnotation is the key for an annotation that should go on a HetznerBareMetalMachine to
 	// reference what HetznerBareMetalHost it corresponds to.
 	HostAnnotation = "infrastructure.cluster.x-k8s.io/HetznerBareMetalHost"
+
+	// WipeDiskAnnotation indicates which Disks (WWNs) to erase before provisioning
+	// The value is a list of WWNS or "all".
+	WipeDiskAnnotation = "wipedisk.hetznerbaremetalhost.infrastructure.cluster.x-k8s.io"
 )
 
 // RootDeviceHints holds the hints for specifying the storage location
@@ -547,6 +553,14 @@ func (host *HetznerBareMetalHost) SetError(errType ErrorType, errMessage string)
 	}
 	host.Spec.Status.ErrorType = errType
 	host.Spec.Status.ErrorMessage = errMessage
+	if errType == PermanentError {
+		if host.Annotations == nil {
+			host.Annotations = make(map[string]string, 1)
+		}
+		host.Annotations[PermanentErrorAnnotation] = time.Now().Format(time.RFC3339)
+		record.Warnf(host, "PermanentErrorSet", "Remove annotation %q, if you want the controller to use the hbmh again.",
+			PermanentErrorAnnotation)
+	}
 }
 
 // ClearError removes the error on the host and resets the error count to 0.
diff --git a/api/v1beta1/hetznerbaremetalremediation_types.go b/api/v1beta1/hetznerbaremetalremediation_types.go
index 527df7c7..a1829588 100644
--- a/api/v1beta1/hetznerbaremetalremediation_types.go
+++ b/api/v1beta1/hetznerbaremetalremediation_types.go
@@ -27,6 +27,11 @@ const (
 
 	// RebootAnnotation indicates that a bare metal host object should be rebooted.
 	RebootAnnotation = "reboot.hetznerbaremetalhost.infrastructure.cluster.x-k8s.io"
+
+	// PermanentErrorAnnotation indicates that the bare metal host has an error which needs to be resolved manually.
+	// After the permanent error the annotation got removed (usually by a human), the controller removes
+	// ErrorType, ErrorCount and ErrorMessages, so that the hbmh will be usable again.
+	PermanentErrorAnnotation = "error.hetznerbaremetalhost.infrastructure.cluster.x-k8s.io"
 )
 
 // HetznerBareMetalRemediationSpec defines the desired state of HetznerBareMetalRemediation.
diff --git a/controllers/csr_controller.go b/controllers/csr_controller.go
index cb80895f..4a57d829 100644
--- a/controllers/csr_controller.go
+++ b/controllers/csr_controller.go
@@ -175,7 +175,7 @@ func (r *GuestCSRReconciler) Reconcile(ctx context.Context, req reconcile.Reques
 			certificateSigningRequest.Spec.Username, err)
 	}
 
-	record.Event(certificateSigningRequest, "CSRApproved", "approved csr")
+	record.Eventf(certificateSigningRequest, "CSRApproved", "approved csr for %q", certificateSigningRequest.Spec.Username)
 	return reconcile.Result{}, nil
 }
 
diff --git a/controllers/hetznerbaremetalhost_controller.go b/controllers/hetznerbaremetalhost_controller.go
index 2f45b95e..be069bc2 100644
--- a/controllers/hetznerbaremetalhost_controller.go
+++ b/controllers/hetznerbaremetalhost_controller.go
@@ -97,6 +97,18 @@ func (r *HetznerBareMetalHostReconciler) Reconcile(ctx context.Context, req ctrl
 		return ctrl.Result{Requeue: true}, nil
 	}
 
+	// Remove permanent error, if the corresponding annotation was removed by the user.
+	removed := removePermanentErrorIfAnnotationIsGone(bmHost)
+	if removed {
+		// The permanent error was removed from Spec.Status.
+		// Save the changes, and then reconcile again.
+		err := r.Update(ctx, bmHost)
+		if err != nil {
+			return reconcile.Result{}, fmt.Errorf("failed to update (after removePermanentErrorIfAnnotationIsGone): %w", err)
+		}
+		return reconcile.Result{Requeue: true}, nil
+	}
+
 	// Certain cases need to be handled here and not later in the host state machine.
 	// If res != nil, then we should return, otherwise not.
 	res, err = r.reconcileSelectedStates(ctx, bmHost)
@@ -451,3 +463,23 @@ func (r *HetznerBareMetalHostReconciler) SetupWithManager(ctx context.Context, m
 		Owns(&corev1.Secret{}).
 		Complete(r)
 }
+
+func removePermanentErrorIfAnnotationIsGone(bmHost *infrav1.HetznerBareMetalHost,
+) (removed bool) {
+	if bmHost.Spec.Status.ErrorType != infrav1.PermanentError {
+		// PermanentError not set. Do nothing.
+		return false
+	}
+	for k := range bmHost.GetAnnotations() {
+		if k == infrav1.PermanentErrorAnnotation {
+			// Annotation was not removed by user. Do nothing.
+			return false
+		}
+	}
+	bmHost.Spec.Status.ErrorType = ""
+	bmHost.Spec.Status.ErrorMessage = ""
+	bmHost.Spec.Status.ErrorCount = 0
+	record.Eventf(bmHost, "PermanentErrorWasRemoved", "The permanent error was removed, because the annotation %q was removed",
+		infrav1.PermanentErrorAnnotation)
+	return true
+}
diff --git a/go.mod b/go.mod
index 00c93d44..47f30345 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/go-logr/logr v1.4.2
 	github.com/go-logr/zapr v1.3.0
 	github.com/hetznercloud/hcloud-go/v2 v2.13.0
-	github.com/onsi/ginkgo/v2 v2.19.0
+	github.com/onsi/ginkgo/v2 v2.20.0
 	github.com/onsi/gomega v1.34.1
 	github.com/spf13/pflag v1.0.5
 	github.com/stoewer/go-strcase v1.3.0
@@ -15,7 +15,7 @@ require (
 	github.com/syself/hrobot-go v0.2.5
 	go.uber.org/zap v1.27.0
 	golang.org/x/crypto v0.26.0
-	golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
+	golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa
 	golang.org/x/mod v0.20.0
 	k8s.io/api v0.29.3
 	k8s.io/apimachinery v0.29.3
@@ -71,7 +71,7 @@ require (
 	github.com/google/go-github/v53 v53.2.0 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
 	github.com/google/gofuzz v1.2.0 // indirect
-	github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 // indirect
+	github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
 	github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2 // indirect
 	github.com/google/uuid v1.4.0 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
@@ -115,14 +115,14 @@ require (
 	go.opentelemetry.io/otel/metric v1.22.0 // indirect
 	go.opentelemetry.io/otel/trace v1.22.0 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
-	golang.org/x/net v0.27.0 // indirect
+	golang.org/x/net v0.28.0 // indirect
 	golang.org/x/oauth2 v0.18.0 // indirect
 	golang.org/x/sync v0.8.0 // indirect
 	golang.org/x/sys v0.23.0 // indirect
 	golang.org/x/term v0.23.0 // indirect
 	golang.org/x/text v0.17.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
-	golang.org/x/tools v0.23.0 // indirect
+	golang.org/x/tools v0.24.0 // indirect
 	gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect
 	google.golang.org/appengine v1.6.8 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 // indirect
diff --git a/go.sum b/go.sum
index 92b5f638..b71603c0 100644
--- a/go.sum
+++ b/go.sum
@@ -115,8 +115,8 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
 github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg=
-github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
+github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
+github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
 github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2 h1:SJ+NtwL6QaZ21U+IrK7d0gGgpjGGvd2kz+FzTHVzdqI=
 github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2/go.mod h1:Tv1PlzqC9t8wNnpPdctvtSUOPUUg4SHeE6vR1Ir2hmg=
 github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
@@ -176,8 +176,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
 github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
-github.com/onsi/ginkgo/v2 v2.19.0 h1:9Cnnf7UHo57Hy3k6/m5k3dRfGTMXGvxhHFvkDTCTpvA=
-github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To=
+github.com/onsi/ginkgo/v2 v2.20.0 h1:PE84V2mHqoT1sglvHc8ZdQtPcwmvvt29WLEEO3xmdZw=
+github.com/onsi/ginkgo/v2 v2.20.0/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI=
 github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
 github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -292,8 +292,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
 golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
 golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
-golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
-golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
+golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa h1:ELnwvuAXPNtPk1TJRuGkI9fDTwym6AYBu0qzT8AcHdI=
+golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa/go.mod h1:akd2r19cwCdwSwWeIdzYQGa/EZZyqcOdwWiwj5L5eKQ=
 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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
@@ -306,8 +306,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
-golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
-golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
+golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
+golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
 golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI=
 golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -350,8 +350,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
 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.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
-golang.org/x/tools v0.23.0 h1:SGsXPZ+2l4JsgaCKkx+FQ9YZ5XEtA1GZYuoDjenLjvg=
-golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI=
+golang.org/x/tools v0.24.0 h1:J1shsA93PJUEVaUSaay7UXAyE8aimq3GW0pjlolpa24=
+golang.org/x/tools v0.24.0/go.mod h1:YhNqVBIfWHdzvTLs0d8LCuMhkKUgSUKldakyV7W/WDQ=
 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=
diff --git a/images/caph/Dockerfile b/images/caph/Dockerfile
index 4f02ec6a..f340c87a 100644
--- a/images/caph/Dockerfile
+++ b/images/caph/Dockerfile
@@ -13,7 +13,7 @@
 # limitations under the License.
 
 # Build the manager binary
-FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.21.6-bullseye@sha256:a8712f27d9ac742e7bded8f81f7547c5635e855e8b80302e8fc0ce424f559295 AS build
+FROM --platform=${BUILDPLATFORM} docker.io/library/golang:1.21.6-bullseye@sha256:c62751ac12cad0c514d941e36f846c1c440ca9e8ec08dd87d022fb03f0887a9b AS build
 ARG TARGETOS TARGETARCH
 
 COPY . /src/cluster-api-provider-hetzner
diff --git a/pkg/services/baremetal/client/mocks/ssh/Client.go b/pkg/services/baremetal/client/mocks/ssh/Client.go
index 213951ce..9ea2354a 100644
--- a/pkg/services/baremetal/client/mocks/ssh/Client.go
+++ b/pkg/services/baremetal/client/mocks/ssh/Client.go
@@ -3,6 +3,8 @@
 package mocks
 
 import (
+	context "context"
+
 	mock "github.com/stretchr/testify/mock"
 	sshclient "github.com/syself/cluster-api-provider-hetzner/pkg/services/baremetal/client/ssh"
 )
@@ -1216,6 +1218,63 @@ func (_c *Client_UntarTGZ_Call) RunAndReturn(run func() sshclient.Output) *Clien
 	return _c
 }
 
+// WipeDisk provides a mock function with given fields: ctx, sliceOfWwns
+func (_m *Client) WipeDisk(ctx context.Context, sliceOfWwns []string) (string, error) {
+	ret := _m.Called(ctx, sliceOfWwns)
+
+	if len(ret) == 0 {
+		panic("no return value specified for WipeDisk")
+	}
+
+	var r0 string
+	var r1 error
+	if rf, ok := ret.Get(0).(func(context.Context, []string) (string, error)); ok {
+		return rf(ctx, sliceOfWwns)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, []string) string); ok {
+		r0 = rf(ctx, sliceOfWwns)
+	} else {
+		r0 = ret.Get(0).(string)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, []string) error); ok {
+		r1 = rf(ctx, sliceOfWwns)
+	} else {
+		r1 = ret.Error(1)
+	}
+
+	return r0, r1
+}
+
+// Client_WipeDisk_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'WipeDisk'
+type Client_WipeDisk_Call struct {
+	*mock.Call
+}
+
+// WipeDisk is a helper method to define mock.On call
+//   - ctx context.Context
+//   - sliceOfWwns []string
+func (_e *Client_Expecter) WipeDisk(ctx interface{}, sliceOfWwns interface{}) *Client_WipeDisk_Call {
+	return &Client_WipeDisk_Call{Call: _e.mock.On("WipeDisk", ctx, sliceOfWwns)}
+}
+
+func (_c *Client_WipeDisk_Call) Run(run func(ctx context.Context, sliceOfWwns []string)) *Client_WipeDisk_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].([]string))
+	})
+	return _c
+}
+
+func (_c *Client_WipeDisk_Call) Return(_a0 string, _a1 error) *Client_WipeDisk_Call {
+	_c.Call.Return(_a0, _a1)
+	return _c
+}
+
+func (_c *Client_WipeDisk_Call) RunAndReturn(run func(context.Context, []string) (string, error)) *Client_WipeDisk_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
 // The first argument is typically a *testing.T value.
 func NewClient(t interface {
diff --git a/pkg/services/baremetal/client/ssh/ssh_client.go b/pkg/services/baremetal/client/ssh/ssh_client.go
index 4cca01dc..cc10bc5f 100644
--- a/pkg/services/baremetal/client/ssh/ssh_client.go
+++ b/pkg/services/baremetal/client/ssh/ssh_client.go
@@ -20,16 +20,19 @@ package sshclient
 import (
 	"bufio"
 	"bytes"
+	"context"
 	_ "embed"
 	"encoding/base64"
 	"errors"
 	"fmt"
 	"os"
 	"regexp"
+	"slices"
 	"strings"
 	"time"
 
 	"golang.org/x/crypto/ssh"
+	ctrl "sigs.k8s.io/controller-runtime"
 )
 
 const (
@@ -39,6 +42,9 @@ const (
 //go:embed detect-linux-on-another-disk.sh
 var detectLinuxOnAnotherDiskShellScript string
 
+//go:embed wipe-disk.sh
+var wipeDiskShellScript string
+
 var downloadFromOciShellScript = `#!/bin/bash
 
 # Copyright 2023 The Kubernetes Authors.
@@ -253,6 +259,10 @@ type Client interface {
 	ResetKubeadm() Output
 	UntarTGZ() Output
 	DetectLinuxOnAnotherDisk(sliceOfWwns []string) Output
+
+	// Erase filesystem, raid and partition-table signatures.
+	// String "all" will wipe all disks.
+	WipeDisk(ctx context.Context, sliceOfWwns []string) (string, error)
 }
 
 // Factory is the interface for creating new Client objects.
@@ -546,10 +556,54 @@ func (c *sshClient) ResetKubeadm() Output {
 }
 
 func (c *sshClient) DetectLinuxOnAnotherDisk(sliceOfWwns []string) Output {
-	return c.runSSH(fmt.Sprintf(`cat <<'EOF_VIA_SSH' | bash -s -- %s
+	return c.runSSH(fmt.Sprintf(`cat >/root/detect-linux-on-another-disk.sh <<'EOF_VIA_SSH'
+%s
+EOF_VIA_SSH
+chmod a+rx /root/detect-linux-on-another-disk.sh
+/root/detect-linux-on-another-disk.sh %s
+`, detectLinuxOnAnotherDiskShellScript, strings.Join(sliceOfWwns, " ")))
+}
+
+var (
+	// I found no details about the format. I found these examples
+	// 10:00:00:05:1e:7a:7a:00 eui.00253885910c8cec 0x500a07511bb48b25
+	isValidWWNRegex = regexp.MustCompile(`^[0-9a-zA-Z.:-]{5,64}$`)
+
+	// ErrInvalidWWN indicates that a WWN has an invalid syntax.
+	ErrInvalidWWN = fmt.Errorf("WWN does not match regex %q", isValidWWNRegex.String())
+)
+
+func (c *sshClient) WipeDisk(ctx context.Context, sliceOfWwns []string) (string, error) {
+	log := ctrl.LoggerFrom(ctx)
+	if len(sliceOfWwns) == 0 {
+		return "", nil
+	}
+	if slices.Contains(sliceOfWwns, "all") {
+		out := c.runSSH("lsblk --nodeps --noheadings -o WWN | sort -u")
+		if out.Err != nil {
+			return "", fmt.Errorf("failed to find WWNs of all disks: %w", out.Err)
+		}
+		log.Info("WipeDisk: 'all' was given. Found these WWNs", "WWNs", sliceOfWwns)
+		sliceOfWwns = strings.Fields(out.StdOut)
+	} else {
+		for _, wwn := range sliceOfWwns {
+			// validate WWN.
+			// It is unlikely, but someone could use this wwn: `"; do-nasty-things-here`
+			if !isValidWWNRegex.MatchString(wwn) {
+				return "", fmt.Errorf("WWN %q is invalid. %w", wwn, ErrInvalidWWN)
+			}
+		}
+	}
+	out := c.runSSH(fmt.Sprintf(`cat >/root/wipe-disk.sh <<'EOF_VIA_SSH'
 %s
 EOF_VIA_SSH
-`, strings.Join(sliceOfWwns, " "), detectLinuxOnAnotherDiskShellScript))
+chmod a+rx /root/wipe-disk.sh
+/root/wipe-disk.sh %s
+`, wipeDiskShellScript, strings.Join(sliceOfWwns, " ")))
+	if out.Err != nil {
+		return "", fmt.Errorf("WipeDisk for %+v failed: %s. %s: %w", sliceOfWwns, out.StdOut, out.StdErr, out.Err)
+	}
+	return out.String(), nil
 }
 
 func (c *sshClient) UntarTGZ() Output {
diff --git a/pkg/services/baremetal/client/ssh/wipe-disk.sh b/pkg/services/baremetal/client/ssh/wipe-disk.sh
new file mode 100644
index 00000000..6495e2bc
--- /dev/null
+++ b/pkg/services/baremetal/client/ssh/wipe-disk.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+
+# Copyright 2024 The Kubernetes 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.
+
+trap 'echo "ERROR: A command has failed. Exiting the script. Line was ($0:$LINENO): $(sed -n "${LINENO}p" "$0")"; exit 3' ERR
+set -Eeuo pipefail
+
+function usage() {
+    echo "$0 wwn1 [wwn2 ...]"
+    echo "    Wipe all filesystem, raid or partition-table signaturesfrom the specified disks."
+    echo "    ATTENTION! THIS DELETES ALL DATA ON THE GIVEN DISK!"
+    echo "Existing WWNs:"
+    lsblk -oNAME,WWN | grep -vi loop || true
+}
+
+if [ $# -eq 0 ]; then
+    echo "Error: No WWN was provided."
+    echo
+    usage
+    exit 3
+fi
+
+# Iterate over all input arguments
+for wwn in "$@"; do
+    if ! lsblk -l -oWWN | grep -qFx "${wwn}"; then
+        echo "$wwn is not a WWN of this machine"
+        echo
+        usage
+        exit 3
+    fi
+    device=$(lsblk -oNAME,WWN,TYPE | grep disk | grep "$wwn" | cut -d' ' -f1)
+    if [ -z "$device" ]; then
+        echo "Failed to find device for WWN $wwn"
+        exit 3
+    fi
+    echo "INFO: Calling wipfs for $wwn (/dev/$device)"
+    wipefs -af "/dev/$device"
+done
diff --git a/pkg/services/baremetal/host/host.go b/pkg/services/baremetal/host/host.go
index d31b20b6..70f64d8c 100644
--- a/pkg/services/baremetal/host/host.go
+++ b/pkg/services/baremetal/host/host.go
@@ -183,6 +183,7 @@ func (s *Service) actionPreparing(_ context.Context) actionResult {
 				clusterv1.ConditionSeverityError,
 				msg,
 			)
+			record.Warnf(s.scope.HetznerBareMetalHost, infrav1.ServerNotFoundReason, msg)
 			s.scope.HetznerBareMetalHost.SetError(infrav1.PermanentError, msg)
 			return actionStop{}
 		}
@@ -263,7 +264,8 @@ func (s *Service) actionPreparing(_ context.Context) actionResult {
 	}
 
 	msg := createRebootEvent(s.scope.HetznerBareMetalHost, rebootType, "Reboot into rescue system.")
-	// we immediately set an error message in the host status to track the reboot we just performed
+	// we immediately set an error message in the host status to track the reboot we just performed.
+	// This is not a real error. Sooner or later we should track the reboots differently.
 	s.scope.HetznerBareMetalHost.SetError(errorType, msg)
 	return actionComplete{} // next: Registering
 }
@@ -1095,6 +1097,48 @@ func (s *Service) actionImageInstalling(ctx context.Context) actionResult {
 }
 
 func (s *Service) actionImageInstallingStartBackgroundProcess(ctx context.Context, sshClient sshclient.Client) actionResult {
+	// Call WipeDisk if the corresponding annotation is set.
+	sliceOfWwns := strings.Fields(s.scope.HetznerBareMetalHost.Annotations[infrav1.WipeDiskAnnotation])
+	if len(sliceOfWwns) > 0 {
+		output, err := sshClient.WipeDisk(ctx, sliceOfWwns)
+		if err != nil {
+			var exitErr *ssh.ExitError
+			if errors.As(err, &exitErr) || errors.Is(err, sshclient.ErrInvalidWWN) {
+				// The script was executed, but an error occurred.
+				// Do not retry. This needs manual intervention.
+				msg := fmt.Sprintf("WipeDisk failed (permanent error): %s",
+					err.Error())
+				conditions.MarkFalse(
+					s.scope.HetznerBareMetalHost,
+					infrav1.ProvisionSucceededCondition,
+					infrav1.WipeDiskFailedReason,
+					clusterv1.ConditionSeverityError,
+					msg,
+				)
+				record.Warn(s.scope.HetznerBareMetalHost, infrav1.WipeDiskFailedReason, msg)
+				s.scope.HetznerBareMetalHost.SetError(infrav1.PermanentError, msg)
+				return actionStop{}
+			}
+			// some other error happened. It is likely that the ssh connection failed.
+			msg := fmt.Sprintf("WipeDisk failed (Will retry): %s",
+				err.Error())
+			conditions.MarkFalse(
+				s.scope.HetznerBareMetalHost,
+				infrav1.ProvisionSucceededCondition,
+				infrav1.WipeDiskFailedReason,
+				clusterv1.ConditionSeverityWarning,
+				msg,
+			)
+			record.Warn(s.scope.HetznerBareMetalHost, infrav1.WipeDiskFailedReason, msg)
+			return actionContinue{
+				delay: 10 * time.Second,
+			}
+		}
+		delete(s.scope.HetznerBareMetalHost.Annotations, infrav1.WipeDiskAnnotation)
+		record.Eventf(s.scope.HetznerBareMetalHost, "WipeDiskDone", "WipeDisk %v was done. Annotation %q was removed.\n%s",
+			sliceOfWwns, infrav1.WipeDiskAnnotation, output)
+	}
+
 	// If there is a Linux OS on an other disk, then the reboot after the provisioning
 	// will likely fail, because the machine boots into the other operating system.
 	// We want detect that early, and not start the provisioning process.
diff --git a/templates/cluster-templates/bases/hcloud-kcp-ubuntu.yaml b/templates/cluster-templates/bases/hcloud-kcp-ubuntu.yaml
index 4a2862b4..64aa8435 100644
--- a/templates/cluster-templates/bases/hcloud-kcp-ubuntu.yaml
+++ b/templates/cluster-templates/bases/hcloud-kcp-ubuntu.yaml
@@ -166,7 +166,7 @@ spec:
           nameserver 2606:4700:4700::1111
     preKubeadmCommands:
       - set -x
-      - export CONTAINERD=1.7.19 # update: datasource=github-tags depName=containerd/containerd extractVersion=^v(?<version>.*)$ versioning=semver
+      - export CONTAINERD=1.7.20 # update: datasource=github-tags depName=containerd/containerd extractVersion=^v(?<version>.*)$ versioning=semver
       - export KUBERNETES_VERSION=$(echo ${KUBERNETES_VERSION} | sed 's/^v//')
       - export TRIMMED_KUBERNETES_VERSION=$(echo ${KUBERNETES_VERSION} | sed 's/^v//' | awk -F . '{print $1 "." $2}')
       - ARCH=amd64
diff --git a/templates/cluster-templates/bases/hetznerbaremetal-kcp-ubuntu.yaml b/templates/cluster-templates/bases/hetznerbaremetal-kcp-ubuntu.yaml
index ec2014b7..98a110ed 100644
--- a/templates/cluster-templates/bases/hetznerbaremetal-kcp-ubuntu.yaml
+++ b/templates/cluster-templates/bases/hetznerbaremetal-kcp-ubuntu.yaml
@@ -185,7 +185,7 @@ spec:
           nameserver 2606:4700:4700::1111
     preKubeadmCommands:
       - set -x
-      - export CONTAINERD=1.7.19 # update: datasource=github-tags depName=containerd/containerd extractVersion=^v(?<version>.*)$ versioning=semver
+      - export CONTAINERD=1.7.20 # update: datasource=github-tags depName=containerd/containerd extractVersion=^v(?<version>.*)$ versioning=semver
       - export KUBERNETES_VERSION=$(echo ${KUBERNETES_VERSION} | sed 's/^v//')
       - export TRIMMED_KUBERNETES_VERSION=$(echo ${KUBERNETES_VERSION} | sed 's/^v//' | awk -F . '{print $1 "." $2}')
       - localectl set-locale LANG=en_US.UTF-8
diff --git a/templates/cluster-templates/bases/kct-md-0-ubuntu.yaml b/templates/cluster-templates/bases/kct-md-0-ubuntu.yaml
index 0ae791da..8c88b91d 100644
--- a/templates/cluster-templates/bases/kct-md-0-ubuntu.yaml
+++ b/templates/cluster-templates/bases/kct-md-0-ubuntu.yaml
@@ -55,7 +55,7 @@ spec:
       preKubeadmCommands:
         - set -x
         - grep VERSION= /etc/os-release; uname -a
-        - export CONTAINERD=1.7.19 # update: datasource=github-tags depName=containerd/containerd extractVersion=^v(?<version>.*)$ versioning=semver
+        - export CONTAINERD=1.7.20 # update: datasource=github-tags depName=containerd/containerd extractVersion=^v(?<version>.*)$ versioning=semver
         - export KUBERNETES_VERSION=$(echo ${KUBERNETES_VERSION} | sed 's/^v//')
         - export TRIMMED_KUBERNETES_VERSION=$(echo ${KUBERNETES_VERSION} | sed 's/^v//' | awk -F . '{print $1 "." $2}')
         - ARCH=amd64
diff --git a/vendor/github.com/google/pprof/profile/profile.go b/vendor/github.com/google/pprof/profile/profile.go
index 62df80a5..5551eb0b 100644
--- a/vendor/github.com/google/pprof/profile/profile.go
+++ b/vendor/github.com/google/pprof/profile/profile.go
@@ -847,7 +847,7 @@ func (p *Profile) HasFileLines() bool {
 // "[vdso]", [vsyscall]" and some others, see the code.
 func (m *Mapping) Unsymbolizable() bool {
 	name := filepath.Base(m.File)
-	return strings.HasPrefix(name, "[") || strings.HasPrefix(name, "linux-vdso") || strings.HasPrefix(m.File, "/dev/dri/")
+	return strings.HasPrefix(name, "[") || strings.HasPrefix(name, "linux-vdso") || strings.HasPrefix(m.File, "/dev/dri/") || m.File == "//anon"
 }
 
 // Copy makes a fully independent copy of a profile.
diff --git a/vendor/github.com/onsi/ginkgo/v2/CHANGELOG.md b/vendor/github.com/onsi/ginkgo/v2/CHANGELOG.md
index 76577dc7..56fa8d24 100644
--- a/vendor/github.com/onsi/ginkgo/v2/CHANGELOG.md
+++ b/vendor/github.com/onsi/ginkgo/v2/CHANGELOG.md
@@ -1,3 +1,23 @@
+## 2.20.0
+
+### Features
+- Add buildvcs flag [be5ab95]
+
+### Maintenance
+- Add update-deps to makefile [d303d14]
+- bump all dependencies [7a50221]
+
+## 2.19.1
+
+### Fixes
+- update supported platforms for race conditions [63c8c30]
+- [build] Allow custom name for binaries. [ff41e27]
+
+### Maintenance
+- bump gomega [76f4e0c]
+- Bump rexml from 3.2.6 to 3.2.8 in /docs (#1417) [b69c00d]
+- Bump golang.org/x/sys from 0.20.0 to 0.21.0 (#1425) [f097741]
+
 ## 2.19.0
 
 ### Features
diff --git a/vendor/github.com/onsi/ginkgo/v2/Makefile b/vendor/github.com/onsi/ginkgo/v2/Makefile
index cb099aff..06dff97c 100644
--- a/vendor/github.com/onsi/ginkgo/v2/Makefile
+++ b/vendor/github.com/onsi/ginkgo/v2/Makefile
@@ -4,8 +4,13 @@ all:  vet test
 
 .PHONY: test
 test:
-	go run github.com/onsi/ginkgo/v2/ginkgo -r -p
+	go run github.com/onsi/ginkgo/v2/ginkgo -r -p -randomize-all -keep-going
 
 .PHONY: vet
 vet:
 	go vet ./...
+
+.PHONY: update-deps
+update-deps:
+	go get -u ./...
+	go mod tidy
\ No newline at end of file
diff --git a/vendor/github.com/onsi/ginkgo/v2/ginkgo/build/build_command.go b/vendor/github.com/onsi/ginkgo/v2/ginkgo/build/build_command.go
index 5db5d1a7..fd172608 100644
--- a/vendor/github.com/onsi/ginkgo/v2/ginkgo/build/build_command.go
+++ b/vendor/github.com/onsi/ginkgo/v2/ginkgo/build/build_command.go
@@ -2,6 +2,8 @@ package build
 
 import (
 	"fmt"
+	"os"
+	"path"
 
 	"github.com/onsi/ginkgo/v2/ginkgo/command"
 	"github.com/onsi/ginkgo/v2/ginkgo/internal"
@@ -53,7 +55,18 @@ func buildSpecs(args []string, cliConfig types.CLIConfig, goFlagsConfig types.Go
 		if suite.State.Is(internal.TestSuiteStateFailedToCompile) {
 			fmt.Println(suite.CompilationError.Error())
 		} else {
-			fmt.Printf("Compiled %s.test\n", suite.PackageName)
+			if len(goFlagsConfig.O) == 0 {
+				goFlagsConfig.O = path.Join(suite.Path, suite.PackageName+".test")
+			} else {
+				stat, err := os.Stat(goFlagsConfig.O)
+				if err != nil {
+					panic(err)
+				}
+				if stat.IsDir() {
+					goFlagsConfig.O += "/" + suite.PackageName + ".test"
+				}
+			}
+			fmt.Printf("Compiled %s\n", goFlagsConfig.O)
 		}
 	}
 
diff --git a/vendor/github.com/onsi/ginkgo/v2/ginkgo/internal/compile.go b/vendor/github.com/onsi/ginkgo/v2/ginkgo/internal/compile.go
index 86da7340..48827cc5 100644
--- a/vendor/github.com/onsi/ginkgo/v2/ginkgo/internal/compile.go
+++ b/vendor/github.com/onsi/ginkgo/v2/ginkgo/internal/compile.go
@@ -25,6 +25,18 @@ func CompileSuite(suite TestSuite, goFlagsConfig types.GoFlagsConfig) TestSuite
 		return suite
 	}
 
+	if len(goFlagsConfig.O) > 0 {
+		userDefinedPath, err := filepath.Abs(goFlagsConfig.O)
+		if err != nil {
+			suite.State = TestSuiteStateFailedToCompile
+			suite.CompilationError = fmt.Errorf("Failed to compute compilation target path %s:\n%s", goFlagsConfig.O, err.Error())
+			return suite
+		}
+		path = userDefinedPath
+	}
+
+	goFlagsConfig.O = path
+
 	ginkgoInvocationPath, _ := os.Getwd()
 	ginkgoInvocationPath, _ = filepath.Abs(ginkgoInvocationPath)
 	packagePath := suite.AbsPath()
@@ -34,7 +46,7 @@ func CompileSuite(suite TestSuite, goFlagsConfig types.GoFlagsConfig) TestSuite
 		suite.CompilationError = fmt.Errorf("Failed to get relative path from package to the current working directory:\n%s", err.Error())
 		return suite
 	}
-	args, err := types.GenerateGoTestCompileArgs(goFlagsConfig, path, "./", pathToInvocationPath)
+	args, err := types.GenerateGoTestCompileArgs(goFlagsConfig, "./", pathToInvocationPath)
 	if err != nil {
 		suite.State = TestSuiteStateFailedToCompile
 		suite.CompilationError = fmt.Errorf("Failed to generate go test compile flags:\n%s", err.Error())
diff --git a/vendor/github.com/onsi/ginkgo/v2/types/config.go b/vendor/github.com/onsi/ginkgo/v2/types/config.go
index 66463cf5..97a049e0 100644
--- a/vendor/github.com/onsi/ginkgo/v2/types/config.go
+++ b/vendor/github.com/onsi/ginkgo/v2/types/config.go
@@ -202,6 +202,7 @@ type GoFlagsConfig struct {
 	A             bool
 	ASMFlags      string
 	BuildMode     string
+	BuildVCS      bool
 	Compiler      string
 	GCCGoFlags    string
 	GCFlags       string
@@ -219,6 +220,7 @@ type GoFlagsConfig struct {
 	ToolExec      string
 	Work          bool
 	X             bool
+	O             string
 }
 
 func NewDefaultGoFlagsConfig() GoFlagsConfig {
@@ -511,7 +513,7 @@ var GinkgoCLIWatchFlags = GinkgoFlags{
 // GoBuildFlags provides flags for the Ginkgo CLI build, run, and watch commands that capture go's build-time flags.  These are passed to go test -c by the ginkgo CLI
 var GoBuildFlags = GinkgoFlags{
 	{KeyPath: "Go.Race", Name: "race", SectionKey: "code-and-coverage-analysis",
-		Usage: "enable data race detection. Supported only on linux/amd64, freebsd/amd64, darwin/amd64, windows/amd64, linux/ppc64le and linux/arm64 (only for 48-bit VMA)."},
+		Usage: "enable data race detection. Supported on linux/amd64, linux/ppc64le, linux/arm64, linux/s390x, freebsd/amd64, netbsd/amd64, darwin/amd64, darwin/arm64, and windows/amd64."},
 	{KeyPath: "Go.Vet", Name: "vet", UsageArgument: "list", SectionKey: "code-and-coverage-analysis",
 		Usage: `Configure the invocation of "go vet" during "go test" to use the comma-separated list of vet checks.  If list is empty, "go test" runs "go vet" with a curated list of checks believed to be always worth addressing.  If list is "off", "go test" does not run "go vet" at all.  Available checks can be found by running 'go doc cmd/vet'`},
 	{KeyPath: "Go.Cover", Name: "cover", SectionKey: "code-and-coverage-analysis",
@@ -527,6 +529,8 @@ var GoBuildFlags = GinkgoFlags{
 		Usage: "arguments to pass on each go tool asm invocation."},
 	{KeyPath: "Go.BuildMode", Name: "buildmode", UsageArgument: "mode", SectionKey: "go-build",
 		Usage: "build mode to use. See 'go help buildmode' for more."},
+	{KeyPath: "Go.BuildVCS", Name: "buildvcs", SectionKey: "go-build",
+		Usage: "adds version control information."},
 	{KeyPath: "Go.Compiler", Name: "compiler", UsageArgument: "name", SectionKey: "go-build",
 		Usage: "name of compiler to use, as in runtime.Compiler (gccgo or gc)."},
 	{KeyPath: "Go.GCCGoFlags", Name: "gccgoflags", UsageArgument: "'[pattern=]arg list'", SectionKey: "go-build",
@@ -561,6 +565,8 @@ var GoBuildFlags = GinkgoFlags{
 		Usage: "print the name of the temporary work directory and do not delete it when exiting."},
 	{KeyPath: "Go.X", Name: "x", SectionKey: "go-build",
 		Usage: "print the commands."},
+	{KeyPath: "Go.O", Name: "o", SectionKey: "go-build",
+		Usage: "output binary path (including name)."},
 }
 
 // GoRunFlags provides flags for the Ginkgo CLI  run, and watch commands that capture go's run-time flags.  These are passed to the compiled test binary by the ginkgo CLI
@@ -614,7 +620,7 @@ func VetAndInitializeCLIAndGoConfig(cliConfig CLIConfig, goFlagsConfig GoFlagsCo
 }
 
 // GenerateGoTestCompileArgs is used by the Ginkgo CLI to generate command line arguments to pass to the go test -c command when compiling the test
-func GenerateGoTestCompileArgs(goFlagsConfig GoFlagsConfig, destination string, packageToBuild string, pathToInvocationPath string) ([]string, error) {
+func GenerateGoTestCompileArgs(goFlagsConfig GoFlagsConfig, packageToBuild string, pathToInvocationPath string) ([]string, error) {
 	// if the user has set the CoverProfile run-time flag make sure to set the build-time cover flag to make sure
 	// the built test binary can generate a coverprofile
 	if goFlagsConfig.CoverProfile != "" {
@@ -637,7 +643,7 @@ func GenerateGoTestCompileArgs(goFlagsConfig GoFlagsConfig, destination string,
 		goFlagsConfig.CoverPkg = strings.Join(adjustedCoverPkgs, ",")
 	}
 
-	args := []string{"test", "-c", "-o", destination, packageToBuild}
+	args := []string{"test", "-c", packageToBuild}
 	goArgs, err := GenerateFlagArgs(
 		GoBuildFlags,
 		map[string]interface{}{
diff --git a/vendor/github.com/onsi/ginkgo/v2/types/version.go b/vendor/github.com/onsi/ginkgo/v2/types/version.go
index acab0349..c6af20b6 100644
--- a/vendor/github.com/onsi/ginkgo/v2/types/version.go
+++ b/vendor/github.com/onsi/ginkgo/v2/types/version.go
@@ -1,3 +1,3 @@
 package types
 
-const VERSION = "2.19.0"
+const VERSION = "2.20.0"
diff --git a/vendor/golang.org/x/net/LICENSE b/vendor/golang.org/x/net/LICENSE
index 6a66aea5..2a7cf70d 100644
--- a/vendor/golang.org/x/net/LICENSE
+++ b/vendor/golang.org/x/net/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2009 The Go Authors. All rights reserved.
+Copyright 2009 The Go Authors.
 
 Redistribution and use in source and binary forms, with or without
 modification, are permitted provided that the following conditions are
@@ -10,7 +10,7 @@ notice, this list of conditions and the following disclaimer.
 copyright notice, this list of conditions and the following disclaimer
 in the documentation and/or other materials provided with the
 distribution.
-   * Neither the name of Google Inc. nor the names of its
+   * Neither the name of Google LLC nor the names of its
 contributors may be used to endorse or promote products derived from
 this software without specific prior written permission.
 
diff --git a/vendor/golang.org/x/tools/LICENSE b/vendor/golang.org/x/tools/LICENSE
index 6a66aea5..2a7cf70d 100644
--- a/vendor/golang.org/x/tools/LICENSE
+++ b/vendor/golang.org/x/tools/LICENSE
@@ -1,4 +1,4 @@
-Copyright (c) 2009 The Go Authors. All rights reserved.
+Copyright 2009 The Go Authors.
 
 Redistribution and use in source and binary forms, with or without
 modification, are permitted provided that the following conditions are
@@ -10,7 +10,7 @@ notice, this list of conditions and the following disclaimer.
 copyright notice, this list of conditions and the following disclaimer
 in the documentation and/or other materials provided with the
 distribution.
-   * Neither the name of Google Inc. nor the names of its
+   * Neither the name of Google LLC nor the names of its
 contributors may be used to endorse or promote products derived from
 this software without specific prior written permission.
 
diff --git a/vendor/modules.txt b/vendor/modules.txt
index d129f98c..3c8a1b5c 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -224,7 +224,7 @@ github.com/google/go-querystring/query
 ## explicit; go 1.12
 github.com/google/gofuzz
 github.com/google/gofuzz/bytesource
-# github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6
+# github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8
 ## explicit; go 1.19
 github.com/google/pprof/profile
 # github.com/google/safetext v0.0.0-20220905092116-b49f7bc46da2
@@ -295,7 +295,7 @@ github.com/modern-go/reflect2
 # github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822
 ## explicit
 github.com/munnerz/goautoneg
-# github.com/onsi/ginkgo/v2 v2.19.0
+# github.com/onsi/ginkgo/v2 v2.20.0
 ## explicit; go 1.20
 github.com/onsi/ginkgo/v2
 github.com/onsi/ginkgo/v2/config
@@ -487,7 +487,7 @@ golang.org/x/crypto/scrypt
 golang.org/x/crypto/sha3
 golang.org/x/crypto/ssh
 golang.org/x/crypto/ssh/internal/bcrypt_pbkdf
-# golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
+# golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa
 ## explicit; go 1.20
 golang.org/x/exp/constraints
 golang.org/x/exp/maps
@@ -501,7 +501,7 @@ golang.org/x/mod/internal/lazyregexp
 golang.org/x/mod/modfile
 golang.org/x/mod/module
 golang.org/x/mod/semver
-# golang.org/x/net v0.27.0
+# golang.org/x/net v0.28.0
 ## explicit; go 1.18
 golang.org/x/net/context
 golang.org/x/net/html
@@ -562,7 +562,7 @@ golang.org/x/text/width
 # golang.org/x/time v0.5.0
 ## explicit; go 1.18
 golang.org/x/time/rate
-# golang.org/x/tools v0.23.0
+# golang.org/x/tools v0.24.0
 ## explicit; go 1.19
 golang.org/x/tools/cover
 golang.org/x/tools/go/ast/inspector


Test patch:
diff --git a/api/v1beta1/hetznerbaremetalhost_types_test.go b/api/v1beta1/hetznerbaremetalhost_types_test.go
index 1bdff132..9b7d205f 100644
--- a/api/v1beta1/hetznerbaremetalhost_types_test.go
+++ b/api/v1beta1/hetznerbaremetalhost_types_test.go
@@ -17,9 +17,15 @@ limitations under the License.
 package v1beta1
 
 import (
+	"cmp"
+	"slices"
+	"testing"
+
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
+	"github.com/stretchr/testify/require"
 	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 )
 
 var _ = Describe("Test update secret status", func() {
@@ -458,3 +464,36 @@ var _ = Describe("Test ClearRebootAnnotations", func() {
 		}),
 	)
 })
+
+func mapKeys[K cmp.Ordered, V any](m map[K]V) []K {
+	ret := make([]K, 0, len(m))
+	for k := range m {
+		ret = append(ret, k)
+	}
+	slices.Sort(ret)
+	return ret
+}
+
+func TestHetznerBareMetalHost_SetError(t *testing.T) {
+	// A PermanentError should add the PermanentErrorAnnotation
+	host := HetznerBareMetalHost{
+		ObjectMeta: metav1.ObjectMeta{
+			Annotations: map[string]string{
+				"other-annotation": "some value",
+			},
+		},
+	}
+	host.SetError(PermanentError, "some error")
+	require.Equal(t, []string{PermanentErrorAnnotation, "other-annotation"}, mapKeys(host.Annotations))
+
+	// Other errors should not add the PermanentErrorAnnotation
+	host = HetznerBareMetalHost{
+		ObjectMeta: metav1.ObjectMeta{
+			Annotations: map[string]string{
+				"other-annotation": "some value",
+			},
+		},
+	}
+	host.SetError(ProvisioningError, "some error")
+	require.Equal(t, []string{"other-annotation"}, mapKeys(host.Annotations))
+}
diff --git a/controllers/hcloudmachine_controller_test.go b/controllers/hcloudmachine_controller_test.go
index 4542cfee..2f2335c0 100644
--- a/controllers/hcloudmachine_controller_test.go
+++ b/controllers/hcloudmachine_controller_test.go
@@ -377,7 +377,7 @@ var _ = Describe("HCloudMachineReconciler", func() {
 					}
 
 					return len(servers) == 0
-				}, timeout, interval).Should(BeTrue())
+				}, 2*timeout, interval).Should(BeTrue())
 
 				By("checking that bootstrap condition is not ready")
 
diff --git a/controllers/hetznerbaremetalhost_controller_test.go b/controllers/hetznerbaremetalhost_controller_test.go
index 2d9e897b..326e54f5 100644
--- a/controllers/hetznerbaremetalhost_controller_test.go
+++ b/controllers/hetznerbaremetalhost_controller_test.go
@@ -17,11 +17,13 @@ limitations under the License.
 package controllers
 
 import (
+	"testing"
 	"time"
 
 	. "github.com/onsi/ginkgo/v2"
 	. "github.com/onsi/gomega"
 	"github.com/stretchr/testify/mock"
+	"github.com/stretchr/testify/require"
 	"github.com/syself/hrobot-go/models"
 	corev1 "k8s.io/api/core/v1"
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
@@ -899,3 +901,70 @@ name="eth0" model="Realtek Semiconductor Co., Ltd. RTL8111/8168/8411 PCI Express
 	sshClient.On("GetInstallImageState").Return(sshclient.InstallImageStateFinished, nil)
 	sshClient.On("GetResultOfInstallImage").Return(hostpkg.PostInstallScriptFinished, nil)
 }
+
+func Test_removePermanentErrorIfAnnotationIsGone(t *testing.T) {
+	// PermanentError with annotation --> Error should not get removed
+	bmHost := infrav1.HetznerBareMetalHost{
+		TypeMeta: metav1.TypeMeta{},
+		ObjectMeta: metav1.ObjectMeta{
+			Annotations: map[string]string{
+				infrav1.PermanentErrorAnnotation: "",
+			},
+		},
+		Spec: infrav1.HetznerBareMetalHostSpec{
+			Status: infrav1.ControllerGeneratedStatus{
+				ErrorType:    infrav1.PermanentError,
+				ErrorCount:   1,
+				ErrorMessage: "my err",
+			},
+		},
+	}
+	removed := removePermanentErrorIfAnnotationIsGone(&bmHost)
+	require.False(t, removed)
+	require.NotEmpty(t, bmHost.Spec.Status.ErrorType)
+	require.NotEmpty(t, bmHost.Spec.Status.ErrorCount)
+	require.NotEmpty(t, bmHost.Spec.Status.ErrorMessage)
+
+	// PermanentError without annotation --> Error should get removed
+	bmHost = infrav1.HetznerBareMetalHost{
+		TypeMeta: metav1.TypeMeta{},
+		ObjectMeta: metav1.ObjectMeta{
+			Annotations: map[string]string{
+				"other-annotation": "some value",
+			},
+		},
+		Spec: infrav1.HetznerBareMetalHostSpec{
+			Status: infrav1.ControllerGeneratedStatus{
+				ErrorType:    infrav1.PermanentError,
+				ErrorCount:   1,
+				ErrorMessage: "my err",
+			},
+		},
+	}
+	removed = removePermanentErrorIfAnnotationIsGone(&bmHost)
+	require.True(t, removed)
+	require.Empty(t, bmHost.Spec.Status.ErrorType)
+	require.Empty(t, bmHost.Spec.Status.ErrorCount)
+	require.Empty(t, bmHost.Spec.Status.ErrorMessage)
+	require.Equal(t, map[string]string{"other-annotation": "some value"}, bmHost.Annotations)
+
+	// Other Error without annotation --> Error should not get removed
+	bmHost = infrav1.HetznerBareMetalHost{
+		TypeMeta: metav1.TypeMeta{},
+		ObjectMeta: metav1.ObjectMeta{
+			Annotations: map[string]string{},
+		},
+		Spec: infrav1.HetznerBareMetalHostSpec{
+			Status: infrav1.ControllerGeneratedStatus{
+				ErrorType:    infrav1.ProvisioningError,
+				ErrorCount:   1,
+				ErrorMessage: "my err",
+			},
+		},
+	}
+	removed = removePermanentErrorIfAnnotationIsGone(&bmHost)
+	require.False(t, removed)
+	require.NotEmpty(t, bmHost.Spec.Status.ErrorType)
+	require.NotEmpty(t, bmHost.Spec.Status.ErrorCount)
+	require.NotEmpty(t, bmHost.Spec.Status.ErrorMessage)
+}
diff --git a/test/e2e/caph.go b/test/e2e/caph.go
index 64e2f304..b5bec6e0 100644
--- a/test/e2e/caph.go
+++ b/test/e2e/caph.go
@@ -46,7 +46,7 @@ type CaphClusterDeploymentSpecInput struct {
 // CaphClusterDeploymentSpec implements a test that verifies that MachineDeployment rolling updates are successful.
 func CaphClusterDeploymentSpec(ctx context.Context, inputGetter func() CaphClusterDeploymentSpecInput) {
 	var (
-		specName         = "caph"
+		specName         = "ci" + os.Getenv("GITHUB_RUN_ID")
 		input            CaphClusterDeploymentSpecInput
 		namespace        *corev1.Namespace
 		cancelWatches    context.CancelFunc
@@ -60,7 +60,7 @@ func CaphClusterDeploymentSpec(ctx context.Context, inputGetter func() CaphClust
 		gomega.Expect(input.E2EConfig).ToNot(gomega.BeNil(), "Invalid argument. input.E2EConfig can't be nil when calling %s spec", specName)
 		gomega.Expect(input.ClusterctlConfigPath).To(gomega.BeAnExistingFile(), "Invalid argument. input.ClusterctlConfigPath must be an existing file when calling %s spec", specName)
 		gomega.Expect(input.BootstrapClusterProxy).ToNot(gomega.BeNil(), "Invalid argument. input.BootstrapClusterProxy can't be nil when calling %s spec", specName)
-		gomega.Expect(os.MkdirAll(input.ArtifactFolder, 0750)).To(gomega.Succeed(), "Invalid argument. input.ArtifactFolder can't be created for %s spec", specName)
+		gomega.Expect(os.MkdirAll(input.ArtifactFolder, 0o750)).To(gomega.Succeed(), "Invalid argument. input.ArtifactFolder can't be created for %s spec", specName)
 		gomega.Expect(input.E2EConfig.Variables).To(gomega.HaveKey(KubernetesVersion))
 		gomega.Expect(input.E2EConfig.Variables).To(HaveValidVersion(input.E2EConfig.GetVariable(KubernetesVersion)))