Repository: metal3-io/baremetal-operator
Pull request #936 (2021-07-10 02:16:24)

Add PreprovisioningImage API and integration
As proposed in https://github.com/metal3-io/metal3-docs/blob/master/design/baremetal-operator/image-builder-integration.md#custom-agent-image-controller

metal3-io-bot: Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with `/test all`
metal3-io-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by:
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please ask for approval from **zaneb** after the PR has been reviewed.

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/master/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"]} -->
zaneb: /test all
metal3-io-bot: @zaneb: The following test **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
unit | fe1f0ee0d438cedfb5f50d9f6a5e5fb9966a3007 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/unit/1413683621935452160) | `/test unit`

<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 -->
zaneb: /test all
metal3-io-bot: @zaneb: 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: @zaneb: The following test **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
markdownlint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/markdownlint/1435795004806139904) | `/test markdownlint`

<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: @zaneb: The following tests **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
markdownlint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/markdownlint/1435795004806139904) | `/test markdownlint`
unit | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/unit/1435795005389148160) | `/test unit`

<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: @zaneb: The following tests **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
markdownlint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/markdownlint/1435795004806139904) | `/test markdownlint`
unit | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/unit/1435795005389148160) | `/test unit`
gofmt | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gofmt/1435795004684505088) | `/test gofmt`

<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: @zaneb: The following tests **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
markdownlint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/markdownlint/1435795004806139904) | `/test markdownlint`
unit | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/unit/1435795005389148160) | `/test unit`
gofmt | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gofmt/1435795004684505088) | `/test gofmt`
gosec | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gosec/1435795004697088000) | `/test gosec`

<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: @zaneb: The following tests **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
markdownlint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/markdownlint/1435795004806139904) | `/test markdownlint`
unit | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/unit/1435795005389148160) | `/test unit`
gofmt | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gofmt/1435795004684505088) | `/test gofmt`
gosec | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gosec/1435795004697088000) | `/test gosec`
shellcheck | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/shellcheck/1435795005192015872) | `/test shellcheck`

<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: @zaneb: The following tests **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
markdownlint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/markdownlint/1435795004806139904) | `/test markdownlint`
unit | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/unit/1435795005389148160) | `/test unit`
gofmt | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gofmt/1435795004684505088) | `/test gofmt`
gosec | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gosec/1435795004697088000) | `/test gosec`
shellcheck | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/shellcheck/1435795005192015872) | `/test shellcheck`
gomod | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gomod/1435795004772585472) | `/test gomod`

<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: @zaneb: The following tests **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
markdownlint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/markdownlint/1435795004806139904) | `/test markdownlint`
unit | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/unit/1435795005389148160) | `/test unit`
gofmt | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gofmt/1435795004684505088) | `/test gofmt`
gosec | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gosec/1435795004697088000) | `/test gosec`
shellcheck | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/shellcheck/1435795005192015872) | `/test shellcheck`
gomod | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gomod/1435795004772585472) | `/test gomod`
manifestlint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/manifestlint/1435795011357642752) | `/test manifestlint`

<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: @zaneb: The following tests **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
markdownlint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/markdownlint/1435795004806139904) | `/test markdownlint`
unit | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/unit/1435795005389148160) | `/test unit`
gofmt | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gofmt/1435795004684505088) | `/test gofmt`
gosec | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gosec/1435795004697088000) | `/test gosec`
shellcheck | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/shellcheck/1435795005192015872) | `/test shellcheck`
gomod | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gomod/1435795004772585472) | `/test gomod`
manifestlint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/manifestlint/1435795011357642752) | `/test manifestlint`
generate | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/generate/1435795006248980480) | `/test generate`

<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: @zaneb: The following tests **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
markdownlint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/markdownlint/1435795004806139904) | `/test markdownlint`
unit | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/unit/1435795005389148160) | `/test unit`
gofmt | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gofmt/1435795004684505088) | `/test gofmt`
gosec | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gosec/1435795004697088000) | `/test gosec`
shellcheck | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/shellcheck/1435795005192015872) | `/test shellcheck`
gomod | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/gomod/1435795004772585472) | `/test gomod`
manifestlint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/manifestlint/1435795011357642752) | `/test manifestlint`
generate | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/generate/1435795006248980480) | `/test generate`
golint | b4887762a536873d98963604f00b9b9c53e9e989 | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/golint/1435795008593596416) | `/test golint`

<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: @zaneb: The following test **failed**, say `/retest` to rerun all failed tests:

