Repository: metal3-io/baremetal-operator

Issue:
No handling of validation errors for image (e.g. missing checksum)
For example, if you set `image.url` without a checksum, the validation will pass until Ironic fails with

```
  errorMessage: 'host validation error: Node 2a6456cd-f6cf-4927-b68e-82579cdb4ee7
    failed to validate deploy image info. Some parameters were missing. Missing are:
    [''instance_info.image_source'']'
```

I think that's because `setDirectDeployUpdateOptsForNode` does nothing when `imageData.GetChecksum()` returns `!ok`.

I'd expect the validation to happen somewhere earlier, but the only other place where `GetChecksum` is used is `ironicHasSameImage`, and it also does not have any error handling.

We need a much better error message. Image source is provided by the user, the checksum isn't.

/kind bug

Discussion:
@dtantsur: /assign
/triage accepted

Solution patch:
diff --git a/.golangci.yaml b/.golangci.yaml
index b22d8759..33892735 100644
--- a/.golangci.yaml
+++ b/.golangci.yaml
@@ -65,7 +65,7 @@ linters:
   - noctx
   - nolintlint
   - nosprintfhostport
-  #- perfsprint
+  - perfsprint
   - prealloc
   - predeclared
   - reassign
diff --git a/apis/metal3.io/v1alpha1/baremetalhost_types.go b/apis/metal3.io/v1alpha1/baremetalhost_types.go
index e7fbb7a1..266ca5e1 100644
--- a/apis/metal3.io/v1alpha1/baremetalhost_types.go
+++ b/apis/metal3.io/v1alpha1/baremetalhost_types.go
@@ -16,6 +16,9 @@ limitations under the License.
 package v1alpha1
 
 import (
+	"errors"
+	"fmt"
+	"strings"
 	"time"
 
 	corev1 "k8s.io/api/core/v1"
@@ -1118,21 +1121,23 @@ func (host *BareMetalHost) OperationMetricForState(operation ProvisioningState)
 	return
 }
 
+var supportedChecksums = strings.Join([]string{string(AutoChecksum), string(MD5), string(SHA256), string(SHA512)}, ", ")
+
 // GetChecksum method returns the checksum of an image.
-func (image *Image) GetChecksum() (checksum, checksumType string, ok bool) {
+func (image *Image) GetChecksum() (checksum, checksumType string, err error) {
 	if image == nil {
-		return "", "", false
+		return "", "", errors.New("image is not provided")
 	}
 
 	if image.DiskFormat != nil && *image.DiskFormat == "live-iso" {
 		// Checksum is not required for live-iso
-		ok = true
-		return "", "", ok
+		return "", "", nil
 	}
 
+	// FIXME(dtantsur): Ironic supports oci:// images with an embedded checksum
 	if image.Checksum == "" {
 		// Return empty if checksum is not provided
-		return "", "", false
+		return "", "", errors.New("checksum is required for normal images")
 	}
 
 	switch image.ChecksumType {
@@ -1141,12 +1146,11 @@ func (image *Image) GetChecksum() (checksum, checksumType string, ok bool) {
 	case "", AutoChecksum:
 		// No type, let Ironic detect
 	default:
-		return "", "", false
+		return "", "", fmt.Errorf("unknown checksumType %s, supported are %s", image.ChecksumType, supportedChecksums)
 	}
 
 	checksum = image.Checksum
-	ok = true
-	return checksum, checksumType, ok
+	return checksum, checksumType, nil
 }
 
 // +kubebuilder:object:root=true
diff --git a/apis/metal3.io/v1alpha1/firmwareschema_types.go b/apis/metal3.io/v1alpha1/firmwareschema_types.go
index d9d020c4..036e00e8 100644
--- a/apis/metal3.io/v1alpha1/firmwareschema_types.go
+++ b/apis/metal3.io/v1alpha1/firmwareschema_types.go
@@ -88,7 +88,7 @@ func (schema *SettingSchema) Validate(name string, value intstr.IntOrString) err
 				return nil
 			}
 		}
-		return SchemaSettingError{name: name, message: fmt.Sprintf("unknown enumeration value - %s", value.String())}
+		return SchemaSettingError{name: name, message: "unknown enumeration value - " + value.String()}
 
 	case "Integer":
 		if value.Type == intstr.String {
@@ -118,7 +118,7 @@ func (schema *SettingSchema) Validate(name string, value intstr.IntOrString) err
 		if value.String() == "true" || value.String() == "false" {
 			return nil
 		}
-		return SchemaSettingError{name: name, message: fmt.Sprintf("%s is not a boolean", value.String())}
+		return SchemaSettingError{name: name, message: value.String() + " is not a boolean"}
 
 	case "Password":
 		// Prevent sets of password types
@@ -130,7 +130,7 @@ func (schema *SettingSchema) Validate(name string, value intstr.IntOrString) err
 
 	default:
 		// Unexpected attribute type
-		return SchemaSettingError{name: name, message: fmt.Sprintf("unexpected attribute type %s", schema.AttributeType)}
+		return SchemaSettingError{name: name, message: "unexpected attribute type " + schema.AttributeType}
 	}
 }
 
