Repository: metal3-io/baremetal-operator
Pull request #884 (2021-05-19 17:24:55)

Custom deploy procedure
See https://github.com/metal3-io/metal3-docs/blob/master/design/deploy-steps.md

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/884#" title="Author self-approved">dtantsur</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)~~ [dtantsur]

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":[]} -->
dtantsur: /test-integration
dtantsur: /test-integration
dtantsur: /test-integration
dtantsur: /test-integration
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 **dtantsur** 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":["dtantsur"]} -->
dtantsur: /test-integration 
dtantsur: /retitle Custom deploy procedure
/test-integration
metal3-io-bot: [APPROVALNOTIFIER] This PR is **NOT APPROVED**

This pull-request has been approved by: *<a href="https://github.com/metal3-io/baremetal-operator/pull/884#pullrequestreview-672841778" title="Approved">elfosardo</a>*
To complete the [pull request process](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process), please ask for approval from **dtantsur** 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":["dtantsur"]} -->
metal3-io-bot: @dtantsur: 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>
dtantsur: /test-integration 
metal3-io-bot: @dtantsur: 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>
dtantsur: /test-integration
dtantsur: /test-integration
dtantsur: /test-integration 

All comments addressed, please check again.

Patch:
diff --git a/apis/metal3.io/v1alpha1/baremetalhost_types.go b/apis/metal3.io/v1alpha1/baremetalhost_types.go
index 0e079cf4..f9e7b09a 100644
--- a/apis/metal3.io/v1alpha1/baremetalhost_types.go
+++ b/apis/metal3.io/v1alpha1/baremetalhost_types.go
@@ -385,6 +385,10 @@ type BareMetalHostSpec struct {
 	// +kubebuilder:default:=metadata
 	// +kubebuilder:validation:Optional
 	AutomatedCleaningMode AutomatedCleaningMode `json:"automatedCleaningMode,omitempty"`
+
+	// A custom deploy procedure.
+	// +optional
+	CustomDeploy *CustomDeploy `json:"customDeploy,omitempty"`
 }
 
 // AutomatedCleaningMode is the interface to enable/disable automated cleaning
@@ -434,6 +438,14 @@ type Image struct {
 	DiskFormat *string `json:"format,omitempty"`
 }
 
+// Custom deploy is a description of a customized deploy process.
+type CustomDeploy struct {
+	// Custom deploy method name.
+	// This name is specific to the deploy ramdisk used. If you don't have
+	// a custom deploy ramdisk, you shouldn't use CustomDeploy.
+	Method string `json:"method"`
+}
+
 // FIXME(dhellmann): We probably want some other module to own these
 // data structures.
 
@@ -740,6 +752,9 @@ type ProvisionStatus struct {
 
 	// The Bios set by the user
 	Firmware *FirmwareConfig `json:"firmware,omitempty"`
+
+	// Custom deploy procedure applied to the host.
+	CustomDeploy *CustomDeploy `json:"customDeploy,omitempty"`
 }
 
 // +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
@@ -869,6 +884,11 @@ func (host *BareMetalHost) NeedsProvisioning() bool {
 		// The host is not supposed to be powered on.
 		return false
 	}
