Repository: metal3-io/baremetal-operator

Issue:
Stop requiring DEPLOY_KERNEL/DEPLOY_RAMDISK
With https://github.com/metal3-io/ironic-image/pull/452 in place for some time, Ironic has reasonable defaults for these values when paired with the default IPA-downloader. Not requiring them makes BMO installation simpler.

/kind feature


Discussion:
@dtantsur: /help@metal3-io-bot: @dtantsur: 
	This request has been marked as needing help from a contributor.

Please ensure the request meets the requirements listed [here](https://git.k8s.io/community/contributors/guide/help-wanted.md).

If this request no longer meets these requirements, the label can be removed
by commenting with the `/remove-help` command.


<details>

In response to [this](https://github.com/metal3-io/baremetal-operator/issues/2100):

>/help


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>@Rozzii: /tirage accepted@Rozzii: /triage accepted@metal3-io-bot: Issues go stale after 90d of inactivity.
Mark the issue as fresh with `/remove-lifecycle stale`.
Stale issues will close after an additional 30d of inactivity.

If this issue is safe to close now please do so with `/close`.

/lifecycle stale@Rozzii: /remove-lifecycle stale@metal3-io-bot: Issues go stale after 90d of inactivity.
Mark the issue as fresh with `/remove-lifecycle stale`.
Stale issues will close after an additional 30d of inactivity.

If this issue is safe to close now please do so with `/close`.

/lifecycle stale@Rozzii: /remove-lifecycle stale@dtantsur: /lifecycle frozen

This is a pretty important installation UX improvement.@dtantsur: /assign

Solution patch:
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/config/default/ironic.env b/config/default/ironic.env
index e72cb3c3..3fe36d25 100644
--- a/config/default/ironic.env
+++ b/config/default/ironic.env
@@ -1,7 +1,5 @@
 HTTP_PORT=6180
 PROVISIONING_INTERFACE=eth2
 DHCP_RANGE=172.22.0.10,172.22.0.100
-DEPLOY_KERNEL_URL=http://172.22.0.2:6180/images/ironic-python-agent.kernel
-DEPLOY_RAMDISK_URL=http://172.22.0.2:6180/images/ironic-python-agent.initramfs
 IRONIC_ENDPOINT=http://172.22.0.2:6385/v1/
 CACHEURL=http://172.22.0.1/images
diff --git a/config/overlays/e2e/ironic.env b/config/overlays/e2e/ironic.env
index 44147ae0..6f200720 100644
--- a/config/overlays/e2e/ironic.env
+++ b/config/overlays/e2e/ironic.env
@@ -1,3 +1 @@
-DEPLOY_KERNEL_URL=http://192.168.222.1:6180/images/ironic-python-agent.kernel
-DEPLOY_RAMDISK_URL=http://192.168.222.1:6180/images/ironic-python-agent.initramfs
 IRONIC_ENDPOINT=https://192.168.222.1:6385/v1/
diff --git a/config/render/capm3.yaml b/config/render/capm3.yaml
index 42283193..7568288f 100644
--- a/config/render/capm3.yaml
+++ b/config/render/capm3.yaml
@@ -2510,8 +2510,6 @@ subjects:
 apiVersion: v1
 data:
   CACHEURL: http://172.22.0.1/images
-  DEPLOY_KERNEL_URL: http://172.22.0.2:6180/images/ironic-python-agent.kernel
-  DEPLOY_RAMDISK_URL: http://172.22.0.2:6180/images/ironic-python-agent.initramfs
   DHCP_RANGE: 172.22.0.10,172.22.0.100
   HTTP_PORT: "6180"
   IRONIC_ENDPOINT: http://172.22.0.2:6385/v1/
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/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/webhooks/metal3.io/v1alpha1/baremetalhost_validation.go b/internal/webhooks/metal3.io/v1alpha1/baremetalhost_validation.go
index e51350d8..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 {
diff --git a/pkg/provisioner/ironic/factory.go b/pkg/provisioner/ironic/factory.go
index 30c1e5c4..32e9c6bc 100644
--- a/pkg/provisioner/ironic/factory.go
+++ b/pkg/provisioner/ironic/factory.go
@@ -114,10 +114,8 @@ func loadConfigFromEnv(havePreprovImgBuilder bool) (ironicConfig, error) {
 	c.deployRamdiskURL = os.Getenv("DEPLOY_RAMDISK_URL")
 	c.deployISOURL = os.Getenv("DEPLOY_ISO_URL")
 	if !havePreprovImgBuilder {
-		if c.deployISOURL == "" &&
-			(c.deployKernelURL == "" || c.deployRamdiskURL == "") {
-			return c, errors.New("either DEPLOY_KERNEL_URL and DEPLOY_RAMDISK_URL or DEPLOY_ISO_URL must be set")
-		}
+		// NOTE(dtantsur): with a PreprovisioningImage controller, it makes sense to set only the kernel.
+		// Without it, either both or neither must be set.
 		if (c.deployKernelURL == "" && c.deployRamdiskURL != "") ||
 			(c.deployKernelURL != "" && c.deployRamdiskURL == "") {
 			return c, errors.New("DEPLOY_KERNEL_URL and DEPLOY_RAMDISK_URL can only be set together")
diff --git a/pkg/provisioner/ironic/ironic.go b/pkg/provisioner/ironic/ironic.go
index e5f9941f..bdbe784a 100644
--- a/pkg/provisioner/ironic/ironic.go
+++ b/pkg/provisioner/ironic/ironic.go
@@ -351,14 +351,10 @@ func (p *ironicProvisioner) configureImages(data provisioner.ManagementAccessDat
 	switch data.State {
 	case metal3api.StateInspecting,
 		metal3api.StatePreparing:
-		if deployImageInfo == nil {
-			if p.config.havePreprovImgBuilder {
-				result, err = transientError(provisioner.ErrNeedsPreprovisioningImage)
-			} else {
-				result, err = operationFailed("no preprovisioning image available")
-			}
-			return result, err
+		if deployImageInfo == nil && p.config.havePreprovImgBuilder {
+			result, err = transientError(provisioner.ErrNeedsPreprovisioningImage)
 		}
+		return result, err
 	default:
 	}
 
@@ -587,9 +583,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
 	}
 
@@ -625,9 +621,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,
@@ -1229,6 +1225,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)
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/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/factory_test.go b/pkg/provisioner/ironic/factory_test.go
index db47d8b2..0d32eccb 100644
--- a/pkg/provisioner/ironic/factory_test.go
+++ b/pkg/provisioner/ironic/factory_test.go
@@ -98,24 +98,19 @@ func TestLoadConfigFromEnv(t *testing.T) {
 				ramdiskURL: "http://ramdisk",
 			},
 		},
-		{
-			name:          "no deploy info",
-			env:           EnvFixture{},
-			expectedError: "either DEPLOY_KERNEL_URL and DEPLOY_RAMDISK_URL or DEPLOY_ISO_URL must be set",
-		},
 		{
 			name: "only kernel",
 			env: EnvFixture{
 				kernelURL: "http://kernel",
 			},
-			expectedError: "either DEPLOY_KERNEL_URL and DEPLOY_RAMDISK_URL or DEPLOY_ISO_URL must be set",
+			expectedError: "DEPLOY_KERNEL_URL and DEPLOY_RAMDISK_URL can only be set together",
 		},
 		{
 			name: "only ramdisk",
 			env: EnvFixture{
 				ramdiskURL: "http://ramdisk",
 			},
-			expectedError:         "either DEPLOY_KERNEL_URL and DEPLOY_RAMDISK_URL or DEPLOY_ISO_URL must be set",
+			expectedError:         "DEPLOY_KERNEL_URL and DEPLOY_RAMDISK_URL can only be set together",
 			expectedImgBuildError: "DEPLOY_RAMDISK_URL requires DEPLOY_KERNEL_URL to be set also",
 		},
 		{
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)