diff --git a/internal/controller/metal3.io/baremetalhost_controller.go b/internal/controller/metal3.io/baremetalhost_controller.go
index d04bb618..979911e4 100644
--- a/internal/controller/metal3.io/baremetalhost_controller.go
+++ b/internal/controller/metal3.io/baremetalhost_controller.go
@@ -906,7 +906,7 @@ func (r *BareMetalHostReconciler) registerHost(prov provisioner.Provisioner, inf
 	// Create the hostFirmwareComponents resource with same host name/namespace if it doesn't exist
 	if info.host.Name != "" {
 		if !info.host.DeletionTimestamp.IsZero() {
-			info.log.Info(fmt.Sprintf("will not attempt to create new hostFirmwareSettings and hostFirmwareComponents in %s", info.host.Namespace))
+			info.log.Info("will not attempt to create new hostFirmwareSettings and hostFirmwareComponents in " + info.host.Namespace)
 		} else {
 			if err = r.createHostFirmwareSettings(info); err != nil {
 				info.log.Info("failed creating hostfirmwaresettings")
@@ -1119,7 +1119,7 @@ func (r *BareMetalHostReconciler) matchProfile(info *reconcileInfo) (dirty bool,
 	if info.host.SetHardwareProfile(hardwareProfile) {
 		dirty = true
 		info.log.Info("updating hardware profile", "profile", hardwareProfile)
-		info.publishEvent("ProfileSet", fmt.Sprintf("Hardware profile set: %s", hardwareProfile))
+		info.publishEvent("ProfileSet", "Hardware profile set: "+hardwareProfile)
 	}
 
 	hintsDirty, err := updateRootDeviceHints(info.host, info)
diff --git a/internal/controller/metal3.io/dataimage_controller.go b/internal/controller/metal3.io/dataimage_controller.go
index 01504d26..fec76495 100644
--- a/internal/controller/metal3.io/dataimage_controller.go
+++ b/internal/controller/metal3.io/dataimage_controller.go
@@ -163,8 +163,22 @@ func (r *DataImageReconciler) Reconcile(ctx context.Context, req ctrl.Request) (
 		return ctrl.Result{Requeue: true}, nil
 	}
 
-	// If the associated BMH is detached, keep requeuing till the annotation is removed
 	if hasDetachedAnnotation(bmh) {
+		// In detached state, if dataImage deletion is requested, then we remove the
+		// finalizer without checking for dataImage attached status
+		if !di.DeletionTimestamp.IsZero() {
+			reqLogger.Info("dataImage deletion requested in detached state, removing finalizer")
+
+			di.Finalizers = utils.FilterStringFromList(
+				di.Finalizers, metal3api.DataImageFinalizer)
+
+			if err := r.Update(ctx, di); err != nil {
+				return ctrl.Result{Requeue: true, RequeueAfter: dataImageRetryDelay}, fmt.Errorf("failed to update resource after remove finalizer, %w", err)
+			}
+			return ctrl.Result{}, nil
+		}
+
+		// If the associated BMH is detached, keep requeuing till the annotation is removed
 		reqLogger.Info("the host is detached, not running reconciler")
 		return ctrl.Result{Requeue: true, RequeueAfter: dataImageUnmanagedRetryDelay}, nil
 	}
diff --git a/internal/controller/metal3.io/errors.go b/internal/controller/metal3.io/errors.go
index 713e384a..fffa064b 100644
--- a/internal/controller/metal3.io/errors.go
+++ b/internal/controller/metal3.io/errors.go
@@ -11,8 +11,7 @@ type EmptyBMCAddressError struct {
 }
 
 func (e EmptyBMCAddressError) Error() string {
-	return fmt.Sprintf("Empty BMC address %s",
-		e.message)
+	return "Empty BMC address " + e.message
 }
 
 // EmptyBMCSecretError is returned when the BMC secret
@@ -22,8 +21,7 @@ type EmptyBMCSecretError struct {
 }
 
 func (e EmptyBMCSecretError) Error() string {
-	return fmt.Sprintf("No BMC CredentialsName defined %s",
-		e.message)
+	return "No BMC CredentialsName defined " + e.message
 }
 
 // ResolveBMCSecretRefError is returned when the BMC secret
@@ -33,8 +31,7 @@ type ResolveBMCSecretRefError struct {
 }
 
 func (e ResolveBMCSecretRefError) Error() string {
-	return fmt.Sprintf("BMC CredentialsName secret doesn't exist %s",
-		e.message)
+	return "BMC CredentialsName secret doesn't exist " + e.message
 }
 
 // NoDataInSecretError is returned when host configuration
diff --git a/internal/controller/metal3.io/host_state_machine.go b/internal/controller/metal3.io/host_state_machine.go
index f321da90..561a9665 100644
--- a/internal/controller/metal3.io/host_state_machine.go
+++ b/internal/controller/metal3.io/host_state_machine.go
@@ -243,6 +243,18 @@ func (hsm *hostStateMachine) checkInitiateDelete(log logr.Logger) bool {
 		} else {
 			hsm.NextState = metal3api.StateDeprovisioning
 		}
+	case metal3api.StateExternallyProvisioned:
+		if hsm.Host.OperationalStatus() == metal3api.OperationalStatusDetached {
+			if delayDeleteForDetachedHost(hsm.Host) {
+				log.Info("Delaying detached host deletion")
+				deleteDelayedForDetached.Inc()
+				return false
+			}
+			// We cannot power off a detached host.  Skip to delete.
+			hsm.NextState = metal3api.StateDeleting
+		} else {
+			hsm.NextState = metal3api.StatePoweringOffBeforeDelete
+		}
 	case metal3api.StateDeprovisioning:
 		if hsm.Host.Status.ErrorType == metal3api.RegistrationError && hsm.Host.Status.ErrorCount > 3 {
 			hsm.NextState = metal3api.StateDeleting
diff --git a/internal/controller/metal3.io/hostfirmwaresettings_controller.go b/internal/controller/metal3.io/hostfirmwaresettings_controller.go
index 2f3fd9f3..78bc88da 100644
--- a/internal/controller/metal3.io/hostfirmwaresettings_controller.go
+++ b/internal/controller/metal3.io/hostfirmwaresettings_controller.go
@@ -19,6 +19,8 @@ package controllers
 import (
 	"context"
 	"crypto/sha256"
+	"encoding/hex"
+	"errors"
 	"fmt"
 	"reflect"
 	"sort"
@@ -381,31 +383,31 @@ func (r *HostFirmwareSettingsReconciler) updateEventHandler(e event.UpdateEvent)
 
 // Validate the HostFirmwareSetting Spec against the schema.
 func (r *HostFirmwareSettingsReconciler) validateHostFirmwareSettings(info *rInfo, status *metal3api.HostFirmwareSettingsStatus, schema *metal3api.FirmwareSchema) []error {
-	var errors []error
+	var errs []error
 
 	for name, val := range info.hfs.Spec.Settings {
 		// Prohibit any Spec settings with "Password"
 		if strings.Contains(name, "Password") {
-			errors = append(errors, fmt.Errorf("cannot set Password field"))
+			errs = append(errs, errors.New("cannot set Password field"))
 			continue
 		}
 
 		// The setting must be in the Status
 		if _, ok := status.Settings[name]; !ok {
-			errors = append(errors, fmt.Errorf("setting %s is not in the Status field", name))
+			errs = append(errs, fmt.Errorf("setting %s is not in the Status field", name))
 			continue
 		}
 
 		// check validity of updated value
 		if schema != nil {
 			if err := schema.ValidateSetting(name, val, schema.Spec.Schema); err != nil {
-				errors = append(errors, err)
+				errs = append(errs, err)
 			}
 		}
 	}
 
-	if len(errors) > 0 {
-		return errors
+	if len(errs) > 0 {
+		return errs
 	}
 
 	return nil
@@ -468,7 +470,7 @@ func GetSchemaName(schema map[string]metal3api.SettingSchema) string {
 
 	h := sha256.New()
 	fmt.Fprintf(h, "%v", hashkeys)
-	hash := fmt.Sprintf("%x", h.Sum(nil))[:8]
+	hash := hex.EncodeToString(h.Sum(nil))[:8]
 
 	return "schema-" + hash
 }
diff --git a/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation.go b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation.go
index 42317923..594c136b 100644
--- a/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation.go
+++ b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation.go
@@ -73,8 +73,8 @@ func (webhook *BareMetalHost) validateHost(host *metal3api.BareMetalHost) []erro
 	}
 
 	if host.Spec.Image != nil {
-		if err := validateImageURL(host.Spec.Image.URL); err != nil {
-			errs = append(errs, err)
+		if err := validateImage(host.Spec.Image); err != nil {
+			errs = append(errs, err...)
 		}
 	}
 
@@ -250,13 +250,19 @@ func validateStatusAnnotation(statusAnnotation string) error {
 	return nil
 }
 
-func validateImageURL(imageURL string) error {
-	_, err := url.ParseRequestURI(imageURL)
+func validateImage(image *metal3api.Image) []error {
+	var errs []error
+
+	_, err := url.ParseRequestURI(image.URL)
 	if err != nil {
-		return fmt.Errorf("image URL %s is invalid: %w", imageURL, err)
+		errs = append(errs, fmt.Errorf("image URL %s is invalid: %w", image.URL, err))
 	}
 
-	return nil
+	if _, _, err = image.GetChecksum(); err != nil {
+		errs = append(errs, err)
+	}
+
+	return errs
 }
 
 func validateRootDeviceHints(rdh *metal3api.RootDeviceHints) error {
@@ -376,7 +382,7 @@ func (webhook *BareMetalHost) validateCrossNamespaceSecretReferences(host *metal
 
 func validatePowerStatus(host *metal3api.BareMetalHost) error {
 	if host.Spec.DisablePowerOff && !host.Spec.Online {
-		return fmt.Errorf("node can't simultaneously have online set to false and have power off disabled")
+		return errors.New("node can't simultaneously have online set to false and have power off disabled")
 	}
 	return nil
 }
diff --git a/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_webhook.go b/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_webhook.go
index 4788b358..587dbec1 100644
--- a/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_webhook.go
+++ b/internal/webhooks/metal3.io/v1alpha1/bmceventsubscription_webhook.go
@@ -16,6 +16,7 @@ package webhooks
 
 import (
 	"context"
+	"errors"
 	"fmt"
 
 	metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
@@ -71,7 +72,7 @@ func (webhook *BMCEventSubscription) ValidateUpdate(_ context.Context, oldObj, n
 	}
 
 	if newBMCES.Spec != oldBMCES.Spec {
-		return nil, fmt.Errorf("subscriptions cannot be updated, please recreate it")
+		return nil, errors.New("subscriptions cannot be updated, please recreate it")
 	}
 
 	return nil, nil
diff --git a/main.go b/main.go
index b8e519c0..ccdb60bf 100644
--- a/main.go
+++ b/main.go
@@ -78,11 +78,11 @@ func init() {
 }
 
 func printVersion() {
-	setupLog.Info(fmt.Sprintf("Go Version: %s", runtime.Version()))
+	setupLog.Info("Go Version: " + runtime.Version())
 	setupLog.Info(fmt.Sprintf("Go OS/Arch: %s/%s", runtime.GOOS, runtime.GOARCH))
-	setupLog.Info(fmt.Sprintf("Git commit: %s", version.Commit))
-	setupLog.Info(fmt.Sprintf("Build time: %s", version.BuildTime))
-	setupLog.Info(fmt.Sprintf("Component: %s", version.String))
+	setupLog.Info("Git commit: " + version.Commit)
+	setupLog.Info("Build time: " + version.BuildTime)
+	setupLog.Info("Component: " + version.String)
 }
 
 func setupChecks(mgr ctrl.Manager) {
diff --git a/pkg/hardwareutils/bmc/errors.go b/pkg/hardwareutils/bmc/errors.go
index a1d96ff9..7fa1b99d 100644
--- a/pkg/hardwareutils/bmc/errors.go
+++ b/pkg/hardwareutils/bmc/errors.go
@@ -23,6 +23,5 @@ type CredentialsValidationError struct {
 }
 
 func (e CredentialsValidationError) Error() string {
-	return fmt.Sprintf("Validation error with BMC credentials: %s",
-		e.message)
+	return "Validation error with BMC credentials: " + e.message
 }
diff --git a/pkg/imageprovider/invalid.go b/pkg/imageprovider/invalid.go
index de837918..9f016bc4 100644
--- a/pkg/imageprovider/invalid.go
+++ b/pkg/imageprovider/invalid.go
@@ -1,15 +1,11 @@
 package imageprovider
 
-import (
-	"fmt"
-)
-
 type ImageBuildInvalid struct {
 	err error
 }
 
 func (ibf ImageBuildInvalid) Error() string {
-	return fmt.Sprintf("Cannot generate image: %s", ibf.err.Error())
+	return "Cannot generate image: " + ibf.err.Error()
 }
 
 func (ibf ImageBuildInvalid) Unwrap() error {
diff --git a/pkg/provisioner/ironic/clients/auth.go b/pkg/provisioner/ironic/clients/auth.go
index 2b3c5e6f..2ff63e12 100644
--- a/pkg/provisioner/ironic/clients/auth.go
+++ b/pkg/provisioner/ironic/clients/auth.go
@@ -1,7 +1,7 @@
 package clients
 
 import (
-	"fmt"
+	"errors"
 	"net/url"
 	"os"
 	"path"
@@ -63,9 +63,9 @@ func LoadAuth() (auth AuthConfig, err error) {
 	}
 
 	if auth.Username == "" {
-		err = fmt.Errorf("empty HTTP Basic Auth username")
+		err = errors.New("empty HTTP Basic Auth username")
 	} else if auth.Password == "" {
-		err = fmt.Errorf("empty HTTP Basic Auth password")
+		err = errors.New("empty HTTP Basic Auth password")
 	}
 	return
 }
@@ -84,7 +84,7 @@ func ConfigFromEndpointURL(endpointURL string) (endpoint string, auth AuthConfig
 		auth.Username = parsedURL.User.Username()
 		auth.Password, hasPasswd = parsedURL.User.Password()
 		if !hasPasswd {
-			err = fmt.Errorf("no password supplied for HTTP Basic Auth")
+			err = errors.New("no password supplied for HTTP Basic Auth")
 		}
 		parsedURL.User = nil
 	} else {
diff --git a/pkg/provisioner/ironic/devicehints/devicehints.go b/pkg/provisioner/ironic/devicehints/devicehints.go
index db575c2d..f058d57f 100644
--- a/pkg/provisioner/ironic/devicehints/devicehints.go
+++ b/pkg/provisioner/ironic/devicehints/devicehints.go
@@ -18,34 +18,34 @@ func MakeHintMap(source *metal3api.RootDeviceHints) map[string]string {
 
 	if source.DeviceName != "" {
 		if strings.HasPrefix(source.DeviceName, "/dev/disk/by-path/") {
-			hints["by_path"] = fmt.Sprintf("s== %s", source.DeviceName)
+			hints["by_path"] = "s== " + source.DeviceName
 		} else {
-			hints["name"] = fmt.Sprintf("s== %s", source.DeviceName)
+			hints["name"] = "s== " + source.DeviceName
 		}
 	}
 	if source.HCTL != "" {
-		hints["hctl"] = fmt.Sprintf("s== %s", source.HCTL)
+		hints["hctl"] = "s== " + source.HCTL
 	}
 	if source.Model != "" {
-		hints["model"] = fmt.Sprintf("<in> %s", source.Model)
+		hints["model"] = "<in> " + source.Model
 	}
 	if source.Vendor != "" {
-		hints["vendor"] = fmt.Sprintf("<in> %s", source.Vendor)
+		hints["vendor"] = "<in> " + source.Vendor
 	}
 	if source.SerialNumber != "" {
-		hints["serial"] = fmt.Sprintf("s== %s", source.SerialNumber)
+		hints["serial"] = "s== " + source.SerialNumber
 	}
 	if source.MinSizeGigabytes != 0 {
 		hints["size"] = fmt.Sprintf(">= %d", source.MinSizeGigabytes)
 	}
 	if source.WWN != "" {
-		hints["wwn"] = fmt.Sprintf("s== %s", source.WWN)
+		hints["wwn"] = "s== " + source.WWN
 	}
 	if source.WWNWithExtension != "" {
-		hints["wwn_with_extension"] = fmt.Sprintf("s== %s", source.WWNWithExtension)
+		hints["wwn_with_extension"] = "s== " + source.WWNWithExtension
 	}
 	if source.WWNVendorExtension != "" {
-		hints["wwn_vendor_extension"] = fmt.Sprintf("s== %s", source.WWNVendorExtension)
+		hints["wwn_vendor_extension"] = "s== " + source.WWNVendorExtension
 	}
 	switch {
 	case source.Rotational == nil:
diff --git a/pkg/provisioner/ironic/factory.go b/pkg/provisioner/ironic/factory.go
index 95cc21b4..30c1e5c4 100644
--- a/pkg/provisioner/ironic/factory.go
+++ b/pkg/provisioner/ironic/factory.go
@@ -138,7 +138,7 @@ func loadConfigFromEnv(havePreprovImgBuilder bool) (ironicConfig, error) {
 
 	if forcePersistentBootDevice := os.Getenv("LIVE_ISO_FORCE_PERSISTENT_BOOT_DEVICE"); forcePersistentBootDevice != "" {
 		if forcePersistentBootDevice != "Default" && forcePersistentBootDevice != "Always" && forcePersistentBootDevice != "Never" {
-			return c, fmt.Errorf("invalid value for variable LIVE_ISO_FORCE_PERSISTENT_BOOT_DEVICE, must be one of Default, Always or Never")
+			return c, errors.New("invalid value for variable LIVE_ISO_FORCE_PERSISTENT_BOOT_DEVICE, must be one of Default, Always or Never")
 		}
 		c.liveISOForcePersistentBootDevice = forcePersistentBootDevice
 	}
diff --git a/pkg/provisioner/ironic/ironic.go b/pkg/provisioner/ironic/ironic.go
index 4bc753f2..52392e40 100644
--- a/pkg/provisioner/ironic/ironic.go
+++ b/pkg/provisioner/ironic/ironic.go
@@ -138,8 +138,7 @@ func (p *ironicProvisioner) validateNode(ironicNode *nodes.Node) (errorMessage s
 		// We expect to see errors of this nature sometimes, so rather
 		// than reporting it as a reconcile error we record the error
 		// status on the host and return.
-		errorMessage = fmt.Sprintf("host validation error: %s",
-			strings.Join(validationErrors, "; "))
+		errorMessage = "host validation error: " + strings.Join(validationErrors, "; ")
 		return errorMessage, nil
 	}
 	return "", nil
@@ -458,7 +457,7 @@ func setDeployImage(config ironicConfig, accessDetails bmc.AccessDetails, hostIm
 			deployImageInfo[deployRamdiskKey] = hostImage.ImageURL
 			if hostImage.ExtraKernelParams != "" {
 				// Using %default% prevents overriding the config in ironic-image
-				deployImageInfo[kernelParamsKey] = fmt.Sprintf("%%default%% %s", hostImage.ExtraKernelParams)
+				deployImageInfo[kernelParamsKey] = "%default% " + hostImage.ExtraKernelParams
 			}
 			return deployImageInfo
 		}
@@ -588,9 +587,9 @@ func (p *ironicProvisioner) setLiveIsoUpdateOptsForNode(ironicNode *nodes.Node,
 }
 
 func (p *ironicProvisioner) setDirectDeployUpdateOptsForNode(ironicNode *nodes.Node, imageData *metal3api.Image, updater *clients.NodeUpdater) {
-	checksum, checksumType, ok := imageData.GetChecksum()
-	if !ok {
-		p.log.Info("image/checksum not found for host")
+	checksum, checksumType, err := imageData.GetChecksum()
+	if err != nil {
+		p.log.Info("image/checksum not found for host", "message", err)
 		return
 	}
 
@@ -626,9 +625,9 @@ func (p *ironicProvisioner) setDirectDeployUpdateOptsForNode(ironicNode *nodes.N
 func (p *ironicProvisioner) setCustomDeployUpdateOptsForNode(ironicNode *nodes.Node, imageData *metal3api.Image, updater *clients.NodeUpdater) {
 	var optValues clients.UpdateOptsData
 	if imageData != nil && imageData.URL != "" {
-		checksum, checksumType, ok := imageData.GetChecksum()
+		checksum, checksumType, err := imageData.GetChecksum()
 		// NOTE(dtantsur): all fields are optional for custom deploy
-		if ok {
+		if err == nil {
 			optValues = clients.UpdateOptsData{
 				"boot_iso":            nil,
 				"image_checksum":      nil,
@@ -755,7 +754,7 @@ func (p *ironicProvisioner) GetFirmwareComponents() ([]metal3api.FirmwareCompone
 	}
 
 	if !p.availableFeatures.HasFirmwareUpdates() {
-		return nil, fmt.Errorf("current ironic version does not support firmware updates")
+		return nil, errors.New("current ironic version does not support firmware updates")
 	}
 
 	// Setting to 2 since we only support bmc and bios
@@ -825,7 +824,7 @@ func (p *ironicProvisioner) setUpForProvisioning(ironicNode *nodes.Node, data pr
 		"deploy step", ironicNode.DeployStep,
 	)
 	p.publisher("ProvisioningStarted",
-		fmt.Sprintf("Image provisioning started for %s", data.Image.URL))
+		"Image provisioning started for "+data.Image.URL)
 	return result, nil
 }
 
@@ -878,8 +877,8 @@ func (p *ironicProvisioner) Adopt(data provisioner.AdoptData, restartOnFailure b
 				},
 			)
 		}
-		return operationFailed(fmt.Sprintf("Host adoption failed: %s",
-			ironicNode.LastError))
+		return operationFailed("Host adoption failed: " +
+			ironicNode.LastError)
 	case nodes.Active:
 		// Empty Fault means that maintenance was set manually, not by Ironic
 		if ironicNode.Maintenance && ironicNode.Fault == "" && data.State != metal3api.StateDeleting {
@@ -1230,6 +1229,11 @@ func (p *ironicProvisioner) Provision(data provisioner.ProvisionData, forceReboo
 		return transientError(err)
 	}
 
+	if err = validateProvisionData(data); err != nil {
+		errorMessage := "Validation failed: " + err.Error()
+		return operationFailed(errorMessage)
+	}
+
 	p.log.Info("provisioning image to host", "state", ironicNode.ProvisionState)
 
 	ironicHasSameImage := p.ironicHasSameImage(ironicNode, data.Image)
@@ -1252,8 +1256,7 @@ func (p *ironicProvisioner) Provision(data provisioner.ProvisionData, forceReboo
 				return retryAfterDelay(0)
 			}
 			p.log.Info("found error", "msg", ironicNode.LastError)
-			return operationFailed(fmt.Sprintf("Image provisioning failed: %s",
-				ironicNode.LastError))
+			return operationFailed("Image provisioning failed: " + ironicNode.LastError)
 		}
 		p.log.Info("recovering from previous failure")
 		if provResult, err = p.setUpForProvisioning(ironicNode, data); err != nil || provResult.Dirty || provResult.ErrorMessage != "" {
@@ -1316,7 +1319,7 @@ func (p *ironicProvisioner) Provision(data provisioner.ProvisionData, forceReboo
 	case nodes.Active:
 		// provisioning is done
 		p.publisher("ProvisioningComplete",
-			fmt.Sprintf("Image provisioning completed for %s", data.Image.URL))
+			"Image provisioning completed for "+data.Image.URL)
 		p.log.Info("finished provisioning")
 		return operationComplete()
 
@@ -1402,7 +1405,7 @@ func (p *ironicProvisioner) Deprovision(restartOnFailure bool) (result provision
 	case nodes.CleanFail:
 		if !restartOnFailure {
 			p.log.Info("cleaning failed", "lastError", ironicNode.LastError)
-			return operationFailed(fmt.Sprintf("Cleaning failed: %s", ironicNode.LastError))
+			return operationFailed("Cleaning failed: " + ironicNode.LastError)
 		}
 		p.log.Info("retrying cleaning")
 		if ironicNode.Maintenance {
@@ -1621,8 +1624,8 @@ func (p *ironicProvisioner) PowerOn(force bool) (result provisioner.Result, err
 		}
 		if ironicNode.LastError != "" && !force {
 			p.log.Info("PowerOn operation failed", "msg", ironicNode.LastError)
-			return operationFailed(fmt.Sprintf("PowerOn operation failed: %s",
-				ironicNode.LastError))
+			return operationFailed("PowerOn operation failed: " +
+				ironicNode.LastError)
 		}
 		return p.changePower(ironicNode, nodes.PowerOn)
 	}
diff --git a/pkg/provisioner/ironic/provision.go b/pkg/provisioner/ironic/provision.go
new file mode 100644
index 00000000..30726294
--- /dev/null
+++ b/pkg/provisioner/ironic/provision.go
@@ -0,0 +1,18 @@
+package ironic
+
+import (
+	"github.com/metal3-io/baremetal-operator/pkg/provisioner"
+)
+
+// validateProvisionData provides early validation for provisioning data.
+// The goal is to report errors as early as possible, before the node goes into
+// cleaning and then deployment.
+func validateProvisionData(data provisioner.ProvisionData) error {
+	if data.Image.URL != "" {
+		if _, _, err := data.Image.GetChecksum(); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/pkg/version/version.go b/pkg/version/version.go
index b2b69007..114e3f6f 100644
--- a/pkg/version/version.go
+++ b/pkg/version/version.go
@@ -1,8 +1,6 @@
 // Package version includes the version information.
 package version
 
-import "fmt"
-
 var (
 	// Raw is the string representation of the version. This will be replaced
 	// with the calculated version at build time.
@@ -10,7 +8,7 @@ var (
 	Raw = "was not built with version info"
 
 	// String is the human-friendly representation of the version.
-	String = fmt.Sprintf("metal3-io/baremetal-operator %s", Raw)
+	String = "metal3-io/baremetal-operator " + Raw
 
 	// Commit is the commit hash from which the software was built.
 	// Set via LDFLAGS in Makefile.
diff --git a/releasenotes/v0.11.0-alpha.0.md b/releasenotes/v0.11.0-alpha.0.md
new file mode 100644
index 00000000..1e4128ce
--- /dev/null
+++ b/releasenotes/v0.11.0-alpha.0.md
@@ -0,0 +1,76 @@
+<!-- markdownlint-disable no-inline-html line-length -->
+# Changes since v0.10.0
+
+## :warning: Breaking Changes
+
+- Remove webhooks from api folder (#2503)
+- Prevent changing ExternallyProvisioned in the webhook (#2472)
+
+## :sparkles: New Features
+
+- Bump CAPI to v1.11.0-alpha.0 and do CAPI v1beta2 related changes (#2527)
+- Allow disabling drivers that require a provisioning network (#2477)
+
+## :bug: Bug Fixes
+
+- Make firmware update handle only a subset of `Spec.Updates` (#2415)
+- Redfish: correctly handle missing system ID (#2418)
+
+## :book: Documentation
+
+- Add release-0.10 triggers to README.md (#2446)
+- update CII badge (#2459)
+
+## :seedling: Others
+
+- Bump github.com/go-viper/mapstructure/v2 from 2.2.1 to 2.3.0 in /test (#2544)
+- bump k8s.io/utils library (#2554)
+- bump 6 k8s libraries and controller-runtime (#2552)
+- Bump sigs.k8s.io/yaml from 1.4.0 to 1.5.0 (#2545)
+- Bump sigs.k8s.io/kustomize/kustomize/v5 from 5.6.0 to 5.7.0 in /hack/tools (#2548)
+- Bump sigs.k8s.io/kustomize/api from 0.19.0 to 0.20.0 in /test (#2550)
+- e2e: wait for detachment to actually happen (#2541)
+- Migrate to golangci-lint v2 (#2528)
+- Bump github.com/cert-manager/cert-manager from 1.18.0 to 1.18.1 in /test (#2534)
+- Bump CAPI to v1.11.0-alpha.1 (#2533)
+- Bump the kubernetes group to v0.32.6 (#2532)
+- Add validation to e2e ugrade config (#2497)
+- Enable variable shadowing check in govet linter (#2521)
+- Enable exhaustive linter (#2506)
+- Enable nilnil linter, fix warnings (#2514)
+- bump golang to 1.24.4 (#2522)
+- Bump github.com/cloudflare/circl from 1.3.7 to 1.6.1 in /test (#2518)
+- Use reusable link-checks and delete old link check (#2515)
+- Bump golang.org/x/crypto from 0.38.0 to 0.39.0 in /test (#2512)
+- Bump golang.org/x/crypto from 0.38.0 to 0.39.0 in /hack/tools (#2511)
+- Bump libvirt.org/go/libvirt from 1.11002.0 to 1.11004.0 in /test (#2513)
+- Bump go.etcd.io/etcd/client/pkg/v3 from 3.6.0 to 3.6.1 (#2510)
+- Bump github.com/go-logr/logr from 1.4.2 to 1.4.3 (#2499)
+- Bump the github-actions group with 2 updates (#2498)
+- Replace errors pkg with stdlib (#2439)
+- Fix optional e2e tests (#2493)
+- Enable dupl linter (#2435)
+- Bump the github-actions group with 5 updates (#2429)
+- Bump sigs.k8s.io/kind from 0.27.0 to 0.29.0 in /test (#2482)
+- Add e2e tests for release 0.10 (#2448)
+- Enable forcetypeassert linter (#2443)
+- Enable forbidigo linter (#2444)
+- make BMO periodics manually triggerable (#2488)
+- E2E: Override IRONIC_HTTP_URL (#2476)
+- Enable tagliatelle linter (#2436)
+- Bump golang.org/x/crypto from 0.37.0 to 0.38.0 in /test (#2453)
+- Bump golang.org/x/crypto from 0.37.0 to 0.38.0 in /hack/tools (#2452)
+- Bump golang.org/x/oauth2 from 0.29.0 to 0.30.0 in /hack/tools (#2451)
+- Add a simple e2e test on externallyProvisioned (#2471)
+- E2E: Temporary workaround for connection issues (#2454)
+- Update periodic workflows for release-0.10 (#2450)
+- enable mnd linter (#2438)
+- add dependabot config for release-0.10 (#2427)
+
+## :recycle: Superseded or Reverted
+
+- #2424,#2480,#2425,#2526,#2474,#2441,#2481
+
+The image for this release is: v0.11.0-alpha.0
+
+_Thanks to all our contributors!_ 😊



Test patch:
diff --git a/apis/metal3.io/v1alpha1/baremetalhost_types_test.go b/apis/metal3.io/v1alpha1/baremetalhost_types_test.go
index 242d73ae..e3b38407 100644
--- a/apis/metal3.io/v1alpha1/baremetalhost_types_test.go
+++ b/apis/metal3.io/v1alpha1/baremetalhost_types_test.go
@@ -464,7 +464,7 @@ func TestGetImageChecksum(t *testing.T) {
 	} {
 		t.Run(tc.Scenario, func(t *testing.T) {
 			_, checksumType, actual := tc.Host.Spec.Image.GetChecksum()
-			assert.Equal(t, tc.Expected, actual)
+			assert.Equal(t, tc.Expected, actual == nil)
 			if tc.Expected {
 				assert.Equal(t, tc.ExpectedType, checksumType)
 			}
diff --git a/internal/controller/metal3.io/host_state_machine_test.go b/internal/controller/metal3.io/host_state_machine_test.go
index b3a99c7c..e0c4ef22 100644
--- a/internal/controller/metal3.io/host_state_machine_test.go
+++ b/internal/controller/metal3.io/host_state_machine_test.go
@@ -251,6 +251,16 @@ func TestDetach(t *testing.T) {
 			// Should move to Deleting without any Deprovisioning
 			ExpectedState: metal3api.StateDeleting,
 		},
+		{
+			Scenario:                  "DeleteDetachedExternallyProvisionedHost",
+			Host:                      host(metal3api.StateExternallyProvisioned).SetOperationalStatus(metal3api.OperationalStatusDetached).setDeletion().withFinalizer().build(),
+			HasDetachedAnnotation:     true,
+			ExpectedDetach:            false,
+			ExpectedDirty:             true,
+			ExpectedOperationalStatus: metal3api.OperationalStatusDetached,
+			// Should move to Deleting without any Deprovisioning
+			ExpectedState: metal3api.StateDeleting,
+		},
 		{
 			Scenario:                  "ExternallyProvisionedHost",
 			Host:                      host(metal3api.StateExternallyProvisioned).SetExternallyProvisioned().build(),
diff --git a/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation_test.go b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation_test.go
index 609e15e5..ca1b2ad1 100644
--- a/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation_test.go
+++ b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation_test.go
@@ -21,6 +21,7 @@ import (
 	metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
 	corev1 "k8s.io/api/core/v1"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/utils/ptr"
 )
 
 func errorArrContains(out []error, want string) bool {
@@ -516,23 +517,92 @@ func TestValidateCreate(t *testing.T) {
 			wantedErr: "device name of root device hint must be a /dev/ path, not \"sda\"",
 		},
 		{
-			name: "invalidImageURL",
+			name: "validImageURL",
 			newBMH: &metal3api.BareMetalHost{
 				TypeMeta:   tm,
 				ObjectMeta: om,
 				Spec: metal3api.BareMetalHostSpec{
-					BMC: metal3api.BMCDetails{
-						Address:         "redfish://127.0.0.1",
-						CredentialsName: "test1",
+					Image: &metal3api.Image{
+						URL:      "https://example.com/image",
+						Checksum: "be254ebfd73e66ca91f6d91f5050aa2ee1ec4813ee65ba472f608ed340cbff09",
 					},
+				},
+			},
+			oldBMH: nil,
+		},
+		{
+			name: "validImageLiveISO",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
 					Image: &metal3api.Image{
-						URL: "test1",
+						URL:        "https://example.com/image",
+						DiskFormat: ptr.To("live-iso"),
+					},
+				},
+			},
+			oldBMH: nil,
+		},
+		{
+			name: "invalidImageURL",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					Image: &metal3api.Image{
+						URL:      "test1",
+						Checksum: "be254ebfd73e66ca91f6d91f5050aa2ee1ec4813ee65ba472f608ed340cbff09",
 					},
 				},
 			},
 			oldBMH:    nil,
 			wantedErr: "image URL test1 is invalid: parse \"test1\": invalid URI for request",
 		},
+		{
+			name: "emptyImageURL",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					Image: &metal3api.Image{
+						Checksum: "be254ebfd73e66ca91f6d91f5050aa2ee1ec4813ee65ba472f608ed340cbff09",
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "image URL  is invalid: parse \"\": empty url",
+		},
+		{
+			name: "imageNoChecksum",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					Image: &metal3api.Image{
+						URL: "https://example.com/image",
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "checksum is required for normal images",
+		},
+		{
+			name: "imageInvalidChecksum",
+			newBMH: &metal3api.BareMetalHost{
+				TypeMeta:   tm,
+				ObjectMeta: om,
+				Spec: metal3api.BareMetalHostSpec{
+					Image: &metal3api.Image{
+						URL:          "https://example.com/image",
+						Checksum:     "be254ebfd73e66ca91f6d91f5050aa2ee1ec4813ee65ba472f608ed340cbff09",
+						ChecksumType: "SHA42",
+					},
+				},
+			},
+			oldBMH:    nil,
+			wantedErr: "unknown checksumType SHA42, supported are auto, md5, sha256, sha512",
+		},
 		{
 			name: "validStatusAnnotation",
 			newBMH: &metal3api.BareMetalHost{
diff --git a/pkg/provisioner/ironic/clients/updateopts_test.go b/pkg/provisioner/ironic/clients/updateopts_test.go
index 8193f71c..fbe9fe3d 100644
--- a/pkg/provisioner/ironic/clients/updateopts_test.go
+++ b/pkg/provisioner/ironic/clients/updateopts_test.go
@@ -1,7 +1,6 @@
 package clients
 
 import (
-	"fmt"
 	"testing"
 
 	"github.com/go-logr/logr"
@@ -358,7 +357,7 @@ func TestGetUpdateOperation(t *testing.T) {
 
 	for _, c := range cases {
 		t.Run(c.Name, func(t *testing.T) {
-			path := fmt.Sprintf("test/%s", c.Field)
+			path := "test/" + c.Field
 			updateOp := getUpdateOperation(
 				c.Field, existingData,
 				c.NewValue,
diff --git a/pkg/provisioner/ironic/provision_test.go b/pkg/provisioner/ironic/provision_test.go
index d94066ce..173b5b30 100644
--- a/pkg/provisioner/ironic/provision_test.go
+++ b/pkg/provisioner/ironic/provision_test.go
@@ -34,6 +34,8 @@ func TestProvision(t *testing.T) {
 		name                   string
 		ironic                 *testserver.IronicMock
 		forceReboot            bool
+		image                  *metal3api.Image
+		customDeploy           *metal3api.CustomDeploy
 		expectedDirty          bool
 		expectedError          bool
 		expectedErrorMessage   string
@@ -96,6 +98,47 @@ func TestProvision(t *testing.T) {
 			expectedDirty:          true,
 			expectedProvisionState: nodes.TargetActive,
 		},
+		{
+			name: "available state: live ISO",
+			ironic: testserver.NewIronic(t).WithDefaultResponses().Node(nodes.Node{
+				ProvisionState: string(nodes.Available),
+				UUID:           nodeUUID,
+			}),
+			image: &metal3api.Image{
+				URL:        testImage.URL,
+				DiskFormat: ptr.To("live-iso"),
+			},
+			expectedRequestAfter:   10,
+			expectedDirty:          true,
+			expectedProvisionState: nodes.TargetActive,
+		},
+		{
+			name: "available state: custom deploy",
+			ironic: testserver.NewIronic(t).WithDefaultResponses().Node(nodes.Node{
+				ProvisionState: string(nodes.Available),
+				UUID:           nodeUUID,
+			}),
+			image: &metal3api.Image{},
+			customDeploy: &metal3api.CustomDeploy{
+				Method: "mine_memecoins",
+			},
+			expectedRequestAfter:   10,
+			expectedDirty:          true,
+			expectedProvisionState: nodes.TargetActive,
+		},
+		{
+			name: "available state: no checksum",
+			ironic: testserver.NewIronic(t).WithDefaultResponses().Node(nodes.Node{
+				ProvisionState: string(nodes.Available),
+				UUID:           nodeUUID,
+			}),
+			image: &metal3api.Image{
+				URL: testImage.URL,
+			},
+			expectedRequestAfter: 0,
+			expectedDirty:        false,
+			expectedErrorMessage: "Validation failed: checksum is required for normal images",
+		},
 		{
 			name: "active state",
 			ironic: testserver.NewIronic(t).Node(nodes.Node{
@@ -175,10 +218,15 @@ func TestProvision(t *testing.T) {
 				t.Fatalf("could not create provisioner: %s", err)
 			}
 
+			image := tc.image
+			if image == nil {
+				image = &testImage
+			}
 			result, err := prov.Provision(provisioner.ProvisionData{
-				Image:      testImage,
-				HostConfig: fixture.NewHostConfigData("testUserData", "test: NetworkData", "test: Meta"),
-				BootMode:   metal3api.DefaultBootMode,
+				Image:        *image,
+				CustomDeploy: tc.customDeploy,
+				HostConfig:   fixture.NewHostConfigData("testUserData", "test: NetworkData", "test: Meta"),
+				BootMode:     metal3api.DefaultBootMode,
 			}, tc.forceReboot)
 
 			assert.Equal(t, tc.expectedDirty, result.Dirty)
diff --git a/pkg/provisioner/ironic/testserver/ironic.go b/pkg/provisioner/ironic/testserver/ironic.go
index d9864e52..0241fe41 100644
--- a/pkg/provisioner/ironic/testserver/ironic.go
+++ b/pkg/provisioner/ironic/testserver/ironic.go
@@ -225,7 +225,7 @@ func (m *IronicMock) NoNode(name string) *IronicMock {
 
 // NodeError configures the server to return the specified error code for /v1/nodes/name.
 func (m *IronicMock) NodeError(name string, errorCode int) *IronicMock {
-	m.ErrorResponse(fmt.Sprintf("/v1/nodes/%s", name), errorCode)
+	m.ErrorResponse("/v1/nodes/"+name, errorCode)
 	return m
 }
 
diff --git a/pkg/provisioner/ironic/testserver/server.go b/pkg/provisioner/ironic/testserver/server.go
index c6502af4..9ba08877 100644
--- a/pkg/provisioner/ironic/testserver/server.go
+++ b/pkg/provisioner/ironic/testserver/server.go
@@ -7,6 +7,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"regexp"
+	"strconv"
 	"strings"
 	"testing"
 )
@@ -152,7 +153,7 @@ func (m *MockServer) ResponseJSON(pattern string, payload interface{}) *MockServ
 func (m *MockServer) ErrorResponse(pattern string, errorCode int) *MockServer {
 	m.t.Logf("%s: adding error response handler for %s", m.name, pattern)
 	m.mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {
-		m.logRequest(r, fmt.Sprintf("%d", errorCode))
+		m.logRequest(r, strconv.Itoa(errorCode))
 		http.Error(w, "An error", errorCode)
 	})
 	return m
diff --git a/test/e2e/e2e_config.go b/test/e2e/e2e_config.go
index 6600d8ba..21d481ef 100644
--- a/test/e2e/e2e_config.go
+++ b/test/e2e/e2e_config.go
@@ -178,7 +178,7 @@ func (c *Config) Validate() error {
 func (c *Config) GetIntervals(spec, key string) []interface{} {
 	intervals, ok := c.Intervals[fmt.Sprintf("%s/%s", spec, key)]
 	if !ok {
-		if intervals, ok = c.Intervals[fmt.Sprintf("default/%s", key)]; !ok {
+		if intervals, ok = c.Intervals["default/"+key]; !ok {
 			return nil
 		}
 	}
diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go
index f189d619..217b1beb 100644
--- a/test/e2e/e2e_suite_test.go
+++ b/test/e2e/e2e_suite_test.go
@@ -99,7 +99,7 @@ func serviceAccountToken() (string, error) {
 	}`
 
 	// Temporary file to store the token request
-	secretName := fmt.Sprintf("%s-token-request", serviceAccountName)
+	secretName := serviceAccountName + "-token-request"
 	tokenRequestFile := filepath.Join("/tmp", secretName) //nolint: gocritic
 	err := os.WriteFile(tokenRequestFile, []byte(tokenRequestRawString), os.FileMode(0o644))
 	if err != nil {
@@ -271,7 +271,7 @@ var _ = SynchronizedBeforeSuite(func() []byte {
 			"--namespace", namespace,
 			"--image=curlimages/curl:7.87.0",
 			"--command",
-			"--", "curl", "-v", "--tlsv1.3", "-k", "-H", fmt.Sprintf("Authorization:Bearer %s", token),
+			"--", "curl", "-v", "--tlsv1.3", "-k", "-H", "Authorization:Bearer "+token,
 			fmt.Sprintf("https://%s.%s.svc.cluster.local:8443/metrics", metricsServiceName, namespace))
 		_, err = cmd.CombinedOutput()
 		Expect(err).NotTo(HaveOccurred(), "Failed to create curl-metrics pod")
diff --git a/test/e2e/live_iso_test.go b/test/e2e/live_iso_test.go
index 0ed61792..da1fdb29 100644
--- a/test/e2e/live_iso_test.go
+++ b/test/e2e/live_iso_test.go
@@ -37,7 +37,7 @@ var _ = Describe("Live-ISO", Label("required", "live-iso"), func() {
 		accessDetails, err := metal3bmc.NewAccessDetails(bmc.Address, false)
 		Expect(err).NotTo(HaveOccurred())
 		if !accessDetails.SupportsISOPreprovisioningImage() {
-			Skip(fmt.Sprintf("BMC does not support ISO images. It does not make sense to test live-ISO here. BMC address: %s", bmc.Address))
+			Skip("BMC does not support ISO images. It does not make sense to test live-ISO here. BMC address: " + bmc.Address)
 		}
 
 		imageURL = e2eConfig.GetVariable("ISO_IMAGE_URL")
diff --git a/test/e2e/upgrade_test.go b/test/e2e/upgrade_test.go
index 428af9cf..1f69c433 100644
--- a/test/e2e/upgrade_test.go
+++ b/test/e2e/upgrade_test.go
@@ -372,7 +372,7 @@ var _ = Describe("Upgrade", Label("optional", "upgrade"), func() {
 			Expect(upgradeClusterProvider).ToNot(BeNil(), "Failed to create a cluster")
 			kubeconfigPath = upgradeClusterProvider.GetKubeconfigPath()
 			DeferCleanup(func() {
-				By(fmt.Sprintf("Disposing the kind cluster %s", upgradeClusterName))
+				By("Disposing the kind cluster " + upgradeClusterName)
 				upgradeClusterProvider.Dispose(ctx)
 			})
 		}