Repository: syself/cluster-api-provider-hetzner
Pull request #1543 (2025-03-11 14:07:25)

:seedling: Add pre-provision command to check baremetal machines.
We want the caph controller to have an extendable way to check baremetal machines before provisioning.

This PR adds `--pre-provision-command`.

> Command to run (in rescue-system) before installing the image on baremetal hosts. You can use that to check if the machine is healthy before installing the image. If the exit value is non-zero, the machine is considered unhealthy. This command must be accessible by the controller pod. You can use an initContainer to copy the command to a shared emptyDir.


Result of manual test:

```
❯ k events -A | grep PrePro
```

>my-workspace                                                                       6m55s (x2 over 9m20s)   Normal    PreProvisionCommandSucceeded                           HetznerBareMetalHost/bm-2                                          my-pre-provision-command.sh: dummy script for caph --pre-provision-command. All is fine.




Patch:
diff --git a/api/v1beta1/conditions_const.go b/api/v1beta1/conditions_const.go
index 57c9d9d2..5d4aeee4 100644
--- a/api/v1beta1/conditions_const.go
+++ b/api/v1beta1/conditions_const.go
@@ -189,14 +189,6 @@ const (
 	CheckDiskFailedReason = "CheckDiskFailed"
 )
 
-const (
-	// SSHAfterInstallImageSucceededCondition indicates that the host is reachable via ssh after installImage.
-	SSHAfterInstallImageSucceededCondition clusterv1.ConditionType = "SSHAfterInstallImageSucceeded"
-
-	// SSHAfterInstallImageFailedReason indicates that the host was not reachable via ssh.
-	SSHAfterInstallImageFailedReason = "SSHAfterInstallImageFailed"
-)
-
 const (
 	// HostAssociateSucceededCondition indicates that a host has been associated.
 	HostAssociateSucceededCondition clusterv1.ConditionType = "HostAssociateSucceeded"
diff --git a/api/v1beta1/hetznerbaremetalhost_types.go b/api/v1beta1/hetznerbaremetalhost_types.go
index 496f3fff..bd74c0f6 100644
--- a/api/v1beta1/hetznerbaremetalhost_types.go
+++ b/api/v1beta1/hetznerbaremetalhost_types.go
@@ -161,6 +161,9 @@ const (
 	// StateRegistering means we are getting hardware details.
 	StateRegistering ProvisioningState = "registering"
 
+	// StatePreProvisioning means we run the pre-provisioning-command (if given).
+	StatePreProvisioning ProvisioningState = "pre-provisioning"
+
 	// StateImageInstalling means we install a new image.
 	StateImageInstalling ProvisioningState = "image-installing"
 
diff --git a/controllers/hetznerbaremetalhost_controller.go b/controllers/hetznerbaremetalhost_controller.go
index 145b2d09..13ccfd8f 100644
--- a/controllers/hetznerbaremetalhost_controller.go
+++ b/controllers/hetznerbaremetalhost_controller.go
@@ -52,11 +52,12 @@ import (
 // HetznerBareMetalHostReconciler reconciles a HetznerBareMetalHost object.
 type HetznerBareMetalHostReconciler struct {
 	client.Client
-	RateLimitWaitTime  time.Duration
-	APIReader          client.Reader
-	RobotClientFactory robotclient.Factory
-	SSHClientFactory   sshclient.Factory
-	WatchFilterValue   string
+	RateLimitWaitTime   time.Duration
+	APIReader           client.Reader
+	RobotClientFactory  robotclient.Factory
+	SSHClientFactory    sshclient.Factory
+	WatchFilterValue    string
+	PreProvisionCommand string
 }
 
 //+kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=hetznerbaremetalhosts,verbs=get;list;watch;create;update;patch;delete
@@ -200,6 +201,7 @@ func (r *HetznerBareMetalHostReconciler) Reconcile(ctx context.Context, req ctrl
 		OSSSHSecret:             osSSHSecret,
 		RescueSSHSecret:         rescueSSHSecret,
 		SecretManager:           secretManager,
+		PreProvisionCommand:     r.PreProvisionCommand,
 	})
 	if err != nil {
 		return reconcile.Result{}, fmt.Errorf("failed to create scope: %w", err)
diff --git a/docs/README.md b/docs/README.md
index 087cb30e..ef32fee9 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -34,3 +34,4 @@ This is the official documentation of Cluster API Provider Hetzner. Before start
 - [Tilt](/docs/caph/04-developers/02-tilt.md)
 - [Releasing](/docs/caph/04-developers/03-releasing.md)
 - [Updating Kubernetes version](/docs/caph/04-developers/04-updating-kubernetes-version.md)
+- [pre-provision-command](/docs/caph/04-developers/05-pre-provision-command.md)
diff --git a/docs/caph/04-developers/05-pre-provision-command.md b/docs/caph/04-developers/05-pre-provision-command.md
new file mode 100644
index 00000000..c015eb57
--- /dev/null
+++ b/docs/caph/04-developers/05-pre-provision-command.md
@@ -0,0 +1,75 @@
+---
+title: pre-provision-command
+metatitle: Cluster API Provider Hetzner Check Bare Metal Server before Provisioning
+sidebar: pre-provision-command
+description: Documentation on the CAPH pre-provision-command.
+---
+
+The `--pre-provision-command` for the caph controller can be used to execute a custom command
+before install-image starts.
+
+This provides you a flexible way to check if the bare metal server is healthy.
+
+Example:
+
+```sh
+--pre-provision-command=/shared/my-pre-provision-command.sh
+```
+
+The script/binary will be copied into the Hetzner Rescue System and executed.
+
+If the exit code is zero, then all is fine.
+
+If the exit code is non-zero, then provisioning of that machine will be stopped.
+
+The CAPH controller runs in a Kubernetes Pod. The container of that pod needs access to the file.
+
+There are several ways to make this command available:
+
+* You could mount a configMap/secret.
+* You create a container image, and use that as init-container.
+* You build a custom image of CAPH. We do not recommend that.
+
+In this example we use an init-container to provide the script.
+
+In the directory `images/pre-provision-command/` you see these files:
+
+* my-pre-provision-command.sh: A simple Bash script which creates a message and exists with 0.
+* Dockerfile: Needed to create a container image.
+* build-and-push.sh: A script to build and upload the script to a container registry.
+
+When the container image was uploaded, you need to adapt the CAPH deployment:
+
+```yaml
+
+      # Init container, which makes the command available to caph.
+      initContainers:
+      - command:
+        - /bin/sh
+        - -c
+        - cp /my-pre-provision-command.sh /shared/
+        image: ghcr.io/syself/caph-staging:pre-provision-command
+        imagePullPolicy: Always
+        name: init-container
+        resources: {}
+        terminationMessagePath: /dev/termination-log
+        terminationMessagePolicy: File
+        volumeMounts:
+        - mountPath: /shared
+          name: shared
+
+
+      # Add this to the args of the caph container:
+        args:
+        - --pre-provision-command=/shared/my-pre-provision-command.sh
+
+        # Add this to the caph container
+        volumeMounts:
+        - mountPath: /shared
+          name: shared
+
+      # Add this after "container"
+      volumes:
+      - emptyDir: {}
+        name: shared
+```
diff --git a/go.mod b/go.mod
index 7c520e80..0eaf1506 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.22.6
 
 require (
 	github.com/blang/semver/v4 v4.0.0
+	github.com/bramvdbogaerde/go-scp v1.5.0
 	github.com/go-logr/logr v1.4.2
 	github.com/go-logr/zapr v1.3.0
 	github.com/hetznercloud/hcloud-go/v2 v2.13.1
diff --git a/go.sum b/go.sum
index 5c829868..0992db1c 100644
--- a/go.sum
+++ b/go.sum
@@ -26,6 +26,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
 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/bramvdbogaerde/go-scp v1.5.0 h1:a9BinAjTfQh273eh7vd3qUgmBC+bx+3TRDtkZWmIpzM=
+github.com/bramvdbogaerde/go-scp v1.5.0/go.mod h1:on2aH5AxaFb2G0N5Vsdy6B0Ml7k9HuHSwfo1y0QzAbQ=
 github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
 github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
 github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
diff --git a/hack/update-operator-dev-deployment.sh b/hack/update-operator-dev-deployment.sh
new file mode 100755
index 00000000..0e494594
--- /dev/null
+++ b/hack/update-operator-dev-deployment.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# Copyright 2025 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.
+
+# This scripts updates the caph deployment in a mgt-cluster. Be sure to be connected to
+# a development cluster.
+# It takes the current code, creates a new image and updates the deployment.
+# By default the image will be uploaded to:
+# ghcr.io/syself/caph-staging:dev-$USER-YOUR_GIT_BRANCH
+# You can change the image path with the --image-path flag, if you want to use a different registry.
+
+# Usually it is better to write a test, than to use this script.
+
+trap 'echo "Warning: A command has failed. Exiting the script. Line was ($0:$LINENO): $(sed -n "${LINENO}p" "$0")"; exit 3' ERR
+set -Eeuo pipefail
+
+image_path="ghcr.io/syself"
+
+while [[ "$#" -gt 0 ]]; do
+    case $1 in
+    --image-path)
+        image_path="$2"
+        shift
+        ;;
+    *)
+        echo "Unknown parameter passed: $1"
+        exit 1
+        ;;
+    esac
+    shift
+done
+
+# remove trailing slash
+image_path="${image_path%/}"
+
+if ! kubectl cluster-info >/dev/null; then
+    echo
+    echo "No kubernetes cluster found."
+    echo "You can use alm to create a mgt-cluster"
+    echo "docs: https://github.com/syself/autopilot-lifecycle-manager"
+    exit 1
+fi
+
+branch=$(git branch --show-current)
+if [ "$branch" == "" ]; then
+    echo "failed to get branch name"
+    exit 1
+fi
+
+tag="dev-$USER-$branch"
+tag="$(echo -n "$tag" | tr -c 'a-zA-Z0-9_.-' '-')"
+
+image="$image_path/caph-staging:$tag"
+
+echo "Building image: $image"
+
+docker build -f images/caph/Dockerfile -t "$image" .
+
+docker push "$image"
+
+# Note: Up to now changes in the CRD are not supported by this script.
+
+kubectl scale --replicas=1 -n mgt-system deployment/caph-controller-manager
+
+kubectl set image -n mgt-system deployment/caph-controller-manager manager="$image"
+
+kubectl patch deployment -n mgt-system -p '[{"op": "replace", "path": "/spec/template/spec/containers/0/imagePullPolicy", "value": "Always"}]' --type='json' caph-controller-manager
+
+kubectl rollout restart -n mgt-system deployment caph-controller-manager
+
+trap "echo 'Interrupted! Exiting...'; exit 1" SIGINT
+
+while ! kubectl rollout status deployment --timeout=3s -n mgt-system caph-controller-manager; do
+    echo "Rollout failed"
+    kubectl events -n mgt-system | grep caph-controller-manager | tail -n 5
+    echo
+    echo
+done
diff --git a/images/pre-provision-command/Dockerfile b/images/pre-provision-command/Dockerfile
new file mode 100644
index 00000000..0d896551
--- /dev/null
+++ b/images/pre-provision-command/Dockerfile
@@ -0,0 +1,17 @@
+# Copyright 2025 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.
+
+FROM bash
+COPY ./my-pre-provision-command.sh /my-pre-provision-command.sh
+RUN chmod +x /my-pre-provision-command.sh
diff --git a/images/pre-provision-command/build-and-push.sh b/images/pre-provision-command/build-and-push.sh
new file mode 100755
index 00000000..a5e0f20f
--- /dev/null
+++ b/images/pre-provision-command/build-and-push.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+# Copyright 2025 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.
+
+# This script builds and pushed the container image for the init-container.
+# See README.md for more information.
+
+# Bash Strict Mode: https://github.com/guettli/bash-strict-mode
+trap 'echo "Warning: A command has failed. Exiting the script. Line was ($0:$LINENO): $(sed -n "${LINENO}p" "$0")"; exit 3' ERR
+set -Eeuo pipefail
+
+DIR="$(dirname "$0")"
+
+docker build -t ghcr.io/syself/caph-staging:pre-provision-command "$DIR"
+
+docker push ghcr.io/syself/caph-staging:pre-provision-command
diff --git a/images/pre-provision-command/my-pre-provision-command.sh b/images/pre-provision-command/my-pre-provision-command.sh
new file mode 100644
index 00000000..d6a67725
--- /dev/null
+++ b/images/pre-provision-command/my-pre-provision-command.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+# Copyright 2025 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.
+
+echo "dummy script for caph --pre-provision-command. All is fine."
+exit 0
diff --git a/main.go b/main.go
index 789cf0d2..5141f903 100644
--- a/main.go
+++ b/main.go
@@ -18,9 +18,12 @@ limitations under the License.
 package main
 
 import (
+	"errors"
 	"flag"
 	"fmt"
 	"os"
+	"path/filepath"
+	"regexp"
 	"sync"
 	"time"
 
@@ -52,6 +55,10 @@ import (
 var (
 	scheme   = runtime.NewScheme()
 	setupLog = ctrl.Log.WithName("setup")
+
+	// We do not want filenames to start with a dot or a number.
+	// Only lowercase letters are allowed.
+	preProvisionCommandRegex = regexp.MustCompile(`^[a-z][a-z0-9_.-]+[a-z0-9]$`)
 )
 
 func init() {
@@ -77,6 +84,7 @@ var (
 	logLevel                           string
 	syncPeriod                         time.Duration
 	rateLimitWaitTime                  time.Duration
+	preProvisionCommand                string
 )
 
 func main() {
@@ -96,12 +104,28 @@ func main() {
 	fs.DurationVar(&syncPeriod, "sync-period", 3*time.Minute, "The minimum interval at which watched resources are reconciled (e.g. 3m)")
 	fs.DurationVar(&rateLimitWaitTime, "rate-limit", 5*time.Minute, "The rate limiting for HCloud controller (e.g. 5m)")
 	fs.BoolVar(&hcloudclient.DebugAPICalls, "debug-hcloud-api-calls", false, "Debug all calls to the hcloud API.")
-
+	fs.StringVar(&preProvisionCommand, "pre-provision-command", "", "Command to run (in rescue-system) before installing the image on bare metal servers. You can use that to check if the machine is healthy before installing the image. If the exit value is non-zero, the machine is considered unhealthy. This command must be accessible by the controller pod. You can use an initContainer to copy the command to a shared emptyDir.")
 	pflag.CommandLine.AddGoFlagSet(flag.CommandLine)
 	pflag.Parse()
 
 	ctrl.SetLogger(utils.GetDefaultLogger(logLevel))
 
+	// If preProvisionCommand is set, check if the file exists and validate the basename.
+	if preProvisionCommand != "" {
+		baseName := filepath.Base(preProvisionCommand)
+		if !preProvisionCommandRegex.MatchString(baseName) {
+			msg := fmt.Sprintf("basename of pre-provision-command (%s) must match the regex %s", baseName, preProvisionCommandRegex.String())
+			setupLog.Error(errors.New(msg), "")
+			os.Exit(1)
+		}
+
+		_, err := os.Stat(preProvisionCommand)
+		if err != nil {
+			setupLog.Error(err, "pre-provision-command not found")
+			os.Exit(1)
+		}
+	}
+
 	var watchNamespaces map[string]cache.Config
 	if watchNamespace != "" {
 		watchNamespaces = map[string]cache.Config{
@@ -179,12 +203,13 @@ func main() {
 	}
 
 	if err = (&controllers.HetznerBareMetalHostReconciler{
-		Client:             mgr.GetClient(),
-		RobotClientFactory: robotclient.NewFactory(),
-		SSHClientFactory:   sshclient.NewFactory(),
-		APIReader:          mgr.GetAPIReader(),
-		RateLimitWaitTime:  rateLimitWaitTime,
-		WatchFilterValue:   watchFilterValue,
+		Client:              mgr.GetClient(),
+		RobotClientFactory:  robotclient.NewFactory(),
+		SSHClientFactory:    sshclient.NewFactory(),
+		APIReader:           mgr.GetAPIReader(),
+		RateLimitWaitTime:   rateLimitWaitTime,
+		WatchFilterValue:    watchFilterValue,
+		PreProvisionCommand: preProvisionCommand,
 	}).SetupWithManager(ctx, mgr, controller.Options{MaxConcurrentReconciles: hetznerBareMetalHostConcurrency}); err != nil {
 		setupLog.Error(err, "unable to create controller", "controller", "HetznerBareMetalHost")
 		os.Exit(1)
diff --git a/pkg/scope/baremetalhost.go b/pkg/scope/baremetalhost.go
index 47db535f..7aecc946 100644
--- a/pkg/scope/baremetalhost.go
+++ b/pkg/scope/baremetalhost.go
@@ -47,6 +47,7 @@ type BareMetalHostScopeParams struct {
 	OSSSHSecret             *corev1.Secret
 	RescueSSHSecret         *corev1.Secret
 	SecretManager           *secretutil.SecretManager
+	PreProvisionCommand     string
 }
 
 // NewBareMetalHostScope creates a new Scope from the supplied parameters.
@@ -91,6 +92,7 @@ func NewBareMetalHostScope(params BareMetalHostScopeParams) (*BareMetalHostScope
 		OSSSHSecret:             params.OSSSHSecret,
 		RescueSSHSecret:         params.RescueSSHSecret,
 		SecretManager:           params.SecretManager,
+		PreProvisionCommand:     params.PreProvisionCommand,
 	}, nil
 }
 
@@ -107,6 +109,7 @@ type BareMetalHostScope struct {
 	Cluster                 *clusterv1.Cluster
 	OSSSHSecret             *corev1.Secret
 	RescueSSHSecret         *corev1.Secret
+	PreProvisionCommand     string
 }
 
 // Name returns the HetznerCluster name.
diff --git a/pkg/services/baremetal/client/mocks/ssh/Client.go b/pkg/services/baremetal/client/mocks/ssh/Client.go
index 985a8012..bd444a58 100644
--- a/pkg/services/baremetal/client/mocks/ssh/Client.go
+++ b/pkg/services/baremetal/client/mocks/ssh/Client.go
@@ -490,6 +490,70 @@ func (_c *Client_ExecuteInstallImage_Call) RunAndReturn(run func(bool) sshclient
 	return _c
 }
 
+// ExecutePreProvisionCommand provides a mock function with given fields: ctx, preProvisionCommand
+func (_m *Client) ExecutePreProvisionCommand(ctx context.Context, preProvisionCommand string) (int, string, error) {
+	ret := _m.Called(ctx, preProvisionCommand)
+
+	if len(ret) == 0 {
+		panic("no return value specified for ExecutePreProvisionCommand")
+	}
+
+	var r0 int
+	var r1 string
+	var r2 error
+	if rf, ok := ret.Get(0).(func(context.Context, string) (int, string, error)); ok {
+		return rf(ctx, preProvisionCommand)
+	}
+	if rf, ok := ret.Get(0).(func(context.Context, string) int); ok {
+		r0 = rf(ctx, preProvisionCommand)
+	} else {
+		r0 = ret.Get(0).(int)
+	}
+
+	if rf, ok := ret.Get(1).(func(context.Context, string) string); ok {
+		r1 = rf(ctx, preProvisionCommand)
+	} else {
+		r1 = ret.Get(1).(string)
+	}
+
+	if rf, ok := ret.Get(2).(func(context.Context, string) error); ok {
+		r2 = rf(ctx, preProvisionCommand)
+	} else {
+		r2 = ret.Error(2)
+	}
+
+	return r0, r1, r2
+}
+
+// Client_ExecutePreProvisionCommand_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExecutePreProvisionCommand'
+type Client_ExecutePreProvisionCommand_Call struct {
+	*mock.Call
+}
+
+// ExecutePreProvisionCommand is a helper method to define mock.On call
+//   - ctx context.Context
+//   - preProvisionCommand string
+func (_e *Client_Expecter) ExecutePreProvisionCommand(ctx interface{}, preProvisionCommand interface{}) *Client_ExecutePreProvisionCommand_Call {
+	return &Client_ExecutePreProvisionCommand_Call{Call: _e.mock.On("ExecutePreProvisionCommand", ctx, preProvisionCommand)}
+}
+
+func (_c *Client_ExecutePreProvisionCommand_Call) Run(run func(ctx context.Context, preProvisionCommand string)) *Client_ExecutePreProvisionCommand_Call {
+	_c.Call.Run(func(args mock.Arguments) {
+		run(args[0].(context.Context), args[1].(string))
+	})
+	return _c
+}
+
+func (_c *Client_ExecutePreProvisionCommand_Call) Return(exitStatus int, stdoutAndStderr string, err error) *Client_ExecutePreProvisionCommand_Call {
+	_c.Call.Return(exitStatus, stdoutAndStderr, err)
+	return _c
+}
+
+func (_c *Client_ExecutePreProvisionCommand_Call) RunAndReturn(run func(context.Context, string) (int, string, error)) *Client_ExecutePreProvisionCommand_Call {
+	_c.Call.Return(run)
+	return _c
+}
+
 // GetCloudInitOutput provides a mock function with given fields:
 func (_m *Client) GetCloudInitOutput() sshclient.Output {
 	ret := _m.Called()
diff --git a/pkg/services/baremetal/client/ssh/ssh_client.go b/pkg/services/baremetal/client/ssh/ssh_client.go
index 3cba2d60..645d07c6 100644
--- a/pkg/services/baremetal/client/ssh/ssh_client.go
+++ b/pkg/services/baremetal/client/ssh/ssh_client.go
@@ -26,11 +26,13 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"path/filepath"
 	"regexp"
 	"slices"
 	"strings"
 	"time"
 
+	scp "github.com/bramvdbogaerde/go-scp"
 	"golang.org/x/crypto/ssh"
 	ctrl "sigs.k8s.io/controller-runtime"
 )
@@ -274,6 +276,10 @@ type Client interface {
 	// CheckDisk checks the given disks via smartctl.
 	// ErrCheckDiskBrokenDisk gets returned, if a disk is broken.
 	CheckDisk(ctx context.Context, sliceOfWwns []string) (info string, err error)
+
+	// ExecutePreProvisionCommand executes a command before the provision process starts.
+	// A non-zero exit status will indicate that provisioning should not start.
+	ExecutePreProvisionCommand(ctx context.Context, preProvisionCommand string) (exitStatus int, stdoutAndStderr string, err error)
 }
 
 // Factory is the interface for creating new Client objects.
@@ -659,11 +665,11 @@ func IsTimeoutError(err error) bool {
 	return strings.Contains(err.Error(), ErrTimeout.Error())
 }
 
-func (c *sshClient) runSSH(command string) Output {
+func (c *sshClient) getSSHClient() (*ssh.Client, *ssh.ClientConfig, error) {
 	// Create the Signer for this private key.
 	signer, err := ssh.ParsePrivateKey([]byte(c.privateSSHKey))
 	if err != nil {
-		return Output{Err: fmt.Errorf("unable to parse private key: %w", err)}
+		return nil, nil, fmt.Errorf("unable to parse private key: %w", err)
 	}
 
 	config := &ssh.ClientConfig{
@@ -677,10 +683,18 @@ func (c *sshClient) runSSH(command string) Output {
 	}
 
 	// Connect to the remote server and perform the SSH handshake.
-
 	client, err := ssh.Dial("tcp", fmt.Sprintf("%s:%v", c.ip, c.port), config)
 	if err != nil {
-		return Output{Err: fmt.Errorf("failed to dial ssh. Error message: %s. DialErr: %w", err.Error(), errSSHDialFailed)}
+		return nil, nil, fmt.Errorf("failed to dial ssh. Error message: %s. DialErr: %w", err.Error(), errSSHDialFailed)
+	}
+
+	return client, config, nil
+}
+
+func (c *sshClient) runSSH(command string) Output {
+	client, _, err := c.getSSHClient()
+	if err != nil {
+		return Output{Err: err}
 	}
 	defer client.Close()
 
@@ -751,3 +765,49 @@ func removeUselessLinesFromCloudInitOutput(s string) string {
 	}
 	return strings.Join(output, "\n")
 }
+
+func (c *sshClient) ExecutePreProvisionCommand(ctx context.Context, command string) (int, string, error) {
+	client, clientConfig, err := c.getSSHClient()
+	if err != nil {
+		return 0, "", err
+	}
+	defer client.Close()
+
+	scpClient, err := scp.NewClientBySSH(client)
+	if err != nil {
+		return 0, "", fmt.Errorf("couldn't create a new scp client: %w", err)
+	}
+
+	defer scpClient.Close()
+
+	scpClient.ClientConfig = clientConfig
+	scpClient.Host = fmt.Sprintf("%s:%d", c.ip, c.port)
+
+	err = scpClient.Connect()
+	if err != nil {
+		return 0, "", fmt.Errorf("couldn't establish a connection to the remote server: %w ", err)
+	}
+
+	f, err := os.Open(command) //nolint:gosec // the variable was valided.
+	if err != nil {
+		return 0, "", fmt.Errorf("error opening file %q: %w", command, err)
+	}
+
+	baseName := filepath.Base(command)
+	dest := "/root/" + baseName
+	err = scpClient.CopyFromFile(ctx, *f, dest, "0700")
+	if err != nil {
+		return 0, "", fmt.Errorf("error copying file %q to %s:%d:%s %w", command, c.ip, c.port, dest, err)
+	}
+
+	out := c.runSSH(dest)
+	exitStatus, err := out.ExitStatus()
+	if err != nil {
+		return 0, "", fmt.Errorf("error executing %q on %s:%d: %w", dest, c.ip, c.port, err)
+	}
+
+	s := out.StdOut + "\n" + out.StdErr
+	s = strings.TrimSpace(s)
+
+	return exitStatus, s, nil
+}
diff --git a/pkg/services/baremetal/host/host.go b/pkg/services/baremetal/host/host.go
index 2d84befd..ee72cbe8 100644
--- a/pkg/services/baremetal/host/host.go
+++ b/pkg/services/baremetal/host/host.go
@@ -23,6 +23,7 @@ import (
 	"errors"
 	"fmt"
 	"os"
+	"path/filepath"
 	"reflect"
 	"slices"
 	"strconv"
@@ -579,7 +580,7 @@ func (s *Service) ensureRescueMode() error {
 }
 
 // previous: Preparing
-// next: ImageInstalling
+// next: PreProvisioning
 func (s *Service) actionRegistering(_ context.Context) actionResult {
 	markProvisionPending(s.scope.HetznerBareMetalHost, infrav1.StateRegistering)
 
@@ -706,7 +707,7 @@ func (s *Service) actionRegistering(_ context.Context) actionResult {
 
 	conditions.MarkTrue(s.scope.HetznerBareMetalHost, infrav1.RootDeviceHintsValidatedCondition)
 	s.scope.HetznerBareMetalHost.ClearError()
-	return actionComplete{} // next: ImageInstalling
+	return actionComplete{}
 }
 
 func validateRootDeviceWwnsAreSubsetOfExistingWwns(rootDeviceHints *infrav1.RootDeviceHints, storageDevices []infrav1.Storage) error {
@@ -1072,6 +1073,48 @@ func validateStdOut(stdOut string) (string, error) {
 }
 
 // previous: Registering
+// next: ImageInstalling
+func (s *Service) actionPreProvisioning(ctx context.Context) actionResult {
+	markProvisionPending(s.scope.HetznerBareMetalHost, infrav1.StatePreProvisioning)
+
+	// Ensure os ssh secret
+	sshKey, actResult := s.ensureSSHKey(s.scope.HetznerBareMetalHost.Spec.Status.SSHSpec.SecretRef, s.scope.OSSSHSecret)
+	if _, isComplete := actResult.(actionComplete); !isComplete {
+		return actResult
+	}
+	s.scope.HetznerBareMetalHost.Spec.Status.SSHStatus.OSKey = &sshKey
+
+	if s.scope.PreProvisionCommand == "" {
+		return actionComplete{}
+	}
+
+	creds := sshclient.CredentialsFromSecret(s.scope.RescueSSHSecret, s.scope.HetznerCluster.Spec.SSHKeys.RobotRescueSecretRef)
+	in := sshclient.Input{
+		PrivateKey: creds.PrivateKey,
+		Port:       rescuePort,
+		IP:         s.scope.HetznerBareMetalHost.Spec.Status.GetIPAddress(),
+	}
+	sshClient := s.scope.SSHClientFactory.NewClient(in)
+
+	exitStatus, output, err := sshClient.ExecutePreProvisionCommand(ctx, s.scope.PreProvisionCommand)
+	if err != nil {
+		return actionError{err: fmt.Errorf("failed to execute pre-provision command: %w", err)}
+	}
+
+	if exitStatus != 0 {
+		record.Warnf(s.scope.HetznerBareMetalHost, "PreProvisionCommandFailed",
+			"%s: %s", filepath.Base(s.scope.PreProvisionCommand), output)
+		s.scope.HetznerBareMetalHost.SetError(infrav1.PermanentError, output)
+		return actionStop{}
+	}
+
+	record.Eventf(s.scope.HetznerBareMetalHost, "PreProvisionCommandSucceeded",
+		"%s: %s", filepath.Base(s.scope.PreProvisionCommand), output)
+
+	return actionComplete{}
+}
+
+// previous: PreProvisioning
 // next: EnsureProvisioned
 func (s *Service) actionImageInstalling(ctx context.Context) actionResult {
 	markProvisionPending(s.scope.HetznerBareMetalHost, infrav1.StateImageInstalling)
@@ -1084,17 +1127,11 @@ func (s *Service) actionImageInstalling(ctx context.Context) actionResult {
 	}
 	sshClient := s.scope.SSHClientFactory.NewClient(in)
 
-	// Ensure os ssh secret
-	sshKey, actResult := s.ensureSSHKey(s.scope.HetznerBareMetalHost.Spec.Status.SSHSpec.SecretRef, s.scope.OSSSHSecret)
-	if _, isComplete := actResult.(actionComplete); !isComplete {
-		return actResult
-	}
-
-	s.scope.HetznerBareMetalHost.Spec.Status.SSHStatus.OSKey = &sshKey
 	state, err := sshClient.GetInstallImageState()
 	if err != nil {
 		return actionError{err: fmt.Errorf("failed to get state of installimage processes: %w", err)}
 	}
+
 	switch state {
 	case sshclient.InstallImageStateRunning:
 		s.scope.Logger.Info("installimage is still running. Checking again in some seconds.")
@@ -1338,7 +1375,7 @@ func (s *Service) actionImageInstallingFinished(_ context.Context, sshClient ssh
 
 	// clear potential errors - all done
 	s.scope.HetznerBareMetalHost.ClearError()
-	return actionComplete{} // next: ensure-provisioned
+	return actionComplete{}
 }
 
 func (s *Service) createAutoSetupInput(sshClient sshclient.Client) (autoSetupInput, actionResult) {
@@ -1577,7 +1614,7 @@ func (s *Service) actionEnsureProvisioned(_ context.Context) (ar actionResult) {
 	record.Event(s.scope.HetznerBareMetalHost, "ServerProvisioned", "server successfully provisioned")
 	conditions.MarkTrue(s.scope.HetznerBareMetalHost, infrav1.ProvisionSucceededCondition)
 	s.scope.HetznerBareMetalHost.ClearError()
-	return createEventWithCloudInitOutput(actionComplete{}) // next: actionProvisioned
+	return createEventWithCloudInitOutput(actionComplete{})
 }
 
 // handleConnectionRefused checks cloud init status via ssh to the old ssh port if the new ssh port
diff --git a/pkg/services/baremetal/host/state_machine.go b/pkg/services/baremetal/host/state_machine.go
index 96c4e23e..06064300 100644
--- a/pkg/services/baremetal/host/state_machine.go
+++ b/pkg/services/baremetal/host/state_machine.go
@@ -58,6 +58,7 @@ func (hsm *hostStateMachine) handlers() map[infrav1.ProvisioningState]stateHandl
 	return map[infrav1.ProvisioningState]stateHandler{
 		infrav1.StatePreparing:         hsm.handlePreparing,
 		infrav1.StateRegistering:       hsm.handleRegistering,
+		infrav1.StatePreProvisioning:   hsm.handlePreProvisioning,
 		infrav1.StateImageInstalling:   hsm.handleImageInstalling,
 		infrav1.StateEnsureProvisioned: hsm.handleEnsureProvisioned,
 		infrav1.StateProvisioned:       hsm.handleProvisioned,
@@ -266,8 +267,32 @@ func (hsm *hostStateMachine) handleRegistering(ctx context.Context) actionResult
 
 	actResult := hsm.reconciler.actionRegistering(ctx)
 	if _, ok := actResult.(actionComplete); ok {
+		hsm.nextState = infrav1.StatePreProvisioning
+	}
+	return actResult
+}
+
+func (hsm *hostStateMachine) handlePreProvisioning(ctx context.Context) actionResult {
+	if hsm.provisioningCancelled() {
+		hsm.nextState = infrav1.StateDeprovisioning
+		return actionComplete{}
+	}
+
+	actResult := hsm.reconciler.actionPreProvisioning(ctx)
+	switch actResult.(type) {
+	case actionComplete:
 		hsm.nextState = infrav1.StateImageInstalling
+	case actionError:
+		// re-enable rescue system. If actionPreProvisioning
+		// failed with actionError, then it is likely that
+		// the next run (without reboot) fails with this error:
+		// ERROR unmounting device(s):
+		// umount: /: target is busy.
+		// Cannot continue, device(s) seem to be in use.
+		// Please unmount used devices manually or reboot the rescue system and retry.
+		hsm.nextState = infrav1.StatePreparing
 	}
+
 	return actResult
 }
 
@@ -287,7 +312,7 @@ func (hsm *hostStateMachine) handleImageInstalling(ctx context.Context) actionRe
 		// ERROR unmounting device(s):
 		// umount: /: target is busy.
 		// Cannot continue, device(s) seem to be in use.
-		// Please unmount used devices manually or reboot the rescuesystem and retry.
+		// Please unmount used devices manually or reboot the rescue system and retry.
 		hsm.nextState = infrav1.StatePreparing
 	}
 
diff --git a/vendor/github.com/bramvdbogaerde/go-scp/.gitignore b/vendor/github.com/bramvdbogaerde/go-scp/.gitignore
new file mode 100644
index 00000000..ab27d1e8
--- /dev/null
+++ b/vendor/github.com/bramvdbogaerde/go-scp/.gitignore
@@ -0,0 +1,2 @@
+/.idea
+/vendor
\ No newline at end of file
diff --git a/vendor/github.com/bramvdbogaerde/go-scp/LICENSE.txt b/vendor/github.com/bramvdbogaerde/go-scp/LICENSE.txt
new file mode 100644
index 00000000..14e2f777
--- /dev/null
+++ b/vendor/github.com/bramvdbogaerde/go-scp/LICENSE.txt
@@ -0,0 +1,373 @@
+Mozilla Public License Version 2.0
+==================================
+
+1. Definitions
+--------------
+
+1.1. "Contributor"
+    means each individual or legal entity that creates, contributes to
+    the creation of, or owns Covered Software.
+
+1.2. "Contributor Version"
+    means the combination of the Contributions of others (if any) used
+    by a Contributor and that particular Contributor's Contribution.
+
+1.3. "Contribution"
+    means Covered Software of a particular Contributor.
+
+1.4. "Covered Software"
+    means Source Code Form to which the initial Contributor has attached
+    the notice in Exhibit A, the Executable Form of such Source Code
+    Form, and Modifications of such Source Code Form, in each case
+    including portions thereof.
+
+1.5. "Incompatible With Secondary Licenses"
+    means
+
+    (a) that the initial Contributor has attached the notice described
+        in Exhibit B to the Covered Software; or
+
+    (b) that the Covered Software was made available under the terms of
+        version 1.1 or earlier of the License, but not also under the
+        terms of a Secondary License.
+
+1.6. "Executable Form"
+    means any form of the work other than Source Code Form.
+
+1.7. "Larger Work"
+    means a work that combines Covered Software with other material, in 
+    a separate file or files, that is not Covered Software.
+
+1.8. "License"
+    means this document.
+
+1.9. "Licensable"
+    means having the right to grant, to the maximum extent possible,
+    whether at the time of the initial grant or subsequently, any and
+    all of the rights conveyed by this License.
+
+1.10. "Modifications"
+    means any of the following:
+
+    (a) any file in Source Code Form that results from an addition to,
+        deletion from, or modification of the contents of Covered
+        Software; or
+
+    (b) any new file in Source Code Form that contains any Covered
+        Software.
+
+1.11. "Patent Claims" of a Contributor
+    means any patent claim(s), including without limitation, method,
+    process, and apparatus claims, in any patent Licensable by such
+    Contributor that would be infringed, but for the grant of the
+    License, by the making, using, selling, offering for sale, having
+    made, import, or transfer of either its Contributions or its
+    Contributor Version.
+
+1.12. "Secondary License"
+    means either the GNU General Public License, Version 2.0, the GNU
+    Lesser General Public License, Version 2.1, the GNU Affero General
+    Public License, Version 3.0, or any later versions of those
+    licenses.
+
+1.13. "Source Code Form"
+    means the form of the work preferred for making modifications.
+
+1.14. "You" (or "Your")
+    means an individual or a legal entity exercising rights under this
+    License. For legal entities, "You" includes any entity that
+    controls, is controlled by, or is under common control with You. For
+    purposes of this definition, "control" means (a) the power, direct
+    or indirect, to cause the direction or management of such entity,
+    whether by contract or otherwise, or (b) ownership of more than
+    fifty percent (50%) of the outstanding shares or beneficial
+    ownership of such entity.
+
+2. License Grants and Conditions
+--------------------------------
+
+2.1. Grants
+
+Each Contributor hereby grants You a world-wide, royalty-free,
+non-exclusive license:
+
+(a) under intellectual property rights (other than patent or trademark)
+    Licensable by such Contributor to use, reproduce, make available,
+    modify, display, perform, distribute, and otherwise exploit its
+    Contributions, either on an unmodified basis, with Modifications, or
+    as part of a Larger Work; and
+
+(b) under Patent Claims of such Contributor to make, use, sell, offer
+    for sale, have made, import, and otherwise transfer either its
+    Contributions or its Contributor Version.
+
+2.2. Effective Date
+
+The licenses granted in Section 2.1 with respect to any Contribution
+become effective for each Contribution on the date the Contributor first
+distributes such Contribution.
+
+2.3. Limitations on Grant Scope
+
+The licenses granted in this Section 2 are the only rights granted under
+this License. No additional rights or licenses will be implied from the
+distribution or licensing of Covered Software under this License.
+Notwithstanding Section 2.1(b) above, no patent license is granted by a
+Contributor:
+
+(a) for any code that a Contributor has removed from Covered Software;
+    or
+
+(b) for infringements caused by: (i) Your and any other third party's
+    modifications of Covered Software, or (ii) the combination of its
+    Contributions with other software (except as part of its Contributor
+    Version); or
+
+(c) under Patent Claims infringed by Covered Software in the absence of
+    its Contributions.
+
+This License does not grant any rights in the trademarks, service marks,
+or logos of any Contributor (except as may be necessary to comply with
+the notice requirements in Section 3.4).
+
+2.4. Subsequent Licenses
+
+No Contributor makes additional grants as a result of Your choice to
+distribute the Covered Software under a subsequent version of this
+License (see Section 10.2) or under the terms of a Secondary License (if
+permitted under the terms of Section 3.3).
+
+2.5. Representation
+
+Each Contributor represents that the Contributor believes its
+Contributions are its original creation(s) or it has sufficient rights
+to grant the rights to its Contributions conveyed by this License.
+
+2.6. Fair Use
+
+This License is not intended to limit any rights You have under
+applicable copyright doctrines of fair use, fair dealing, or other
+equivalents.
+
+2.7. Conditions
+
+Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
+in Section 2.1.
+
+3. Responsibilities
+-------------------
+
+3.1. Distribution of Source Form
+
+All distribution of Covered Software in Source Code Form, including any
+Modifications that You create or to which You contribute, must be under
+the terms of this License. You must inform recipients that the Source
+Code Form of the Covered Software is governed by the terms of this
+License, and how they can obtain a copy of this License. You may not
+attempt to alter or restrict the recipients' rights in the Source Code
+Form.
+
+3.2. Distribution of Executable Form
+
+If You distribute Covered Software in Executable Form then:
+
+(a) such Covered Software must also be made available in Source Code
+    Form, as described in Section 3.1, and You must inform recipients of
+    the Executable Form how they can obtain a copy of such Source Code
+    Form by reasonable means in a timely manner, at a charge no more
+    than the cost of distribution to the recipient; and
+
+(b) You may distribute such Executable Form under the terms of this
+    License, or sublicense it under different terms, provided that the
+    license for the Executable Form does not attempt to limit or alter
+    the recipients' rights in the Source Code Form under this License.
+
+3.3. Distribution of a Larger Work
+
+You may create and distribute a Larger Work under terms of Your choice,
+provided that You also comply with the requirements of this License for
+the Covered Software. If the Larger Work is a combination of Covered
+Software with a work governed by one or more Secondary Licenses, and the
+Covered Software is not Incompatible With Secondary Licenses, this
+License permits You to additionally distribute such Covered Software
+under the terms of such Secondary License(s), so that the recipient of
+the Larger Work may, at their option, further distribute the Covered
+Software under the terms of either this License or such Secondary
+License(s).
+
+3.4. Notices
+
+You may not remove or alter the substance of any license notices
+(including copyright notices, patent notices, disclaimers of warranty,
+or limitations of liability) contained within the Source Code Form of
+the Covered Software, except that You may alter any license notices to
+the extent required to remedy known factual inaccuracies.
+
+3.5. Application of Additional Terms
+
+You may choose to offer, and to charge a fee for, warranty, support,
+indemnity or liability obligations to one or more recipients of Covered
+Software. However, You may do so only on Your own behalf, and not on
+behalf of any Contributor. You must make it absolutely clear that any
+such warranty, support, indemnity, or liability obligation is offered by
+You alone, and You hereby agree to indemnify every Contributor for any
+liability incurred by such Contributor as a result of warranty, support,
+indemnity or liability terms You offer. You may include additional
+disclaimers of warranty and limitations of liability specific to any
+jurisdiction.
+
+4. Inability to Comply Due to Statute or Regulation
+---------------------------------------------------
+
+If it is impossible for You to comply with any of the terms of this
+License with respect to some or all of the Covered Software due to
+statute, judicial order, or regulation then You must: (a) comply with
+the terms of this License to the maximum extent possible; and (b)
+describe the limitations and the code they affect. Such description must
+be placed in a text file included with all distributions of the Covered
+Software under this License. Except to the extent prohibited by statute
+or regulation, such description must be sufficiently detailed for a
+recipient of ordinary skill to be able to understand it.
+
+5. Termination
+--------------
+
+5.1. The rights granted under this License will terminate automatically
+if You fail to comply with any of its terms. However, if You become
+compliant, then the rights granted under this License from a particular
+Contributor are reinstated (a) provisionally, unless and until such
+Contributor explicitly and finally terminates Your grants, and (b) on an
+ongoing basis, if such Contributor fails to notify You of the
+non-compliance by some reasonable means prior to 60 days after You have
+come back into compliance. Moreover, Your grants from a particular
+Contributor are reinstated on an ongoing basis if such Contributor
+notifies You of the non-compliance by some reasonable means, this is the
+first time You have received notice of non-compliance with this License
+from such Contributor, and You become compliant prior to 30 days after
+Your receipt of the notice.
+
+5.2. If You initiate litigation against any entity by asserting a patent
+infringement claim (excluding declaratory judgment actions,
+counter-claims, and cross-claims) alleging that a Contributor Version
+directly or indirectly infringes any patent, then the rights granted to
+You by any and all Contributors for the Covered Software under Section
+2.1 of this License shall terminate.
+
+5.3. In the event of termination under Sections 5.1 or 5.2 above, all
+end user license agreements (excluding distributors and resellers) which
+have been validly granted by You or Your distributors under this License
+prior to termination shall survive termination.
+
+************************************************************************
+*                                                                      *
+*  6. Disclaimer of Warranty                                           *
+*  -------------------------                                           *
+*                                                                      *
+*  Covered Software is provided under this License on an "as is"       *
+*  basis, without warranty of any kind, either expressed, implied, or  *
+*  statutory, including, without limitation, warranties that the       *
+*  Covered Software is free of defects, merchantable, fit for a        *
+*  particular purpose or non-infringing. The entire risk as to the     *
+*  quality and performance of the Covered Software is with You.        *
+*  Should any Covered Software prove defective in any respect, You     *
+*  (not any Contributor) assume the cost of any necessary servicing,   *
+*  repair, or correction. This disclaimer of warranty constitutes an   *
+*  essential part of this License. No use of any Covered Software is   *
+*  authorized under this License except under this disclaimer.         *
+*                                                                      *
+************************************************************************
+
+************************************************************************
+*                                                                      *
+*  7. Limitation of Liability                                          *
+*  --------------------------                                          *
+*                                                                      *
+*  Under no circumstances and under no legal theory, whether tort      *
+*  (including negligence), contract, or otherwise, shall any           *
+*  Contributor, or anyone who distributes Covered Software as          *
+*  permitted above, be liable to You for any direct, indirect,         *
+*  special, incidental, or consequential damages of any character      *
+*  including, without limitation, damages for lost profits, loss of    *
+*  goodwill, work stoppage, computer failure or malfunction, or any    *
+*  and all other commercial damages or losses, even if such party      *
+*  shall have been informed of the possibility of such damages. This   *
+*  limitation of liability shall not apply to liability for death or   *
+*  personal injury resulting from such party's negligence to the       *
+*  extent applicable law prohibits such limitation. Some               *
+*  jurisdictions do not allow the exclusion or limitation of           *
+*  incidental or consequential damages, so this exclusion and          *
+*  limitation may not apply to You.                                    *
+*                                                                      *
+************************************************************************
+
+8. Litigation
+-------------
+
+Any litigation relating to this License may be brought only in the
+courts of a jurisdiction where the defendant maintains its principal
+place of business and such litigation shall be governed by laws of that
+jurisdiction, without reference to its conflict-of-law provisions.
+Nothing in this Section shall prevent a party's ability to bring
+cross-claims or counter-claims.
+
+9. Miscellaneous
+----------------
+
+This License represents the complete agreement concerning the subject
+matter hereof. If any provision of this License is held to be
+unenforceable, such provision shall be reformed only to the extent
+necessary to make it enforceable. Any law or regulation which provides
+that the language of a contract shall be construed against the drafter
+shall not be used to construe this License against a Contributor.
+
+10. Versions of the License
+---------------------------
+
+10.1. New Versions
+
+Mozilla Foundation is the license steward. Except as provided in Section
+10.3, no one other than the license steward has the right to modify or
+publish new versions of this License. Each version will be given a
+distinguishing version number.
+
+10.2. Effect of New Versions
+
+You may distribute the Covered Software under the terms of the version
+of the License under which You originally received the Covered Software,
+or under the terms of any subsequent version published by the license
+steward.
+
+10.3. Modified Versions
+
+If you create software not governed by this License, and you want to
+create a new license for such software, you may create and use a
+modified version of this License if you rename the license and remove
+any references to the name of the license steward (except to note that
+such modified license differs from this License).
+
+10.4. Distributing Source Code Form that is Incompatible With Secondary
+Licenses
+
+If You choose to distribute Source Code Form that is Incompatible With
+Secondary Licenses under the terms of this version of the License, the
+notice described in Exhibit B of this License must be attached.
+
+Exhibit A - Source Code Form License Notice
+-------------------------------------------
+
+  This Source Code Form is subject to the terms of the Mozilla Public
+  License, v. 2.0. If a copy of the MPL was not distributed with this
+  file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+If it is not possible or desirable to put the notice in a particular
+file, then You may include the notice in a location (such as a LICENSE
+file in a relevant directory) where a recipient would be likely to look
+for such a notice.
+
+You may add additional accurate notices of copyright ownership.
+
+Exhibit B - "Incompatible With Secondary Licenses" Notice
+---------------------------------------------------------
+
+  This Source Code Form is "Incompatible With Secondary Licenses", as
+  defined by the Mozilla Public License, v. 2.0.
diff --git a/vendor/github.com/bramvdbogaerde/go-scp/README.md b/vendor/github.com/bramvdbogaerde/go-scp/README.md
new file mode 100644
index 00000000..e37a76c1
--- /dev/null
+++ b/vendor/github.com/bramvdbogaerde/go-scp/README.md
@@ -0,0 +1,97 @@
+Copy files over SCP with Go
+=============================
+[![Go Report Card](https://goreportcard.com/badge/bramvdbogaerde/go-scp)](https://goreportcard.com/report/bramvdbogaerde/go-scp) [![](https://godoc.org/github.com/bramvdbogaerde/go-scp?status.svg)](https://godoc.org/github.com/bramvdbogaerde/go-scp)
+
+This package makes it very easy to copy files over scp in Go.
+It uses the golang.org/x/crypto/ssh package to establish a secure connection to a remote server in order to copy the files via the SCP protocol.
+
+### Example usage
+
+
+```go
+package main
+
+import (
+	"fmt"
+	scp "github.com/bramvdbogaerde/go-scp"
+	"github.com/bramvdbogaerde/go-scp/auth"
+	"golang.org/x/crypto/ssh"
+	"os"
+        "context"
+)
+
+func main() {
+	// Use SSH key authentication from the auth package
+	// we ignore the host key in this example, please change this if you use this library
+	clientConfig, _ := auth.PrivateKey("username", "/path/to/rsa/key", ssh.InsecureIgnoreHostKey())
+
+	// For other authentication methods see ssh.ClientConfig and ssh.AuthMethod
+
+	// Create a new SCP client
+	client := scp.NewClient("example.com:22", &clientConfig)
+
+	// Connect to the remote server
+	err := client.Connect()
+	if err != nil {
+		fmt.Println("Couldn't establish a connection to the remote server ", err)
+		return
+	}
+
+	// Open a file
+	f, _ := os.Open("/path/to/local/file")
+
+	// Close client connection after the file has been copied
+	defer client.Close()
+
+	// Close the file after it has been copied
+	defer f.Close()
+
+	// Finally, copy the file over
+	// Usage: CopyFromFile(context, file, remotePath, permission)
+
+        // the context can be adjusted to provide time-outs or inherit from other contexts if this is embedded in a larger application.
+	err = client.CopyFromFile(context.Background(), *f, "/home/server/test.txt", "0655")
+
+	if err != nil {
+		fmt.Println("Error while copying file ", err)
+	}
+}
+```
+
+#### Using an existing SSH connection
+
+If you have an existing established SSH connection, you can use that instead.
+
+```go
+func connectSSH() *ssh.Client {
+   // setup SSH connection
+}
+
+func main() {
+   sshClient := connectSSH()
+
+   // Create a new SCP client, note that this function might
+   // return an error, as a new SSH session is established using the existing connecton
+
+   client, err := scp.NewClientBySSH(sshClient)
+   if err != nil {
+      fmt.Println("Error creating new SSH session from existing connection", err)
+   }
+
+   /* .. same as above .. */
+}
+```
+
+#### Copying Files from Remote Server
+
+It is also possible to copy remote files using this library. 
+The usage is similar to the example at the top of this section, except that `CopyFromRemote` needsto be used instead.
+
+For a more comprehensive example, please consult the `TestDownloadFile` function in t he `tests/basic_test.go` file.
+
+### License
+
+This library is licensed under the Mozilla Public License 2.0.    
+A copy of the license is provided in the `LICENSE.txt` file.
+
+Copyright (c) 2020 Bram Vandenbogaerde
diff --git a/vendor/github.com/bramvdbogaerde/go-scp/client.go b/vendor/github.com/bramvdbogaerde/go-scp/client.go
new file mode 100644
index 00000000..bde8638c
--- /dev/null
+++ b/vendor/github.com/bramvdbogaerde/go-scp/client.go
@@ -0,0 +1,446 @@
+/* Copyright (c) 2024 Bram Vandenbogaerde And Contributors
+ * You may use, distribute or modify this code under the
+ * terms of the Mozilla Public License 2.0, which is distributed
+ * along with the source code.
+ */
+
+package scp
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"os"
+	"path"
+	"sync"
+	"time"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// Callback for freeing managed resources
+type ICloseHandler interface {
+	Close()
+}
+
+// Close handler equivalent to a no-op. Used by default
+// when no resources have to be cleaned.
+type EmptyHandler struct{}
+
+func (EmptyHandler) Close() {}
+
+// Close handler to close an SSH client
+type CloseSSHCLient struct {
+	// Reference to the used SSH client
+	sshClient *ssh.Client
+}
+
+func (scp CloseSSHCLient) Close() {
+	scp.sshClient.Close()
+}
+
+type PassThru func(r io.Reader, total int64) io.Reader
+
+type Client struct {
+	// Host the host to connect to.
+	Host string
+
+	// ClientConfig the client config to use.
+	ClientConfig *ssh.ClientConfig
+
+	// Keep the ssh client around for generating new sessions
+	sshClient *ssh.Client
+
+	// Timeout the maximal amount of time to wait for a file transfer to complete.
+	// Deprecated: use context.Context for each function instead.
+	Timeout time.Duration
+
+	// RemoteBinary the absolute path to the remote SCP binary.
+	RemoteBinary string
+
+	// Handler called when calling `Close` to clean up any remaining
+	// resources managed by `Client`.
+	closeHandler ICloseHandler
+}
+
+// Connect connects to the remote SSH server, returns error if it couldn't establish a session to the SSH server.
+func (a *Client) Connect() error {
+	client, err := ssh.Dial("tcp", a.Host, a.ClientConfig)
+	if err != nil {
+		return err
+	}
+
+	a.sshClient = client
+	a.closeHandler = CloseSSHCLient{sshClient: client}
+	return nil
+}
+
+// Returns the underlying SSH client, this should be used carefully as
+// it will be closed by `client.Close`.
+func (a *Client) SSHClient() *ssh.Client {
+	return a.sshClient
+}
+
+// CopyFromFile copies the contents of an os.File to a remote location, it will get the length of the file by looking it up from the filesystem.
+func (a *Client) CopyFromFile(
+	ctx context.Context,
+	file os.File,
+	remotePath string,
+	permissions string,
+) error {
+	return a.CopyFromFilePassThru(ctx, file, remotePath, permissions, nil)
+}
+
+// CopyFromFilePassThru copies the contents of an os.File to a remote location, it will get the length of the file by looking it up from the filesystem.
+// Access copied bytes by providing a PassThru reader factory.
+func (a *Client) CopyFromFilePassThru(
+	ctx context.Context,
+	file os.File,
+	remotePath string,
+	permissions string,
+	passThru PassThru,
+) error {
+	stat, err := file.Stat()
+	if err != nil {
+		return fmt.Errorf("failed to stat file: %w", err)
+	}
+	return a.CopyPassThru(ctx, &file, remotePath, permissions, stat.Size(), passThru)
+}
+
+// CopyFile copies the contents of an io.Reader to a remote location, the length is determined by reading the io.Reader until EOF
+// if the file length in know in advance please use "Copy" instead.
+func (a *Client) CopyFile(
+	ctx context.Context,
+	fileReader io.Reader,
+	remotePath string,
+	permissions string,
+) error {
+	return a.CopyFilePassThru(ctx, fileReader, remotePath, permissions, nil)
+}
+
+// CopyFilePassThru copies the contents of an io.Reader to a remote location, the length is determined by reading the io.Reader until EOF
+// if the file length in know in advance please use "Copy" instead.
+// Access copied bytes by providing a PassThru reader factory.
+func (a *Client) CopyFilePassThru(
+	ctx context.Context,
+	fileReader io.Reader,
+	remotePath string,
+	permissions string,
+	passThru PassThru,
+) error {
+	contentsBytes, err := ioutil.ReadAll(fileReader)
+	if err != nil {
+		return fmt.Errorf("failed to read all data from reader: %w", err)
+	}
+	bytesReader := bytes.NewReader(contentsBytes)
+
+	return a.CopyPassThru(
+		ctx,
+		bytesReader,
+		remotePath,
+		permissions,
+		int64(len(contentsBytes)),
+		passThru,
+	)
+}
+
+// wait waits for the waitgroup for the specified max timeout.
+// Returns true if waiting timed out.
+func wait(wg *sync.WaitGroup, ctx context.Context) error {
+	c := make(chan struct{})
+	go func() {
+		defer close(c)
+		wg.Wait()
+	}()
+
+	select {
+	case <-c:
+		return nil
+
+	case <-ctx.Done():
+		return ctx.Err()
+	}
+}
+
+// checkResponse checks the response it reads from the remote, and will return a single error in case
+// of failure.
+func checkResponse(r io.Reader) error {
+	_, err := ParseResponse(r, nil)
+	if err != nil {
+		return err
+	}
+
+	return nil
+
+}
+
+// Copy copies the contents of an io.Reader to a remote location.
+func (a *Client) Copy(
+	ctx context.Context,
+	r io.Reader,
+	remotePath string,
+	permissions string,
+	size int64,
+) error {
+	return a.CopyPassThru(ctx, r, remotePath, permissions, size, nil)
+}
+
+// CopyPassThru copies the contents of an io.Reader to a remote location.
+// Access copied bytes by providing a PassThru reader factory
+func (a *Client) CopyPassThru(
+	ctx context.Context,
+	r io.Reader,
+	remotePath string,
+	permissions string,
+	size int64,
+	passThru PassThru,
+) error {
+	session, err := a.sshClient.NewSession()
+	if err != nil {
+		return fmt.Errorf("Error creating ssh session in copy to remote: %v", err)
+	}
+	defer session.Close()
+
+	stdout, err := session.StdoutPipe()
+	if err != nil {
+		return err
+	}
+	w, err := session.StdinPipe()
+	if err != nil {
+		return err
+	}
+	defer w.Close()
+
+	if passThru != nil {
+		r = passThru(r, size)
+	}
+
+	filename := path.Base(remotePath)
+
+	// Start the command first and get confirmation that it has been started
+	// before sending anything through the pipes.
+	err = session.Start(fmt.Sprintf("%s -qt %q", a.RemoteBinary, remotePath))
+	if err != nil {
+		return err
+	}
+
+	wg := sync.WaitGroup{}
+	wg.Add(2)
+
+	errCh := make(chan error, 2)
+
+	// SCP protocol and file sending
+	go func() {
+		defer wg.Done()
+		defer w.Close()
+
+		_, err = fmt.Fprintln(w, "C"+permissions, size, filename)
+		if err != nil {
+			errCh <- err
+			return
+		}
+
+		if err = checkResponse(stdout); err != nil {
+			errCh <- err
+			return
+		}
+
+		_, err = io.Copy(w, r)
+		if err != nil {
+			errCh <- err
+			return
+		}
+
+		_, err = fmt.Fprint(w, "\x00")
+		if err != nil {
+			errCh <- err
+			return
+		}
+
+		if err = checkResponse(stdout); err != nil {
+			errCh <- err
+			return
+		}
+	}()
+
+	// Wait for the process to exit
+	go func() {
+		defer wg.Done()
+		err := session.Wait()
+		if err != nil {
+			errCh <- err
+			return
+		}
+	}()
+
+	// If there is a timeout, stop the transfer if it has been exceeded
+	if a.Timeout > 0 {
+		var cancel context.CancelFunc
+		ctx, cancel = context.WithTimeout(ctx, a.Timeout)
+		defer cancel()
+	}
+
+	// Wait for one of the conditions (error/timeout/completion) to occur
+	if err := wait(&wg, ctx); err != nil {
+		return err
+	}
+
+	close(errCh)
+
+	// Collect any errors from the error channel
+	for err := range errCh {
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// CopyFromRemote copies a file from the remote to the local file given by the `file`
+// parameter. Use `CopyFromRemotePassThru` if a more generic writer
+// is desired instead of writing directly to a file on the file system.
+func (a *Client) CopyFromRemote(ctx context.Context, file *os.File, remotePath string) error {
+	return a.CopyFromRemotePassThru(ctx, file, remotePath, nil)
+}
+
+// CopyFromRemotePassThru copies a file from the remote to the given writer. The passThru parameter can be used
+// to keep track of progress and how many bytes that were download from the remote.
+// `passThru` can be set to nil to disable this behaviour.
+func (a *Client) CopyFromRemotePassThru(
+	ctx context.Context,
+	w io.Writer,
+	remotePath string,
+	passThru PassThru,
+) error {
+	_, err := a.copyFromRemote(ctx, w, remotePath, passThru, false)
+
+	return err
+}
+
+// CopyFroRemoteFileInfos copies a file from the remote to a given writer and return a FileInfos struct
+// containing information about the file such as permissions, the file size, modification time and access time
+func (a *Client) CopyFromRemoteFileInfos(
+	ctx context.Context,
+	w io.Writer,
+	remotePath string,
+	passThru PassThru,
+) (*FileInfos, error) {
+	return a.copyFromRemote(ctx, w, remotePath, passThru, true)
+}
+
+func (a *Client) copyFromRemote(
+	ctx context.Context,
+	w io.Writer,
+	remotePath string,
+	passThru PassThru,
+	preserveFileTimes bool,
+) (*FileInfos, error) {
+	session, err := a.sshClient.NewSession()
+	if err != nil {
+		return nil, fmt.Errorf("Error creating ssh session in copy from remote: %v", err)
+	}
+	defer session.Close()
+
+	wg := sync.WaitGroup{}
+	errCh := make(chan error, 4)
+	var fileInfos *FileInfos
+
+	wg.Add(1)
+	go func() {
+		var err error
+
+		defer func() {
+			// NOTE: this might send an already sent error another time, but since we only receive one, this is fine. On the "happy-path" of this function, the error will be `nil` therefore completing the "err<-errCh" at the bottom of the function.
+			errCh <- err
+			// We must unblock the go routine first as we block on reading the channel later
+			wg.Done()
+
+		}()
+
+		r, err := session.StdoutPipe()
+		if err != nil {
+			errCh <- err
+			return
+		}
+
+		in, err := session.StdinPipe()
+		if err != nil {
+			errCh <- err
+			return
+		}
+		defer in.Close()
+
+		if preserveFileTimes {
+			err = session.Start(fmt.Sprintf("%s -pf %q", a.RemoteBinary, remotePath))
+		} else {
+			err = session.Start(fmt.Sprintf("%s -f %q", a.RemoteBinary, remotePath))
+		}
+		if err != nil {
+			errCh <- err
+			return
+		}
+
+		err = Ack(in)
+		if err != nil {
+			errCh <- err
+			return
+		}
+
+		fileInfo, err := ParseResponse(r, in)
+		if err != nil {
+			errCh <- err
+			return
+		}
+
+		fileInfos = fileInfo
+
+		err = Ack(in)
+		if err != nil {
+			errCh <- err
+			return
+		}
+
+		if passThru != nil {
+			r = passThru(r, fileInfo.Size)
+		}
+
+		_, err = CopyN(w, r, fileInfo.Size)
+		if err != nil {
+			errCh <- err
+			return
+		}
+
+		err = Ack(in)
+		if err != nil {
+			errCh <- err
+			return
+		}
+
+		err = session.Wait()
+		if err != nil {
+			errCh <- err
+			return
+		}
+	}()
+
+	if a.Timeout > 0 {
+		var cancel context.CancelFunc
+		ctx, cancel = context.WithTimeout(ctx, a.Timeout)
+		defer cancel()
+	}
+
+	if err := wait(&wg, ctx); err != nil {
+		return nil, err
+	}
+
+	finalErr := <-errCh
+	close(errCh)
+	return fileInfos, finalErr
+}
+
+func (a *Client) Close() {
+	a.closeHandler.Close()
+}
diff --git a/vendor/github.com/bramvdbogaerde/go-scp/configurer.go b/vendor/github.com/bramvdbogaerde/go-scp/configurer.go
new file mode 100644
index 00000000..db1d9f6b
--- /dev/null
+++ b/vendor/github.com/bramvdbogaerde/go-scp/configurer.go
@@ -0,0 +1,83 @@
+/* Copyright (c) 2020 Bram Vandenbogaerde
+ * You may use, distribute or modify this code under the
+ * terms of the Mozilla Public License 2.0, which is distributed
+ * along with the source code.
+ */
+
+package scp
+
+import (
+	"time"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// ClientConfigurer a struct containing all the configuration options
+// used by an scp client.
+type ClientConfigurer struct {
+	host         string
+	clientConfig *ssh.ClientConfig
+	session      *ssh.Session
+	timeout      time.Duration
+	remoteBinary string
+	sshClient    *ssh.Client
+}
+
+// NewConfigurer creates a new client configurer.
+// It takes the required parameters: the host and the ssh.ClientConfig and
+// returns a configurer populated with the default values for the optional
+// parameters.
+//
+// These optional parameters can be set by using the methods provided on the
+// ClientConfigurer struct.
+func NewConfigurer(host string, config *ssh.ClientConfig) *ClientConfigurer {
+	return &ClientConfigurer{
+		host:         host,
+		clientConfig: config,
+		timeout:      0, // no timeout by default
+		remoteBinary: "scp",
+	}
+}
+
+// RemoteBinary sets the path of the location of the remote scp binary
+// Defaults to: /usr/bin/scp.
+func (c *ClientConfigurer) RemoteBinary(path string) *ClientConfigurer {
+	c.remoteBinary = path
+	return c
+}
+
+// Host alters the host of the client connects to.
+func (c *ClientConfigurer) Host(host string) *ClientConfigurer {
+	c.host = host
+	return c
+}
+
+// Timeout Changes the connection timeout.
+// Defaults to one minute.
+func (c *ClientConfigurer) Timeout(timeout time.Duration) *ClientConfigurer {
+	c.timeout = timeout
+	return c
+}
+
+// ClientConfig alters the ssh.ClientConfig.
+func (c *ClientConfigurer) ClientConfig(config *ssh.ClientConfig) *ClientConfigurer {
+	c.clientConfig = config
+	return c
+}
+
+func (c *ClientConfigurer) SSHClient(sshClient *ssh.Client) *ClientConfigurer {
+	c.sshClient = sshClient
+	return c
+}
+
+// Create builds a client with the configuration stored within the ClientConfigurer.
+func (c *ClientConfigurer) Create() Client {
+	return Client{
+		Host:         c.host,
+		ClientConfig: c.clientConfig,
+		Timeout:      c.timeout,
+		RemoteBinary: c.remoteBinary,
+		sshClient:    c.sshClient,
+		closeHandler: EmptyHandler{},
+	}
+}
diff --git a/vendor/github.com/bramvdbogaerde/go-scp/protocol.go b/vendor/github.com/bramvdbogaerde/go-scp/protocol.go
new file mode 100644
index 00000000..6871c29e
--- /dev/null
+++ b/vendor/github.com/bramvdbogaerde/go-scp/protocol.go
@@ -0,0 +1,206 @@
+/* Copyright (c) 2021 Bram Vandenbogaerde And Contributors
+ * You may use, distribute or modify this code under the
+ * terms of the Mozilla Public License 2.0, which is distributed
+ * along with the source code.
+ */
+
+package scp
+
+import (
+	"bufio"
+	"errors"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+)
+
+type ResponseType = byte
+
+const (
+	Ok      ResponseType = 0
+	Warning ResponseType = 1
+	Error   ResponseType = 2
+	Create  ResponseType = 'C'
+	Time    ResponseType = 'T'
+)
+
+// ParseResponse reads from the given reader (assuming it is the output of the remote) and parses it into a Response structure.
+func ParseResponse(reader io.Reader, writer io.Writer) (*FileInfos, error) {
+	fileInfos := NewFileInfos()
+
+	buffer := make([]uint8, 1)
+	_, err := reader.Read(buffer)
+	if err != nil {
+		return fileInfos, err
+	}
+
+	responseType := buffer[0]
+	message := ""
+	if responseType > 0 {
+		bufferedReader := bufio.NewReader(reader)
+		message, err = bufferedReader.ReadString('\n')
+		if err != nil {
+			return fileInfos, err
+		}
+
+		if responseType == Warning || responseType == Error {
+			return fileInfos, errors.New(message)
+		}
+
+		// Exit early because we're only interested in the ok response
+		if responseType == Ok {
+			return fileInfos, nil
+		}
+
+		if !(responseType == Create || responseType == Time) {
+			return fileInfos, errors.New(
+				fmt.Sprintf(
+					"Message does not follow scp protocol: %s\n Cmmmm <length> <filename> or T<mtime> 0 <atime> 0",
+					message,
+				),
+			)
+		}
+
+		if responseType == Time {
+			err = ParseFileTime(message, fileInfos)
+			if err != nil {
+				return nil, err
+			}
+
+			// A custom ssh server can send both time, permissions and size information at once
+			// without needing an Ack response. Example: wish from charmbracelet while using their default scp implementation
+			// If the buffer is empty, then it's likely the default implementation for ssh, so send Ack
+			if bufferedReader.Buffered() == 0 {
+				err = Ack(writer)
+				if err != nil {
+					return fileInfos, err
+				}
+			}
+
+			message, err = bufferedReader.ReadString('\n')
+
+			if err != nil {
+				return fileInfos, err
+			}
+
+			responseType = message[0]
+		}
+
+		if responseType == Create {
+			err = ParseFileInfos(message, fileInfos)
+			if err != nil {
+				return nil, err
+			}
+		}
+	}
+
+	return fileInfos, nil
+}
+
+type FileInfos struct {
+	Message     string
+	Filename    string
+	Permissions uint32
+	Size        int64
+	Atime       int64
+	Mtime       int64
+}
+
+func NewFileInfos() *FileInfos {
+	return &FileInfos{}
+}
+
+func (fileInfos *FileInfos) Update(new *FileInfos) {
+	if new == nil {
+		return
+	}
+	if new.Filename != "" {
+		fileInfos.Filename = new.Filename
+	}
+	if new.Permissions != 0 {
+		fileInfos.Permissions = new.Permissions
+	}
+	if new.Size != 0 {
+		fileInfos.Size = new.Size
+	}
+	if new.Atime != 0 {
+		fileInfos.Atime = new.Atime
+	}
+	if new.Mtime != 0 {
+		fileInfos.Mtime = new.Mtime
+	}
+}
+
+func ParseFileInfos(message string, fileInfos *FileInfos) error {
+	processMessage := strings.ReplaceAll(message, "\n", "")
+	parts := strings.Split(processMessage, " ")
+	if len(parts) < 3 {
+		return errors.New("unable to parse Chmod protocol")
+	}
+
+	permissions, err := strconv.ParseUint(parts[0][1:], 0, 32)
+	if err != nil {
+		return err
+	}
+
+	size, err := strconv.ParseInt(parts[1], 10, 64)
+	if err != nil {
+		return err
+	}
+
+	fileInfos.Update(&FileInfos{
+		Filename:    parts[2],
+		Permissions: uint32(permissions),
+		Size:        int64(size),
+	})
+
+	return nil
+}
+
+func ParseFileTime(
+	message string,
+	fileInfos *FileInfos,
+) error {
+	processMessage := strings.ReplaceAll(message, "\n", "")
+	parts := strings.Split(processMessage, " ")
+	if len(parts) < 3 {
+		return errors.New("unable to parse Time protocol")
+	}
+
+	if len(parts[0]) != 10 {
+		return errors.New("length of ATime is not 10")
+	}
+	mTime, err := strconv.Atoi(parts[0][0:10])
+	if err != nil {
+		return errors.New("unable to parse ATime component of message")
+	}
+
+	if len(parts[2]) != 10 {
+		return errors.New("length of MTime is not 10")
+	}
+	aTime, err := strconv.Atoi(parts[2][0:10])
+	if err != nil {
+		return errors.New("unable to parse MTime component of message")
+	}
+
+	fileInfos.Update(&FileInfos{
+		Atime: int64(aTime),
+		Mtime: int64(mTime),
+	})
+	return nil
+}
+
+// Ack writes an `Ack` message to the remote, does not await its response, a seperate call to ParseResponse is
+// therefore required to check if the acknowledgement succeeded.
+func Ack(writer io.Writer) error {
+	var msg = []byte{0}
+	n, err := writer.Write(msg)
+	if err != nil {
+		return err
+	}
+	if n < len(msg) {
+		return errors.New("failed to write ack buffer")
+	}
+	return nil
+}
diff --git a/vendor/github.com/bramvdbogaerde/go-scp/scp.go b/vendor/github.com/bramvdbogaerde/go-scp/scp.go
new file mode 100644
index 00000000..837ccaa8
--- /dev/null
+++ b/vendor/github.com/bramvdbogaerde/go-scp/scp.go
@@ -0,0 +1,37 @@
+/* Copyright (c) 2021 Bram Vandenbogaerde
+ * You may use, distribute or modify this code under the
+ * terms of the Mozilla Public License 2.0, which is distributed
+ * along with the source code.
+ */
+
+// Package scp.
+// Simple scp package to copy files over SSH.
+package scp
+
+import (
+	"time"
+
+	"golang.org/x/crypto/ssh"
+)
+
+// NewClient returns a new scp.Client with provided host and ssh.clientConfig.
+func NewClient(host string, config *ssh.ClientConfig) Client {
+	return NewConfigurer(host, config).Create()
+}
+
+// NewClientWithTimeout returns a new scp.Client with provides host, ssh.ClientConfig and timeout.
+// Deprecated: provide meaningful context to each "Copy*" function instead.
+func NewClientWithTimeout(host string, config *ssh.ClientConfig, timeout time.Duration) Client {
+	return NewConfigurer(host, config).Timeout(timeout).Create()
+}
+
+// NewClientBySSH returns a new scp.Client using an already existing established SSH connection.
+func NewClientBySSH(ssh *ssh.Client) (Client, error) {
+	return NewConfigurer("", nil).SSHClient(ssh).Create(), nil
+}
+
+// NewClientBySSHWithTimeout same as NewClientWithTimeout but uses an existing SSH client.
+// Deprecated: provide meaningful context to each "Copy*" function instead.
+func NewClientBySSHWithTimeout(ssh *ssh.Client, timeout time.Duration) (Client, error) {
+	return NewConfigurer("", nil).SSHClient(ssh).Timeout(timeout).Create(), nil
+}
diff --git a/vendor/github.com/bramvdbogaerde/go-scp/utils.go b/vendor/github.com/bramvdbogaerde/go-scp/utils.go
new file mode 100644
index 00000000..447775f1
--- /dev/null
+++ b/vendor/github.com/bramvdbogaerde/go-scp/utils.go
@@ -0,0 +1,25 @@
+/* Copyright (c) 2021 Bram Vandenbogaerde And Contributors
+ * You may use, distribute or modify this code under the
+ * terms of the Mozilla Public License 2.0, which is distributed
+ * along with the source code.
+ */
+
+package scp
+
+import "io"
+
+// CopyN an adaptation of io.CopyN that keeps reading if it did not return
+// a sufficient amount of bytes.
+func CopyN(writer io.Writer, src io.Reader, size int64) (int64, error) {
+	var total int64
+	total = 0
+	for total < size {
+		n, err := io.CopyN(writer, src, size)
+		if err != nil {
+			return 0, err
+		}
+		total += n
+	}
+
+	return total, nil
+}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 6638a4e1..10fb29ac 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -58,6 +58,9 @@ github.com/beorn7/perks/quantile
 # github.com/blang/semver/v4 v4.0.0
 ## explicit; go 1.14
 github.com/blang/semver/v4
+# github.com/bramvdbogaerde/go-scp v1.5.0
+## explicit; go 1.21
+github.com/bramvdbogaerde/go-scp
 # github.com/cenkalti/backoff/v4 v4.2.1
 ## explicit; go 1.18
 github.com/cenkalti/backoff/v4


Test patch:
diff --git a/controllers/controllers_suite_test.go b/controllers/controllers_suite_test.go
index f398901e..9a9a5f54 100644
--- a/controllers/controllers_suite_test.go
+++ b/controllers/controllers_suite_test.go
@@ -88,10 +88,11 @@ var _ = BeforeSuite(func() {
 	}).SetupWithManager(ctx, testEnv.Manager, controller.Options{})).To(Succeed())
 
 	Expect((&HetznerBareMetalHostReconciler{
-		Client:             testEnv.Manager.GetClient(),
-		APIReader:          testEnv.Manager.GetAPIReader(),
-		RobotClientFactory: testEnv.RobotClientFactory,
-		SSHClientFactory:   testEnv.SSHClientFactory,
+		Client:              testEnv.Manager.GetClient(),
+		APIReader:           testEnv.Manager.GetAPIReader(),
+		RobotClientFactory:  testEnv.RobotClientFactory,
+		SSHClientFactory:    testEnv.SSHClientFactory,
+		PreProvisionCommand: "dummy-pre-provision-command",
 	}).SetupWithManager(ctx, testEnv.Manager, controller.Options{})).To(Succeed())
 
 	Expect((&HetznerBareMetalMachineReconciler{
diff --git a/controllers/hetznerbaremetalhost_controller_test.go b/controllers/hetznerbaremetalhost_controller_test.go
index 9c77fc87..8e9af73c 100644
--- a/controllers/hetznerbaremetalhost_controller_test.go
+++ b/controllers/hetznerbaremetalhost_controller_test.go
@@ -348,6 +348,9 @@ var _ = Describe("HetznerBareMetalHostReconciler", func() {
 						"should-state", infrav1.StateImageInstalling)
 					return false
 				}, 10*time.Second).Should(BeTrue())
+
+				By("Checking if pre-provision-command was executed")
+				rescueSSHClient.AssertCalled(GinkgoT(), "ExecutePreProvisionCommand", mock.Anything, mock.Anything)
 			})
 		})
 
@@ -898,6 +901,7 @@ name="eth0" model="Realtek Semiconductor Co., Ltd. RTL8111/8168/8411 PCI Express
 	sshClient.On("Reboot").Return(sshclient.Output{})
 	sshClient.On("GetCloudInitOutput").Return(sshclient.Output{StdOut: "dummy content of /var/log/cloud-init-output.log"})
 	sshClient.On("DetectLinuxOnAnotherDisk", mock.Anything).Return(sshclient.Output{})
+	sshClient.On("ExecutePreProvisionCommand", mock.Anything, mock.Anything).Return(0, "", nil)
 	sshClient.On("GetInstallImageState").Return(sshclient.InstallImageStateFinished, nil)
 	sshClient.On("GetResultOfInstallImage").Return(hostpkg.PostInstallScriptFinished, nil)
 }
diff --git a/pkg/services/baremetal/client/ssh/ssh_client_test.go b/pkg/services/baremetal/client/ssh/ssh_client_test.go
index 6281269f..8ea4aca3 100644
--- a/pkg/services/baremetal/client/ssh/ssh_client_test.go
+++ b/pkg/services/baremetal/client/ssh/ssh_client_test.go
@@ -18,8 +18,11 @@ limitations under the License.
 package sshclient
 
 import (
+	"context"
 	_ "embed"
 	"fmt"
+	"os"
+	"path/filepath"
 	"testing"
 
 	"github.com/stretchr/testify/require"
@@ -102,3 +105,24 @@ func TestOutput_String(t *testing.T) {
 		Err:    fmt.Errorf("some err"),
 	}, "mystdout. Stderr: mystderr. Err: some err")
 }
+
+func Test_ExecutePreProvisionCommand_withRealServer(t *testing.T) {
+	// This test is disabled by default because it requires a real server to be up and running.
+	// It is useful to enable it when debugging scp issues.
+	t.SkipNow()
+
+	ctx := context.Background()
+	pk, err := os.ReadFile(filepath.Join(os.Getenv("HOME"), ".ssh", "id_rsa"))
+	require.NoError(t, err)
+
+	c := sshClient{
+		privateSSHKey: string(pk),
+		ip:            "178.63.61.147",
+		port:          22,
+	}
+
+	exitStatus, output, err := c.ExecutePreProvisionCommand(ctx, "/usr/bin/hostname")
+	require.NoError(t, err)
+	require.Equal(t, 0, exitStatus)
+	require.Equal(t, "hz1", output)
+}