Test name | Commit | Details | Rerun command
--- | --- | --- | ---
golint | 33bbbcae1162677826aa233ddaf454912438618f | [link](https://prow.apps.test.metal3.io/view/gs/metal3-prow/pr-logs/pull/metal3-io_baremetal-operator/936/golint/1437615482629787648) | `/test golint`

<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 -->
zaneb: I got this working and added unit tests, I believe it's ready for review now.
/test-integration
zaneb: /retest
/test-integration
zaneb: /test all
/test-integration
metal3-io-bot: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/metal3-io/baremetal-operator/pull/936#pullrequestreview-760784871" title="Approved">andfasano</a>*

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

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

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

- ~~[OWNERS](https://github.com/metal3-io/baremetal-operator/blob/master/OWNERS)~~ [andfasano]

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: /test-integration
zaneb: /test-integration
zaneb: /test-integration

Patch:
diff --git a/PROJECT b/PROJECT
index e546544e..f3e77ea5 100644
--- a/PROJECT
+++ b/PROJECT
@@ -10,4 +10,7 @@ resources:
 - group: metal3.io
   kind: FirmwareSchema
   version: v1alpha1
+- group: metal3.io
+  kind: PreprovisioningImage
+  version: v1alpha1
 version: "2"
diff --git a/apis/metal3.io/v1alpha1/baremetalhost_types.go b/apis/metal3.io/v1alpha1/baremetalhost_types.go
index 160c410a..3caa24c5 100644
--- a/apis/metal3.io/v1alpha1/baremetalhost_types.go
+++ b/apis/metal3.io/v1alpha1/baremetalhost_types.go
@@ -367,13 +367,19 @@ type BareMetalHostSpec struct {
 	// data to be passed to the host before it boots.
 	UserData *corev1.SecretReference `json:"userData,omitempty"`
 
+	// PreprovisioningNetworkDataName is the name of the Secret in the
+	// local namespace containing network configuration (e.g content of
+	// network_data.json) which is passed to the preprovisioning image, and to
+	// the Config Drive if not overridden by specifying NetworkData.
+	PreprovisioningNetworkDataName string `json:"preprovisioningNetworkDataName,omitempty"`
+
 	// NetworkData holds the reference to the Secret containing network
-	// configuration (e.g content of network_data.json which is passed
-	// to Config Drive).
+	// configuration (e.g content of network_data.json) which is passed
+	// to the Config Drive.
 	NetworkData *corev1.SecretReference `json:"networkData,omitempty"`
 
 	// MetaData holds the reference to the Secret containing host metadata
-	// (e.g. meta_data.json which is passed to Config Drive).
+	// (e.g. meta_data.json) which is passed to the Config Drive.
 	MetaData *corev1.SecretReference `json:"metaData,omitempty"`
 
 	// Description is a human-entered text used to help identify the host
diff --git a/apis/metal3.io/v1alpha1/preprovisioningimage_types.go b/apis/metal3.io/v1alpha1/preprovisioningimage_types.go
new file mode 100644
index 00000000..84697a94
--- /dev/null
+++ b/apis/metal3.io/v1alpha1/preprovisioningimage_types.go
@@ -0,0 +1,110 @@
+/*
+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 v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// ImageFormat enumerates the allowed image formats
+// +kubebuilder:validation:Enum=iso;initrd
+type ImageFormat string
+
+const (
+	ImageFormatISO    ImageFormat = "iso"
+	ImageFormatInitRD ImageFormat = "initrd"
+)
+
+// PreprovisioningImageSpec defines the desired state of PreprovisioningImage
+type PreprovisioningImageSpec struct {
+	// networkDataName is the name of a Secret in the local namespace that
+	// contains network data to build in to the image.
+	// +optional
+	NetworkDataName string `json:"networkDataName,omitempty"`
+
+	// architecture is the processor architecture for which to build the image.
+	// +optional
+	Architecture string `json:"architecture,omitempty"`
+}
+
+type SecretStatus struct {
+	Name    string `json:"name,omitempty"`
+	Version string `json:"version,omitempty"`
+}
+
+type ImageStatusConditionType string
+
+const (
+	// Ready indicates that the Image is available and ready to be downloaded.
+	ConditionImageReady ImageStatusConditionType = "Ready"
+
+	// Error indicates that the operator was unable to build an image.
+	ConditionImageError ImageStatusConditionType = "Error"
+)
+
+// PreprovisioningImageStatus defines the observed state of PreprovisioningImage
+type PreprovisioningImageStatus struct {
+	// imageUrl is the URL from which the built image can be downloaded.
+	ImageUrl string `json:"imageUrl,omitempty"`
+
+	// format is the type of image that is available at the download url:
+	// either iso or initrd.
+	// +optional
+	Format ImageFormat `json:"format,omitempty"`
+
+	// networkData is a reference to the version of the Secret containing the
+	// network data used to build the image.
+	// +optional
+	NetworkData SecretStatus `json:"networkData,omitempty"`
+
+	// architecture is the processor architecture for which the image is built
+	Architecture string `json:"architecture,omitempty"`
+
+	// conditions describe the state of the built image
+	// +patchMergeKey=type
+	// +patchStrategy=merge
+	// +listType=map
+	// +listMapKey=type
+	// +optional
+	Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"`
+}
+
+// +kubebuilder:object:root=true
+// +kubebuilder:resource:shortName=ppimg
+// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status",description="Whether the image is ready"
+// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].reason",description="The reason for the image readiness status"
+// +kubebuilder:subresource:status
+
+// PreprovisioningImage is the Schema for the preprovisioningimages API
+type PreprovisioningImage struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec   PreprovisioningImageSpec   `json:"spec,omitempty"`
+	Status PreprovisioningImageStatus `json:"status,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// PreprovisioningImageList contains a list of PreprovisioningImage
+type PreprovisioningImageList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []PreprovisioningImage `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&PreprovisioningImage{}, &PreprovisioningImageList{})
+}
diff --git a/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go b/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go
index 7ab1507e..44f8b914 100644
--- a/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go
@@ -679,6 +679,103 @@ func (in *OperationMetric) DeepCopy() *OperationMetric {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PreprovisioningImage) DeepCopyInto(out *PreprovisioningImage) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	out.Spec = in.Spec
+	in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PreprovisioningImage.
+func (in *PreprovisioningImage) DeepCopy() *PreprovisioningImage {
+	if in == nil {
+		return nil
+	}
+	out := new(PreprovisioningImage)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *PreprovisioningImage) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PreprovisioningImageList) DeepCopyInto(out *PreprovisioningImageList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]PreprovisioningImage, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PreprovisioningImageList.
+func (in *PreprovisioningImageList) DeepCopy() *PreprovisioningImageList {
+	if in == nil {
+		return nil
+	}
+	out := new(PreprovisioningImageList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *PreprovisioningImageList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PreprovisioningImageSpec) DeepCopyInto(out *PreprovisioningImageSpec) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PreprovisioningImageSpec.
+func (in *PreprovisioningImageSpec) DeepCopy() *PreprovisioningImageSpec {
+	if in == nil {
+		return nil
+	}
+	out := new(PreprovisioningImageSpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PreprovisioningImageStatus) DeepCopyInto(out *PreprovisioningImageStatus) {
+	*out = *in
+	out.NetworkData = in.NetworkData
+	if in.Conditions != nil {
+		in, out := &in.Conditions, &out.Conditions
+		*out = make([]metav1.Condition, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PreprovisioningImageStatus.
+func (in *PreprovisioningImageStatus) DeepCopy() *PreprovisioningImageStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(PreprovisioningImageStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *ProvisionStatus) DeepCopyInto(out *ProvisionStatus) {
 	*out = *in
@@ -809,6 +906,21 @@ func (in *SchemaSettingError) DeepCopy() *SchemaSettingError {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *SecretStatus) DeepCopyInto(out *SecretStatus) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretStatus.
+func (in *SecretStatus) DeepCopy() *SecretStatus {
+	if in == nil {
+		return nil
+	}
+	out := new(SecretStatus)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *SettingSchema) DeepCopyInto(out *SettingSchema) {
 	*out = *in
diff --git a/config/crd/bases/metal3.io_baremetalhosts.yaml b/config/crd/bases/metal3.io_baremetalhosts.yaml
index 4ff1fbab..6fe6077e 100644
--- a/config/crd/bases/metal3.io_baremetalhosts.yaml
+++ b/config/crd/bases/metal3.io_baremetalhosts.yaml
@@ -244,7 +244,8 @@ spec:
                 type: object
               metaData:
                 description: MetaData holds the reference to the Secret containing
-                  host metadata (e.g. meta_data.json which is passed to Config Drive).
+                  host metadata (e.g. meta_data.json) which is passed to the Config
+                  Drive.
                 properties:
                   name:
                     description: Name is unique within a namespace to reference a
@@ -257,8 +258,8 @@ spec:
                 type: object
               networkData:
                 description: NetworkData holds the reference to the Secret containing
-                  network configuration (e.g content of network_data.json which is
-                  passed to Config Drive).
+                  network configuration (e.g content of network_data.json) which is
+                  passed to the Config Drive.
                 properties:
                   name:
                     description: Name is unique within a namespace to reference a
@@ -272,6 +273,12 @@ spec:
               online:
                 description: Should the server be online?
                 type: boolean
+              preprovisioningNetworkDataName:
+                description: PreprovisioningNetworkDataName is the name of the Secret
+                  in the local namespace containing network configuration (e.g content
+                  of network_data.json) which is passed to the preprovisioning image,
+                  and to the Config Drive if not overridden by specifying NetworkData.
+                type: string
               raid:
                 description: RAID configuration for bare metal server
                 properties:
diff --git a/config/crd/bases/metal3.io_preprovisioningimages.yaml b/config/crd/bases/metal3.io_preprovisioningimages.yaml
new file mode 100644
index 00000000..180f1f60
--- /dev/null
+++ b/config/crd/bases/metal3.io_preprovisioningimages.yaml
@@ -0,0 +1,172 @@
+
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.6.2
+  creationTimestamp: null
+  name: preprovisioningimages.metal3.io
+spec:
+  group: metal3.io
+  names:
+    kind: PreprovisioningImage
+    listKind: PreprovisioningImageList
+    plural: preprovisioningimages
+    shortNames:
+    - ppimg
+    singular: preprovisioningimage
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - description: Whether the image is ready
+      jsonPath: .status.conditions[?(@.type=='Ready')].status
+      name: Ready
+      type: string
+    - description: The reason for the image readiness status
+      jsonPath: .status.conditions[?(@.type=='Ready')].reason
+      name: Reason
+      type: string
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: PreprovisioningImage is the Schema for the preprovisioningimages
+          API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: PreprovisioningImageSpec defines the desired state of PreprovisioningImage
+            properties:
+              architecture:
+                description: architecture is the processor architecture for which
+                  to build the image.
+                type: string
+              networkDataName:
+                description: networkDataName is the name of a Secret in the local
+                  namespace that contains network data to build in to the image.
+                type: string
+            type: object
+          status:
+            description: PreprovisioningImageStatus defines the observed state of
+              PreprovisioningImage
+            properties:
+              architecture:
+                description: architecture is the processor architecture for which
+                  the image is built
+                type: string
+              conditions:
+                description: conditions describe the state of the built image
+                items:
+                  description: "Condition contains details for one aspect of the current
+                    state of this API Resource. --- This struct is intended for direct
+                    use as an array at the field path .status.conditions.  For example,
+                    type FooStatus struct{     // Represents the observations of a
+                    foo's current state.     // Known .status.conditions.type are:
+                    \"Available\", \"Progressing\", and \"Degraded\"     // +patchMergeKey=type
+                    \    // +patchStrategy=merge     // +listType=map     // +listMapKey=type
+                    \    Conditions []metav1.Condition `json:\"conditions,omitempty\"
+                    patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`
+                    \n     // other fields }"
+                  properties:
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the condition
+                        transitioned from one status to another. This should be when
+                        the underlying condition changed.  If that is not known, then
+                        using the time when the API field changed is acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: message is a human readable message indicating
+                        details about the transition. This may be an empty string.
+                      maxLength: 32768
+                      type: string
+                    observedGeneration:
+                      description: observedGeneration represents the .metadata.generation
+                        that the condition was set based upon. For instance, if .metadata.generation
+                        is currently 12, but the .status.conditions[x].observedGeneration
+                        is 9, the condition is out of date with respect to the current
+                        state of the instance.
+                      format: int64
+                      minimum: 0
+                      type: integer
+                    reason:
+                      description: reason contains a programmatic identifier indicating
+                        the reason for the condition's last transition. Producers
+                        of specific condition types may define expected values and
+                        meanings for this field, and whether the values are considered
+                        a guaranteed API. The value should be a CamelCase string.
+                        This field may not be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    status:
+                      description: status of the condition, one of True, False, Unknown.
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of condition in CamelCase or in foo.example.com/CamelCase.
+                        --- Many .condition.type values are consistent across resources
+                        like Available, but because arbitrary conditions can be useful
+                        (see .node.status.conditions), the ability to deconflict is
+                        important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt)
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - message
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+                x-kubernetes-list-map-keys:
+                - type
+                x-kubernetes-list-type: map
+              format:
+                description: 'format is the type of image that is available at the
+                  download url: either iso or initrd.'
+                enum:
+                - iso
+                - initrd
+                type: string
+              imageUrl:
+                description: imageUrl is the URL from which the built image can be
+                  downloaded.
+                type: string
+              networkData:
+                description: networkData is a reference to the version of the Secret
+                  containing the network data used to build the image.
+                properties:
+                  name:
+                    type: string
+                  version:
+                    type: string
+                type: object
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index d155aa68..0160d1a9 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -5,6 +5,7 @@ resources:
 - bases/metal3.io_baremetalhosts.yaml
 - bases/metal3.io_hostfirmwaresettings.yaml
 - bases/metal3.io_firmwareschemas.yaml
+- bases/metal3.io_preprovisioningimages.yaml
 # +kubebuilder:scaffold:crdkustomizeresource
 
 patchesStrategicMerge:
@@ -13,6 +14,7 @@ patchesStrategicMerge:
 #- patches/webhook_in_baremetalhosts.yaml
 #- patches/webhook_in_hostfirmwaresettings.yaml
 #- patches/webhook_in_firmwareschemas.yaml
+#- patches/webhook_in_preprovisioningimages.yaml
 # +kubebuilder:scaffold:crdkustomizewebhookpatch
 
 # [CERTMANAGER] To enable webhook, uncomment all the sections with [CERTMANAGER] prefix.
@@ -20,6 +22,7 @@ patchesStrategicMerge:
 - patches/cainjection_in_baremetalhosts.yaml
 #- patches/cainjection_in_hostfirmwaresettings.yaml
 #- patches/cainjection_in_firmwareschemas.yaml
+#- patches/cainjection_in_preprovisioningimages.yaml
 # +kubebuilder:scaffold:crdkustomizecainjectionpatch
 
 # the following config is for teaching kustomize how to do kustomization for CRDs.
diff --git a/config/crd/patches/cainjection_in_preprovisioningimages.yaml b/config/crd/patches/cainjection_in_preprovisioningimages.yaml
new file mode 100644
index 00000000..66b6e24f
--- /dev/null
+++ b/config/crd/patches/cainjection_in_preprovisioningimages.yaml
@@ -0,0 +1,8 @@
+# The following patch adds a directive for certmanager to inject CA into the CRD
+# CRD conversion requires k8s 1.13 or later.
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME)
+  name: preprovisioningimages.metal3.io
diff --git a/config/crd/patches/webhook_in_preprovisioningimages.yaml b/config/crd/patches/webhook_in_preprovisioningimages.yaml
new file mode 100644
index 00000000..2773ae6f
--- /dev/null
+++ b/config/crd/patches/webhook_in_preprovisioningimages.yaml
@@ -0,0 +1,17 @@
+# The following patch enables conversion webhook for CRD
+# CRD conversion requires k8s 1.13 or later.
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  name: preprovisioningimages.metal3.io
+spec:
+  conversion:
+    strategy: Webhook
+    webhookClientConfig:
+      # this is "\n" used as a placeholder, otherwise it will be rejected by the apiserver for being blank,
+      # but we're going to set it later using the cert-manager (or potentially a patch if not using cert-manager)
+      caBundle: Cg==
+      service:
+        namespace: system
+        name: webhook-service
+        path: /convert
diff --git a/config/rbac/preprovisioningimage_editor_role.yaml b/config/rbac/preprovisioningimage_editor_role.yaml
new file mode 100644
index 00000000..7a868a85
--- /dev/null
+++ b/config/rbac/preprovisioningimage_editor_role.yaml
@@ -0,0 +1,24 @@
+# permissions for end users to edit preprovisioningimages.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: preprovisioningimage-editor-role
+rules:
+- apiGroups:
+  - metal3.io
+  resources:
+  - preprovisioningimages
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - metal3.io
+  resources:
+  - preprovisioningimages/status
+  verbs:
+  - get
diff --git a/config/rbac/preprovisioningimage_viewer_role.yaml b/config/rbac/preprovisioningimage_viewer_role.yaml
new file mode 100644
index 00000000..40f73365
--- /dev/null
+++ b/config/rbac/preprovisioningimage_viewer_role.yaml
@@ -0,0 +1,20 @@
+# permissions for end users to view preprovisioningimages.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: preprovisioningimage-viewer-role
+rules:
+- apiGroups:
+  - metal3.io
+  resources:
+  - preprovisioningimages
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - metal3.io
+  resources:
+  - preprovisioningimages/status
+  verbs:
+  - get
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 27b97fa4..a944ddaa 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -86,3 +86,23 @@ rules:
   - get
   - patch
   - update
+- apiGroups:
+  - metal3.io
+  resources:
+  - preprovisioningimages
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - metal3.io
+  resources:
+  - preprovisioningimages/status
+  verbs:
+  - get
+  - patch
+  - update
diff --git a/config/render/capm3.yaml b/config/render/capm3.yaml
index 9760186f..7c994b33 100644
--- a/config/render/capm3.yaml
+++ b/config/render/capm3.yaml
@@ -242,7 +242,8 @@ spec:
                 type: object
               metaData:
                 description: MetaData holds the reference to the Secret containing
-                  host metadata (e.g. meta_data.json which is passed to Config Drive).
+                  host metadata (e.g. meta_data.json) which is passed to the Config
+                  Drive.
                 properties:
                   name:
                     description: Name is unique within a namespace to reference a
@@ -255,8 +256,8 @@ spec:
                 type: object
               networkData:
                 description: NetworkData holds the reference to the Secret containing
-                  network configuration (e.g content of network_data.json which is
-                  passed to Config Drive).
+                  network configuration (e.g content of network_data.json) which is
+                  passed to the Config Drive.
                 properties:
                   name:
                     description: Name is unique within a namespace to reference a
@@ -270,6 +271,12 @@ spec:
               online:
                 description: Should the server be online?
                 type: boolean
+              preprovisioningNetworkDataName:
+                description: PreprovisioningNetworkDataName is the name of the Secret
+                  in the local namespace containing network configuration (e.g content
+                  of network_data.json) which is passed to the preprovisioning image,
+                  and to the Config Drive if not overridden by specifying NetworkData.
+                type: string
               raid:
                 description: RAID configuration for bare metal server
                 properties:
@@ -1358,6 +1365,177 @@ status:
   conditions: []
   storedVersions: []
 ---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.6.2
+  creationTimestamp: null
+  name: preprovisioningimages.metal3.io
+spec:
+  group: metal3.io
+  names:
+    kind: PreprovisioningImage
+    listKind: PreprovisioningImageList
+    plural: preprovisioningimages
+    shortNames:
+    - ppimg
+    singular: preprovisioningimage
+  scope: Namespaced
+  versions:
+  - additionalPrinterColumns:
+    - description: Whether the image is ready
+      jsonPath: .status.conditions[?(@.type=='Ready')].status
+      name: Ready
+      type: string
+    - description: The reason for the image readiness status
+      jsonPath: .status.conditions[?(@.type=='Ready')].reason
+      name: Reason
+      type: string
+    name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: PreprovisioningImage is the Schema for the preprovisioningimages
+          API
+        properties:
+          apiVersion:
+            description: 'APIVersion defines the versioned schema of this representation
+              of an object. Servers should convert recognized schemas to the latest
+              internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources'
+            type: string
+          kind:
+            description: 'Kind is a string value representing the REST resource this
+              object represents. Servers may infer this from the endpoint the client
+              submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds'
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: PreprovisioningImageSpec defines the desired state of PreprovisioningImage
+            properties:
+              architecture:
+                description: architecture is the processor architecture for which
+                  to build the image.
+                type: string
+              networkDataName:
+                description: networkDataName is the name of a Secret in the local
+                  namespace that contains network data to build in to the image.
+                type: string
+            type: object
+          status:
+            description: PreprovisioningImageStatus defines the observed state of
+              PreprovisioningImage
+            properties:
+              architecture:
+                description: architecture is the processor architecture for which
+                  the image is built
+                type: string
+              conditions:
+                description: conditions describe the state of the built image
+                items:
+                  description: "Condition contains details for one aspect of the current
+                    state of this API Resource. --- This struct is intended for direct
+                    use as an array at the field path .status.conditions.  For example,
+                    type FooStatus struct{     // Represents the observations of a
+                    foo's current state.     // Known .status.conditions.type are:
+                    \"Available\", \"Progressing\", and \"Degraded\"     // +patchMergeKey=type
+                    \    // +patchStrategy=merge     // +listType=map     // +listMapKey=type
+                    \    Conditions []metav1.Condition `json:\"conditions,omitempty\"
+                    patchStrategy:\"merge\" patchMergeKey:\"type\" protobuf:\"bytes,1,rep,name=conditions\"`
+                    \n     // other fields }"
+                  properties:
+                    lastTransitionTime:
+                      description: lastTransitionTime is the last time the condition
+                        transitioned from one status to another. This should be when
+                        the underlying condition changed.  If that is not known, then
+                        using the time when the API field changed is acceptable.
+                      format: date-time
+                      type: string
+                    message:
+                      description: message is a human readable message indicating
+                        details about the transition. This may be an empty string.
+                      maxLength: 32768
+                      type: string
+                    observedGeneration:
+                      description: observedGeneration represents the .metadata.generation
+                        that the condition was set based upon. For instance, if .metadata.generation
+                        is currently 12, but the .status.conditions[x].observedGeneration
+                        is 9, the condition is out of date with respect to the current
+                        state of the instance.
+                      format: int64
+                      minimum: 0
+                      type: integer
+                    reason:
+                      description: reason contains a programmatic identifier indicating
+                        the reason for the condition's last transition. Producers
+                        of specific condition types may define expected values and
+                        meanings for this field, and whether the values are considered
+                        a guaranteed API. The value should be a CamelCase string.
+                        This field may not be empty.
+                      maxLength: 1024
+                      minLength: 1
+                      pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
+                      type: string
+                    status:
+                      description: status of the condition, one of True, False, Unknown.
+                      enum:
+                      - "True"
+                      - "False"
+                      - Unknown
+                      type: string
+                    type:
+                      description: type of condition in CamelCase or in foo.example.com/CamelCase.
+                        --- Many .condition.type values are consistent across resources
+                        like Available, but because arbitrary conditions can be useful
+                        (see .node.status.conditions), the ability to deconflict is
+                        important. The regex it matches is (dns1123SubdomainFmt/)?(qualifiedNameFmt)
+                      maxLength: 316
+                      pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
+                      type: string
+                  required:
+                  - lastTransitionTime
+                  - message
+                  - reason
+                  - status
+                  - type
+                  type: object
+                type: array
+                x-kubernetes-list-map-keys:
+                - type
+                x-kubernetes-list-type: map
+              format:
+                description: 'format is the type of image that is available at the
+                  download url: either iso or initrd.'
+                enum:
+                - iso
+                - initrd
+                type: string
+              imageUrl:
+                description: imageUrl is the URL from which the built image can be
+                  downloaded.
+                type: string
+              networkData:
+                description: networkData is a reference to the version of the Secret
+                  containing the network data used to build the image.
+                properties:
+                  name:
+                    type: string
+                  version:
+                    type: string
+                type: object
+            type: object
+        type: object
+    served: true
+    storage: true
+    subresources:
+      status: {}
+status:
+  acceptedNames:
+    kind: ""
+    plural: ""
+  conditions: []
+  storedVersions: []
+---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: Role
 metadata:
@@ -1489,6 +1667,26 @@ rules:
   - get
   - patch
   - update
+- apiGroups:
+  - metal3.io
+  resources:
+  - preprovisioningimages
+  verbs:
+  - create
+  - delete
+  - get
+  - list
+  - patch
+  - update
+  - watch
+- apiGroups:
+  - metal3.io
+  resources:
+  - preprovisioningimages/status
+  verbs:
+  - get
+  - patch
+  - update
 ---
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRole
diff --git a/config/samples/metal3.io_v1alpha1_preprovisioningimage.yaml b/config/samples/metal3.io_v1alpha1_preprovisioningimage.yaml
new file mode 100644
index 00000000..ece45e31
--- /dev/null
+++ b/config/samples/metal3.io_v1alpha1_preprovisioningimage.yaml
@@ -0,0 +1,7 @@
+apiVersion: metal3.io/v1alpha1
+kind: PreprovisioningImage
+metadata:
+  name: preprovisioningimage-sample
+spec:
+  # Add fields here
+  foo: bar
diff --git a/controllers/metal3.io/baremetalhost_controller.go b/controllers/metal3.io/baremetalhost_controller.go
index 244a5b65..ef0b5d32 100644
--- a/controllers/metal3.io/baremetalhost_controller.go
+++ b/controllers/metal3.io/baremetalhost_controller.go
@@ -33,12 +33,14 @@ import (
 	"github.com/prometheus/client_golang/prometheus"
 
 	corev1 "k8s.io/api/core/v1"
+	apiequality "k8s.io/apimachinery/pkg/api/equality"
 	k8serrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/api/meta"
 	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/controller/controllerutil"
 	"sigs.k8s.io/controller-runtime/pkg/event"
 	"sigs.k8s.io/controller-runtime/pkg/predicate"
 
@@ -52,6 +54,7 @@ import (
 const (
 	hostErrorRetryDelay           = time.Second * 10
 	unmanagedRetryDelay           = time.Minute * 10
+	preprovImageRetryDelay        = time.Minute * 5
 	provisionerNotReadyRetryDelay = time.Second * 30
 	rebootAnnotationPrefix        = "reboot.metal3.io"
 	inspectAnnotationPrefix       = "inspect.metal3.io"
@@ -85,6 +88,7 @@ func (info *reconcileInfo) publishEvent(reason, message string) {
 
 // +kubebuilder:rbac:groups=metal3.io,resources=baremetalhosts,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=metal3.io,resources=baremetalhosts/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups=metal3.io,resources=preprovisioningimages,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;update
 // +kubebuilder:rbac:groups="",resources=events,verbs=get;list;watch;create;update;patch
 
@@ -598,6 +602,128 @@ func (r *BareMetalHostReconciler) detachHost(prov provisioner.Provisioner, info
 	return slowPoll
 }
 
+type imageBuildError struct {
+	Message string
+}
+
+func (ibe imageBuildError) Error() string {
+	return ibe.Message
+}
+
+func (r *BareMetalHostReconciler) preprovImageAvailable(info *reconcileInfo, image *metal3v1alpha1.PreprovisioningImage) (bool, error) {
+	if image.Status.Architecture != image.Spec.Architecture {
+		info.log.Info("pre-provisioning image architecture mismatch",
+			"wanted", image.Spec.Architecture,
+			"current", image.Status.Architecture)
+		return false, nil
+	}
+
+	if image.Spec.NetworkDataName != "" {
+		secretKey := client.ObjectKey{
+			Name:      image.Spec.NetworkDataName,
+			Namespace: image.ObjectMeta.Namespace,
+		}
+		secretManager := r.secretManager(info.log)
+		networkData, err := secretManager.AcquireSecret(secretKey, info.host, false)
+		if err != nil {
+			return false, err
+		}
+		if image.Status.NetworkData.Version != networkData.GetResourceVersion() {
+			info.log.Info("network data in pre-provisioning image is out of date",
+				"latestVersion", networkData.GetResourceVersion(),
+				"currentVersion", image.Status.NetworkData.Version)
+			return false, nil
+		}
+	}
+	if image.Status.NetworkData.Name != image.Spec.NetworkDataName {
+		info.log.Info("network data location in pre-provisioning image is out of date")
+		return false, nil
+	}
+
+	for _, cond := range image.Status.Conditions {
+		if cond.Status == metav1.ConditionTrue {
+			switch metal3v1alpha1.ImageStatusConditionType(cond.Type) {
+			case metal3v1alpha1.ConditionImageReady:
+				return true, nil
+			case metal3v1alpha1.ConditionImageError:
+				info.log.Info("error building PreprovisioningImage",
+					"message", cond.Message)
+				return false, imageBuildError{cond.Message}
+			}
+		}
+	}
+
+	info.log.Info("pending PreprovisioningImage not ready")
+	return false, nil
+}
+
+func getHostArchitecture(host *metal3v1alpha1.BareMetalHost) string {
+	if host.Status.HardwareDetails != nil &&
+		host.Status.HardwareDetails.CPU.Arch != "" {
+		return host.Status.HardwareDetails.CPU.Arch
+	}
+	if hwprof, err := hardware.GetProfile(getHardwareProfileName(host)); err == nil {
+		return hwprof.CPUArch
+	}
+	return ""
+}
+
+func (r *BareMetalHostReconciler) getPreprovImage(info *reconcileInfo, formats []metal3v1alpha1.ImageFormat) (*provisioner.PreprovisioningImage, error) {
+	if formats == nil {
+		// No image build requested
+		return nil, nil
+	}
+
+	if len(formats) == 0 {
+		return nil, imageBuildError{"no acceptable formats for preprovisioning image"}
+	}
+
+	expectedSpec := metal3v1alpha1.PreprovisioningImageSpec{
+		NetworkDataName: info.host.Spec.PreprovisioningNetworkDataName,
+		Architecture:    getHostArchitecture(info.host),
+	}
+
+	preprovImage := metal3v1alpha1.PreprovisioningImage{}
+	key := client.ObjectKey{
+		Name:      info.host.Name,
+		Namespace: info.host.Namespace,
+	}
+	err := r.Get(context.TODO(), key, &preprovImage)
+	if k8serrors.IsNotFound(err) {
+		info.log.Info("creating new PreprovisioningImage")
+		preprovImage = metal3v1alpha1.PreprovisioningImage{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      key.Name,
+				Namespace: key.Namespace,
+			},
+			Spec: expectedSpec,
+		}
+		controllerutil.SetControllerReference(info.host, &preprovImage, r.Scheme())
+		err = r.Create(context.TODO(), &preprovImage)
+		return nil, err
+	}
+	if err != nil {
+		return nil, errors.Wrap(err, "failed to retrieve pre-provisioning image data")
+	}
+
+	if !apiequality.Semantic.DeepEqual(preprovImage.Spec, expectedSpec) {
+		info.log.Info("updating PreprovisioningImage spec")
+		preprovImage.Spec = expectedSpec
+		err = r.Update(context.TODO(), &preprovImage)
+		return nil, err
+	}
+	if available, err := r.preprovImageAvailable(info, &preprovImage); err != nil || !available {
+		return nil, err
+	}
+
+	image := provisioner.PreprovisioningImage{
+		ImageURL: preprovImage.Status.ImageUrl,
+		Format:   preprovImage.Status.Format,
+	}
+	info.log.Info("using PreprovisioningImage")
+	return &image, nil
+}
+
 // Test the credentials by connecting to the management controller.
 func (r *BareMetalHostReconciler) registerHost(prov provisioner.Provisioner, info *reconcileInfo) actionResult {
 	info.log.Info("registering and validating access to management controller",
@@ -612,16 +738,46 @@ func (r *BareMetalHostReconciler) registerHost(prov provisioner.Provisioner, inf
 		dirty = true
 	}
 
+	preprovImgFormats, err := prov.PreprovisioningImageFormats()
+	if err != nil {
+		return actionError{err}
+	}
+	switch info.host.Status.Provisioning.State {
+	case metal3v1alpha1.StateRegistering, metal3v1alpha1.StateExternallyProvisioned:
+		// No need to create PreprovisioningImage if host is not yet registered
+		// or is externally provisioned
+		preprovImgFormats = nil
+	}
+
+	preprovImg, err := r.getPreprovImage(info, preprovImgFormats)
+	if err != nil {
+		if errors.As(err, &imageBuildError{}) {
+			return recordActionFailure(info, metal3v1alpha1.RegistrationError, err.Error())
+		}
+		return actionError{err}
+	}
+
 	provResult, provID, err := prov.ValidateManagementAccess(
 		provisioner.ManagementAccessData{
 			BootMode:              info.host.Status.Provisioning.BootMode,
 			AutomatedCleaningMode: info.host.Spec.AutomatedCleaningMode,
 			State:                 info.host.Status.Provisioning.State,
 			CurrentImage:          getCurrentImage(info.host),
+			PreprovisioningImage:  preprovImg,
 			HasCustomDeploy:       hasCustomDeploy(info.host),
 		},
 		credsChanged,
 		info.host.Status.ErrorType == metal3v1alpha1.RegistrationError)
+
+	if errors.Is(err, provisioner.ErrNeedsPreprovisioningImage) &&
+		preprovImgFormats != nil {
+		if preprovImg == nil {
+			waitingForPreprovImage.Inc()
+			return actionContinue{preprovImageRetryDelay}
+		}
+		return recordActionFailure(info, metal3v1alpha1.RegistrationError,
+			"Preprovisioning Image is not acceptable to provisioner")
+	}
 	if err != nil {
 		noManagementAccess.Inc()
 		return actionError{errors.Wrap(err, "failed to validate BMC access")}
@@ -725,39 +881,35 @@ func (r *BareMetalHostReconciler) actionInspecting(prov provisioner.Provisioner,
 	return actionComplete{}
 }
 
-func (r *BareMetalHostReconciler) actionMatchProfile(prov provisioner.Provisioner, info *reconcileInfo) actionResult {
-
-	var hardwareProfile string
-
-	info.log.Info("determining hardware profile")
-
-	// Start by looking for an override value from the user
-	if info.host.Spec.HardwareProfile != "" {
-		info.log.Info("using spec value for profile name",
-			"name", info.host.Spec.HardwareProfile)
-		hardwareProfile = info.host.Spec.HardwareProfile
-		_, err := hardware.GetProfile(hardwareProfile)
-		if err != nil {
-			info.log.Info("invalid hardware profile", "profile", hardwareProfile)
-			return actionError{err}
-		}
+func getHardwareProfileName(host *metal3v1alpha1.BareMetalHost) string {
+	if host.Status.HardwareProfile != "" {
+		// Profile name already set
+		return host.Status.HardwareProfile
+	}
+	if host.Spec.HardwareProfile != "" {
+		// Profile name supplied by user
+		return host.Spec.HardwareProfile
 	}
 
-	// Now do a bit of matching.
-	//
 	// FIXME(dhellmann): Insert more robust logic to match
 	// hardware profiles here.
-	if hardwareProfile == "" {
-		if strings.HasPrefix(info.host.Spec.BMC.Address, "libvirt") {
-			hardwareProfile = "libvirt"
-			info.log.Info("determining from BMC address", "name", hardwareProfile)
-		}
+	if strings.HasPrefix(host.Spec.BMC.Address, "libvirt") {
+		return "libvirt"
 	}
+	return hardware.DefaultProfileName
+}
 
-	// Now default to a value just in case there is no match
-	if hardwareProfile == "" {
-		hardwareProfile = hardware.DefaultProfileName
-		info.log.Info("using the default", "name", hardwareProfile)
+func (r *BareMetalHostReconciler) actionMatchProfile(prov provisioner.Provisioner, info *reconcileInfo) actionResult {
+
+	hardwareProfile := getHardwareProfileName(info.host)
+	info.log.Info("using hardware profile", "profile", hardwareProfile)
+
+	_, err := hardware.GetProfile(hardwareProfile)
+	if err != nil {
+		info.log.Info("invalid hardware profile", "profile", hardwareProfile)
+		// FIXME(zaneb): This error requires a Spec change to fix, so we
+		// shouldn't treat it as transient
+		return actionError{err}
 	}
 
 	if info.host.SetHardwareProfile(hardwareProfile) {
@@ -765,7 +917,6 @@ func (r *BareMetalHostReconciler) actionMatchProfile(prov provisioner.Provisione
 		info.publishEvent("ProfileSet", fmt.Sprintf("Hardware profile set: %s", hardwareProfile))
 	}
 
-	clearError(info.host)
 	return actionComplete{}
 }
 
@@ -1370,7 +1521,7 @@ func (r *BareMetalHostReconciler) updateEventHandler(e event.UpdateEvent) bool {
 }
 
 // SetupWithManager registers the reconciler to be run by the manager
-func (r *BareMetalHostReconciler) SetupWithManager(mgr ctrl.Manager) error {
+func (r *BareMetalHostReconciler) SetupWithManager(mgr ctrl.Manager, preprovImgEnable bool) error {
 
 	maxConcurrentReconciles := runtime.NumCPU()
 	if maxConcurrentReconciles > 8 {
@@ -1398,13 +1549,18 @@ func (r *BareMetalHostReconciler) SetupWithManager(mgr ctrl.Manager) error {
 		MaxConcurrentReconciles: maxConcurrentReconciles,
 	}
 
-	return ctrl.NewControllerManagedBy(mgr).
+	controller := ctrl.NewControllerManagedBy(mgr).
 		For(&metal3v1alpha1.BareMetalHost{}).
 		WithEventFilter(
 			predicate.Funcs{
 				UpdateFunc: r.updateEventHandler,
 			}).
 		WithOptions(opts).
-		Owns(&corev1.Secret{}).
-		Complete(r)
+		Owns(&corev1.Secret{})
+
+	if preprovImgEnable {
+		controller.Owns(&metal3v1alpha1.PreprovisioningImage{})
+	}
+
+	return controller.Complete(r)
 }
diff --git a/controllers/metal3.io/host_config_data.go b/controllers/metal3.io/host_config_data.go
index ab2f330e..00c57a1c 100644
--- a/controllers/metal3.io/host_config_data.go
+++ b/controllers/metal3.io/host_config_data.go
@@ -2,6 +2,7 @@ package controllers
 
 import (
 	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
 	"k8s.io/apimachinery/pkg/types"
 
 	metal3v1alpha1 "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
@@ -64,16 +65,22 @@ func (hcd *hostConfigData) UserData() (string, error) {
 
 // NetworkData get network configuration
 func (hcd *hostConfigData) NetworkData() (string, error) {
-	if hcd.host.Spec.NetworkData == nil {
-		hcd.log.Info("NetworkData is not set returning epmty(nil) data")
+	networkData := hcd.host.Spec.NetworkData
+	if networkData == nil && hcd.host.Spec.PreprovisioningNetworkDataName != "" {
+		networkData = &corev1.SecretReference{
+			Name: hcd.host.Spec.PreprovisioningNetworkDataName,
+		}
+	}
+	if networkData == nil {
+		hcd.log.Info("NetworkData is not set, returning empty data")
 		return "", nil
 	}
-	namespace := hcd.host.Spec.NetworkData.Namespace
+	namespace := networkData.Namespace
 	if namespace == "" {
 		namespace = hcd.host.Namespace
 	}
 	return hcd.getSecretData(
-		hcd.host.Spec.NetworkData.Name,
+		networkData.Name,
 		namespace,
 		"networkData",
 	)
diff --git a/controllers/metal3.io/metrics.go b/controllers/metal3.io/metrics.go
index 3113603c..3dc7d854 100644
--- a/controllers/metal3.io/metrics.go
+++ b/controllers/metal3.io/metrics.go
@@ -53,6 +53,10 @@ var updatedCredentials = prometheus.NewCounter(prometheus.CounterOpts{
 	Name: "metal3_credentials_updated_total",
 	Help: "Number of times a host's credentials change",
 })
+var waitingForPreprovImage = prometheus.NewCounter(prometheus.CounterOpts{
+	Name: "metal3_waiting_for_preprov_image_total",
+	Help: "Number of times the preprovisioning image is required but not available",
+})
 var noManagementAccess = prometheus.NewCounter(prometheus.CounterOpts{
 	Name: "metal3_credentials_no_management_access_total",
 	Help: "Number of times a host management interface is unavailable",
diff --git a/controllers/metal3.io/preprovisioningimage_controller.go b/controllers/metal3.io/preprovisioningimage_controller.go
new file mode 100644
index 00000000..16622831
--- /dev/null
+++ b/controllers/metal3.io/preprovisioningimage_controller.go
@@ -0,0 +1,237 @@
+/*
+
+
+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"
+	"os"
+	"time"
+
+	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
+	apiequality "k8s.io/apimachinery/pkg/api/equality"
+	k8serrors "k8s.io/apimachinery/pkg/api/errors"
+	"k8s.io/apimachinery/pkg/api/meta"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+
+	metal3 "github.com/metal3-io/baremetal-operator/apis/metal3.io/v1alpha1"
+	"github.com/metal3-io/baremetal-operator/pkg/secretutils"
+)
+
+const (
+	minRetryDelay = time.Second * 10
+	maxRetryDelay = time.Minute * 10
+)
+
+// PreprovisioningImageReconciler reconciles a PreprovisioningImage object
+type PreprovisioningImageReconciler struct {
+	client.Client
+	Log       logr.Logger
+	Scheme    *runtime.Scheme
+	APIReader client.Reader
+}
+
+type imageConditionReason string
+
+const (
+	reasonImageSuccess            imageConditionReason = "ImageSuccess"
+	reasonImageConfigurationError imageConditionReason = "ConfigurationError"
+	reasonImageMissingNetworkData imageConditionReason = "MissingNetworkData"
+)
+
+// +kubebuilder:rbac:groups=metal3.io,resources=preprovisioningimages,verbs=get;list;watch;update;patch
+// +kubebuilder:rbac:groups=metal3.io,resources=preprovisioningimages/status,verbs=get;update;patch
+// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;update
+
+func (r *PreprovisioningImageReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
+	log := r.Log.WithValues("preprovisioningimage", req.NamespacedName)
+
+	result := ctrl.Result{}
+
+	img := metal3.PreprovisioningImage{}
+	err := r.Get(ctx, req.NamespacedName, &img)
+	if err != nil {
+		if k8serrors.IsNotFound(err) {
+			log.Info("PreprovisioningImage not found")
+			err = nil
+		}
+		return ctrl.Result{}, err
+	}
+
+	changed, err := r.update(&img, log)
+
+	if k8serrors.IsNotFound(err) {
+		delay := getErrorRetryDelay(img.Status)
+		log.Info("requeuing to check for secret", "after", delay)
+		result.RequeueAfter = delay
+	}
+	if changed {
+		log.Info("updating status")
+		err = r.Status().Update(ctx, &img)
+	}
+
+	return result, err
+}
+
+func (r *PreprovisioningImageReconciler) update(img *metal3.PreprovisioningImage, log logr.Logger) (bool, error) {
+	generation := img.GetGeneration()
+
+	url, format, errorMessage := getImageURL()
+	if errorMessage != "" {
+		log.Info("no suitable image URL available", "preferredFormat", format)
+		return setError(generation, &img.Status, reasonImageConfigurationError, errorMessage), nil
+	}
+
+	log.Info("image URL available", "url", url, "format", format)
+	secretManager := secretutils.NewSecretManager(log, r.Client, r.APIReader)
+	secretStatus, err := getNetworkDataStatus(secretManager, img)
+	if err == nil {
+		return setImage(generation, &img.Status, url, format,
+			secretStatus, img.Spec.Architecture,
+			"Set default image"), nil
+	}
+
+	if k8serrors.IsNotFound(err) {
+		log.Info("network data Secret does not exist")
+		return setError(generation, &img.Status, reasonImageMissingNetworkData, "NetworkData secret not found"), err
+	}
+
+	return false, err
+}
+
+func getImageURL() (url string, format metal3.ImageFormat, errorMessage string) {
+	format = metal3.ImageFormatISO
+	if iso := os.Getenv("DEPLOY_ISO_URL"); iso != "" {
+		url = iso
+	} else {
+		errorMessage = "No DEPLOY_ISO_URL specified"
+	}
+	return
+}
+
+func getErrorRetryDelay(status metal3.PreprovisioningImageStatus) time.Duration {
+	errorCond := meta.FindStatusCondition(status.Conditions, string(metal3.ConditionImageError))
+	if errorCond == nil || errorCond.Status != metav1.ConditionTrue {
+		return 0
+	}
+
+	// exponential delay
+	delay := time.Since(errorCond.LastTransitionTime.Time) + minRetryDelay
+
+	if delay > maxRetryDelay {
+		return maxRetryDelay
+	}
+	return delay
+}
+
+func getNetworkDataStatus(secretManager secretutils.SecretManager, img *metal3.PreprovisioningImage) (metal3.SecretStatus, error) {
+	networkDataSecret := img.Spec.NetworkDataName
+	if networkDataSecret == "" {
+		return metal3.SecretStatus{}, nil
+	}
+
+	secretKey := client.ObjectKey{
+		Name:      networkDataSecret,
+		Namespace: img.ObjectMeta.Namespace,
+	}
+	secret, err := secretManager.AcquireSecret(secretKey, img, false)
+	if err != nil {
+		return metal3.SecretStatus{}, err
+	}
+
+	return metal3.SecretStatus{
+		Name:    networkDataSecret,
+		Version: secret.GetResourceVersion(),
+	}, nil
+}
+
+func setImageCondition(generation int64, status *metal3.PreprovisioningImageStatus,
+	cond metal3.ImageStatusConditionType, newStatus metav1.ConditionStatus,
+	time metav1.Time, reason imageConditionReason, message string) {
+	newCondition := metav1.Condition{
+		Type:               string(cond),
+		Status:             newStatus,
+		LastTransitionTime: time,
+		ObservedGeneration: generation,
+		Reason:             string(reason),
+		Message:            message,
+	}
+	meta.SetStatusCondition(&status.Conditions, newCondition)
+}
+
+func setImage(generation int64, status *metal3.PreprovisioningImageStatus, url string,
+	format metal3.ImageFormat, networkData metal3.SecretStatus, arch string,
+	message string) bool {
+
+	newStatus := status.DeepCopy()
+	newStatus.ImageUrl = url
+	newStatus.Format = format
+	newStatus.Architecture = arch
+	newStatus.NetworkData = networkData
+
+	time := metav1.Now()
+	reason := reasonImageSuccess
+	setImageCondition(generation, newStatus,
+		metal3.ConditionImageReady, metav1.ConditionTrue,
+		time, reason, message)
+	setImageCondition(generation, newStatus,
+		metal3.ConditionImageError, metav1.ConditionFalse,
+		time, reason, "")
+
+	changed := !apiequality.Semantic.DeepEqual(status, &newStatus)
+	*status = *newStatus
+	return changed
+}
+
+func setError(generation int64, status *metal3.PreprovisioningImageStatus, reason imageConditionReason, message string) bool {
+	newStatus := status.DeepCopy()
+	newStatus.ImageUrl = ""
+
+	time := metav1.Now()
+	setImageCondition(generation, newStatus,
+		metal3.ConditionImageReady, metav1.ConditionFalse,
+		time, reason, "")
+	setImageCondition(generation, newStatus,
+		metal3.ConditionImageError, metav1.ConditionTrue,
+		time, reason, message)
+
+	changed := !apiequality.Semantic.DeepEqual(status, &newStatus)
+	*status = *newStatus
+	return changed
+}
+
+func (r *PreprovisioningImageReconciler) CanStart() bool {
+	deployISOURL := os.Getenv("DEPLOY_ISO_URL")
+	hasCfg := deployISOURL != ""
+	if hasCfg {
+		r.Log.Info("have deploy image data",
+			"iso_url", deployISOURL)
+	} else {
+		r.Log.Info("not starting preprovisioning image controller; no image data available")
+	}
+	return hasCfg
+}
+
+func (r *PreprovisioningImageReconciler) SetupWithManager(mgr ctrl.Manager) error {
+	return ctrl.NewControllerManagedBy(mgr).
+		For(&metal3.PreprovisioningImage{}).
+		Owns(&corev1.Secret{}).
+		Complete(r)
+}
diff --git a/main.go b/main.go
index 9ab9ffab..2c259c97 100644
--- a/main.go
+++ b/main.go
@@ -85,6 +85,7 @@ func main() {
 	var watchNamespace string
 	var metricsAddr string
 	var enableLeaderElection bool
+	var preprovImgEnable bool
 	var devLogging bool
 	var runInTestMode bool
 	var runInDemoMode bool
@@ -101,6 +102,7 @@ func main() {
 	flag.BoolVar(&enableLeaderElection, "enable-leader-election", false,
 		"Enable leader election for controller manager. "+
 			"Enabling this will ensure there is only one active controller manager.")
+	flag.BoolVar(&preprovImgEnable, "build-preprov-image", false, "enable integration with the PreprovisioningImage API")
 	flag.BoolVar(&devLogging, "dev", false, "enable developer logging")
 	flag.BoolVar(&runInTestMode, "test-mode", false, "disable ironic communication")
 	flag.BoolVar(&runInDemoMode, "demo-mode", false,
@@ -149,7 +151,7 @@ func main() {
 		ctrl.Log.Info("using demo provisioner")
 		provisionerFactory = &demo.Demo{}
 	} else {
-		provisionerFactory = ironic.NewProvisionerFactory()
+		provisionerFactory = ironic.NewProvisionerFactory(preprovImgEnable)
 	}
 
 	if err = (&metal3iocontroller.BareMetalHostReconciler{
@@ -157,11 +159,27 @@ func main() {
 		Log:                ctrl.Log.WithName("controllers").WithName("BareMetalHost"),
 		ProvisionerFactory: provisionerFactory,
 		APIReader:          mgr.GetAPIReader(),
-	}).SetupWithManager(mgr); err != nil {
+	}).SetupWithManager(mgr, preprovImgEnable); err != nil {
 		setupLog.Error(err, "unable to create controller", "controller", "BareMetalHost")
 		os.Exit(1)
 	}
 
+	if preprovImgEnable {
+		imgReconciler := metal3iocontroller.PreprovisioningImageReconciler{
+			Client:    mgr.GetClient(),
+			Log:       ctrl.Log.WithName("controllers").WithName("PreprovisioningImage"),
+			APIReader: mgr.GetAPIReader(),
+			Scheme:    mgr.GetScheme(),
+		}
+		if imgReconciler.CanStart() {
+			if err = (&imgReconciler).SetupWithManager(mgr); err != nil {
+				setupLog.Error(err, "unable to create controller", "controller", "PreprovisioningImage")
+				os.Exit(1)
+			}
+		}
+	}
+	// +kubebuilder:scaffold:builder
+
 	if err = (&metal3iocontroller.HostFirmwareSettingsReconciler{
 		Client:             mgr.GetClient(),
 		Log:                ctrl.Log.WithName("controllers").WithName("HostFirmwareSettings"),
@@ -177,8 +195,6 @@ func main() {
 		setupWebhooks(mgr)
 	}
 
-	// +kubebuilder:scaffold:builder
-
 	setupLog.Info("starting manager")
 	if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
 		setupLog.Error(err, "problem running manager")
diff --git a/pkg/ironic/bmc/access.go b/pkg/ironic/bmc/access.go
index ee42d657..b8efa128 100644
--- a/pkg/ironic/bmc/access.go
+++ b/pkg/ironic/bmc/access.go
@@ -67,6 +67,9 @@ type AccessDetails interface {
 	// Whether the driver supports changing secure boot state.
 	SupportsSecureBoot() bool
 
+	// Whether the driver supports booting a preprovisioning image in ISO format
+	SupportsISOPreprovisioningImage() bool
+
 	// Build bios clean steps for ironic
 	BuildBIOSSettings(firmwareConfig *metal3v1alpha1.FirmwareConfig) (settings []map[string]string, err error)
 }
diff --git a/pkg/ironic/bmc/ibmc.go b/pkg/ironic/bmc/ibmc.go
index 02e8d20e..623f81b1 100644
--- a/pkg/ironic/bmc/ibmc.go
+++ b/pkg/ironic/bmc/ibmc.go
@@ -109,6 +109,10 @@ func (a *ibmcAccessDetails) SupportsSecureBoot() bool {
 	return false
 }
 
+func (a *ibmcAccessDetails) SupportsISOPreprovisioningImage() bool {
+	return false
+}
+
 func (a *ibmcAccessDetails) BuildBIOSSettings(firmwareConfig *metal3v1alpha1.FirmwareConfig) (settings []map[string]string, err error) {
 	if firmwareConfig != nil {
 		return nil, fmt.Errorf("firmware settings for %s are not supported", a.Driver())
diff --git a/pkg/ironic/bmc/idrac.go b/pkg/ironic/bmc/idrac.go
index 4739dc84..4db59f22 100644
--- a/pkg/ironic/bmc/idrac.go
+++ b/pkg/ironic/bmc/idrac.go
@@ -106,6 +106,10 @@ func (a *iDracAccessDetails) SupportsSecureBoot() bool {
 	return false
 }
 
+func (a *iDracAccessDetails) SupportsISOPreprovisioningImage() bool {
+	return false
+}
+
 func (a *iDracAccessDetails) BuildBIOSSettings(firmwareConfig *metal3v1alpha1.FirmwareConfig) (settings []map[string]string, err error) {
 	if firmwareConfig == nil {
 		return nil, nil
diff --git a/pkg/ironic/bmc/idrac_virtualmedia.go b/pkg/ironic/bmc/idrac_virtualmedia.go
index d23c74a8..f4d69afe 100644
--- a/pkg/ironic/bmc/idrac_virtualmedia.go
+++ b/pkg/ironic/bmc/idrac_virtualmedia.go
@@ -99,6 +99,10 @@ func (a *redfishiDracVirtualMediaAccessDetails) SupportsSecureBoot() bool {
 	return true
 }
 
+func (a *redfishiDracVirtualMediaAccessDetails) SupportsISOPreprovisioningImage() bool {
+	return true
+}
+
 func (a *redfishiDracVirtualMediaAccessDetails) BuildBIOSSettings(firmwareConfig *metal3v1alpha1.FirmwareConfig) (settings []map[string]string, err error) {
 	if firmwareConfig != nil {
 		return nil, fmt.Errorf("firmware settings for %s are not supported", a.Driver())
diff --git a/pkg/ironic/bmc/ilo4.go b/pkg/ironic/bmc/ilo4.go
index 6db153fd..cd5a2685 100644
--- a/pkg/ironic/bmc/ilo4.go
+++ b/pkg/ironic/bmc/ilo4.go
@@ -100,6 +100,10 @@ func (a *iLOAccessDetails) SupportsSecureBoot() bool {
 	return true
 }
 
+func (a *iLOAccessDetails) SupportsISOPreprovisioningImage() bool {
+	return false
+}
+
 func (a *iLOAccessDetails) BuildBIOSSettings(firmwareConfig *metal3v1alpha1.FirmwareConfig) (settings []map[string]string, err error) {
 	if firmwareConfig == nil {
 		return nil, nil
diff --git a/pkg/ironic/bmc/ilo5.go b/pkg/ironic/bmc/ilo5.go
index 39c15532..6c0f3538 100644
--- a/pkg/ironic/bmc/ilo5.go
+++ b/pkg/ironic/bmc/ilo5.go
@@ -100,6 +100,10 @@ func (a *iLO5AccessDetails) SupportsSecureBoot() bool {
 	return true
 }
 
+func (a *iLO5AccessDetails) SupportsISOPreprovisioningImage() bool {
+	return false
+}
+
 func (a *iLO5AccessDetails) BuildBIOSSettings(firmwareConfig *metal3v1alpha1.FirmwareConfig) (settings []map[string]string, err error) {
 	if firmwareConfig == nil {
 		return nil, nil
diff --git a/pkg/ironic/bmc/ipmi.go b/pkg/ironic/bmc/ipmi.go
index 346cc39e..0ad68483 100644
--- a/pkg/ironic/bmc/ipmi.go
+++ b/pkg/ironic/bmc/ipmi.go
@@ -119,6 +119,10 @@ func (a *ipmiAccessDetails) SupportsSecureBoot() bool {
 	return false
 }
 
+func (a *ipmiAccessDetails) SupportsISOPreprovisioningImage() bool {
+	return false
+}
+
 func (a *ipmiAccessDetails) BuildBIOSSettings(firmwareConfig *metal3v1alpha1.FirmwareConfig) (settings []map[string]string, err error) {
 	if firmwareConfig != nil {
 		return nil, fmt.Errorf("firmware settings for %s are not supported", a.Driver())
diff --git a/pkg/ironic/bmc/irmc.go b/pkg/ironic/bmc/irmc.go
index 4dbb4634..0ce33998 100644
--- a/pkg/ironic/bmc/irmc.go
+++ b/pkg/ironic/bmc/irmc.go
@@ -98,6 +98,10 @@ func (a *iRMCAccessDetails) SupportsSecureBoot() bool {
 	return true
 }
 
+func (a *iRMCAccessDetails) SupportsISOPreprovisioningImage() bool {
+	return false
+}
+
 func (a *iRMCAccessDetails) BuildBIOSSettings(firmwareConfig *metal3v1alpha1.FirmwareConfig) (settings []map[string]string, err error) {
 	if firmwareConfig == nil {
 		return nil, nil
diff --git a/pkg/ironic/bmc/redfish.go b/pkg/ironic/bmc/redfish.go
index d791d5e6..8750662a 100644
--- a/pkg/ironic/bmc/redfish.go
+++ b/pkg/ironic/bmc/redfish.go
@@ -129,6 +129,10 @@ func (a *redfishAccessDetails) SupportsSecureBoot() bool {
 	return true
 }
 
+func (a *redfishAccessDetails) SupportsISOPreprovisioningImage() bool {
+	return false
+}
+
 func (a *redfishAccessDetails) BuildBIOSSettings(firmwareConfig *metal3v1alpha1.FirmwareConfig) (settings []map[string]string, err error) {
 	if firmwareConfig != nil {
 		return nil, fmt.Errorf("firmware settings for %s are not supported", a.Driver())
diff --git a/pkg/ironic/bmc/redfish_virtualmedia.go b/pkg/ironic/bmc/redfish_virtualmedia.go
index adbbc57b..945a1002 100644
--- a/pkg/ironic/bmc/redfish_virtualmedia.go
+++ b/pkg/ironic/bmc/redfish_virtualmedia.go
@@ -97,6 +97,10 @@ func (a *redfishVirtualMediaAccessDetails) SupportsSecureBoot() bool {
 	return true
 }
 
+func (a *redfishVirtualMediaAccessDetails) SupportsISOPreprovisioningImage() bool {
+	return true
+}
+
 func (a *redfishVirtualMediaAccessDetails) BuildBIOSSettings(firmwareConfig *metal3v1alpha1.FirmwareConfig) (settings []map[string]string, err error) {
 	if firmwareConfig != nil {
 		return nil, fmt.Errorf("firmware settings for %s are not supported", a.Driver())
diff --git a/pkg/provisioner/demo/demo.go b/pkg/provisioner/demo/demo.go
index 753ac181..a8a5248a 100644
--- a/pkg/provisioner/demo/demo.go
+++ b/pkg/provisioner/demo/demo.go
@@ -115,6 +115,10 @@ func (p *demoProvisioner) ValidateManagementAccess(data provisioner.ManagementAc
 	return
 }
 
+func (p *demoProvisioner) PreprovisioningImageFormats() ([]metal3v1alpha1.ImageFormat, error) {
+	return nil, nil
+}
+
 // InspectHardware updates the HardwareDetails field of the host with
 // details of devices discovered on the hardware. It may be called
 // multiple times, and should return true for its dirty flag until the
diff --git a/pkg/provisioner/fixture/fixture.go b/pkg/provisioner/fixture/fixture.go
index b11b6733..20ca21f9 100644
--- a/pkg/provisioner/fixture/fixture.go
+++ b/pkg/provisioner/fixture/fixture.go
@@ -118,6 +118,10 @@ func (p *fixtureProvisioner) ValidateManagementAccess(data provisioner.Managemen
 	return
 }
 
+func (p *fixtureProvisioner) PreprovisioningImageFormats() ([]metal3v1alpha1.ImageFormat, error) {
+	return nil, nil
+}
+
 // InspectHardware updates the HardwareDetails field of the host with
 // details of devices discovered on the hardware. It may be called
 // multiple times, and should return true for its dirty flag until the
diff --git a/pkg/provisioner/ironic/factory.go b/pkg/provisioner/ironic/factory.go
index 9739e03a..c4fe3df4 100644
--- a/pkg/provisioner/ironic/factory.go
+++ b/pkg/provisioner/ironic/factory.go
@@ -26,12 +26,12 @@ type ironicProvisionerFactory struct {
 	clientInspector *gophercloud.ServiceClient
 }
 
-func NewProvisionerFactory() provisioner.Factory {
+func NewProvisionerFactory(havePreprovImgBuilder bool) provisioner.Factory {
 	factory := ironicProvisionerFactory{}
 
 	factory.log = logz.New().WithName("provisioner").WithName("ironic")
 
-	err := factory.init()
+	err := factory.init(havePreprovImgBuilder)
 	if err != nil {
 		factory.log.Error(err, "Cannot start ironic provisioner")
 		os.Exit(1)
@@ -39,13 +39,13 @@ func NewProvisionerFactory() provisioner.Factory {
 	return factory
 }
 
-func (f *ironicProvisionerFactory) init() error {
+func (f *ironicProvisionerFactory) init(havePreprovImgBuilder bool) error {
 	ironicAuth, inspectorAuth, err := clients.LoadAuth()
 	if err != nil {
 		return err
 	}
 
-	f.config, err = loadConfigFromEnv()
+	f.config, err = loadConfigFromEnv(havePreprovImgBuilder)
 	if err != nil {
 		return err
 	}
@@ -115,19 +115,26 @@ func (f ironicProvisionerFactory) NewProvisioner(hostData provisioner.HostData,
 	return f.ironicProvisioner(hostData, publisher)
 }
 
-func loadConfigFromEnv() (ironicConfig, error) {
-	c := ironicConfig{}
+func loadConfigFromEnv(havePreprovImgBuilder bool) (ironicConfig, error) {
+	c := ironicConfig{
+		havePreprovImgBuilder: havePreprovImgBuilder,
+	}
 
 	c.deployKernelURL = os.Getenv("DEPLOY_KERNEL_URL")
 	c.deployRamdiskURL = os.Getenv("DEPLOY_RAMDISK_URL")
 	c.deployISOURL = os.Getenv("DEPLOY_ISO_URL")
-	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")
+	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")
+		}
+		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")
+		}
 	}
-	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")
+	if c.deployKernelURL == "" && c.deployRamdiskURL != "" {
+		return c, errors.New("DEPLOY_RAMDISK_URL requires DEPLOY_KERNEL_URL to be set also")
 	}
 
 	c.maxBusyHosts = 20
diff --git a/pkg/provisioner/ironic/ironic.go b/pkg/provisioner/ironic/ironic.go
index be5ea60d..4c3d9420 100644
--- a/pkg/provisioner/ironic/ironic.go
+++ b/pkg/provisioner/ironic/ironic.go
@@ -41,6 +41,10 @@ const (
 	powerNone            = "None"
 	nameSeparator        = "~"
 	customDeployPriority = 80
+
+	deployKernelKey  = "deploy_kernel"
+	deployRamdiskKey = "deploy_ramdisk"
+	deployISOKey     = "deploy_iso"
 )
 
 var bootModeCapabilities = map[metal3v1alpha1.BootMode]string{
@@ -64,6 +68,7 @@ func NewMacAddressConflictError(address, node string) error {
 }
 
 type ironicConfig struct {
+	havePreprovImgBuilder            bool
 	deployKernelURL                  string
 	deployRamdiskURL                 string
 	deployISOURL                     string
@@ -346,15 +351,7 @@ func (p *ironicProvisioner) ValidateManagementAccess(data provisioner.Management
 	}
 
 	driverInfo := bmcAccess.DriverInfo(p.bmcCreds)
-	// FIXME(dhellmann): We need to get our IP on the
-	// provisioning network from somewhere.
-	if p.config.deployKernelURL != "" && p.config.deployRamdiskURL != "" {
-		driverInfo["deploy_kernel"] = p.config.deployKernelURL
-		driverInfo["deploy_ramdisk"] = p.config.deployRamdiskURL
-	}
-	if p.config.deployISOURL != "" {
-		driverInfo["deploy_iso"] = p.config.deployISOURL
-	}
+	deployImageInfo := setDeployImage(driverInfo, p.config, bmcAccess, data.PreprovisioningImage)
 
 	// If we have not found a node yet, we need to create one
 	if ironicNode == nil {
@@ -442,10 +439,13 @@ func (p *ironicProvisioner) ValidateManagementAccess(data provisioner.Management
 			}
 		}
 
-		// Look for the case where we previously enrolled this node
-		// and now the credentials have changed.
+		// The actual password is not returned from ironic, so we want to
+		// update the whole DriverInfo only if the credentials have changed
+		// otherwise we will be writing on every call to this function.
 		if credentialsChanged {
-			updater.SetTopLevelOpt("driver_info", driverInfo, nil)
+			updater.SetTopLevelOpt("driver_info", driverInfo, ironicNode.DriverInfo)
+		} else {
+			updater.SetDriverInfoOpts(deployImageInfo, ironicNode)
 		}
 
 		// We don't return here because we also have to set the
@@ -507,24 +507,122 @@ func (p *ironicProvisioner) ValidateManagementAccess(data provisioner.Management
 		result, err = operationContinuing(provisionRequeueDelay)
 		return
 
+	case nodes.Active:
+		// The host is already running, maybe it's a master?
+		p.debugLog.Info("have active host", "image_source", ironicNode.InstanceInfo["image_source"])
+		fallthrough
+
 	case nodes.Manageable:
-		return
+		fallthrough
 
 	case nodes.Available:
 		// The host is fully registered (and probably wasn't cleanly
 		// deleted previously)
-		return
-
-	case nodes.Active:
-		// The host is already running, maybe it's a master?
-		p.debugLog.Info("have active host", "image_source", ironicNode.InstanceInfo["image_source"])
-		return
+		fallthrough
 
 	default:
+		switch data.State {
+		case metal3v1alpha1.StateInspecting,
+			metal3v1alpha1.StatePreparing,
+			metal3v1alpha1.StateProvisioning,
+			metal3v1alpha1.StateDeprovisioning:
+			if deployImageInfo == nil {
+				if p.config.havePreprovImgBuilder {
+					result, err = transientError(provisioner.ErrNeedsPreprovisioningImage)
+				} else {
+					result, err = operationFailed("no preprovisioning image available")
+				}
+			}
+		}
 		return
 	}
 }
 
+// PreprovisioningImageFormats returns a list of acceptable formats for a
+// pre-provisioning image to be built by a PreprovisioningImage object. The
+// list should be nil if no image build is requested.
+func (p *ironicProvisioner) PreprovisioningImageFormats() ([]metal3v1alpha1.ImageFormat, error) {
+	if !p.config.havePreprovImgBuilder {
+		return nil, nil
+	}
+
+	accessDetails, err := p.bmcAccess()
+	if err != nil {
+		return nil, err
+	}
+
+	var formats []metal3v1alpha1.ImageFormat
+	if accessDetails.SupportsISOPreprovisioningImage() {
+		formats = append(formats, metal3v1alpha1.ImageFormatISO)
+	} else {
+		if p.config.deployKernelURL != "" && p.config.deployRamdiskURL != "" {
+			// This is a PXE driver (no ISO support) so it shouldn't require any
+			// network customisation to boot the image, and we have sufficient
+			// data available to configure. Therefore, do not request an image
+			// build.
+			return nil, nil
+		}
+	}
+	if p.config.deployKernelURL != "" {
+		formats = append(formats, metal3v1alpha1.ImageFormatInitRD)
+	}
+
+	return formats, nil
+}
+
+func setDeployImage(driverInfo map[string]interface{}, config ironicConfig, accessDetails bmc.AccessDetails, hostImage *provisioner.PreprovisioningImage) optionsData {
+	deployImageInfo := optionsData{
+		deployKernelKey:  nil,
+		deployRamdiskKey: nil,
+		deployISOKey:     nil,
+	}
+
+	defer func() {
+		// Copy into driverInfo so that if we end up creating a new Node, the
+		// info will be there from the outset.
+		for k, v := range deployImageInfo {
+			if v != nil {
+				driverInfo[k] = v
+			}
+		}
+	}()
+
+	allowISO := accessDetails.SupportsISOPreprovisioningImage()
+	allowInitRD := config.deployKernelURL != ""
+
+	if hostImage != nil {
+		switch hostImage.Format {
+		case metal3v1alpha1.ImageFormatISO:
+			if allowISO {
+				deployImageInfo[deployISOKey] = hostImage.ImageURL
+				return deployImageInfo
+			}
+		case metal3v1alpha1.ImageFormatInitRD:
+			if allowInitRD {
+				deployImageInfo[deployKernelKey] = config.deployKernelURL
+				deployImageInfo[deployRamdiskKey] = hostImage.ImageURL
+				return deployImageInfo
+			}
+		}
+	}
+
+	if !config.havePreprovImgBuilder {
+		if allowISO && config.deployISOURL != "" {
+			deployImageInfo[deployISOKey] = config.deployISOURL
+			return deployImageInfo
+		}
+	}
+	if !config.havePreprovImgBuilder || !allowISO {
+		if allowInitRD && config.deployRamdiskURL != "" {
+			deployImageInfo[deployKernelKey] = config.deployKernelURL
+			deployImageInfo[deployRamdiskKey] = config.deployRamdiskURL
+			return deployImageInfo
+		}
+	}
+
+	return nil
+}
+
 func (p *ironicProvisioner) tryUpdateNode(ironicNode *nodes.Node, updater *nodeUpdater) (success bool, result provisioner.Result, err error) {
 	if len(updater.Updates) == 0 {
 		success = true
diff --git a/pkg/provisioner/provisioner.go b/pkg/provisioner/provisioner.go
index 9b2de6c0..8a16e46a 100644
--- a/pkg/provisioner/provisioner.go
+++ b/pkg/provisioner/provisioner.go
@@ -68,11 +68,17 @@ type HostConfigData interface {
 	MetaData() (string, error)
 }
 
+type PreprovisioningImage struct {
+	ImageURL string
+	Format   metal3v1alpha1.ImageFormat
+}
+
 type ManagementAccessData struct {
 	BootMode              metal3v1alpha1.BootMode
 	AutomatedCleaningMode metal3v1alpha1.AutomatedCleaningMode
 	State                 metal3v1alpha1.ProvisioningState
 	CurrentImage          *metal3v1alpha1.Image
+	PreprovisioningImage  *PreprovisioningImage
 	HasCustomDeploy       bool
 }
 
@@ -119,6 +125,11 @@ type Provisioner interface {
 	// credentials is correct.
 	ValidateManagementAccess(data ManagementAccessData, credentialsChanged, force bool) (result Result, provID string, err error)
 
+	// PreprovisioningImageFormats returns a list of acceptable formats for a
+	// pre-provisioning image to be built by a PreprovisioningImage object. The
+	// list should be nil if no image build is requested.
+	PreprovisioningImageFormats() ([]metal3v1alpha1.ImageFormat, error)
+
 	// InspectHardware updates the HardwareDetails field of the host with
 	// details of devices discovered on the hardware. It may be called
 	// multiple times, and should return true for its dirty flag until the
@@ -199,5 +210,9 @@ type HardwareState struct {
 	PoweredOn *bool
 }
 
-// ErrNeedsRegistration raised if the host is not registered
+// ErrNeedsRegistration is returned if the host is not registered
 var ErrNeedsRegistration = errors.New("Host not registered")
+
+// ErrNeedsPreprovisioningImage is returned if a preprovisioning image is
+// required
+var ErrNeedsPreprovisioningImage = errors.New("No suitable Preprovisioning image available")


Test patch:
diff --git a/controllers/metal3.io/baremetalhost_controller_test.go b/controllers/metal3.io/baremetalhost_controller_test.go
index deb4639f..b2195dab 100644
--- a/controllers/metal3.io/baremetalhost_controller_test.go
+++ b/controllers/metal3.io/baremetalhost_controller_test.go
@@ -5,6 +5,7 @@ import (
 	goctx "context"
 	"encoding/base64"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"testing"
 
@@ -14,12 +15,13 @@ import (
 
 	corev1 "k8s.io/api/core/v1"
 
-	"k8s.io/apimachinery/pkg/api/errors"
+	k8serrors "k8s.io/apimachinery/pkg/api/errors"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"k8s.io/apimachinery/pkg/types"
 
 	ctrl "sigs.k8s.io/controller-runtime"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 	fakeclient "sigs.k8s.io/controller-runtime/pkg/client/fake"
 	"sigs.k8s.io/controller-runtime/pkg/event"
 	logf "sigs.k8s.io/controller-runtime/pkg/log"
@@ -154,7 +156,7 @@ func tryReconcile(t *testing.T, r *BareMetalHostReconciler, host *metal3v1alpha1
 		// need to replace the one we have with the updated data in
 		// order to test it. In case it was not found, let's set it to nil
 		updatedHost := &metal3v1alpha1.BareMetalHost{}
-		if err = r.Get(goctx.TODO(), request.NamespacedName, updatedHost); errors.IsNotFound(err) {
+		if err = r.Get(goctx.TODO(), request.NamespacedName, updatedHost); k8serrors.IsNotFound(err) {
 			host = nil
 		} else {
 			updatedHost.DeepCopyInto(host)
@@ -1995,3 +1997,394 @@ func TestCredentialsFromSecret(t *testing.T) {
 		})
 	}
 }
+
+func TestGetHardwareProfileName(t *testing.T) {
+	testCases := []struct {
+		Scenario      string
+		Address       string
+		StatusProfile string
+		SpecProfile   string
+		Expected      string
+	}{
+		{
+			Scenario: "default",
+			Expected: "unknown",
+		},
+		{
+			Scenario: "infer libvirt",
+			Address:  "libvirt://example.test",
+			Expected: "libvirt",
+		},
+		{
+			Scenario:    "not yet set",
+			SpecProfile: "foo",
+			Expected:    "foo",
+		},
+		{
+			Scenario:      "already set",
+			SpecProfile:   "foo",
+			StatusProfile: "foo",
+			Expected:      "foo",
+		},
+		{
+			Scenario:      "changed",
+			SpecProfile:   "bar",
+			StatusProfile: "foo",
+			Expected:      "foo",
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.Scenario, func(t *testing.T) {
+			host := newHost("test", &metal3v1alpha1.BareMetalHostSpec{
+				BMC: metal3v1alpha1.BMCDetails{
+					Address: tc.Address,
+				},
+				HardwareProfile: tc.SpecProfile,
+			})
+			host.Status.HardwareProfile = tc.StatusProfile
+
+			assert.Equal(t, tc.Expected, getHardwareProfileName(host))
+		})
+	}
+}
+
+func TestGetHostArchitecture(t *testing.T) {
+	host := newDefaultHost(t)
+	assert.Equal(t, "x86_64", getHostArchitecture(host))
+
+	host.Status.HardwareDetails = &metal3v1alpha1.HardwareDetails{
+		CPU: metal3v1alpha1.CPU{
+			Arch: "aarch64",
+		},
+	}
+	assert.Equal(t, "aarch64", getHostArchitecture(host))
+}
+
+func TestGetPreprovImageNoFormats(t *testing.T) {
+	host := newDefaultHost(t)
+	r := newTestReconciler(host)
+	i := makeReconcileInfo(host)
+
+	imgData, err := r.getPreprovImage(i, nil)
+
+	assert.NoError(t, err)
+	assert.Nil(t, imgData)
+
+	imgData, err = r.getPreprovImage(i, []metal3v1alpha1.ImageFormat{})
+	assert.True(t, errors.As(err, &imageBuildError{}))
+	assert.EqualError(t, err, "no acceptable formats for preprovisioning image")
+	assert.Nil(t, imgData)
+
+	assert.Error(t, r.Client.Get(goctx.TODO(), client.ObjectKey{
+		Name:      host.Name,
+		Namespace: host.Namespace,
+	},
+		&metal3v1alpha1.PreprovisioningImage{}))
+}
+
+func TestGetPreprovImageCreateUpdate(t *testing.T) {
+	secretName := "net_secret"
+	host := newDefaultHost(t)
+	host.Spec.PreprovisioningNetworkDataName = secretName
+	r := newTestReconciler(host, newSecret(secretName, nil))
+	i := makeReconcileInfo(host)
+
+	imgData, err := r.getPreprovImage(i, []metal3v1alpha1.ImageFormat{"iso"})
+	assert.NoError(t, err)
+	assert.Nil(t, imgData)
+
+	img := metal3v1alpha1.PreprovisioningImage{}
+	assert.NoError(t, r.Client.Get(goctx.TODO(), client.ObjectKey{
+		Name:      host.Name,
+		Namespace: host.Namespace,
+	},
+		&img))
+	assert.Equal(t, "x86_64", img.Spec.Architecture)
+	assert.Equal(t, secretName, img.Spec.NetworkDataName)
+
+	newSecretName := "new_net_secret"
+	host.Spec.PreprovisioningNetworkDataName = newSecretName
+
+	imgData, err = r.getPreprovImage(i, []metal3v1alpha1.ImageFormat{"iso"})
+	assert.NoError(t, err)
+	assert.Nil(t, imgData)
+
+	assert.NoError(t, r.Client.Get(goctx.TODO(), client.ObjectKey{
+		Name:      host.Name,
+		Namespace: host.Namespace,
+	},
+		&img))
+	assert.Equal(t, newSecretName, img.Spec.NetworkDataName)
+}
+
+func TestGetPreprovImage(t *testing.T) {
+	host := newDefaultHost(t)
+	imageURL := "http://example.test/image.iso"
+	image := &metal3v1alpha1.PreprovisioningImage{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      host.Name,
+			Namespace: namespace,
+		},
+		Spec: metal3v1alpha1.PreprovisioningImageSpec{
+			Architecture: "x86_64",
+		},
+		Status: metal3v1alpha1.PreprovisioningImageStatus{
+			Architecture: "x86_64",
+			Format:       metal3v1alpha1.ImageFormatISO,
+			ImageUrl:     imageURL,
+			Conditions: []metav1.Condition{
+				{
+					Type:   string(metal3v1alpha1.ConditionImageReady),
+					Status: metav1.ConditionTrue,
+				},
+				{
+					Type:   string(metal3v1alpha1.ConditionImageError),
+					Status: metav1.ConditionFalse,
+				},
+			},
+		},
+	}
+	r := newTestReconciler(host, image)
+	i := makeReconcileInfo(host)
+
+	imgData, err := r.getPreprovImage(i, []metal3v1alpha1.ImageFormat{metal3v1alpha1.ImageFormatISO})
+	assert.NoError(t, err)
+	assert.NotNil(t, imgData)
+	assert.Equal(t, imageURL, imgData.ImageURL)
+	assert.Equal(t, metal3v1alpha1.ImageFormatISO, imgData.Format)
+}
+
+func TestGetPreprovImageNotCurrent(t *testing.T) {
+	host := newDefaultHost(t)
+	imageURL := "http://example.test/image.iso"
+	image := &metal3v1alpha1.PreprovisioningImage{
+		ObjectMeta: metav1.ObjectMeta{
+			Name:      host.Name,
+			Namespace: namespace,
+		},
+		Spec: metal3v1alpha1.PreprovisioningImageSpec{
+			Architecture: "x86_64",
+		},
+		Status: metal3v1alpha1.PreprovisioningImageStatus{
+			Architecture: "x86_64",
+			Format:       metal3v1alpha1.ImageFormatISO,
+			ImageUrl:     imageURL,
+			Conditions: []metav1.Condition{
+				{
+					Type:   string(metal3v1alpha1.ConditionImageReady),
+					Status: metav1.ConditionFalse,
+				},
+				{
+					Type:   string(metal3v1alpha1.ConditionImageError),
+					Status: metav1.ConditionFalse,
+				},
+			},
+		},
+	}
+	r := newTestReconciler(host, image)
+	i := makeReconcileInfo(host)
+
+	imgData, err := r.getPreprovImage(i, []metal3v1alpha1.ImageFormat{metal3v1alpha1.ImageFormatISO})
+	assert.NoError(t, err)
+	assert.Nil(t, imgData)
+}
+
+func TestPreprovImageAvailable(t *testing.T) {
+	host := newDefaultHost(t)
+	r := newTestReconciler(host, newSecret("network_secret_1", nil))
+
+	testCases := []struct {
+		Scenario   string
+		Spec       metal3v1alpha1.PreprovisioningImageSpec
+		Status     metal3v1alpha1.PreprovisioningImageStatus
+		Available  bool
+		BuildError bool
+	}{
+		{
+			Scenario: "ready no netdata",
+			Spec: metal3v1alpha1.PreprovisioningImageSpec{
+				Architecture: "x86_64",
+			},
+			Status: metal3v1alpha1.PreprovisioningImageStatus{
+				Architecture: "x86_64",
+				Format:       "iso",
+				Conditions: []metav1.Condition{
+					{
+						Type:   string(metal3v1alpha1.ConditionImageReady),
+						Status: metav1.ConditionTrue,
+					},
+					{
+						Type:   string(metal3v1alpha1.ConditionImageError),
+						Status: metav1.ConditionFalse,
+					},
+				},
+			},
+			Available: true,
+		},
+		{
+			Scenario: "ready",
+			Spec: metal3v1alpha1.PreprovisioningImageSpec{
+				Architecture:    "x86_64",
+				NetworkDataName: "network_secret_1",
+			},
+			Status: metal3v1alpha1.PreprovisioningImageStatus{
+				Architecture: "x86_64",
+				Format:       "iso",
+				NetworkData: metal3v1alpha1.SecretStatus{
+					Name:    "network_secret_1",
+					Version: "1000",
+				},
+				Conditions: []metav1.Condition{
+					{
+						Type:   string(metal3v1alpha1.ConditionImageReady),
+						Status: metav1.ConditionTrue,
+					},
+					{
+						Type:   string(metal3v1alpha1.ConditionImageError),
+						Status: metav1.ConditionFalse,
+					},
+				},
+			},
+			Available: true,
+		},
+		{
+			Scenario: "ready secret outdated",
+			Spec: metal3v1alpha1.PreprovisioningImageSpec{
+				Architecture:    "x86_64",
+				NetworkDataName: "network_secret_1",
+			},
+			Status: metal3v1alpha1.PreprovisioningImageStatus{
+				Architecture: "x86_64",
+				Format:       "iso",
+				NetworkData: metal3v1alpha1.SecretStatus{
+					Name:    "network_secret_1",
+					Version: "42",
+				},
+				Conditions: []metav1.Condition{
+					{
+						Type:   string(metal3v1alpha1.ConditionImageReady),
+						Status: metav1.ConditionTrue,
+					},
+					{
+						Type:   string(metal3v1alpha1.ConditionImageError),
+						Status: metav1.ConditionFalse,
+					},
+				},
+			},
+			Available: false,
+		},
+		{
+			Scenario: "ready secret mismatch",
+			Spec: metal3v1alpha1.PreprovisioningImageSpec{
+				Architecture:    "x86_64",
+				NetworkDataName: "network_secret_1",
+			},
+			Status: metal3v1alpha1.PreprovisioningImageStatus{
+				Architecture: "x86_64",
+				Format:       "iso",
+				NetworkData: metal3v1alpha1.SecretStatus{
+					Name:    "network_secret_0",
+					Version: "1",
+				},
+				Conditions: []metav1.Condition{
+					{
+						Type:   string(metal3v1alpha1.ConditionImageReady),
+						Status: metav1.ConditionTrue,
+					},
+					{
+						Type:   string(metal3v1alpha1.ConditionImageError),
+						Status: metav1.ConditionFalse,
+					},
+				},
+			},
+			Available: false,
+		},
+		{
+			Scenario: "ready arch mismatch",
+			Spec: metal3v1alpha1.PreprovisioningImageSpec{
+				Architecture: "aarch64",
+			},
+			Status: metal3v1alpha1.PreprovisioningImageStatus{
+				Architecture: "x86_64",
+				Format:       "iso",
+				Conditions: []metav1.Condition{
+					{
+						Type:   string(metal3v1alpha1.ConditionImageReady),
+						Status: metav1.ConditionTrue,
+					},
+					{
+						Type:   string(metal3v1alpha1.ConditionImageError),
+						Status: metav1.ConditionFalse,
+					},
+				},
+			},
+			Available: false,
+		},
+		{
+			Scenario: "not ready",
+			Spec: metal3v1alpha1.PreprovisioningImageSpec{
+				Architecture: "x86_64",
+			},
+			Status: metal3v1alpha1.PreprovisioningImageStatus{
+				Architecture: "x86_64",
+				Format:       "iso",
+				Conditions: []metav1.Condition{
+					{
+						Type:   string(metal3v1alpha1.ConditionImageReady),
+						Status: metav1.ConditionFalse,
+					},
+					{
+						Type:   string(metal3v1alpha1.ConditionImageError),
+						Status: metav1.ConditionFalse,
+					},
+				},
+			},
+			Available: false,
+		},
+		{
+			Scenario: "failed",
+			Spec: metal3v1alpha1.PreprovisioningImageSpec{
+				Architecture: "x86_64",
+			},
+			Status: metal3v1alpha1.PreprovisioningImageStatus{
+				Architecture: "x86_64",
+				Format:       "iso",
+				Conditions: []metav1.Condition{
+					{
+						Type:   string(metal3v1alpha1.ConditionImageReady),
+						Status: metav1.ConditionFalse,
+					},
+					{
+						Type:    string(metal3v1alpha1.ConditionImageError),
+						Status:  metav1.ConditionTrue,
+						Message: "oops",
+					},
+				},
+			},
+			Available:  false,
+			BuildError: true,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.Scenario, func(t *testing.T) {
+			image := metal3v1alpha1.PreprovisioningImage{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      host.Name,
+					Namespace: namespace,
+				},
+				Spec:   tc.Spec,
+				Status: tc.Status,
+			}
+			available, err := r.preprovImageAvailable(makeReconcileInfo(host), &image)
+			if tc.BuildError {
+				assert.EqualError(t, err, "oops")
+				assert.True(t, errors.As(err, &imageBuildError{}))
+			} else {
+				assert.NoError(t, err)
+			}
+			assert.Equal(t, tc.Available, available)
+		})
+	}
+}
diff --git a/controllers/metal3.io/host_config_data_test.go b/controllers/metal3.io/host_config_data_test.go
index 6891226d..a20c3041 100644
--- a/controllers/metal3.io/host_config_data_test.go
+++ b/controllers/metal3.io/host_config_data_test.go
@@ -92,16 +92,17 @@ func TestProvisionWithHostConfig(t *testing.T) {
 	testBMCSecret := newBMCCredsSecret(defaultSecretName, "User", "Pass")
 
 	testCases := []struct {
-		Scenario            string
-		Host                *metal3v1alpha1.BareMetalHost
-		UserDataSecret      *corev1.Secret
-		NetworkDataSecret   *corev1.Secret
-		ExpectedUserData    string
-		ErrUserData         bool
-		ExpectedNetworkData string
-		ErrNetworkData      bool
-		ExpectedMetaData    string
-		ErrMetaData         bool
+		Scenario                 string
+		Host                     *metal3v1alpha1.BareMetalHost
+		UserDataSecret           *corev1.Secret
+		PreprovNetworkDataSecret *corev1.Secret
+		NetworkDataSecret        *corev1.Secret
+		ExpectedUserData         string
+		ErrUserData              bool
+		ExpectedNetworkData      string
+		ErrNetworkData           bool
+		ExpectedMetaData         string
+		ErrMetaData              bool
 	}{
 		{
 			Scenario: "host with user data only",
@@ -140,6 +141,42 @@ func TestProvisionWithHostConfig(t *testing.T) {
 			ExpectedNetworkData: "",
 			ErrNetworkData:      false,
 		},
+		{
+			Scenario: "host with preprov network data only",
+			Host: newHost("host-user-data",
+				&metal3v1alpha1.BareMetalHostSpec{
+					BMC: metal3v1alpha1.BMCDetails{
+						Address:         "ipmi://192.168.122.1:6233",
+						CredentialsName: defaultSecretName,
+					},
+					PreprovisioningNetworkDataName: "net-data",
+				}),
+			NetworkDataSecret:   newSecret("net-data", map[string]string{"networkData": "key: value"}),
+			ExpectedUserData:    "",
+			ErrUserData:         false,
+			ExpectedNetworkData: base64.StdEncoding.EncodeToString([]byte("key: value")),
+			ErrNetworkData:      false,
+		},
+		{
+			Scenario: "host with preprov and regular network data",
+			Host: newHost("host-user-data",
+				&metal3v1alpha1.BareMetalHostSpec{
+					BMC: metal3v1alpha1.BMCDetails{
+						Address:         "ipmi://192.168.122.1:6233",
+						CredentialsName: defaultSecretName,
+					},
+					PreprovisioningNetworkDataName: "preprov-net-data",
+					NetworkData: &corev1.SecretReference{
+						Name:      "net-data",
+						Namespace: namespace,
+					},
+				}),
+			NetworkDataSecret:   newSecret("net-data", map[string]string{"networkData": "key: value"}),
+			ExpectedUserData:    "",
+			ErrUserData:         false,
+			ExpectedNetworkData: base64.StdEncoding.EncodeToString([]byte("key: value")),
+			ErrNetworkData:      false,
+		},
 		{
 			Scenario: "host with network data only",
 			Host: newHost("host-user-data",
diff --git a/controllers/metal3.io/host_state_machine_test.go b/controllers/metal3.io/host_state_machine_test.go
index 8db1c18c..36d4ecc9 100644
--- a/controllers/metal3.io/host_state_machine_test.go
+++ b/controllers/metal3.io/host_state_machine_test.go
@@ -1007,7 +1007,9 @@ func TestErrorClean(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.Scenario, func(t *testing.T) {
 			prov := newMockProvisioner()
-			hsm := newHostStateMachine(tt.Host, &BareMetalHostReconciler{}, prov, true)
+			hsm := newHostStateMachine(tt.Host, &BareMetalHostReconciler{
+				Client: fakeclient.NewFakeClient(),
+			}, prov, true)
 
 			info := makeDefaultReconcileInfo(tt.Host)
 			if tt.SecretName != "" {
@@ -1198,6 +1200,10 @@ func (m *mockProvisioner) ValidateManagementAccess(data provisioner.ManagementAc
 	return m.getNextResultByMethod("ValidateManagementAccess"), "", err
 }
 
+func (m *mockProvisioner) PreprovisioningImageFormats() ([]metal3v1alpha1.ImageFormat, error) {
+	return nil, nil
+}
+
 func (m *mockProvisioner) InspectHardware(data provisioner.InspectData, force, refresh bool) (result provisioner.Result, started bool, details *metal3v1alpha1.HardwareDetails, err error) {
 	details = &metal3v1alpha1.HardwareDetails{}
 	return m.getNextResultByMethod("InspectHardware"), true, details, err
diff --git a/controllers/metal3.io/hostfirmwaresettings_test.go b/controllers/metal3.io/hostfirmwaresettings_test.go
index 126ca167..c87d5a0d 100644
--- a/controllers/metal3.io/hostfirmwaresettings_test.go
+++ b/controllers/metal3.io/hostfirmwaresettings_test.go
@@ -67,6 +67,10 @@ func (m *hsfMockProvisioner) ValidateManagementAccess(data provisioner.Managemen
 	return
 }
 
+func (m *hsfMockProvisioner) PreprovisioningImageFormats() ([]metal3v1alpha1.ImageFormat, error) {
+	return nil, nil
+}
+
 func (m *hsfMockProvisioner) InspectHardware(data provisioner.InspectData, force, refresh bool) (result provisioner.Result, started bool, details *metal3v1alpha1.HardwareDetails, err error) {
 	return
 }
diff --git a/pkg/ironic/testbmc/testbmc.go b/pkg/ironic/testbmc/testbmc.go
index fca8e3f6..6634aa91 100644
--- a/pkg/ironic/testbmc/testbmc.go
+++ b/pkg/ironic/testbmc/testbmc.go
@@ -92,6 +92,10 @@ func (a *testAccessDetails) SupportsSecureBoot() bool {
 	return false
 }
 
+func (a *testAccessDetails) SupportsISOPreprovisioningImage() bool {
+	return false
+}
+
 func (a *testAccessDetails) BuildBIOSSettings(firmwareConfig *metal3v1alpha1.FirmwareConfig) (settings []map[string]string, err error) {
 
 	// Return sample BMC data for test purposes
diff --git a/pkg/provisioner/ironic/factory_test.go b/pkg/provisioner/ironic/factory_test.go
index ca96a0e8..c72b9054 100644
--- a/pkg/provisioner/ironic/factory_test.go
+++ b/pkg/provisioner/ironic/factory_test.go
@@ -61,10 +61,11 @@ func (f EnvFixture) VerifyEndpoints(t *testing.T, ironic, inspector string) {
 
 func TestLoadConfigFromEnv(t *testing.T) {
 	cases := []struct {
-		name            string
-		env             EnvFixture
-		expectedError   string
-		forcePersistent string
+		name                  string
+		env                   EnvFixture
+		expectedError         string
+		expectedImgBuildError string
+		forcePersistent       string
 	}{
 		{
 			name: "kernel and ramdisk",
@@ -104,7 +105,8 @@ func TestLoadConfigFromEnv(t *testing.T) {
 			env: EnvFixture{
 				ramdiskURL: "http://ramdisk",
 			},
-			expectedError: "Either DEPLOY_KERNEL_URL and DEPLOY_RAMDISK_URL or DEPLOY_ISO_URL must be set",
+			expectedError:         "Either DEPLOY_KERNEL_URL and DEPLOY_RAMDISK_URL or DEPLOY_ISO_URL must be set",
+			expectedImgBuildError: "DEPLOY_RAMDISK_URL requires DEPLOY_KERNEL_URL to be set also",
 		},
 		{
 			name: "ISO and kernel",
@@ -120,7 +122,8 @@ func TestLoadConfigFromEnv(t *testing.T) {
 				ramdiskURL: "http://ramdisk",
 				isoURL:     "http://iso",
 			},
-			expectedError: "DEPLOY_KERNEL_URL and DEPLOY_RAMDISK_URL can only be set together",
+			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",
 		},
 		{
 			name: "Force Persistent Default",
@@ -152,22 +155,30 @@ func TestLoadConfigFromEnv(t *testing.T) {
 				isoURL:                           "http://iso",
 				liveISOForcePersistentBootDevice: "NotAValidOption",
 			},
-			expectedError: "Invalid value for variable LIVE_ISO_FORCE_PERSISTENT_BOOT_DEVICE",
+			expectedError:         "Invalid value for variable LIVE_ISO_FORCE_PERSISTENT_BOOT_DEVICE",
+			expectedImgBuildError: "Invalid value for variable LIVE_ISO_FORCE_PERSISTENT_BOOT_DEVICE",
 		},
 	}
 
-	for _, tc := range cases {
-		t.Run(tc.name, func(t *testing.T) {
-			defer tc.env.TearDown()
-			tc.env.SetUp()
-			config, err := loadConfigFromEnv()
-			if tc.expectedError != "" {
-				assert.Regexp(t, tc.expectedError, err, "error message")
-			} else {
-				assert.Nil(t, err)
-				tc.env.VerifyConfig(t, config, tc.forcePersistent)
-			}
-		})
+	for _, tt := range []string{"", " (with img builder)"} {
+		for _, tc := range cases {
+			t.Run(tc.name+tt, func(t *testing.T) {
+				defer tc.env.TearDown()
+				tc.env.SetUp()
+				imgBuild := tt != ""
+				config, err := loadConfigFromEnv(imgBuild)
+				expectedError := tc.expectedError
+				if imgBuild {
+					expectedError = tc.expectedImgBuildError
+				}
+				if expectedError != "" {
+					assert.Regexp(t, expectedError, err)
+				} else {
+					assert.Nil(t, err)
+					tc.env.VerifyConfig(t, config, tc.forcePersistent)
+				}
+			})
+		}
 	}
 }
 
diff --git a/pkg/provisioner/ironic/prepare_test.go b/pkg/provisioner/ironic/prepare_test.go
index 2f80e5a0..1cb37fdc 100644
--- a/pkg/provisioner/ironic/prepare_test.go
+++ b/pkg/provisioner/ironic/prepare_test.go
@@ -24,6 +24,7 @@ func (r *RAIDTestBMC) NeedsMAC() bool                                        { r
 func (r *RAIDTestBMC) Driver() string                                        { return "raid-test" }
 func (r *RAIDTestBMC) DisableCertificateVerification() bool                  { return false }
 func (r *RAIDTestBMC) DriverInfo(bmc.Credentials) (i map[string]interface{}) { return }
+func (r *RAIDTestBMC) SupportsISOPreprovisioningImage() bool                 { return false }
 func (r *RAIDTestBMC) BIOSInterface() string                                 { return "" }
 func (r *RAIDTestBMC) BootInterface() string                                 { return "" }
 func (r *RAIDTestBMC) ManagementInterface() string                           { return "" }
diff --git a/pkg/provisioner/ironic/validatemanagementaccess_test.go b/pkg/provisioner/ironic/validatemanagementaccess_test.go
index 4b82c51c..738e446a 100644
--- a/pkg/provisioner/ironic/validatemanagementaccess_test.go
+++ b/pkg/provisioner/ironic/validatemanagementaccess_test.go
@@ -307,6 +307,14 @@ func TestValidateManagementAccessExistingNodeContinue(t *testing.T) {
 				UUID:           "uuid", // to match status in host
 				ProvisionState: string(status),
 				AutomatedClean: &clean,
+				DriverInfo: map[string]interface{}{
+					"deploy_kernel":  "http://deploy.test/ipa.kernel",
+					"deploy_ramdisk": "http://deploy.test/ipa.initramfs",
+					"test_address":   "test.bmc",
+					"test_username":  "",
+					"test_password":  "******", // ironic returns a placeholder
+					"test_port":      "42",
+				},
 			}).NodeUpdate(nodes.Node{
 				UUID: "uuid",
 			})
@@ -345,7 +353,14 @@ func TestValidateManagementAccessExistingSteadyStateNoUpdate(t *testing.T) {
 			InstanceInfo: map[string]interface{}{
 				"capabilities": map[string]interface{}{},
 			},
-			DriverInfo: map[string]interface{}{},
+			DriverInfo: map[string]interface{}{
+				"deploy_kernel":  "http://deploy.test/ipa.kernel",
+				"deploy_ramdisk": "http://deploy.test/ipa.initramfs",
+				"test_address":   "test.bmc",
+				"test_username":  "",
+				"test_password":  "******", // ironic returns a placeholder
+				"test_port":      "42",
+			},
 		},
 		{
 			Image: &metal3v1alpha1.Image{
@@ -361,6 +376,12 @@ func TestValidateManagementAccessExistingSteadyStateNoUpdate(t *testing.T) {
 			},
 			DriverInfo: map[string]interface{}{
 				"force_persistent_boot_device": "Default",
+				"deploy_kernel":                "http://deploy.test/ipa.kernel",
+				"deploy_ramdisk":               "http://deploy.test/ipa.initramfs",
+				"test_address":                 "test.bmc",
+				"test_username":                "",
+				"test_password":                "******", // ironic returns a placeholder
+				"test_port":                    "42",
 			},
 		},
 		{
@@ -375,6 +396,12 @@ func TestValidateManagementAccessExistingSteadyStateNoUpdate(t *testing.T) {
 			},
 			DriverInfo: map[string]interface{}{
 				"force_persistent_boot_device": "Default",
+				"deploy_kernel":                "http://deploy.test/ipa.kernel",
+				"deploy_ramdisk":               "http://deploy.test/ipa.initramfs",
+				"test_address":                 "test.bmc",
+				"test_username":                "",
+				"test_password":                "******", // ironic returns a placeholder
+				"test_port":                    "42",
 			},
 		},
 	}
@@ -448,6 +475,14 @@ func TestValidateManagementAccessExistingNodeWaiting(t *testing.T) {
 				Name:           host.Namespace + nameSeparator + host.Name,
 				UUID:           "uuid", // to match status in host
 				ProvisionState: string(status),
+				DriverInfo: map[string]interface{}{
+					"deploy_kernel":  "http://deploy.test/ipa.kernel",
+					"deploy_ramdisk": "http://deploy.test/ipa.initramfs",
+					"test_address":   "test.bmc",
+					"test_username":  "",
+					"test_password":  "******", // ironic returns a placeholder
+					"test_port":      "42",
+				},
 			}
 			ironic := testserver.NewIronic(t).Ready().CreateNodes(createCallback).Node(node).NodeUpdate(nodes.Node{
 				UUID: "uuid",
@@ -767,3 +802,292 @@ func TestValidateManagementAccessMalformedBMCAddress(t *testing.T) {
 	}
 	assert.Equal(t, "failed to parse BMC address information: failed to parse BMC address information: parse \"<ipmi://192.168.122.1:6233>\": first path segment in URL cannot contain colon", result.ErrorMessage)
 }
+
+func TestPreprovisioningImageFormats(t *testing.T) {
+	ironicEndpoint := "http://ironic.test"
+	auth := clients.AuthConfig{Type: clients.NoAuth}
+
+	testCases := []struct {
+		Name              string
+		Address           string
+		PreprovImgEnabled bool
+		Expected          []metal3v1alpha1.ImageFormat
+	}{
+		{
+			Name:     "disabled ipmi",
+			Address:  "ipmi://example.test",
+			Expected: nil,
+		},
+		{
+			Name:     "disabled virtualmedia",
+			Address:  "redfish-virtualmedia://example.test",
+			Expected: nil,
+		},
+		{
+			Name:              "enabled ipmi",
+			Address:           "ipmi://example.test",
+			PreprovImgEnabled: true,
+			Expected:          nil,
+		},
+		{
+			Name:              "enabled virtualmedia",
+			Address:           "redfish-virtualmedia://example.test",
+			PreprovImgEnabled: true,
+			Expected:          []metal3v1alpha1.ImageFormat{"iso", "initrd"},
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.Name, func(t *testing.T) {
+			host := makeHost()
+			host.Spec.BMC.Address = tc.Address
+
+			prov, _ := newProvisionerWithSettings(host, bmc.Credentials{}, nil,
+				ironicEndpoint, auth,
+				ironicEndpoint, auth,
+			)
+			prov.config.havePreprovImgBuilder = tc.PreprovImgEnabled
+
+			fmts, err := prov.PreprovisioningImageFormats()
+
+			assert.NoError(t, err)
+			assert.Equal(t, tc.Expected, fmts)
+		})
+	}
+}
+
+func TestSetDeployImage(t *testing.T) {
+	isoDriver, _ := bmc.NewAccessDetails("redfish-virtualmedia://example.test/", true)
+	pxeDriver, _ := bmc.NewAccessDetails("ipmi://example.test/", true)
+
+	const (
+		localKernel  = "http://local.test/ipa.kernel"
+		localRamdisk = "http://local.test/ipa.initrd"
+		localIso     = "http://local.test/ipa.iso"
+
+		buildKernel  = localKernel
+		buildRamdisk = "http://build.test/ipa.initrd"
+		buildIso     = "http://build.test/ipa.iso"
+	)
+
+	testCases := []struct {
+		Scenario    string
+		Config      ironicConfig
+		Driver      bmc.AccessDetails
+		Image       *provisioner.PreprovisioningImage
+		ExpectBuild bool
+		ExpectISO   bool
+		ExpectPXE   bool
+	}{
+		{
+			Scenario: "iso no imgbuilder",
+			Config: ironicConfig{
+				havePreprovImgBuilder: false,
+				deployKernelURL:       localKernel,
+				deployRamdiskURL:      localRamdisk,
+				deployISOURL:          localIso,
+			},
+			Driver:      isoDriver,
+			ExpectBuild: false,
+			ExpectISO:   true,
+			ExpectPXE:   false,
+		},
+		{
+			Scenario: "no imgbuilder no iso",
+			Config: ironicConfig{
+				havePreprovImgBuilder: false,
+				deployKernelURL:       localKernel,
+				deployRamdiskURL:      localRamdisk,
+			},
+			Driver:      isoDriver,
+			ExpectBuild: false,
+			ExpectISO:   false,
+			ExpectPXE:   true,
+		},
+		{
+			Scenario: "pxe no imgbuilder",
+			Config: ironicConfig{
+				havePreprovImgBuilder: false,
+				deployKernelURL:       localKernel,
+				deployRamdiskURL:      localRamdisk,
+				deployISOURL:          localIso,
+			},
+			Driver:      pxeDriver,
+			ExpectBuild: false,
+			ExpectISO:   false,
+			ExpectPXE:   true,
+		},
+		{
+			Scenario: "iso no build",
+			Config: ironicConfig{
+				havePreprovImgBuilder: true,
+				deployKernelURL:       localKernel,
+				deployRamdiskURL:      localRamdisk,
+				deployISOURL:          localIso,
+			},
+			Driver:    isoDriver,
+			ExpectISO: false,
+			ExpectPXE: false,
+		},
+		{
+			Scenario: "iso build",
+			Config: ironicConfig{
+				havePreprovImgBuilder: true,
+				deployKernelURL:       localKernel,
+				deployRamdiskURL:      localRamdisk,
+				deployISOURL:          localIso,
+			},
+			Driver: isoDriver,
+			Image: &provisioner.PreprovisioningImage{
+				ImageURL: buildIso,
+				Format:   metal3v1alpha1.ImageFormatISO,
+			},
+			ExpectBuild: true,
+			ExpectISO:   true,
+			ExpectPXE:   false,
+		},
+		{
+			Scenario: "pxe build",
+			Config: ironicConfig{
+				havePreprovImgBuilder: true,
+				deployKernelURL:       localKernel,
+				deployRamdiskURL:      localRamdisk,
+				deployISOURL:          localIso,
+			},
+			Driver: pxeDriver,
+			Image: &provisioner.PreprovisioningImage{
+				ImageURL: buildRamdisk,
+				Format:   metal3v1alpha1.ImageFormatInitRD,
+			},
+			ExpectBuild: true,
+			ExpectISO:   false,
+			ExpectPXE:   true,
+		},
+		{
+			Scenario: "pxe iso build",
+			Config: ironicConfig{
+				havePreprovImgBuilder: true,
+				deployKernelURL:       localKernel,
+				deployRamdiskURL:      localRamdisk,
+				deployISOURL:          localIso,
+			},
+			Driver: pxeDriver,
+			Image: &provisioner.PreprovisioningImage{
+				ImageURL: buildIso,
+				Format:   metal3v1alpha1.ImageFormatISO,
+			},
+			ExpectBuild: false,
+			ExpectISO:   false,
+			ExpectPXE:   true,
+		},
+		{
+			Scenario: "pxe build no kernel",
+			Config: ironicConfig{
+				havePreprovImgBuilder: true,
+				deployISOURL:          localIso,
+			},
+			Driver: pxeDriver,
+			Image: &provisioner.PreprovisioningImage{
+				ImageURL: buildRamdisk,
+				Format:   metal3v1alpha1.ImageFormatInitRD,
+			},
+			ExpectISO: false,
+			ExpectPXE: false,
+		},
+		{
+			Scenario: "pxe iso build no kernel",
+			Config: ironicConfig{
+				havePreprovImgBuilder: true,
+			},
+			Driver: pxeDriver,
+			Image: &provisioner.PreprovisioningImage{
+				ImageURL: buildRamdisk,
+				Format:   metal3v1alpha1.ImageFormatISO,
+			},
+			ExpectISO: false,
+			ExpectPXE: false,
+		},
+		{
+			Scenario: "pxe iso build no initrd",
+			Config: ironicConfig{
+				havePreprovImgBuilder: true,
+				deployKernelURL:       localKernel,
+			},
+			Driver: pxeDriver,
+			Image: &provisioner.PreprovisioningImage{
+				ImageURL: buildRamdisk,
+				Format:   metal3v1alpha1.ImageFormatISO,
+			},
+			ExpectISO: false,
+			ExpectPXE: false,
+		},
+		{
+			Scenario: "no build no initrd",
+			Config: ironicConfig{
+				havePreprovImgBuilder: true,
+				deployKernelURL:       localKernel,
+			},
+			Driver:    pxeDriver,
+			ExpectISO: false,
+			ExpectPXE: false,
+		},
+		{
+			Scenario: "pxe no imgbuilder no pxe",
+			Config: ironicConfig{
+				havePreprovImgBuilder: false,
+				deployISOURL:          localIso,
+			},
+			Driver:    pxeDriver,
+			ExpectISO: false,
+			ExpectPXE: false,
+		},
+		{
+			Scenario: "iso no imgbuilder no images",
+			Config: ironicConfig{
+				havePreprovImgBuilder: false,
+			},
+			Driver:    isoDriver,
+			ExpectISO: false,
+			ExpectPXE: false,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.Scenario, func(t *testing.T) {
+			driverInfo := make(map[string]interface{}, 0)
+			opts := setDeployImage(driverInfo, tc.Config, tc.Driver, tc.Image)
+
+			switch {
+			case tc.ExpectISO:
+				if tc.ExpectBuild {
+					assert.Equal(t, buildIso, opts["deploy_iso"])
+					assert.Equal(t, buildIso, driverInfo["deploy_iso"])
+				} else {
+					assert.Equal(t, localIso, opts["deploy_iso"])
+					assert.Equal(t, localIso, driverInfo["deploy_iso"])
+				}
+				assert.Nil(t, opts["deploy_kernel"])
+				assert.Nil(t, opts["deploy_ramdisk"])
+				assert.Nil(t, driverInfo["deploy_kernel"])
+				assert.Nil(t, driverInfo["deploy_ramdisk"])
+			case tc.ExpectPXE:
+				assert.Nil(t, opts["deploy_iso"])
+				assert.Nil(t, driverInfo["deploy_iso"])
+				if tc.ExpectBuild {
+					assert.Equal(t, buildKernel, opts["deploy_kernel"])
+					assert.Equal(t, buildRamdisk, opts["deploy_ramdisk"])
+					assert.Equal(t, buildKernel, driverInfo["deploy_kernel"])
+					assert.Equal(t, buildRamdisk, driverInfo["deploy_ramdisk"])
+				} else {
+					assert.Equal(t, localKernel, opts["deploy_kernel"])
+					assert.Equal(t, localRamdisk, opts["deploy_ramdisk"])
+					assert.Equal(t, localKernel, driverInfo["deploy_kernel"])
+					assert.Equal(t, localRamdisk, driverInfo["deploy_ramdisk"])
+				}
+			default:
+				assert.Nil(t, opts)
+				assert.Empty(t, driverInfo)
+			}
+		})
+	}
+}