+
+	return host.hasNewImage() || host.hasNewCustomDeploy()
+}
+
+func (host *BareMetalHost) hasNewImage() bool {
 	if host.Spec.Image == nil {
 		// Without an image, there is nothing to provision.
 		return false
@@ -884,6 +904,22 @@ func (host *BareMetalHost) NeedsProvisioning() bool {
 	return false
 }
 
+func (host *BareMetalHost) hasNewCustomDeploy() bool {
+	if host.Spec.CustomDeploy == nil {
+		return false
+	}
+	if host.Spec.CustomDeploy.Method == "" {
+		return false
+	}
+	if host.Status.Provisioning.CustomDeploy == nil {
+		return true
+	}
+	if host.Status.Provisioning.CustomDeploy.Method != host.Spec.CustomDeploy.Method {
+		return true
+	}
+	return false
+}
+
 // WasProvisioned returns true when we think we have placed an image
 // on the host.
 func (host *BareMetalHost) WasProvisioned() bool {
diff --git a/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go b/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go
index 8993e77c..c0d9bbf8 100644
--- a/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go
+++ b/apis/metal3.io/v1alpha1/zz_generated.deepcopy.go
@@ -165,6 +165,11 @@ func (in *BareMetalHostSpec) DeepCopyInto(out *BareMetalHostSpec) {
 		*out = new(v1.SecretReference)
 		**out = **in
 	}
+	if in.CustomDeploy != nil {
+		in, out := &in.CustomDeploy, &out.CustomDeploy
+		*out = new(CustomDeploy)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BareMetalHostSpec.
@@ -245,6 +250,21 @@ func (in *CredentialsStatus) DeepCopy() *CredentialsStatus {
 	return out
 }
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *CustomDeploy) DeepCopyInto(out *CustomDeploy) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CustomDeploy.
+func (in *CustomDeploy) DeepCopy() *CustomDeploy {
+	if in == nil {
+		return nil
+	}
+	out := new(CustomDeploy)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *Firmware) DeepCopyInto(out *Firmware) {
 	*out = *in
@@ -461,6 +481,11 @@ func (in *ProvisionStatus) DeepCopyInto(out *ProvisionStatus) {
 		*out = new(FirmwareConfig)
 		(*in).DeepCopyInto(*out)
 	}
+	if in.CustomDeploy != nil {
+		in, out := &in.CustomDeploy, &out.CustomDeploy
+		*out = new(CustomDeploy)
+		**out = **in
+	}
 }
 
 // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProvisionStatus.
diff --git a/config/crd/bases/metal3.io_baremetalhosts.yaml b/config/crd/bases/metal3.io_baremetalhosts.yaml
index 06f22eda..7340a55b 100644
--- a/config/crd/bases/metal3.io_baremetalhosts.yaml
+++ b/config/crd/bases/metal3.io_baremetalhosts.yaml
@@ -126,6 +126,15 @@ spec:
                     description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids'
                     type: string
                 type: object
+              customDeploy:
+                description: A custom deploy procedure.
+                properties:
+                  method:
+                    description: Custom deploy method name. This name is specific to the deploy ramdisk used. If you don't have a custom deploy ramdisk, you shouldn't use CustomDeploy.
+                    type: string
+                required:
+                - method
+                type: object
               description:
                 description: Description is a human-entered text used to help identify the host
                 type: string
@@ -643,6 +652,15 @@ spec:
                     - UEFISecureBoot
                     - legacy
                     type: string
+                  customDeploy:
+                    description: Custom deploy procedure applied to the host.
+                    properties:
+                      method:
+                        description: Custom deploy method name. This name is specific to the deploy ramdisk used. If you don't have a custom deploy ramdisk, you shouldn't use CustomDeploy.
+                        type: string
+                    required:
+                    - method
+                    type: object
                   firmware:
                     description: The Bios set by the user
                     properties:
diff --git a/config/render/capm3.yaml b/config/render/capm3.yaml
index 589c4170..319c3b18 100644
--- a/config/render/capm3.yaml
+++ b/config/render/capm3.yaml
@@ -124,6 +124,15 @@ spec:
                     description: 'UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids'
                     type: string
                 type: object
+              customDeploy:
+                description: A custom deploy procedure.
+                properties:
+                  method:
+                    description: Custom deploy method name. This name is specific to the deploy ramdisk used. If you don't have a custom deploy ramdisk, you shouldn't use CustomDeploy.
+                    type: string
+                required:
+                - method
+                type: object
               description:
                 description: Description is a human-entered text used to help identify the host
                 type: string
@@ -641,6 +650,15 @@ spec:
                     - UEFISecureBoot
                     - legacy
                     type: string
+                  customDeploy:
+                    description: Custom deploy procedure applied to the host.
+                    properties:
+                      method:
+                        description: Custom deploy method name. This name is specific to the deploy ramdisk used. If you don't have a custom deploy ramdisk, you shouldn't use CustomDeploy.
+                        type: string
+                    required:
+                    - method
+                    type: object
                   firmware:
                     description: The Bios set by the user
                     properties:
diff --git a/controllers/metal3.io/baremetalhost_controller.go b/controllers/metal3.io/baremetalhost_controller.go
index 0901766f..0d26e607 100644
--- a/controllers/metal3.io/baremetalhost_controller.go
+++ b/controllers/metal3.io/baremetalhost_controller.go
@@ -551,6 +551,19 @@ func getCurrentImage(host *metal3v1alpha1.BareMetalHost) *metal3v1alpha1.Image {
 	return nil
 }
 
+func hasCustomDeploy(host *metal3v1alpha1.BareMetalHost) bool {
+	if host.Status.Provisioning.CustomDeploy != nil && host.Status.Provisioning.CustomDeploy.Method != "" {
+		return true
+	}
+
+	switch host.Status.Provisioning.State {
+	case metal3v1alpha1.StateProvisioning, metal3v1alpha1.StateExternallyProvisioned:
+		return host.Spec.CustomDeploy != nil && host.Spec.CustomDeploy.Method != ""
+	default:
+		return false
+	}
+}
+
 // detachHost() detaches the host from the Provisioner
 func (r *BareMetalHostReconciler) detachHost(prov provisioner.Provisioner, info *reconcileInfo) actionResult {
 	provResult, err := prov.Detach()
@@ -598,6 +611,7 @@ func (r *BareMetalHostReconciler) registerHost(prov provisioner.Provisioner, inf
 			AutomatedCleaningMode: info.host.Spec.AutomatedCleaningMode,
 			State:                 info.host.Status.Provisioning.State,
 			CurrentImage:          getCurrentImage(info.host),
+			HasCustomDeploy:       hasCustomDeploy(info.host),
 		},
 		credsChanged,
 		info.host.Status.ErrorType == metal3v1alpha1.RegistrationError)
@@ -817,8 +831,14 @@ func (r *BareMetalHostReconciler) actionProvisioning(prov provisioner.Provisione
 		return actionContinue{}
 	}
 
+	var image metal3v1alpha1.Image
+	if info.host.Spec.Image != nil {
+		image = *info.host.Spec.Image.DeepCopy()
+	}
+
 	provResult, err := prov.Provision(provisioner.ProvisionData{
-		Image:           *info.host.Spec.Image.DeepCopy(),
+		Image:           image,
+		CustomDeploy:    info.host.Spec.CustomDeploy.DeepCopy(),
 		HostConfig:      hostConf,
 		BootMode:        info.host.Status.Provisioning.BootMode,
 		HardwareProfile: hwProf,
@@ -845,11 +865,16 @@ func (r *BareMetalHostReconciler) actionProvisioning(prov provisioner.Provisione
 	}
 
 	// If the provisioner had no work, ensure the image settings match.
-	if info.host.Status.Provisioning.Image != *(info.host.Spec.Image) {
+	if info.host.Spec.Image != nil && info.host.Status.Provisioning.Image != *(info.host.Spec.Image) {
 		info.log.Info("updating deployed image in status")
 		info.host.Status.Provisioning.Image = *(info.host.Spec.Image)
 	}
 
+	if info.host.Spec.CustomDeploy != nil && (info.host.Status.Provisioning.CustomDeploy == nil || !reflect.DeepEqual(*info.host.Spec.CustomDeploy, *info.host.Status.Provisioning.CustomDeploy)) {
+		info.log.Info("updating custom deploy in status")
+		info.host.Status.Provisioning.CustomDeploy = info.host.Spec.CustomDeploy.DeepCopy()
+	}
+
 	// After provisioning we always requeue to ensure we enter the
 	// "provisioned" state and start monitoring power status.
 	return actionComplete{}
@@ -915,6 +940,7 @@ func (r *BareMetalHostReconciler) actionDeprovisioning(prov provisioner.Provisio
 	// After the provisioner is done, clear the provisioning settings
 	// so we transition to the next state.
 	info.host.Status.Provisioning.Image = metal3v1alpha1.Image{}
+	info.host.Status.Provisioning.CustomDeploy = nil
 	clearHostProvisioningSettings(info.host)
 
 	return actionComplete{}
diff --git a/controllers/metal3.io/host_state_machine.go b/controllers/metal3.io/host_state_machine.go
index dc63858f..f6daf5f5 100644
--- a/controllers/metal3.io/host_state_machine.go
+++ b/controllers/metal3.io/host_state_machine.go
@@ -428,6 +428,27 @@ func (hsm *hostStateMachine) handleReady(info *reconcileInfo) actionResult {
 }
 
 func (hsm *hostStateMachine) provisioningCancelled() bool {
+	if hsm.Host.Spec.CustomDeploy != nil && hsm.Host.Spec.CustomDeploy.Method != "" {
+		if hsm.Host.Status.Provisioning.CustomDeploy != nil && hsm.Host.Spec.CustomDeploy.Method != hsm.Host.Status.Provisioning.CustomDeploy.Method {
+			return true
+		}
+		// At this point spec.CustomDeploy value didn't change (and it's not empty). Only a discrepancy in the Image
+		// could require a deprovisioning, but if it isn't set or empty then is fine
+		if hsm.Host.Status.Provisioning.Image.URL != "" &&
+			(hsm.Host.Spec.Image == nil ||
+				hsm.Host.Spec.Image.URL != hsm.Host.Status.Provisioning.Image.URL) {
+			return true
+		}
+
+		return false
+	} else if hsm.Host.Status.Provisioning.CustomDeploy != nil && hsm.Host.Status.Provisioning.CustomDeploy.Method != "" {
+		return true
+	}
+
+	return hsm.imageProvisioningCancelled()
+}
+
+func (hsm *hostStateMachine) imageProvisioningCancelled() bool {
 	if hsm.Host.Spec.Image == nil {
 		return true
 	}
diff --git a/docs/configuration.md b/docs/configuration.md
index 4cce1185..20dcb680 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -56,3 +56,13 @@ configurations, namely:
 
 A detailed overview of the configuration is presented in [Bare Metal Operator
 and Ironic Configuration](deploying.md).
+
+Notes on external Ironic
+------------------------
+
+When an external Ironic is used, the following requirements must be met:
+
+* Either HTTP basic or no-auth authentication must be used (Keystone is not
+  supported).
+
+* API version 1.69 (Wallaby release cycle) or newer must be available.
diff --git a/pkg/provisioner/fixture/fixture.go b/pkg/provisioner/fixture/fixture.go
index ffe241ae..ab0b2e21 100644
--- a/pkg/provisioner/fixture/fixture.go
+++ b/pkg/provisioner/fixture/fixture.go
@@ -71,6 +71,8 @@ type Fixture struct {
 	poweredOn bool
 
 	validateError string
+
+	customDeploy *metal3v1alpha1.CustomDeploy
 }
 
 // New returns a new Fixture Provisioner
@@ -209,7 +211,16 @@ func (p *fixtureProvisioner) Adopt(data provisioner.AdoptData, force bool) (resu
 func (p *fixtureProvisioner) Provision(data provisioner.ProvisionData) (result provisioner.Result, err error) {
 	p.log.Info("provisioning image to host")
 
-	if p.state.image.URL == "" {
+	if data.CustomDeploy != nil && p.state.customDeploy == nil {
+		p.publisher("ProvisioningComplete", "Custom deploy provisioning completed")
+		p.log.Info("moving to done")
+		p.state.customDeploy = data.CustomDeploy.DeepCopy()
+		result.Dirty = true
+		result.RequeueAfter = provisionRequeueDelay
+		return result, nil
+	}
+
+	if data.Image.URL != "" && p.state.image.URL == "" {
 		p.publisher("ProvisioningComplete", "Image provisioning completed")
 		p.log.Info("moving to done")
 		p.state.image = data.Image
@@ -241,6 +252,14 @@ func (p *fixtureProvisioner) Deprovision(force bool) (result provisioner.Result,
 		return result, nil
 	}
 
+	if p.state.customDeploy != nil {
+		p.publisher("DeprovisionStarted", "Custom deploy deprovisioning started")
+		p.log.Info("clearing hardware details")
+		p.state.customDeploy = nil
+		result.Dirty = true
+		return result, nil
+	}
+
 	p.publisher("DeprovisionComplete", "Image deprovisioning completed")
 	return result, nil
 }
diff --git a/pkg/provisioner/ironic/clients/client.go b/pkg/provisioner/ironic/clients/client.go
index bc14f28b..348ac480 100644
--- a/pkg/provisioner/ironic/clients/client.go
+++ b/pkg/provisioner/ironic/clients/client.go
@@ -94,8 +94,9 @@ func IronicClient(ironicEndpoint string, auth AuthConfig, tls TLSConfig) (client
 	}
 
 	// Ensure we have a microversion high enough to get the features
-	// we need.
-	client.Microversion = "1.56"
+	// we need. Update docs/configuration.md when updating the version.
+	// Version 1.69 introduces deploySteps argument to provisioning.
+	client.Microversion = "1.69"
 
 	return updateHTTPClient(client, tls)
 }
diff --git a/pkg/provisioner/ironic/ironic.go b/pkg/provisioner/ironic/ironic.go
index 7dd7ee2d..7d4a3580 100644
--- a/pkg/provisioner/ironic/ironic.go
+++ b/pkg/provisioner/ironic/ironic.go
@@ -32,11 +32,12 @@ var (
 
 const (
 	// See nodes.Node.PowerState for details
-	powerOn       = "power on"
-	powerOff      = "power off"
-	softPowerOff  = "soft power off"
-	powerNone     = "None"
-	nameSeparator = "~"
+	powerOn              = "power on"
+	powerOff             = "power off"
+	softPowerOff         = "soft power off"
+	powerNone            = "None"
+	nameSeparator        = "~"
+	customDeployPriority = 80
 )
 
 var bootModeCapabilities = map[metal3v1alpha1.BootMode]string{
@@ -446,8 +447,8 @@ func (p *ironicProvisioner) ValidateManagementAccess(data provisioner.Management
 		// target provision state to manageable, which happens
 		// below.
 	}
-	if data.CurrentImage != nil {
-		p.getImageUpdateOptsForNode(ironicNode, data.CurrentImage, data.BootMode, updater)
+	if data.CurrentImage != nil || data.HasCustomDeploy {
+		p.getImageUpdateOptsForNode(ironicNode, data.CurrentImage, data.BootMode, data.HasCustomDeploy, updater)
 	}
 	updater.SetTopLevelOpt("automated_clean",
 		data.AutomatedCleaningMode != metal3v1alpha1.CleaningModeDisabled,
@@ -732,7 +733,48 @@ func (p *ironicProvisioner) setDirectDeployUpdateOptsForNode(ironicNode *nodes.N
 		SetTopLevelOpt("deploy_interface", "direct", ironicNode.DeployInterface)
 }
 
-func (p *ironicProvisioner) getImageUpdateOptsForNode(ironicNode *nodes.Node, imageData *metal3v1alpha1.Image, bootMode metal3v1alpha1.BootMode, updater *nodeUpdater) {
+func (p *ironicProvisioner) setCustomDeployUpdateOptsForNode(ironicNode *nodes.Node, imageData *metal3v1alpha1.Image, updater *nodeUpdater) {
+	var optValues optionsData
+	if imageData != nil && imageData.URL != "" {
+		checksum, checksumType, ok := imageData.GetChecksum()
+		// NOTE(dtantsur): all fields are optional for custom deploy
+		if ok {
+			optValues = optionsData{
+				"boot_iso":            nil,
+				"image_checksum":      nil,
+				"image_source":        imageData.URL,
+				"image_os_hash_algo":  checksumType,
+				"image_os_hash_value": checksum,
+				"image_disk_format":   imageData.DiskFormat,
+			}
+		} else {
+			optValues = optionsData{
+				"boot_iso":            nil,
+				"image_checksum":      nil,
+				"image_source":        imageData.URL,
+				"image_os_hash_algo":  nil,
+				"image_os_hash_value": nil,
+				"image_disk_format":   imageData.DiskFormat,
+			}
+		}
+	} else {
+		// Clean up everything
+		optValues = optionsData{
+			"boot_iso":            nil,
+			"image_checksum":      nil,
+			"image_source":        nil,
+			"image_os_hash_algo":  nil,
+			"image_os_hash_value": nil,
+			"image_disk_format":   nil,
+		}
+	}
+
+	updater.
+		SetInstanceInfoOpts(optValues, ironicNode).
+		SetTopLevelOpt("deploy_interface", "custom-agent", ironicNode.DeployInterface)
+}
+
+func (p *ironicProvisioner) getImageUpdateOptsForNode(ironicNode *nodes.Node, imageData *metal3v1alpha1.Image, bootMode metal3v1alpha1.BootMode, hasCustomDeploy bool, updater *nodeUpdater) {
 	// instance_uuid
 	updater.SetTopLevelOpt("instance_uuid", string(p.objectMeta.UID), ironicNode.InstanceUUID)
 
@@ -750,7 +792,10 @@ func (p *ironicProvisioner) getImageUpdateOptsForNode(ironicNode *nodes.Node, im
 
 	updater.SetInstanceInfoOpts(optionsData{"capabilities": capabilitiesII}, ironicNode)
 
-	if imageData.DiskFormat != nil && *imageData.DiskFormat == "live-iso" {
+	if hasCustomDeploy {
+		// Custom deploy process
+		p.setCustomDeployUpdateOptsForNode(ironicNode, imageData, updater)
+	} else if imageData.DiskFormat != nil && *imageData.DiskFormat == "live-iso" {
 		// Set live-iso format options
 		p.setLiveIsoUpdateOptsForNode(ironicNode, imageData, updater)
 	} else {
@@ -762,7 +807,8 @@ func (p *ironicProvisioner) getImageUpdateOptsForNode(ironicNode *nodes.Node, im
 func (p *ironicProvisioner) getUpdateOptsForNode(ironicNode *nodes.Node, data provisioner.ProvisionData) *nodeUpdater {
 	updater := updateOptsBuilder(p.debugLog)
 
-	p.getImageUpdateOptsForNode(ironicNode, &data.Image, data.BootMode, updater)
+	hasCustomDeploy := data.CustomDeploy != nil && data.CustomDeploy.Method != ""
+	p.getImageUpdateOptsForNode(ironicNode, &data.Image, data.BootMode, hasCustomDeploy, updater)
 
 	opts := optionsData{
 		"root_device": devicehints.MakeHintMap(data.RootDeviceHints),
@@ -1130,6 +1176,19 @@ func (p *ironicProvisioner) getConfigDrive(data provisioner.ProvisionData) (conf
 	return
 }
 
+func (p *ironicProvisioner) getCustomDeploySteps(customDeploy *metal3v1alpha1.CustomDeploy) (deploySteps []nodes.DeployStep) {
+	if customDeploy != nil && customDeploy.Method != "" {
+		deploySteps = append(deploySteps, nodes.DeployStep{
+			Interface: nodes.InterfaceDeploy,
+			Step:      customDeploy.Method,
+			Args:      map[string]interface{}{},
+			Priority:  customDeployPriority,
+		})
+	}
+
+	return
+}
+
 // Provision writes the image from the host spec to the host. It may
 // be called multiple times, and should return true for its dirty flag
 // until the provisioning operation is completed.
@@ -1178,6 +1237,7 @@ func (p *ironicProvisioner) Provision(data provisioner.ProvisionData) (result pr
 			nodes.ProvisionStateOpts{
 				Target:      nodes.TargetActive,
 				ConfigDrive: configDrive,
+				DeploySteps: p.getCustomDeploySteps(data.CustomDeploy),
 			},
 		)
 
@@ -1214,6 +1274,7 @@ func (p *ironicProvisioner) Provision(data provisioner.ProvisionData) (result pr
 			nodes.ProvisionStateOpts{
 				Target:      nodes.TargetActive,
 				ConfigDrive: configDrive,
+				DeploySteps: p.getCustomDeploySteps(data.CustomDeploy),
 			},
 		)
 
diff --git a/pkg/provisioner/provisioner.go b/pkg/provisioner/provisioner.go
index 620a6160..39d241b8 100644
--- a/pkg/provisioner/provisioner.go
+++ b/pkg/provisioner/provisioner.go
@@ -64,6 +64,7 @@ type ManagementAccessData struct {
 	AutomatedCleaningMode metal3v1alpha1.AutomatedCleaningMode
 	State                 metal3v1alpha1.ProvisioningState
 	CurrentImage          *metal3v1alpha1.Image
+	HasCustomDeploy       bool
 }
 
 type AdoptData struct {
@@ -86,6 +87,7 @@ type ProvisionData struct {
 	BootMode        metal3v1alpha1.BootMode
 	HardwareProfile hardware.Profile
 	RootDeviceHints *metal3v1alpha1.RootDeviceHints
+	CustomDeploy    *metal3v1alpha1.CustomDeploy
 }
 
 // Provisioner holds the state information for talking to the


Test patch:
diff --git a/controllers/metal3.io/baremetalhost_controller_test.go b/controllers/metal3.io/baremetalhost_controller_test.go
index 1f9ad10c..474fb726 100644
--- a/controllers/metal3.io/baremetalhost_controller_test.go
+++ b/controllers/metal3.io/baremetalhost_controller_test.go
@@ -1035,6 +1035,77 @@ func TestNeedsProvisioning(t *testing.T) {
 	}
 }
 
+// TestNeedsProvisioning verifies the logic for deciding when a host
+// needs to be provisioned when custom deploy is used.
+func TestNeedsProvisioningCustomDeploy(t *testing.T) {
+	cases := []struct {
+		name string
+
+		customDeploy        string
+		currentCustomDeploy string
+		online              bool
+		image               *metal3v1alpha1.Image
+
+		needsProvisioning bool
+	}{
+		{
+			name:              "empty host",
+			needsProvisioning: false,
+		},
+		{
+			name:              "with custom deploy but not online",
+			customDeploy:      "install_everything",
+			needsProvisioning: false,
+		},
+		{
+			name:              "with custom deploy and online",
+			customDeploy:      "install_everything",
+			online:            true,
+			needsProvisioning: true,
+		},
+		{
+			name:                "with matching custom deploy and online",
+			customDeploy:        "install_everything",
+			currentCustomDeploy: "install_everything",
+			online:              true,
+			needsProvisioning:   false,
+		},
+		{
+			name:                "with custom deploy and new image",
+			customDeploy:        "install_everything",
+			currentCustomDeploy: "install_everything",
+			image: &metal3v1alpha1.Image{
+				URL:      "https://example.com/image-name",
+				Checksum: "12345",
+			},
+			online:            true,
+			needsProvisioning: true,
+		},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			host := newDefaultHost(t)
+
+			host.Spec.Online = tc.online
+			if tc.customDeploy != "" {
+				host.Spec.CustomDeploy = &metal3v1alpha1.CustomDeploy{
+					Method: tc.customDeploy,
+				}
+			}
+			if tc.currentCustomDeploy != "" {
+				host.Status.Provisioning.CustomDeploy = &metal3v1alpha1.CustomDeploy{
+					Method: tc.currentCustomDeploy,
+				}
+			}
+			if tc.image != nil {
+				host.Spec.Image = tc.image
+			}
+
+			assert.Equal(t, tc.needsProvisioning, host.NeedsProvisioning())
+		})
+	}
+}
+
 // TestProvision ensures that the Provisioning.Image portion of the
 // status block is filled in for provisioned hosts.
 func TestProvision(t *testing.T) {
@@ -1059,6 +1130,52 @@ func TestProvision(t *testing.T) {
 	)
 }
 
+// TestProvisionCustomDeploy ensures that the Provisioning.CustomDeploy portion
+// of the status block is filled in for provisioned hosts.
+func TestProvisionCustomDeploy(t *testing.T) {
+	host := newDefaultHost(t)
+	host.Spec.CustomDeploy = &metal3v1alpha1.CustomDeploy{
+		Method: "install_everything",
+	}
+	host.Spec.Online = true
+	r := newTestReconciler(host)
+
+	tryReconcile(t, r, host,
+		func(host *metal3v1alpha1.BareMetalHost, result reconcile.Result) bool {
+			t.Logf("custom deploy: %v", host.Spec.CustomDeploy)
+			t.Logf("provisioning custom deploy: %v", host.Status.Provisioning.CustomDeploy)
+			t.Logf("provisioning state: %v", host.Status.Provisioning.State)
+			return host.Status.Provisioning.CustomDeploy != nil && host.Status.Provisioning.CustomDeploy.Method == "install_everything" && host.Status.Provisioning.State == metal3v1alpha1.StateProvisioned
+		},
+	)
+}
+
+// TestProvisionCustomDeployWithURL ensures that the Provisioning.CustomDeploy
+// portion of the status block is filled in for provisioned hosts.
+func TestProvisionCustomDeployWithURL(t *testing.T) {
+	host := newDefaultHost(t)
+	host.Spec.CustomDeploy = &metal3v1alpha1.CustomDeploy{
+		Method: "install_everything",
+	}
+	host.Spec.Image = &metal3v1alpha1.Image{
+		URL:      "https://example.com/image-name",
+		Checksum: "12345",
+	}
+	host.Spec.Online = true
+	r := newTestReconciler(host)
+
+	tryReconcile(t, r, host,
+		func(host *metal3v1alpha1.BareMetalHost, result reconcile.Result) bool {
+			t.Logf("image details: %v", host.Spec.Image)
+			t.Logf("custom deploy: %v", host.Spec.CustomDeploy)
+			t.Logf("provisioning image details: %v", host.Status.Provisioning.Image)
+			t.Logf("provisioning custom deploy: %v", host.Status.Provisioning.CustomDeploy)
+			t.Logf("provisioning state: %v", host.Status.Provisioning.State)
+			return host.Status.Provisioning.CustomDeploy != nil && host.Status.Provisioning.CustomDeploy.Method != "" && host.Status.Provisioning.Image.URL != ""
+		},
+	)
+}
+
 // TestExternallyProvisionedTransitions ensures that host enters the
 // expected states when it looks like it has been provisioned by
 // another tool.
diff --git a/controllers/metal3.io/host_state_machine_test.go b/controllers/metal3.io/host_state_machine_test.go
index 0f986567..acab71fb 100644
--- a/controllers/metal3.io/host_state_machine_test.go
+++ b/controllers/metal3.io/host_state_machine_test.go
@@ -455,6 +455,23 @@ func TestProvisioningCancelled(t *testing.T) {
 			Expected: false,
 		},
 
+		{
+			Scenario: "with custom deploy, unprovisioned",
+			Host: metal3v1alpha1.BareMetalHost{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "myhost",
+					Namespace: "myns",
+				},
+				Spec: metal3v1alpha1.BareMetalHostSpec{
+					CustomDeploy: &metal3v1alpha1.CustomDeploy{
+						Method: "install_everything",
+					},
+					Online: true,
+				},
+			},
+			Expected: false,
+		},
+
 		{
 			Scenario: "with image, unprovisioned",
 			Host: metal3v1alpha1.BareMetalHost{
@@ -502,7 +519,24 @@ func TestProvisioningCancelled(t *testing.T) {
 		},
 
 		{
-			Scenario: "provisioned",
+			Scenario: "with custom deploy, offline",
+			Host: metal3v1alpha1.BareMetalHost{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "myhost",
+					Namespace: "myns",
+				},
+				Spec: metal3v1alpha1.BareMetalHostSpec{
+					CustomDeploy: &metal3v1alpha1.CustomDeploy{
+						Method: "install_everything",
+					},
+					Online: false,
+				},
+			},
+			Expected: false,
+		},
+
+		{
+			Scenario: "provisioned with image",
 			Host: metal3v1alpha1.BareMetalHost{
 				ObjectMeta: metav1.ObjectMeta{
 					Name:      "myhost",
@@ -552,6 +586,31 @@ func TestProvisioningCancelled(t *testing.T) {
 			Expected: false,
 		},
 
+		{
+
+			Scenario: "provisioned with custom deploy",
+			Host: metal3v1alpha1.BareMetalHost{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "myhost",
+					Namespace: "myns",
+				},
+				Spec: metal3v1alpha1.BareMetalHostSpec{
+					CustomDeploy: &metal3v1alpha1.CustomDeploy{
+						Method: "install_everything",
+					},
+					Online: true,
+				},
+				Status: metal3v1alpha1.BareMetalHostStatus{
+					Provisioning: metal3v1alpha1.ProvisionStatus{
+						CustomDeploy: &metal3v1alpha1.CustomDeploy{
+							Method: "install_everything",
+						},
+					},
+				},
+			},
+			Expected: false,
+		},
+
 		{
 			Scenario: "removed image",
 			Host: metal3v1alpha1.BareMetalHost{
@@ -573,6 +632,27 @@ func TestProvisioningCancelled(t *testing.T) {
 			Expected: true,
 		},
 
+		{
+			Scenario: "removed custom deploy",
+			Host: metal3v1alpha1.BareMetalHost{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "myhost",
+					Namespace: "myns",
+				},
+				Spec: metal3v1alpha1.BareMetalHostSpec{
+					Online: true,
+				},
+				Status: metal3v1alpha1.BareMetalHostStatus{
+					Provisioning: metal3v1alpha1.ProvisionStatus{
+						CustomDeploy: &metal3v1alpha1.CustomDeploy{
+							Method: "install_everything",
+						},
+					},
+				},
+			},
+			Expected: true,
+		},
+
 		{
 			Scenario: "changed image",
 			Host: metal3v1alpha1.BareMetalHost{
@@ -596,6 +676,117 @@ func TestProvisioningCancelled(t *testing.T) {
 			},
 			Expected: true,
 		},
+
+		{
+			Scenario: "changed custom deploy",
+			Host: metal3v1alpha1.BareMetalHost{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "myhost",
+					Namespace: "myns",
+				},
+				Spec: metal3v1alpha1.BareMetalHostSpec{
+					CustomDeploy: &metal3v1alpha1.CustomDeploy{
+						Method: "install_not_everything",
+					},
+					Online: true,
+				},
+				Status: metal3v1alpha1.BareMetalHostStatus{
+					Provisioning: metal3v1alpha1.ProvisionStatus{
+						CustomDeploy: &metal3v1alpha1.CustomDeploy{
+							Method: "install_everything",
+						},
+					},
+				},
+			},
+			Expected: true,
+		},
+
+		{
+			Scenario: "changed image with custom deploy",
+			Host: metal3v1alpha1.BareMetalHost{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "myhost",
+					Namespace: "myns",
+				},
+				Spec: metal3v1alpha1.BareMetalHostSpec{
+					Image: &metal3v1alpha1.Image{
+						URL: "not-empty",
+					},
+					CustomDeploy: &metal3v1alpha1.CustomDeploy{
+						Method: "install_everything",
+					},
+					Online: true,
+				},
+				Status: metal3v1alpha1.BareMetalHostStatus{
+					Provisioning: metal3v1alpha1.ProvisionStatus{
+						Image: metal3v1alpha1.Image{
+							URL: "also-not-empty",
+						},
+						CustomDeploy: &metal3v1alpha1.CustomDeploy{
+							Method: "install_everything",
+						},
+					},
+				},
+			},
+			Expected: true,
+		},
+
+		{
+			Scenario: "changed custom deploy with image",
+			Host: metal3v1alpha1.BareMetalHost{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "myhost",
+					Namespace: "myns",
+				},
+				Spec: metal3v1alpha1.BareMetalHostSpec{
+					Image: &metal3v1alpha1.Image{
+						URL: "not-empty",
+					},
+					CustomDeploy: &metal3v1alpha1.CustomDeploy{
+						Method: "install_not_everything",
+					},
+					Online: true,
+				},
+				Status: metal3v1alpha1.BareMetalHostStatus{
+					Provisioning: metal3v1alpha1.ProvisionStatus{
+						Image: metal3v1alpha1.Image{
+							URL: "not-empty",
+						},
+						CustomDeploy: &metal3v1alpha1.CustomDeploy{
+							Method: "install_everything",
+						},
+					},
+				},
+			},
+			Expected: true,
+		},
+
+		{
+			Scenario: "removed custom deploy with image",
+			Host: metal3v1alpha1.BareMetalHost{
+				ObjectMeta: metav1.ObjectMeta{
+					Name:      "myhost",
+					Namespace: "myns",
+				},
+				Spec: metal3v1alpha1.BareMetalHostSpec{
+					Image: &metal3v1alpha1.Image{
+						URL: "not-empty",
+					},
+					Online: true,
+				},
+				Status: metal3v1alpha1.BareMetalHostStatus{
+					Provisioning: metal3v1alpha1.ProvisionStatus{
+						Image: metal3v1alpha1.Image{
+							URL: "not-empty",
+						},
+						CustomDeploy: &metal3v1alpha1.CustomDeploy{
+							Method: "install_everything",
+						},
+					},
+				},
+			},
+			Expected: true,
+		},
 	}
 	for _, tc := range testCases {
 		t.Run(tc.Scenario, func(t *testing.T) {
diff --git a/pkg/provisioner/ironic/ironic_test.go b/pkg/provisioner/ironic/ironic_test.go
index 80ed4be8..22e6aaf2 100644
--- a/pkg/provisioner/ironic/ironic_test.go
+++ b/pkg/provisioner/ironic/ironic_test.go
@@ -119,6 +119,17 @@ func makeHostLiveIso() (host metal3v1alpha1.BareMetalHost) {
 	return host
 }
 
+func makeHostCustomDeploy(only bool) (host metal3v1alpha1.BareMetalHost) {
+	host = makeHost()
+	host.Spec.CustomDeploy = &metal3v1alpha1.CustomDeploy{
+		Method: "install_everything",
+	}
+	if only {
+		host.Spec.Image = nil
+	}
+	return host
+}
+
 // Implements provisioner.EventPublisher to swallow events for tests.
 func nullEventPublisher(reason, message string) {}
 
diff --git a/pkg/provisioner/ironic/updateopts_test.go b/pkg/provisioner/ironic/updateopts_test.go
index 515158fc..44044962 100644
--- a/pkg/provisioner/ironic/updateopts_test.go
+++ b/pkg/provisioner/ironic/updateopts_test.go
@@ -948,6 +948,202 @@ func TestGetUpdateOptsForNodeLiveIsoToImage(t *testing.T) {
 	}
 }
 
+func TestGetUpdateOptsForNodeCustomDeploy(t *testing.T) {
+	eventPublisher := func(reason, message string) {}
+	auth := clients.AuthConfig{Type: clients.NoAuth}
+
+	host := makeHostCustomDeploy(true)
+	prov, err := newProvisionerWithSettings(host, bmc.Credentials{}, eventPublisher,
+		"https://ironic.test", auth, "https://ironic.test", auth,
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+	ironicNode := &nodes.Node{}
+
+	provData := provisioner.ProvisionData{
+		Image:        metal3v1alpha1.Image{},
+		BootMode:     metal3v1alpha1.DefaultBootMode,
+		CustomDeploy: host.Spec.CustomDeploy,
+	}
+	patches := prov.getUpdateOptsForNode(ironicNode, provData).Updates
+
+	t.Logf("patches: %v", patches)
+
+	expected := []struct {
+		Path  string         // the node property path
+		Key   string         // if value is a map, the key we care about
+		Value interface{}    // the value being passed to ironic (or value associated with the key)
+		Op    nodes.UpdateOp // The operation add/replace/remove
+	}{
+		{
+			Path:  "/instance_uuid",
+			Value: "27720611-e5d1-45d3-ba3a-222dcfaa4ca2",
+		},
+		{
+			Path:  "/deploy_interface",
+			Value: "custom-agent",
+			Op:    nodes.AddOp,
+		},
+	}
+
+	for _, e := range expected {
+		t.Run(e.Path, func(t *testing.T) {
+			t.Logf("expected: %v", e)
+			var update nodes.UpdateOperation
+			for _, patch := range patches {
+				update = patch.(nodes.UpdateOperation)
+				if update.Path == e.Path {
+					break
+				}
+			}
+			if update.Path != e.Path {
+				t.Errorf("did not find %q in updates", e.Path)
+				return
+			}
+			t.Logf("update: %v", update)
+			assert.Equal(t, e.Value, update.Value, fmt.Sprintf("%s does not match", e.Path))
+		})
+	}
+}
+
+func TestGetUpdateOptsForNodeCustomDeployWithImage(t *testing.T) {
+	eventPublisher := func(reason, message string) {}
+	auth := clients.AuthConfig{Type: clients.NoAuth}
+
+	host := makeHostCustomDeploy(false)
+	prov, err := newProvisionerWithSettings(host, bmc.Credentials{}, eventPublisher,
+		"https://ironic.test", auth, "https://ironic.test", auth,
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+	ironicNode := &nodes.Node{}
+
+	provData := provisioner.ProvisionData{
+		Image:        *host.Spec.Image,
+		BootMode:     metal3v1alpha1.DefaultBootMode,
+		CustomDeploy: host.Spec.CustomDeploy,
+	}
+	patches := prov.getUpdateOptsForNode(ironicNode, provData).Updates
+
+	t.Logf("patches: %v", patches)
+
+	expected := []struct {
+		Path  string         // the node property path
+		Key   string         // if value is a map, the key we care about
+		Value interface{}    // the value being passed to ironic (or value associated with the key)
+		Op    nodes.UpdateOp // The operation add/replace/remove
+	}{
+		{
+			Path:  "/instance_info/image_source",
+			Value: "not-empty",
+		},
+		{
+			Path:  "/instance_uuid",
+			Value: "27720611-e5d1-45d3-ba3a-222dcfaa4ca2",
+		},
+		{
+			Path:  "/deploy_interface",
+			Value: "custom-agent",
+			Op:    nodes.AddOp,
+		},
+	}
+
+	for _, e := range expected {
+		t.Run(e.Path, func(t *testing.T) {
+			t.Logf("expected: %v", e)
+			var update nodes.UpdateOperation
+			for _, patch := range patches {
+				update = patch.(nodes.UpdateOperation)
+				if update.Path == e.Path {
+					break
+				}
+			}
+			if update.Path != e.Path {
+				t.Errorf("did not find %q in updates", e.Path)
+				return
+			}
+			t.Logf("update: %v", update)
+			assert.Equal(t, e.Value, update.Value, fmt.Sprintf("%s does not match", e.Path))
+		})
+	}
+}
+
+func TestGetUpdateOptsForNodeImageToCustomDeploy(t *testing.T) {
+	eventPublisher := func(reason, message string) {}
+	auth := clients.AuthConfig{Type: clients.NoAuth}
+
+	host := makeHostCustomDeploy(false)
+	prov, err := newProvisionerWithSettings(host, bmc.Credentials{}, eventPublisher,
+		"https://ironic.test", auth, "https://ironic.test", auth,
+	)
+	if err != nil {
+		t.Fatal(err)
+	}
+	ironicNode := &nodes.Node{
+		InstanceInfo: map[string]interface{}{
+			"image_source":        "oldimage",
+			"image_os_hash_value": "thechecksum",
+			"image_os_hash_algo":  "md5",
+		},
+	}
+
+	provData := provisioner.ProvisionData{
+		Image:        metal3v1alpha1.Image{},
+		BootMode:     metal3v1alpha1.DefaultBootMode,
+		CustomDeploy: host.Spec.CustomDeploy,
+	}
+	patches := prov.getUpdateOptsForNode(ironicNode, provData).Updates
+
+	t.Logf("patches: %v", patches)
+
+	expected := []struct {
+		Path  string         // the node property path
+		Key   string         // if value is a map, the key we care about
+		Value interface{}    // the value being passed to ironic (or value associated with the key)
+		Op    nodes.UpdateOp // The operation add/replace/remove
+	}{
+		{
+			Path:  "/deploy_interface",
+			Value: "custom-agent",
+			Op:    nodes.AddOp,
+		},
+		{
+			Path: "/instance_info/image_source",
+			Op:   nodes.RemoveOp,
+		},
+		{
+			Path: "/instance_info/image_os_hash_algo",
+			Op:   nodes.RemoveOp,
+		},
+		{
+			Path: "/instance_info/image_os_hash_value",
+			Op:   nodes.RemoveOp,
+		},
+	}
+
+	for _, e := range expected {
+		t.Run(e.Path, func(t *testing.T) {
+			t.Logf("expected: %v", e)
+			var update nodes.UpdateOperation
+			for _, patch := range patches {
+				update = patch.(nodes.UpdateOperation)
+				if update.Path == e.Path {
+					break
+				}
+			}
+			if update.Path != e.Path {
+				t.Errorf("did not find %q in updates", e.Path)
+				return
+			}
+			t.Logf("update: %v", update)
+			assert.Equal(t, e.Value, update.Value, fmt.Sprintf("%s value does not match", e.Path))
+			assert.Equal(t, e.Op, update.Op, fmt.Sprintf("%s operation does not match", e.Path))
+		})
+	}
+}
+
 func TestGetUpdateOptsForNodeSecureBoot(t *testing.T) {
 	host := metal3v1alpha1.BareMetalHost{
 		ObjectMeta: metav1.ObjectMeta{