Repository: metal3-io/baremetal-operator
Pull request #1594 (2024-03-05 07:50:08)

:sparkles: Attach non-bootable iso to BareMetalHost
**What this PR does / why we need it**:
Add new `DataImage` CRD which allows attachment/detachment of non-bootable iso to BareMetalHost, based on [this proposal](https://github.com/metal3-io/metal3-docs/blob/072349dac347b9ae39c94f1aaf2ec750db642a0a/design/baremetal-operator/bmh_non-bootable_iso.md).


metal3-io-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

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

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

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

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

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

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
golint | 495cf4e5243a7f607a2135d8e9c4423bf7c5527e | [link](https://prow.apps.test.metal3.io/view/s3/prow-logs/pr-logs/pull/metal3-io_baremetal-operator/1594/golint/1764921336271998976) | true | `/test golint`

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

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
metal3-io-bot: PR needs rebase.

<details>

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

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

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

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository. I understand the commands that are listed [here](https://go.k8s.io/bot-commands).
</details>
<!-- test report -->
metal3-io-bot: PR needs rebase.

<details>

Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes/test-infra](https://github.com/kubernetes/test-infra/issues/new?title=Prow%20issue:) repository.
</details>
elfosardo: /test metal3-bmo-e2e-test-pull  metal3-centos-e2e-integration-test-main 
elfosardo: /test metal3-bmo-e2e-test-pull metal3-centos-e2e-integration-test-main
elfosardo: /lgtm
looks good, just waiting for CI results
metal3-io-bot: @hroyrh: The following test **failed**, say `/retest` to rerun all failed tests or `/retest-required` to rerun all mandatory failed tests:

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
metal3-centos-e2e-integration-test-main | dbb88b7cacd3716d0636a8bcb31746b1d4e653f7 | [link](https://jenkins.nordix.org/view/Metal3/job/metal3-centos-e2e-integration-test-main/44/) | true | `/test metal3-centos-e2e-integration-test-main`

<details>

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

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
metal3-centos-e2e-integration-test-main | dbb88b7cacd3716d0636a8bcb31746b1d4e653f7 | [link](https://jenkins.nordix.org/view/Metal3/job/metal3-centos-e2e-integration-test-main/44/) | true | `/test metal3-centos-e2e-integration-test-main`

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

<details>

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

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
metal3-centos-e2e-integration-test-main | dbb88b7cacd3716d0636a8bcb31746b1d4e653f7 | [link](https://jenkins.nordix.org/view/Metal3/job/metal3-centos-e2e-integration-test-main/45/) | true | `/test metal3-centos-e2e-integration-test-main`

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

<details>

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

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
metal3-centos-e2e-integration-test-main | dbb88b7cacd3716d0636a8bcb31746b1d4e653f7 | [link](https://jenkins.nordix.org/view/Metal3/job/metal3-centos-e2e-integration-test-main/45/) | true | `/test metal3-centos-e2e-integration-test-main`

<details>

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

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
metal3-centos-e2e-integration-test-main | 19d0515466c6bdeb663cc526c0b2f95f7a43c79b | [link](https://jenkins.nordix.org/view/Metal3/job/metal3-centos-e2e-integration-test-main/60/) | true | `/test metal3-centos-e2e-integration-test-main`

<details>

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

Test name | Commit | Details | Required | Rerun command
--- | --- | --- | --- | ---
metal3-centos-e2e-integration-test-main | 19d0515466c6bdeb663cc526c0b2f95f7a43c79b | [link](https://jenkins.nordix.org/view/Metal3/job/metal3-centos-e2e-integration-test-main/60/) | true | `/test metal3-centos-e2e-integration-test-main`

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

<details>

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

Patch:
diff --git a/PROJECT b/PROJECT
index 192a6021..e244d3da 100644
--- a/PROJECT
+++ b/PROJECT
@@ -79,4 +79,13 @@ resources:
   kind: DataImage
   path: github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1
   version: v1alpha1
+- api:
+    crdVersion: v1
+    namespaced: true
+  controller: true
+  domain: metal3.io
+  group: metal3.io
+  kind: DataImage
+  path: github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1
+  version: v1alpha1
 version: "3"
diff --git a/apis/metal3.io/v1alpha1/dataimage_types.go b/apis/metal3.io/v1alpha1/dataimage_types.go
index c2807e81..6d0259b5 100644
--- a/apis/metal3.io/v1alpha1/dataimage_types.go
+++ b/apis/metal3.io/v1alpha1/dataimage_types.go
@@ -47,10 +47,10 @@ type DataImageStatus struct {
 	LastReconciled *metav1.Time `json:"lastReconciled,omitempty"`
 
 	// Currently attached DataImage
-	AttachedImage *AttachedImageReference `json:"attachedImage,omitempty"`
+	AttachedImage AttachedImageReference `json:"attachedImage,omitempty"`
 
 	// Error count and message when attaching/detaching
-	Error *DataImageError `json:"error,omitempty"`
+	Error DataImageError `json:"error,omitempty"`
 }
 
 //+kubebuilder:object:root=true
diff --git a/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go b/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go
index 21843aa7..e5a09153 100644
--- a/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go
@@ -472,16 +472,8 @@ func (in *DataImageStatus) DeepCopyInto(out *DataImageStatus) {
 		in, out := &in.LastReconciled, &out.LastReconciled
 		*out = (*in).DeepCopy()
 	}
-	if in.AttachedImage != nil {
-		in, out := &in.AttachedImage, &out.AttachedImage
-		*out = new(AttachedImageReference)
-		**out = **in
-	}
-	if in.Error != nil {
-		in, out := &in.Error, &out.Error
-		*out = new(DataImageError)
-		**out = **in
-	}
+	out.AttachedImage = in.AttachedImage
+	out.Error = in.Error
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DataImageStatus.
diff --git a/config/base/rbac/dataimage_editor_role.yaml b/config/base/rbac/dataimage_editor_role.yaml
new file mode 100644
index 00000000..751707a1
--- /dev/null
+++ b/config/base/rbac/dataimage_editor_role.yaml
@@ -0,0 +1,24 @@
+# permissions for end users to edit dataimages.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: dataimage-editor-role
+rules:
+- apiGroups:
+  - metal3.io
+  resources:
+  - dataimages
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - metal3.io
+  resources:
+  - dataimages/status
+  verbs:
+  - get
diff --git a/config/base/rbac/dataimage_viewer_role.yaml b/config/base/rbac/dataimage_viewer_role.yaml
new file mode 100644
index 00000000..cbf297c3
--- /dev/null
+++ b/config/base/rbac/dataimage_viewer_role.yaml
@@ -0,0 +1,20 @@
+# permissions for end users to view dataimages.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: dataimage-viewer-role
+rules:
+- apiGroups:
+  - metal3.io
+  resources:
+  - dataimages
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metal3.io
+  resources:
+  - dataimages/status
+  verbs:
+  - get
diff --git a/config/base/rbac/role.yaml b/config/base/rbac/role.yaml
index 0094daea..b4687c47 100644
--- a/config/base/rbac/role.yaml
+++ b/config/base/rbac/role.yaml
@@ -71,6 +71,32 @@ rules:
   - get
   - patch
   - update
+- apiGroups:
+  - metal3.io
+  resources:
+  - dataimages
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - metal3.io
+  resources:
+  - dataimages/finalizers
+  verbs:
+  - update
+- apiGroups:
+  - metal3.io
+  resources:
+  - dataimages/status
+  verbs:
+  - get
+  - patch
+  - update
 - apiGroups:
   - metal3.io
   resources:
diff --git a/config/render/capm3.yaml b/config/render/capm3.yaml
index 518a174d..fd612ea1 100644
--- a/config/render/capm3.yaml
+++ b/config/render/capm3.yaml
@@ -2246,6 +2246,32 @@ rules:
   - get
   - patch
   - update
+- apiGroups:
+  - metal3.io
+  resources:
+  - dataimages
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - metal3.io
+  resources:
+  - dataimages/finalizers
+  verbs:
+  - update
+- apiGroups:
+  - metal3.io
+  resources:
+  - dataimages/status
+  verbs:
+  - get
+  - patch
+  - update
 - apiGroups:
   - metal3.io
   resources:
diff --git a/controllers/metal3.io/baremetalhost_controller.go b/controllers/metal3.io/baremetalhost_controller.go
index ed1e9e17..5186ee98 100644
--- a/controllers/metal3.io/baremetalhost_controller.go
+++ b/controllers/metal3.io/baremetalhost_controller.go
@@ -99,6 +99,10 @@ func (info *reconcileInfo) publishEvent(reason, message string) {
 //+kubebuilder:rbac:groups=metal3.io,resources=bmceventsubscriptions,verbs=get;list;watch;create;update;patch
 //+kubebuilder:rbac:groups=metal3.io,resources=hostfirmwarecomponents,verbs=get;list;watch;create;update;patch
 
+// Allow for updating dataimage
+// +kubebuilder:rbac:groups=metal3.io,resources=dataimages,verbs=get;list;watch;create;update;patch
+// +kubebuilder:rbac:groups=metal3.io,resources=dataimages/status,verbs=get;update;patch
+
 // Reconcile handles changes to BareMetalHost resources.
 func (r *BareMetalHostReconciler) Reconcile(ctx context.Context, request ctrl.Request) (result ctrl.Result, err error) {
 	reconcileCounters.With(hostMetricLabels(request)).Inc()
@@ -1380,6 +1384,15 @@ func (r *BareMetalHostReconciler) manageHostPower(prov provisioner.Provisioner,
 		"reboot process", desiredPowerOnState != info.host.Spec.Online)
 
 	if desiredPowerOnState {
+		if isProvisioned {
+			// If DataImage exists, handle attachment/detachment
+			dataImageResult := r.handleDataImageActions(prov, info)
+			if dataImageResult != nil {
+				// attaching/detaching DataImage failed, so we will requeue
+				return dataImageResult
+			}
+		}
+
 		provResult, err = prov.PowerOn(info.host.Status.ErrorType == metal3api.PowerManagementError)
 	} else {
 		if info.host.Status.ErrorCount > 0 {
@@ -1424,6 +1437,215 @@ func (r *BareMetalHostReconciler) manageHostPower(prov provisioner.Provisioner,
 	return actionUpdate{steadyStateResult}
 }
 
+// DataImage handler for attaching/detaching image.
+func (r *BareMetalHostReconciler) handleDataImageActions(prov provisioner.Provisioner, info *reconcileInfo) actionResult {
+	dataImage := &metal3api.DataImage{}
+	if err := r.Get(info.ctx, info.request.NamespacedName, dataImage); err != nil {
+		// DataImage does not exist or it may have been deleted
+		if k8serrors.IsNotFound(err) {
+			info.log.Info("dataImage not found")
+			return nil
+		}
+		// Error reading the object - requeue the request.
+		return actionError{fmt.Errorf("could not load dataImage, %w", err)}
+	}
+
+	// Set ControllerReference to DataImage
+	if !ownerReferenceExists(info.host, dataImage) {
+		if err := controllerutil.SetControllerReference(info.host, dataImage, r.Scheme()); err != nil {
+			return actionError{fmt.Errorf("could not set bmh as controller, %w", err)}
+		}
+		if err := r.Update(info.ctx, dataImage); err != nil {
+			return actionError{fmt.Errorf("failure updating dataImage status, %w", err)}
+		}
+
+		return actionContinue{}
+	}
+
+	// Update reconciliation timestamp for dataImage
+	dataImage.Status.LastReconciled = &metav1.Time{Time: time.Now()}
+
+	// dataImageRetryBackoff calculated based on persistent errors while
+	// attaching/detaching dataImage, every requeue when handling
+	// dataImage will use this delay
+	// TODO(hroyrh) : Should we fail after the error count exceeds a
+	// given constant ?
+	dataImageRetryBackoff := max(dataImageUpdateDelay, calculateBackoff(dataImage.Status.Error.Count))
+
+	// Check if any attach/detach action is pending or failed to attach
+	// We are assuming that the action will have completed by the time
+	// this reconcile is called ( after the delay specified in the previous
+	// action)
+	// TODO(hroyrh) : update this once vmedia.get api is available
+	isNodeBusy, nodeError := prov.IsDataImageReady()
+	if isNodeBusy {
+		info.log.Info("Node is busy, requeuing")
+
+		// In case the last node error was not nil for dataimage,
+		// update message and counter
+		if nodeError != nil {
+			info.log.Info("DataImage action failed", "Error", nodeError.Error())
+
+			dataImage.Status.Error.Message = nodeError.Error()
+			dataImage.Status.Error.Count++
+
+			if err := r.Status().Update(info.ctx, dataImage); err != nil {
+				return actionError{fmt.Errorf("failed to update DataImage status, %w", err)}
+			}
+		}
+
+		return actionContinue{dataImageRetryBackoff}
+	}
+
+	// Is the current dataImage status valid
+	dirty := false
+
+	// In case the last node error was not nil for dataimage,
+	// update message and counter
+	if nodeError != nil {
+		info.log.Info("DataImage not ready", "Error", nodeError.Error())
+
+		dataImage.Status.Error.Message = nodeError.Error()
+		dataImage.Status.Error.Count++
+		dirty = true
+	}
+
+	deleteDataImage := !dataImage.DeletionTimestamp.IsZero()
+
+	requestedURL := dataImage.Spec.URL
+
+	attachedURL := dataImage.Status.AttachedImage.URL
+
+	if deleteDataImage {
+		info.log.Info("DataImage requested for deletion")
+		if attachedURL != "" || dirty {
+			info.log.Info("Detaching DataImage as it was deleted")
+			err := r.detachDataImage(prov, info, dataImage)
+			if err != nil {
+				return actionError{fmt.Errorf("failed to detach, %w", err)}
+			}
+
+			// Requeue to give time to the DataImage Reconciler to update the
+			// status. In case of failure, we will enter this section and
+			// detachDataImage will be called again -> can this cause issues ?
+			return actionContinue{dataImageRetryBackoff}
+		}
+
+		return nil
+	}
+
+	if requestedURL != attachedURL || dirty {
+		info.log.Info("DataImage change detected")
+		if attachedURL != "" {
+			info.log.Info("Detaching DataImage")
+			err := r.detachDataImage(prov, info, dataImage)
+			if err != nil {
+				return actionError{fmt.Errorf("failed to detach, %w", err)}
+			}
+
+			// Requeue to give time to the DataImage Reconciler to update the
+			// status. In case of failure, we will enter this section and
+			// detachDataImage will be called again -> can this cause issues ?
+			return actionContinue{dataImageRetryBackoff}
+		}
+		if requestedURL != "" {
+			info.log.Info("Attaching DataImage", "URL", requestedURL)
+			err := r.attachDataImage(prov, info, dataImage)
+			if err != nil {
+				return actionError{fmt.Errorf("failed to attach, %w", err)}
+			}
+
+			// Requeue to give time to the DataImage Reconciler to update the
+			// status. In case of failure, we will enter this section and
+			// attachDataImage will be called again -> can this cause issues ?
+			return actionContinue{dataImageRetryBackoff}
+		}
+	}
+
+	// Clear dataImage errors if nodeError is nil
+	if !dirty {
+		dataImage.Status.Error.Message = ""
+		dataImage.Status.Error.Count = 0
+	}
+
+	if err := r.Status().Update(info.ctx, dataImage); err != nil {
+		return actionError{fmt.Errorf("failed to update DataImage status, %w", err)}
+	}
+
+	info.log.Info("Updated DataImage Status after handling attachment/detachment")
+
+	if dirty {
+		return actionContinue{dataImageRetryBackoff}
+	}
+
+	return nil
+}
+
+func ownerReferenceExists(owner metav1.Object, resource metav1.Object) bool {
+	ownerReferences := resource.GetOwnerReferences()
+
+	for _, ownRef := range ownerReferences {
+		if ownRef.UID == owner.GetUID() {
+			// Owner reference exists
+			return true
+		}
+	}
+
+	return false
+}
+
+// Attach the DataImage to the BareMetalHost.
+func (r *BareMetalHostReconciler) attachDataImage(prov provisioner.Provisioner, info *reconcileInfo, dataImage *metal3api.DataImage) error {
+	if err := prov.AttachDataImage(dataImage.Spec.URL); err != nil {
+		info.log.Info("Error while attaching DataImage", "URL", dataImage.Spec.URL, "Error", err.Error())
+
+		dataImage.Status.Error.Count++
+		dataImage.Status.Error.Message = err.Error()
+		// Error updating DataImage Status
+		if err := r.Status().Update(info.ctx, dataImage); err != nil {
+			return fmt.Errorf("failed to update DataImage status, %w", err)
+		}
+
+		return fmt.Errorf("failed to attach dataImage, %w", err)
+	}
+
+	// Update attached.URL here, we will mark it dirty in case any node errors
+	// are encountered
+	dataImage.Status.AttachedImage.URL = dataImage.Spec.URL
+	// Error updating DataImage Status
+	if err := r.Status().Update(info.ctx, dataImage); err != nil {
+		return fmt.Errorf("failed to update DataImage status, %w", err)
+	}
+
+	return nil
+}
+
+// Detach the DataImage from the BareMetalHost.
+func (r *BareMetalHostReconciler) detachDataImage(prov provisioner.Provisioner, info *reconcileInfo, dataImage *metal3api.DataImage) error {
+	if err := prov.DetachDataImage(); err != nil {
+		info.log.Info("Error while detaching DataImage", "DataImage", dataImage.Name, "Error", err.Error())
+
+		dataImage.Status.Error.Count++
+		dataImage.Status.Error.Message = err.Error()
+		// Error updating DataImage Status
+		if err := r.Status().Update(info.ctx, dataImage); err != nil {
+			return fmt.Errorf("failed to update DataImage status, %w", err)
+		}
+
+		return fmt.Errorf("failed to detach dataImage, %w", err)
+	}
+
+	// Update attached.URL here, we will mark it dirty in case any node errors
+	// are encountered
+	dataImage.Status.AttachedImage.URL = ""
+	// Error updating DataImage Status
+	if err := r.Status().Update(info.ctx, dataImage); err != nil {
+		return fmt.Errorf("failed to update DataImage status, %w", err)
+	}
+
+	return nil
+}
+
 // A host reaching this action handler should be provisioned or externally
 // provisioned -- a state that it will stay in until the user takes further
 // action. We use the Adopt() API to make sure that the provisioner is aware of
diff --git a/controllers/metal3.io/dataimage_controller.go b/controllers/metal3.io/dataimage_controller.go
new file mode 100644
index 00000000..5b9b78fe
--- /dev/null
+++ b/controllers/metal3.io/dataimage_controller.go
@@ -0,0 +1,260 @@
+/*
+
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package controllers
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"github.com/go-logr/logr"
+
+	metal3api "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
+	"github.com/metal3-io/baremetal-operator/pkg/provisioner"
+	"github.com/metal3-io/baremetal-operator/pkg/utils"
+	corev1 "k8s.io/api/core/v1"
+	k8serrors "k8s.io/apimachinery/pkg/api/errors"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/controller"
+	"sigs.k8s.io/controller-runtime/pkg/event"
+	"sigs.k8s.io/controller-runtime/pkg/predicate"
+)
+
+const (
+	dataImageRetryDelay  = time.Second * 60
+	dataImageUpdateDelay = time.Second * 30
+)
+
+// DataImageReconciler reconciles a DataImage object.
+type DataImageReconciler struct {
+	client.Client
+	Log                logr.Logger
+	ProvisionerFactory provisioner.Factory
+}
+
+type rdiInfo struct {
+	ctx     context.Context
+	log     logr.Logger
+	request ctrl.Request
+	di      *metal3api.DataImage
+	bmh     *metal3api.BareMetalHost
+	events  []corev1.Event
+}
+
+// match the provisioner.EventPublisher interface.
+func (info *rdiInfo) publishEvent(reason, message string) {
+	t := metav1.Now()
+	dataImageEvent := corev1.Event{
+		ObjectMeta: metav1.ObjectMeta{
+			GenerateName: reason + "-",
+			Namespace:    info.di.ObjectMeta.Namespace,
+		},
+		InvolvedObject: corev1.ObjectReference{
+			Kind:       "DataImage",
+			Namespace:  info.di.Namespace,
+			Name:       info.di.Name,
+			UID:        info.di.UID,
+			APIVersion: metal3api.GroupVersion.String(),
+		},
+		Reason:  reason,
+		Message: message,
+		Source: corev1.EventSource{
+			Component: "metal3-dataimage-controller",
+		},
+		FirstTimestamp:      t,
+		LastTimestamp:       t,
+		Count:               1,
+		Type:                corev1.EventTypeNormal,
+		ReportingController: "metal3.io/dataimage-controller",
+	}
+
+	info.events = append(info.events, dataImageEvent)
+}
+
+//+kubebuilder:rbac:groups=metal3.io,resources=dataimages,verbs=get;list;watch;create;update;patch;delete
+//+kubebuilder:rbac:groups=metal3.io,resources=dataimages/status,verbs=get;update;patch
+//+kubebuilder:rbac:groups=metal3.io,resources=dataimages/finalizers,verbs=update
+
+// Reconcile is part of the main kubernetes reconciliation loop which aims to
+// move the current state of the cluster closer to the desired state.
+func (r *DataImageReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	reqLogger := r.Log.WithValues("dataimage", req.NamespacedName)
+	reqLogger.Info("start dataImage reconciliation")
+
+	di := &metal3api.DataImage{}
+	if err := r.Get(ctx, req.NamespacedName, di); err != nil {
+		// The DataImage resource may have been deleted
+		if k8serrors.IsNotFound(err) {
+			reqLogger.Info("dataImage not found")
+			return ctrl.Result{}, nil
+		}
+		// Error reading the object - requeue the request.
+		return ctrl.Result{Requeue: true, RequeueAfter: dataImageRetryDelay}, fmt.Errorf("could not load dataImage, %w", err)
+	}
+
+	bmh := &metal3api.BareMetalHost{}
+	if err := r.Get(ctx, req.NamespacedName, bmh); err != nil {
+		// There might not be any BareMetalHost for the DataImage
+		if k8serrors.IsNotFound(err) {
+			reqLogger.Info("bareMetalHost not found for the dataImage")
+			return ctrl.Result{}, nil
+		}
+
+		// Error reading the object - requeue the request.
+		return ctrl.Result{Requeue: true, RequeueAfter: dataImageRetryDelay}, fmt.Errorf("could not load baremetalhost, %w", err)
+	}
+
+	info := &rdiInfo{ctx: ctx, log: reqLogger, request: req, di: di, bmh: bmh}
+
+	if hasDetachedAnnotation(bmh) {
+		reqLogger.Info("the host is detached, not running reconciler")
+		return ctrl.Result{Requeue: true, RequeueAfter: unmanagedRetryDelay}, nil
+	}
+
+	// If the reconciliation is paused, requeue
+	annotations := bmh.GetAnnotations()
+	if _, ok := annotations[metal3api.PausedAnnotation]; ok {
+		reqLogger.Info("host is paused, no work to do")
+		return ctrl.Result{Requeue: false}, nil
+	}
+
+	// Add finalizer for newly created DataImage
+	if di.DeletionTimestamp.IsZero() && !utils.StringInList(di.Finalizers, metal3api.DataImageFinalizer) {
+		reqLogger.Info("adding finalizer")
+		di.Finalizers = append(di.Finalizers, metal3api.DataImageFinalizer)
+
+		// Update dataImage after adding finalizer, requeue in case of failure
+		err := r.Update(ctx, di)
+		if err != nil {
+			return ctrl.Result{RequeueAfter: dataImageUpdateDelay}, fmt.Errorf("failed to update resource after add finalizer, %w", err)
+		}
+		return ctrl.Result{Requeue: true}, nil
+	}
+
+	// Create a provisioner that can access Ironic API
+	prov, err := r.ProvisionerFactory.NewProvisioner(ctx, provisioner.BuildHostDataNoBMC(*bmh), info.publishEvent)
+	if err != nil {
+		return ctrl.Result{}, fmt.Errorf("failed to create provisioner, %w", err)
+	}
+
+	ready, err := prov.TryInit()
+	if err != nil || !ready {
+		var msg string
+		if err == nil {
+			msg = "Not ready"
+		} else {
+			msg = err.Error()
+		}
+		reqLogger.Info("provisioner is not ready", "Error", msg, "RequeueAfter", provisionerRetryDelay)
+		return ctrl.Result{Requeue: true, RequeueAfter: provisionerRetryDelay}, nil
+	}
+
+	// Check if any attach/detach action is pending or failed to attach
+	_, nodeError := prov.IsDataImageReady()
+
+	// Is the current dataImage status valid
+	dirty := false
+	// In case the last node error was not nil for dataimage,
+	// upadate message and counter
+	if nodeError != nil {
+		di.Status.Error.Message = nodeError.Error()
+		di.Status.Error.Count++
+		dirty = true
+	}
+
+	// Remove finalizer if DataImage has been requested for deletion and
+	// there is no attached image, else wait for the detachment.
+	if !di.DeletionTimestamp.IsZero() {
+		reqLogger.Info("cleaning up deleted dataImage resource")
+
+		dataImageAttachedURL := di.Status.AttachedImage.URL
+
+		if dataImageAttachedURL != "" || dirty {
+			reqLogger.Info("Wait for DataImage to detach before removing finalizer, requeueing")
+			return ctrl.Result{Requeue: true, RequeueAfter: dataImageRetryDelay}, nil
+		}
+
+		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
+	}
+
+	// Update the latest status fetched from the Node
+	if err := r.updateStatus(info); err != nil {
+		return ctrl.Result{Requeue: true, RequeueAfter: dataImageRetryDelay}, fmt.Errorf("failed to update resource statu, %w", err)
+	}
+
+	for _, e := range info.events {
+		r.publishEvent(ctx, req, e)
+	}
+
+	return ctrl.Result{}, nil
+}
+
+// Update the DataImage status after fetching current status from provisioner.
+func (r *DataImageReconciler) updateStatus(info *rdiInfo) (err error) {
+	dataImage := info.di
+
+	if err := r.Status().Update(info.ctx, dataImage); err != nil {
+		return fmt.Errorf("failed to update DataImage status, %w", err)
+	}
+	info.log.Info("Updating DataImage Status", "Updated DataImage is", dataImage)
+
+	return nil
+}
+
+// Publish reconciler events.
+func (r *DataImageReconciler) publishEvent(ctx context.Context, request ctrl.Request, event corev1.Event) {
+	reqLogger := r.Log.WithValues("dataimage", request.NamespacedName)
+	reqLogger.Info("publishing event", "reason", event.Reason, "message", event.Message)
+	err := r.Create(ctx, &event)
+	if err != nil {
+		reqLogger.Info("failed to record event, ignoring",
+			"reason", event.Reason, "message", event.Message, "error", err)
+	}
+}
+
+// Update events.
+func (r *DataImageReconciler) updateEventHandler(e event.UpdateEvent) bool {
+	r.Log.Info("dataimage in event handler")
+
+	// If the update increased the resource Generation then let's process it
+	if e.ObjectNew.GetGeneration() != e.ObjectOld.GetGeneration() {
+		r.Log.Info("returning true as generation changed from event handler")
+		return true
+	}
+
+	return false
+}
+
+// SetupWithManager sets up the controller with the Manager.
+func (r *DataImageReconciler) SetupWithManager(mgr ctrl.Manager, maxConcurrentReconcile int) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&metal3api.DataImage{}).
+		WithOptions(controller.Options{MaxConcurrentReconciles: maxConcurrentReconcile}).
+		WithEventFilter(
+			predicate.Funcs{
+				UpdateFunc: r.updateEventHandler,
+			}).
+		Complete(r)
+}
diff --git a/hack/verify-release.sh b/hack/verify-release.sh
index 0c02e0ef..2c45730c 100755
--- a/hack/verify-release.sh
+++ b/hack/verify-release.sh
@@ -26,7 +26,7 @@
 # repository, with the release commit/tag in question checked out.
 #
 # Command line arguments:
-# arg1: mandatory: version without leading v, eg. 0.6.0
+# arg1: mandatory: version without leading v, eg. 0.4.0
 #
 # Environment variables:
 # GITHUB_TOKEN: mandatory: your bearer token that has access to the release
@@ -42,7 +42,7 @@ shopt -s globstar
 GLOBIGNORE=./hack/tools/go.mod
 
 # user input
-VERSION="${1:?release version missing, provide without leading v. Example: 0.6.0}"
+VERSION="${1:?release version missing, provide without leading v. Example: 0.4.0}"
 GITHUB_TOKEN="${GITHUB_TOKEN:?export GITHUB_TOKEN with permissions to read unpublished release notes}"
 
 # if CONTAINER_RUNTIME is set, we will use crane and osv-scanner from images
@@ -89,11 +89,11 @@ declare -a release_note_strings=(
 
 # required strings that are postfixed with correct release number
 declare -a release_note_tag_strings=(
-    "The image for this release is: v${VERSION}"
+    "The container image for this release is: v${VERSION}"
 )
 
-# release artifacts
-declare -a release_artifacts=(
+# release artefacts
+declare -a release_artefacts=(
 )
 
 # quay images
@@ -112,7 +112,6 @@ declare -A module_groups=(
         k8s.io/api
         k8s.io/apiextensions-apiserver
         k8s.io/apimachinery
-        k8s.io/apiserver
         k8s.io/client-go
         k8s.io/cluster-bootstrap
         k8s.io/component-base
@@ -141,13 +140,10 @@ if [[ -n "${CONTAINER_RUNTIME}" ]]; then
     )
     declare -a GCRANE_CMD=(
         "${CONTAINER_RUNTIME}" run --rm
-        --pull always
         gcr.io/go-containerregistry/gcrane:latest
     )
     declare -a OSVSCANNER_CMD=(
-        "${CONTAINER_RUNTIME}" run --rm
-        -v "${PWD}":/src -w /src
-        --pull always
+        "${CONTAINER_RUNTIME}" run --rm -v "${PWD}":/src -w /src
         ghcr.io/google/osv-scanner:latest
     )
 else
@@ -182,44 +178,12 @@ trap cleanup EXIT
 #
 # pre-requisites
 #
-_version_check()
-{
-    # check version of the tool, return failure if smaller
-    local min_version version
-
-    min_version="$1"
-    version="$2"
-
-    [[ "${min_version}" == $(echo -e "${min_version}\n${version}" | sort -s -t. -k 1,1 -k 2,2n -k 3,3n | head -n1) ]]
-}
-
 check_tools()
 {
-    # check that all tools are present, and pass version check too
-    # TODO: if more tools need versioning, add the version info directly to the
-    # array defining required tools
-    local min_version version
-
     echo "Checking required tools ..."
 
     for tool in "${required_tools[@]}"; do
         type "${tool}" &>/dev/null || { echo "FATAL: need ${tool} to be installed"; exit 1; }
-        case "${tool}" in
-            osv-scanner)
-                version=$("${OSVSCANNER_CMD[@]}" -v | grep version | cut -f3 -d" ")
-                min_version="1.5.0"
-                ;;
-            *)
-                # dummy values here for other tools
-                version="1.0.0"
-                min_version="1.0.0"
-                ;;
-        esac
-
-        # shellcheck disable=SC2310
-        if ! _version_check "${min_version}" "${version}"; then
-            echo "WARNING: tool ${tool} is version ${version}, should be >= ${min_version}"
-        fi
     done
 
     echo -e "Done\n"
@@ -250,7 +214,7 @@ check_input()
     # check version is input without leading v, since we have extra annotated
     # tags in history and it needs manually to be edited out
     if [[ "${VERSION}" =~ ^v\d+ ]]; then
-        echo "FATAL: given version includes a leading v. Example: 0.6.0"
+        echo "FATAL: given version includes a leading v. Example: 0.4.0"
         exit 1
     fi
 
@@ -401,15 +365,15 @@ verify_release_notes()
     echo -e "Done\n"
 }
 
-verify_release_artifacts()
+verify_release_artefacts()
 {
-    # check that the release json lists all artifacts as present
-    echo "Verifying release artifacts ..."
+    # check that the release json lists all artefacts as present
+    echo "Verifying release artefacts ..."
 
-    for artifact in "${release_artifacts[@]}"; do
+    for artefact in "${release_artefacts[@]}"; do
         # shellcheck disable=SC2076
-        if ! [[ "$(jq .assets[].name "${RELEASE_JSON}")" =~ "\"${artifact}\"" ]]; then
-            echo "ERROR: release artifact '${artifact}' not found in release"
+        if ! [[ "$(jq .assets[].name "${RELEASE_JSON}")" =~ "\"${artefact}\"" ]]; then
+            echo "ERROR: release artefact '${artefact}' not found in release"
         fi
     done
 
@@ -422,7 +386,7 @@ verify_container_images()
     # if tag doesn't appear, the build trigger might've been disabled
     local image tag
 
-    echo "Verifying container images are built and tagged ..."
+    echo "Verifying container images ..."
 
     for image_and_tag in "${container_images[@]}"; do
         image="${image_and_tag/:*}"
@@ -442,10 +406,12 @@ verify_container_images()
     echo -e "Done\n"
 }
 
-_get_golang_version_from_dockerfile()
+verify_container_base_image()
 {
-    # read golang version from Dockerfile and return
-    local image_and_tag image image_and_tag_without_sha tag tag_minor
+    # check if the golang used for container image build is latest of its minor
+    local image tag
+
+    echo "Verifying container base images ..."
 
     image_and_tag="$(grep "^ARG BUILD_IMAGE=" Dockerfile | cut -f2 -d=)"
     image="${image_and_tag/:*}"
@@ -453,19 +419,6 @@ _get_golang_version_from_dockerfile()
     tag="${image_and_tag_without_sha/*:}"
     tag_minor="${tag%.*}"
 
-    echo "${image_and_tag} ${image} ${image_and_tag_without_sha} ${tag} ${tag_minor}"
-}
-
-verify_container_base_image()
-{
-    # check if the golang used for container image build is latest of its minor
-    local image_and_tag image image_and_tag_without_sha tag tag_minor
-
-    echo "Verifying container base images are up to date ..."
-
-    read -r image_and_tag image image_and_tag_without_sha tag tag_minor < \
-        <(_get_golang_version_from_dockerfile)
-
     # quay paginates 50 items at a time, so it is simpler to use gcrane
     # to list all the tags, than DIY parse the pagination logic
     if ! "${GCRANE_CMD[@]}" ls --platform "linux/amd64" "${image}" 2>/dev/null > "${TAG_LOG}"; then
@@ -550,7 +503,7 @@ verify_module_versions()
 {
     # verify all dependencies are using the same version across all go.mod
     # in the repository. Ignore indirect ones.
-    echo "Verify all go.mod direct dependencies are the same across go.mods ..."
+    echo "Verify all go.mod dependencies are the same across go.mods ..."
 
     # shellcheck disable=SC2119
     _module_direct_dependencies | while read -r module version; do
@@ -629,43 +582,16 @@ verify_module_releases()
     echo -e "Done\n"
 }
 
-_mutate_gomod_files_for_osv_scanner()
-{
-    # mutate go.mod files to include go directive with exact patch version
-    # from main Dockerfile for correct golang stdlib vulnerability information
-    local image_and_tag image image_and_tag_without_sha tag tag_minor
-
-    read -r image_and_tag image image_and_tag_without_sha tag tag_minor < \
-        <(_get_golang_version_from_dockerfile)
-
-    for modfile in **/go.mod; do
-        sed -i.bak -e "s/^go [[:digit:]]\.[[:digit:]]\+/go ${tag}/" "${modfile}"
-    done
-}
-
-_restore_mutated_gomod_files()
-{
-    # restore mutated gomod files to original state
-    for bakfile in **/go.mod.bak; do
-        modfile="${bakfile/.bak}"
-        mv "${bakfile}" "${modfile}"
-    done
-}
-
 verify_vulnerabilities()
 {
     # run osv-scanner to verify if we have open vulnerabilities in deps
     echo "Verifying vulnerabilities ..."
 
-    _mutate_gomod_files_for_osv_scanner
-
-    "${OSVSCANNER_CMD[@]}" --skip-git -r . > "${SCAN_LOG}" || true
+    "${OSVSCANNER_CMD[@]}" -r . > "${SCAN_LOG}" || true
     if ! grep -q "No vulnerabilities found" "${SCAN_LOG}"; then
         cat "${SCAN_LOG}"
     fi
 
-    _restore_mutated_gomod_files
-
     echo -e "Done\n"
 }
 
@@ -686,7 +612,7 @@ if [[ -n "${TAG_EXISTS}" ]]; then
     verify_git_tag_types
     if [[ -n "${RELEASE_EXISTS}" ]]; then
         verify_release_notes
-        verify_release_artifacts
+        verify_release_artefacts
     fi
     verify_container_images
 fi
diff --git a/main.go b/main.go
index b5aa8d05..db13d889 100644
--- a/main.go
+++ b/main.go
@@ -297,6 +297,14 @@ func main() {
 		ProvisionerFactory: provisionerFactory,
 	}).SetupWithManager(mgr, maxConcurrency); err != nil {
 		setupLog.Error(err, "unable to create controller", "controller", "HostFirmwareComponents")
+	}
+
+	if err = (&metal3iocontroller.DataImageReconciler{
+		Client:             mgr.GetClient(),
+		Log:                ctrl.Log.WithName("controllers").WithName("DataImage"),
+		ProvisionerFactory: provisionerFactory,
+	}).SetupWithManager(mgr, maxConcurrency); err != nil {
+		setupLog.Error(err, "unable to create controller", "controller", "DataImage")
 		os.Exit(1)
 	}
 
diff --git a/pkg/provisioner/demo/demo.go b/pkg/provisioner/demo/demo.go
index 77e61b02..e01f7748 100644
--- a/pkg/provisioner/demo/demo.go
+++ b/pkg/provisioner/demo/demo.go
@@ -306,3 +306,15 @@ func (p *demoProvisioner) RemoveBMCEventSubscriptionForNode(_ metal3api.BMCEvent
 func (p *demoProvisioner) GetFirmwareComponents() (components []metal3api.FirmwareComponentStatus, err error) {
 	return components, nil
 }
+
+func (p *demoProvisioner) IsDataImageReady() (isNodeBusy bool, nodeError error) {
+	return false, nil
+}
+
+func (p *demoProvisioner) AttachDataImage(_ string) (err error) {
+	return nil
+}
+
+func (p *demoProvisioner) DetachDataImage() (err error) {
+	return nil
+}
diff --git a/pkg/provisioner/fixture/fixture.go b/pkg/provisioner/fixture/fixture.go
index d0bf7ed7..7e890a3d 100644
--- a/pkg/provisioner/fixture/fixture.go
+++ b/pkg/provisioner/fixture/fixture.go
@@ -360,3 +360,15 @@ func (p *fixtureProvisioner) RemoveBMCEventSubscriptionForNode(_ metal3api.BMCEv
 func (p *fixtureProvisioner) GetFirmwareComponents() (components []metal3api.FirmwareComponentStatus, err error) {
 	return components, nil
 }
+
+func (p *fixtureProvisioner) IsDataImageReady() (isNodeBusy bool, nodeError error) {
+	return false, nil
+}
+
+func (p *fixtureProvisioner) AttachDataImage(_ string) (err error) {
+	return nil
+}
+
+func (p *fixtureProvisioner) DetachDataImage() (err error) {
+	return nil
+}
diff --git a/pkg/provisioner/ironic/clients/features.go b/pkg/provisioner/ironic/clients/features.go
index 44e4c3f0..10c4b7be 100644
--- a/pkg/provisioner/ironic/clients/features.go
+++ b/pkg/provisioner/ironic/clients/features.go
@@ -42,14 +42,23 @@ func (af AvailableFeatures) Log(logger logr.Logger) {
 	logger.Info("supported Ironic API features",
 		"maxVersion", fmt.Sprintf("1.%d", af.MaxVersion),
 		"chosenVersion", af.ChooseMicroversion(),
-		"firmwareUpdates", af.HasFirmwareUpdates())
+		"firmwareUpdates", af.HasFirmwareUpdates(),
+		"dataImage", af.HasDataImage())
 }
 
 func (af AvailableFeatures) HasFirmwareUpdates() bool {
 	return af.MaxVersion >= 86
 }
 
+func (af AvailableFeatures) HasDataImage() bool {
+	return af.MaxVersion >= 89
+}
+
 func (af AvailableFeatures) ChooseMicroversion() string {
+	if af.HasDataImage() {
+		return "1.89"
+	}
+
 	if af.HasFirmwareUpdates() {
 		return "1.86"
 	}
diff --git a/pkg/provisioner/ironic/ironic.go b/pkg/provisioner/ironic/ironic.go
index 152aedd2..6890e13b 100644
--- a/pkg/provisioner/ironic/ironic.go
+++ b/pkg/provisioner/ironic/ironic.go
@@ -2194,3 +2194,68 @@ func (p *ironicProvisioner) RemoveBMCEventSubscriptionForNode(subscription metal
 	}
 	return operationComplete()
 }
+
+// TODO(hroyrh) : Replace with GetDataImageStatus function once the virtua_media.get
+// api is available.
+// Checks if the last VirtualMedia action(attach/detach) to a BareMetalHost was
+// successful of not.
+func (p *ironicProvisioner) IsDataImageReady() (isNodeBusy bool, nodeError error) {
+	// TODO(hroyrh)
+	// Get BareMetalHost VirtualMedia details using a GET api to vmedia
+
+	// Check if Ironic API version supports DataImage API
+	// Needs version >= 1.89
+	if !p.availableFeatures.HasDataImage() {
+		return true, fmt.Errorf("ironic version=%d doesn't support DataImage API, needs version>=1.89", p.availableFeatures.MaxVersion)
+	}
+
+	node, err := p.getNode()
+	if err != nil {
+		return true, err
+	}
+
+	isNodeBusy = node.Reservation != ""
+
+	// In case the error node encountered was related to something else
+	// TODO(hroyrh) : Is this check valid ?
+	if !strings.Contains(node.LastError, "attach") && !strings.Contains(node.LastError, "detach") {
+		return isNodeBusy, nil
+	}
+
+	return isNodeBusy, fmt.Errorf("last dataImage action failed, %s", node.LastError)
+}
+
+func (p *ironicProvisioner) AttachDataImage(url string) (err error) {
+	// Check if Ironic API version supports DataImage API
+	// Needs version >= 1.89
+	if !p.availableFeatures.HasDataImage() {
+		return fmt.Errorf("ironic version=%d doesn't support DataImage API, needs version>=1.89", p.availableFeatures.MaxVersion)
+	}
+
+	err = nodes.AttachVirtualMedia(p.ctx, p.client, p.nodeID, nodes.AttachVirtualMediaOpts{
+		DeviceType: nodes.VirtualMediaCD,
+		ImageURL:   url,
+	}).ExtractErr()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (p *ironicProvisioner) DetachDataImage() (err error) {
+	// Check if Ironic API version supports DataImage API
+	// Needs version >= 1.89
+	if !p.availableFeatures.HasDataImage() {
+		return fmt.Errorf("ironic version=%d doesn't support DataImage API, needs version>=1.89", p.availableFeatures.MaxVersion)
+	}
+
+	err = nodes.DetachVirtualMedia(p.ctx, p.client, p.nodeID, nodes.DetachVirtualMediaOpts{
+		DeviceTypes: []nodes.VirtualMediaDeviceType{nodes.VirtualMediaCD},
+	}).ExtractErr()
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/pkg/provisioner/provisioner.go b/pkg/provisioner/provisioner.go
index 0773b67d..689dbd93 100644
--- a/pkg/provisioner/provisioner.go
+++ b/pkg/provisioner/provisioner.go
@@ -204,6 +204,15 @@ type Provisioner interface {
 
 	// GetFirmwareComponents gets all firmware components available from a note
 	GetFirmwareComponents() (components []metal3api.FirmwareComponentStatus, err error)
+
+	// Check if DataImage attach/detach was successful
+	IsDataImageReady() (isNodeBusy bool, nodeError error)
+
+	// Attach DataImage
+	AttachDataImage(URL string) (err error)
+
+	// Detach DataImage
+	DetachDataImage() (err error)
 }
 
 // Result holds the response from a call in the Provsioner API.


Test patch:
diff --git a/controllers/metal3.io/host_state_machine_test.go b/controllers/metal3.io/host_state_machine_test.go
index 987a134d..5fd5b9c6 100644
--- a/controllers/metal3.io/host_state_machine_test.go
+++ b/controllers/metal3.io/host_state_machine_test.go
@@ -1401,6 +1401,18 @@ func (p *mockProvisioner) GetFirmwareComponents() (components []metal3api.Firmwa
 	return components, nil
 }
 
+func (m *mockProvisioner) IsDataImageReady() (isNodeBusy bool, nodeError error) {
+	return false, nil
+}
+
+func (p *mockProvisioner) AttachDataImage(url string) (err error) {
+	return nil
+}
+
+func (p *mockProvisioner) DetachDataImage() (err error) {
+	return nil
+}
+
 func TestUpdateBootModeStatus(t *testing.T) {
 	testCases := []struct {
 		Scenario       string
diff --git a/pkg/provisioner/ironic/clients/features_test.go b/pkg/provisioner/ironic/clients/features_test.go
index c7665d36..517dc2ab 100644
--- a/pkg/provisioner/ironic/clients/features_test.go
+++ b/pkg/provisioner/ironic/clients/features_test.go
@@ -6,7 +6,7 @@ import (
 )
 
 func TestAvailableFeatures_ChooseMicroversion(t *testing.T) {
-	microVersion := "1.86"
+	microVersion := "1.89"
 	type fields struct {
 		MaxVersion int
 	}
@@ -16,21 +16,21 @@ func TestAvailableFeatures_ChooseMicroversion(t *testing.T) {
 		want    string
 	}{
 		{
-			name: fmt.Sprintf("MaxVersion < %d return microversion %s", 86, baseline),
+			name: fmt.Sprintf("MaxVersion < %d return microversion %s", 89, baseline),
 			feature: fields{
 				MaxVersion: 50,
 			},
 			want: baseline,
 		},
 		{
-			name: fmt.Sprintf("MaxVersion = %d return %s", 86, microVersion),
+			name: fmt.Sprintf("MaxVersion = %d return %s", 89, microVersion),
 			feature: fields{
-				MaxVersion: 86,
+				MaxVersion: 89,
 			},
 			want: microVersion,
 		},
 		{
-			name: fmt.Sprintf("MaxVersion > %d return %s", 86, microVersion),
+			name: fmt.Sprintf("MaxVersion > %d return %s", 89, microVersion),
 			feature: fields{
 				MaxVersion: 100,
 			},