diff --git a/api-ref/source/baremetal-api-v1-runbooks.inc b/api-ref/source/baremetal-api-v1-runbooks.inc
new file mode 100644
index 0000000000..5e4392c4ab
--- /dev/null
+++ b/api-ref/source/baremetal-api-v1-runbooks.inc
@@ -0,0 +1,245 @@
+.. -*- rst -*-
+
+===================
+Runbooks (runbooks)
+===================
+
+The Runbook resource represents a collection of steps that define a
+series of actions to be executed on a node. Runbooks enable users to perform
+complex operations in a predefined, automated manner. A runbook is
+matched for a node if the runbook's name matches a trait in the node.
+
+.. versionadded:: 1.92
+    Runbook API was introduced.
+
+Create Runbook
+==============
+
+.. rest_method::  POST /v1/runbooks
+
+Creates a runbook.
+
+.. versionadded:: 1.92
+    Runbook API was introduced.
+
+Normal response codes: 201
+
+Error response codes: 400, 401, 403, 409
+
+Request
+-------
+
+.. rest_parameters:: parameters.yaml
+
+   - name: runbook_name
+   - steps: runbook_steps
+   - disable_ramdisk: req_disable_ramdisk
+   - uuid: req_uuid
+   - extra: req_extra
+
+Request Step
+------------
+
+.. rest_parameters:: parameters.yaml
+
+   - interface: runbook_step_interface
+   - step: runbook_step_step
+   - args: runbook_step_args
+   - order: runbook_step_order
+
+Request Example
+---------------
+
+.. literalinclude:: samples/runbook-create-request.json
+   :language: javascript
+
+Response Parameters
+-------------------
+
+.. rest_parameters:: parameters.yaml
+
+   - uuid: uuid
+   - name: runbook_name
+   - steps: runbook_steps
+   - disable_ramdisk: disable_ramdisk
+   - extra: extra
+   - public: runbook_public
+   - owner: runbook_owner
+   - created_at: created_at
+   - updated_at: updated_at
+   - links: links
+
+Response Example
+----------------
+
+.. literalinclude:: samples/runbook-create-response.json
+   :language: javascript
+
+List Runbooks
+=============
+
+.. rest_method::  GET /v1/runbooks
+
+Lists all runbooks.
+
+.. versionadded:: 1.92
+    Runbook API was introduced.
+
+Normal response codes: 200
+
+Error response codes: 400, 401, 403, 404
+
+Request
+-------
+
+.. rest_parameters:: parameters.yaml
+
+   - fields: fields
+   - limit: limit
+   - marker: marker
+   - sort_dir: sort_dir
+   - sort_key: sort_key
+   - detail: detail
+
+Response Parameters
+-------------------
+
+.. rest_parameters:: parameters.yaml
+
+   - uuid: uuid
+   - name: runbook_name
+   - disable_ramdisk: disable_ramdisk
+   - steps: runbook_steps
+   - extra: extra
+   - public: runbook_public
+   - owner: runbook_owner
+   - created_at: created_at
+   - updated_at: updated_at
+   - links: links
+
+Response Example
+----------------
+
+**Example runbook list response:**
+
+.. literalinclude:: samples/runbook-list-response.json
+   :language: javascript
+
+**Example detailed runbook list response:**
+
+.. literalinclude:: samples/runbook-detail-response.json
+   :language: javascript
+
+Show Runbook Details
+====================
+
+.. rest_method::  GET /v1/runbooks/{runbook_id}
+
+Shows details for a runbook.
+
+.. versionadded:: 1.92
+    Runbook API was introduced.
+
+Normal response codes: 200
+
+Error response codes: 400, 401, 403, 404
+
+Request
+-------
+
+.. rest_parameters:: parameters.yaml
+
+   - fields: fields
+   - runbook_id: runbook_ident
+
+Response Parameters
+-------------------
+
+.. rest_parameters:: parameters.yaml
+
+   - uuid: uuid
+   - name: runbook_name
+   - steps: runbook_steps
+   - disable_ramdisk: disable_ramdisk
+   - extra: extra
+   - public: runbook_public
+   - owner: runbook_owner
+   - created_at: created_at
+   - updated_at: updated_at
+   - links: links
+
+Response Example
+----------------
+
+.. literalinclude:: samples/runbook-show-response.json
+   :language: javascript
+
+Update a Runbook
+================
+
+.. rest_method:: PATCH /v1/runbooks/{runbook_id}
+
+Update a runbook.
+
+.. versionadded:: 1.92
+    Runbook API was introduced.
+
+Normal response code: 200
+
+Error response codes: 400, 401, 403, 404, 409
+
+Request
+-------
+
+The BODY of the PATCH request must be a JSON PATCH document, adhering to
+`RFC 6902 <https://tools.ietf.org/html/rfc6902>`_.
+
+Request
+-------
+
+.. rest_parameters:: parameters.yaml
+
+    - runbook_id: runbook_ident
+
+.. literalinclude:: samples/runbook-update-request.json
+   :language: javascript
+
+Response
+--------
+
+.. rest_parameters:: parameters.yaml
+
+   - uuid: uuid
+   - name: runbook_name
+   - steps: runbook_steps
+   - disable_ramdisk: disable_ramdisk
+   - extra: extra
+   - public: runbook_public
+   - owner: runbook_owner
+   - created_at: created_at
+   - updated_at: updated_at
+   - links: links
+
+.. literalinclude:: samples/runbook-update-response.json
+   :language: javascript
+
+Delete Runbook
+==============
+
+.. rest_method::  DELETE /v1/runbooks/{runbook_id}
+
+Deletes a runbook.
+
+.. versionadded:: 1.92
+    Runbook API was introduced.
+
+Normal response codes: 204
+
+Error response codes: 400, 401, 403, 404
+
+Request
+-------
+
+.. rest_parameters:: parameters.yaml
+
+  - runbook_id: runbook_ident
diff --git a/doc/source/admin/cleaning.rst b/doc/source/admin/cleaning.rst
index bbfa1ac36c..16030bda78 100644
--- a/doc/source/admin/cleaning.rst
+++ b/doc/source/admin/cleaning.rst
@@ -209,6 +209,15 @@ In the above example, the node's RAID interface would configure hardware
 RAID without non-root volumes, and then all devices would be erased
 (in that order).
 
+Alternatively, you can specify a runbook instead of clean_steps::
+
+  {
+    "target":"clean",
+    "runbook": "<runbook_name_or_uuid>"
+  }
+
+The specified runbook must match one of the node's traits to be used.
+
 Starting manual cleaning via "openstack metal" CLI
 ------------------------------------------------------
 
@@ -246,6 +255,24 @@ Or with stdin::
     cat my-clean-steps.txt | baremetal node clean <node> \
         --clean-steps -
 
+To use a runbook instead of specifying clean steps:
+
+    baremetal node clean <node> --runbook <runbook_name_or_uuid>
+
+Runbooks for Manual Cleaning
+----------------------------
+Instead of passing a list of clean steps, operators can now use runbooks.
+Runbooks are curated lists of steps that can be associated with nodes via
+traits which simplifies the process of performing consistent cleaning
+operations across similar nodes.
+
+To use a runbook for manual cleaning:
+
+    baremetal node clean <node> --runbook <runbook_name_or_uuid>
+
+Runbooks must be created and associated with nodes beforehand. Only runbooks
+that match the node's traits can be used for cleaning that node.
+
 Cleaning Network
 ================
 
diff --git a/doc/source/admin/servicing.rst b/doc/source/admin/servicing.rst
index 55eb85f161..69c2c17070 100644
--- a/doc/source/admin/servicing.rst
+++ b/doc/source/admin/servicing.rst
@@ -109,6 +109,15 @@ configuration, and then the vendor interface's ``send_raw`` step would be
 called to send a raw command to the BMC. Please note, ``send_raw`` is only
 available for the ``ipmi`` hardware type.
 
+Alternatively, you can specify a runbook instead of service_steps::
+
+  {
+    "target":"service",
+    "runbook": "<runbook_name_or_uuid>"
+  }
+
+The specified runbook must match one of the node's traits to be used.
+
 Starting servicing via "openstack baremetal" CLI
 ------------------------------------------------
 
@@ -137,6 +146,23 @@ Or with stdin::
     cat my-clean-steps.txt | baremetal node service <node> \
         --service-steps -
 
+To use a runbook instead of specifying service steps:
+
+    baremetal node service <node> --runbook <runbook_name_or_uuid>
+
+Using Runbooks for Servicing
+----------------------------
+Similar to manual cleaning, you can use runbooks for node servicing.
+Runbooks provide a predefined list of service steps associated with nodes
+via traits.
+
+To use a runbook for servicing:
+
+    baremetal node service <node> --runbook <runbook_name_or_uuid>
+
+Ensure that the runbook matches one of the node's traits before using it
+for servicing.
+
 Available Steps in Ironic
 -------------------------
 
diff --git a/ironic/api/controllers/v1/__init__.py b/ironic/api/controllers/v1/__init__.py
index e0b2200f7f..edf6154998 100644
--- a/ironic/api/controllers/v1/__init__.py
+++ b/ironic/api/controllers/v1/__init__.py
@@ -36,6 +36,7 @@ from ironic.api.controllers.v1 import node
 from ironic.api.controllers.v1 import port
 from ironic.api.controllers.v1 import portgroup
 from ironic.api.controllers.v1 import ramdisk
+from ironic.api.controllers.v1 import runbook
 from ironic.api.controllers.v1 import shard
 from ironic.api.controllers.v1 import utils
 from ironic.api.controllers.v1 import versions
@@ -77,6 +78,7 @@ VERSIONED_CONTROLLERS = {
     'events': utils.allow_expose_events,
     'deploy_templates': utils.allow_deploy_templates,
     'shards': utils.allow_shards_endpoint,
+    'runbooks': utils.allow_runbooks,
     # NOTE(dtantsur): continue_inspection is available in 1.1 as a
     # compatibility hack to make it usable with IPA without changes.
     # Hide this fact from consumers since it was not actually available
@@ -131,6 +133,7 @@ class Controller(object):
         'deploy_templates': deploy_template.DeployTemplatesController(),
         'shards': shard.ShardController(),
         'continue_inspection': ramdisk.ContinueInspectionController(),
+        'runbooks': runbook.RunbooksController()
     }
 
     @method.expose()
diff --git a/ironic/api/controllers/v1/deploy_template.py b/ironic/api/controllers/v1/deploy_template.py
index a3a800b2a8..36bc904d31 100644
--- a/ironic/api/controllers/v1/deploy_template.py
+++ b/ironic/api/controllers/v1/deploy_template.py
@@ -10,7 +10,6 @@
 # License for the specific language governing permissions and limitations
 # under the License.
 
-import collections
 from http import client as http_client
 
 from ironic_lib import metrics_utils
@@ -57,46 +56,13 @@ PATCH_ALLOWED_FIELDS = ['extra', 'name', 'steps', 'description']
 STEP_PATCH_ALLOWED_FIELDS = ['args', 'interface', 'priority', 'step']
 
 
-def duplicate_steps(name, value):
-    """Argument validator to check template for duplicate steps"""
-    # TODO(mgoddard): Determine the consequences of allowing duplicate
-    # steps.
-    # * What if one step has zero priority and another non-zero?
-    # * What if a step that is enabled by default is included in a
-    #   template? Do we override the default or add a second invocation?
-
-    # Check for duplicate steps. Each interface/step combination can be
-    # specified at most once.
-    counter = collections.Counter((step['interface'], step['step'])
-                                  for step in value['steps'])
-    duplicates = {key for key, count in counter.items() if count > 1}
-    if duplicates:
-        duplicates = {"interface: %s, step: %s" % (interface, step)
-                      for interface, step in duplicates}
-        err = _("Duplicate deploy steps. A deploy template cannot have "
-                "multiple deploy steps with the same interface and step. "
-                "Duplicates: %s") % "; ".join(duplicates)
-        raise exception.InvalidDeployTemplate(err=err)
-    return value
-
-
 TEMPLATE_VALIDATOR = args.and_valid(
     args.schema(TEMPLATE_SCHEMA),
-    duplicate_steps,
+    api_utils.duplicate_steps,
     args.dict_valid(uuid=args.uuid)
 )
 
 
-def convert_steps(rpc_steps):
-    for step in rpc_steps:
-        yield {
-            'interface': step['interface'],
-            'step': step['step'],
-            'args': step['args'],
-            'priority': step['priority'],
-        }
-
-
 def convert_with_links(rpc_template, fields=None, sanitize=True):
     """Add links to the deploy template."""
     template = api_utils.object_to_dict(
@@ -104,7 +70,7 @@ def convert_with_links(rpc_template, fields=None, sanitize=True):
         fields=('name', 'extra'),
         link_resource='deploy_templates',
     )
-    template['steps'] = list(convert_steps(rpc_template.steps))
+    template['steps'] = list(api_utils.convert_steps(rpc_template.steps))
 
     if fields is not None:
         api_utils.check_for_invalid_fields(fields, template)
diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py
index e721ce7769..0fc6395d40 100644
--- a/ironic/api/controllers/v1/node.py
+++ b/ironic/api/controllers/v1/node.py
@@ -86,6 +86,10 @@ _STEPS_SCHEMA = {
                 "type": "object",
                 "properties": {}
             },
+            'order': {'anyOf': [
+                {'type': 'integer', 'minimum': 0},
+                {'type': 'string', 'minLength': 1, 'pattern': '^[0-9]+$'}
+            ]},
             "execute_on_child_nodes": {
                 "description": "Boolean if the step should be executed "
                                "on child nodes.",
@@ -988,6 +992,41 @@ class NodeStatesController(rest.RestController):
         url_args = '/'.join([node_ident, 'states'])
         api.response.location = link.build_url('nodes', url_args)
 
+    def _handle_runbook(self, rpc_node, target, runbook, clean_steps,
+                        service_steps):
+        if not api_utils.allow_runbooks():
+            raise exception.NotAcceptable()
+
+        rpc_runbook = api_utils.check_runbook_policy_and_retrieve(
+            policy_name='baremetal:runbook:use',
+            runbook_ident=runbook)
+
+        node_traits = rpc_node.traits.get_trait_names() or []
+        if rpc_runbook.name not in node_traits:
+            msg = (_('This runbook has not been approved for '
+                     'use on this node %s. Please ask an administrator '
+                     'to add it to your node traits.') % rpc_node.uuid)
+            raise exception.ClientSideError(
+                msg, status_code=http_client.BAD_REQUEST)
+
+        disable_ramdisk = rpc_runbook.disable_ramdisk
+        if target == ir_states.VERBS['clean']:
+            if clean_steps:
+                msg = (_('Please provide either "clean_steps" or a '
+                         'runbook, but not both.'))
+                raise exception.ClientSideError(
+                    msg, status_code=http_client.BAD_REQUEST)
+            clean_steps = list(api_utils.convert_steps(rpc_runbook.steps))
+        elif target == ir_states.VERBS['service']:
+            if service_steps:
+                msg = (_('Please provide either "service_steps" or a '
+                         'runbook, but not both.'))
+                raise exception.ClientSideError(
+                    msg, status_code=http_client.BAD_REQUEST)
+            service_steps = list(api_utils.convert_steps(
+                rpc_runbook.steps))
+        return clean_steps, service_steps, disable_ramdisk
+
     def _do_provision_action(self, rpc_node, target, configdrive=None,
                              clean_steps=None, deploy_steps=None,
                              rescue_password=None, disable_ramdisk=None,
@@ -1061,11 +1100,12 @@ class NodeStatesController(rest.RestController):
                    deploy_steps=args.types(type(None), list),
                    rescue_password=args.string,
                    disable_ramdisk=args.boolean,
-                   service_steps=args.types(type(None), list))
+                   service_steps=args.types(type(None), list),
+                   runbook=args.types(type(None), str))
     def provision(self, node_ident, target, configdrive=None,
                   clean_steps=None, deploy_steps=None,
                   rescue_password=None, disable_ramdisk=None,
-                  service_steps=None):
+                  service_steps=None, runbook=None):
         """Asynchronous trigger the provisioning of the node.
 
         This will set the target provision state of the node, and a
@@ -1142,6 +1182,7 @@ class NodeStatesController(rest.RestController):
                 'args': {'force': True},
                 'priority': 90 }
 
+        :param runbook: UUID or logical name of a runbook.
         :raises: NodeLocked (HTTP 409) if the node is currently locked.
         :raises: ClientSideError (HTTP 409) if the node is already being
                  provisioned.
@@ -1187,9 +1228,26 @@ class NodeStatesController(rest.RestController):
         api_utils.check_allow_configdrive(target, configdrive)
         api_utils.check_allow_clean_disable_ramdisk(target, disable_ramdisk)
 
+        if runbook:
+            clean_steps, service_steps, disable_ramdisk = self._handle_runbook(
+                rpc_node, target, runbook, clean_steps, service_steps
+            )
+        else:
+            if clean_steps:
+                api_utils.check_policy(
+                    'baremetal:node:set_provision_state:clean_steps')
+            if service_steps:
+                api_utils.check_policy(
+                    'baremetal:node:set_provision_state:service_steps')
+
         if clean_steps and target != ir_states.VERBS['clean']:
             msg = (_('"clean_steps" is only valid when setting target '
                      'provision state to %s') % ir_states.VERBS['clean'])
+            if runbook:
+                rb_allowed_targets = [ir_states.VERBS['clean'],
+                                      ir_states.VERBS['service']]
+                msg = (_('"runbooks" is only valid when setting target '
+                         'provision state to any of %s') % rb_allowed_targets)
             raise exception.ClientSideError(
                 msg, status_code=http_client.BAD_REQUEST)
 
@@ -1214,6 +1272,17 @@ class NodeStatesController(rest.RestController):
             if not api_utils.allow_unhold_verb():
                 raise exception.NotAcceptable()
 
+        if service_steps and target != ir_states.VERBS['service']:
+            msg = (_('"service_steps" is only valid when setting target '
+                     'provision state to %s') % ir_states.VERBS['service'])
+            if runbook:
+                rb_allowed_targets = [ir_states.VERBS['clean'],
+                                      ir_states.VERBS['service']]
+                msg = (_('"runbooks" is only valid when setting target '
+                         'provision state to any of %s') % rb_allowed_targets)
+            raise exception.ClientSideError(
+                msg, status_code=http_client.BAD_REQUEST)
+
         if target == ir_states.VERBS['service']:
             if not api_utils.allow_service_verb():
                 raise exception.NotAcceptable()
diff --git a/ironic/api/controllers/v1/notification_utils.py b/ironic/api/controllers/v1/notification_utils.py
index 14afbe22e7..e36b7a2cea 100644
--- a/ironic/api/controllers/v1/notification_utils.py
+++ b/ironic/api/controllers/v1/notification_utils.py
@@ -28,6 +28,7 @@ from ironic.objects import node as node_objects
 from ironic.objects import notification
 from ironic.objects import port as port_objects
 from ironic.objects import portgroup as portgroup_objects
+from ironic.objects import runbook as runbook_objects
 from ironic.objects import volume_connector as volume_connector_objects
 from ironic.objects import volume_target as volume_target_objects
 
@@ -48,6 +49,8 @@ CRUD_NOTIFY_OBJ = {
              port_objects.PortCRUDPayload),
     'portgroup': (portgroup_objects.PortgroupCRUDNotification,
                   portgroup_objects.PortgroupCRUDPayload),
+    'runbook': (runbook_objects.RunbookCRUDNotification,
+                runbook_objects.RunbookCRUDPayload),
     'volumeconnector':
         (volume_connector_objects.VolumeConnectorCRUDNotification,
          volume_connector_objects.VolumeConnectorCRUDPayload),
diff --git a/ironic/api/controllers/v1/runbook.py b/ironic/api/controllers/v1/runbook.py
new file mode 100644
index 0000000000..aab310db72
--- /dev/null
+++ b/ironic/api/controllers/v1/runbook.py
@@ -0,0 +1,391 @@
+# 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.
+
+from http import client as http_client
+
+from ironic_lib import metrics_utils
+from oslo_log import log
+from oslo_utils import strutils
+from oslo_utils import uuidutils
+import pecan
+from pecan import rest
+from webob import exc as webob_exc
+
+from ironic import api
+from ironic.api.controllers import link
+from ironic.api.controllers.v1 import collection
+from ironic.api.controllers.v1 import notification_utils as notify
+from ironic.api.controllers.v1 import utils as api_utils
+from ironic.api import method
+from ironic.common import args
+from ironic.common import exception
+from ironic.common.i18n import _
+import ironic.conf
+from ironic import objects
+
+
+CONF = ironic.conf.CONF
+LOG = log.getLogger(__name__)
+METRICS = metrics_utils.get_metrics_logger(__name__)
+
+DEFAULT_RETURN_FIELDS = ['uuid', 'name']
+
+RUNBOOK_SCHEMA = {
+    'type': 'object',
+    'properties': {
+        'uuid': {'type': ['string', 'null']},
+        'name': api_utils.TRAITS_SCHEMA,
+        'description': {'type': ['string', 'null'], 'maxLength': 255},
+        'steps': {
+            'type': 'array',
+            'items': api_utils.RUNBOOK_STEP_SCHEMA,
+            'minItems': 1},
+        'disable_ramdisk': {'type': ['boolean', 'null']},
+        'extra': {'type': ['object', 'null']},
+        'public': {'type': ['boolean', 'null']},
+        'owner': {'type': ['string', 'null'], 'maxLength': 255}
+    },
+    'required': ['steps', 'name'],
+    'additionalProperties': False,
+}
+
+PATCH_ALLOWED_FIELDS = [
+    'extra',
+    'name',
+    'steps',
+    'description',
+    'public',
+    'owner'
+]
+STEP_PATCH_ALLOWED_FIELDS = ['args', 'interface', 'order', 'step']
+
+
+RUNBOOK_VALIDATOR = args.and_valid(
+    args.schema(RUNBOOK_SCHEMA),
+    api_utils.duplicate_steps,
+    args.dict_valid(uuid=args.uuid)
+)
+
+
+def convert_with_links(rpc_runbook, fields=None, sanitize=True):
+    """Add links to the runbook."""
+    runbook = api_utils.object_to_dict(
+        rpc_runbook,
+        fields=('name', 'extra', 'public', 'owner', 'disable_ramdisk'),
+        link_resource='runbooks',
+    )
+    runbook['steps'] = list(api_utils.convert_steps(rpc_runbook.steps))
+
+    if fields is not None:
+        api_utils.check_for_invalid_fields(fields, runbook)
+
+    if sanitize:
+        runbook_sanitize(runbook, fields)
+
+    return runbook
+
+
+def runbook_sanitize(runbook, fields):
+    """Removes sensitive and unrequested data.
+
+    Will only keep the fields specified in the ``fields`` parameter.
+
+    :param fields:
+        list of fields to preserve, or ``None`` to preserve them all
+    :type fields: list of str
+    """
+    api_utils.sanitize_dict(runbook, fields)
+    if runbook.get('steps'):
+        for step in runbook['steps']:
+            step_sanitize(step)
+
+
+def step_sanitize(step):
+    if step.get('args'):
+        step['args'] = strutils.mask_dict_password(step['args'], "******")
+
+
+def list_convert_with_links(rpc_runbooks, limit, fields=None, **kwargs):
+    return collection.list_convert_with_links(
+        items=[convert_with_links(t, fields=fields, sanitize=False)
+               for t in rpc_runbooks],
+        item_name='runbooks',
+        url='runbooks',
+        limit=limit,
+        fields=fields,
+        sanitize_func=runbook_sanitize,
+        **kwargs
+    )
+
+
+class RunbooksController(rest.RestController):
+    """REST controller for runbooks."""
+
+    invalid_sort_key_list = ['extra', 'steps']
+
+    @pecan.expose()
+    def _route(self, args, request=None):
+        if not api_utils.allow_runbooks():
+            msg = _("The API version does not allow runbooks")
+            if api.request.method == "GET":
+                raise webob_exc.HTTPNotFound(msg)
+            else:
+                raise webob_exc.HTTPMethodNotAllowed(msg)
+        return super(RunbooksController, self)._route(args, request)
+
+    @METRICS.timer('RunbooksController.get_all')
+    @method.expose()
+    @args.validate(marker=args.name, limit=args.integer, sort_key=args.string,
+                   sort_dir=args.string, fields=args.string_list,
+                   detail=args.boolean, project=args.boolean)
+    def get_all(self, marker=None, limit=None, sort_key='id', sort_dir='asc',
+                fields=None, detail=None, project=None):
+        """Retrieve a list of runbooks.
+
+        :param marker: pagination marker for large data sets.
+        :param limit: maximum number of resources to return in a single result.
+                      This value cannot be larger than the value of max_limit
+                      in the [api] section of the ironic configuration, or only
+                      max_limit resources will be returned.
+        :param project: Optional string value that set the project
+                        whose runbooks are to be returned.
+        :param sort_key: column to sort results by. Default: id.
+        :param sort_dir: direction to sort. "asc" or "desc". Default: asc.
+        :param fields: Optional, a list with a specified set of fields
+                       of the resource to be returned.
+        :param detail: Optional, boolean to indicate whether retrieve a list
+                       of runbooks with detail.
+        """
+        if not api_utils.allow_runbooks():
+            raise exception.NotFound()
+
+        project_id = api_utils.check_list_policy('runbook', project)
+
+        api_utils.check_allowed_fields(fields)
+        api_utils.check_allowed_fields([sort_key])
+
+        fields = api_utils.get_request_return_fields(fields, detail,
+                                                     DEFAULT_RETURN_FIELDS)
+
+        limit = api_utils.validate_limit(limit)
+        sort_dir = api_utils.validate_sort_dir(sort_dir)
+
+        if sort_key in self.invalid_sort_key_list:
+            raise exception.InvalidParameterValue(
+                _("The sort_key value %(key)s is an invalid field for "
+                  "sorting") % {'key': sort_key})
+
+        filters = {}
+        if project_id:
+            filters['project'] = project_id
+
+        marker_obj = None
+        if marker:
+            marker_obj = objects.Runbook.get_by_uuid(
+                api.request.context, marker)
+
+        runbooks = objects.Runbook.list(
+            api.request.context, limit=limit, marker=marker_obj,
+            sort_key=sort_key, sort_dir=sort_dir, filters=filters)
+
+        parameters = {'sort_key': sort_key, 'sort_dir': sort_dir}
+
+        if detail is not None:
+            parameters['detail'] = detail
+
+        return list_convert_with_links(
+            runbooks, limit, fields=fields, **parameters)
+
+    @METRICS.timer('RunbooksController.get_one')
+    @method.expose()
+    @args.validate(runbook_ident=args.uuid_or_name, fields=args.string_list)
+    def get_one(self, runbook_ident, fields=None):
+        """Retrieve information about the given runbook.
+
+        :param runbook_ident: UUID or logical name of a runbook.
+        :param fields: Optional, a list with a specified set of fields
+            of the resource to be returned.
+        """
+        if not api_utils.allow_runbooks():
+            raise exception.NotFound()
+
+        try:
+            rpc_runbook = api_utils.check_runbook_policy_and_retrieve(
+                'baremetal:runbook:get', runbook_ident)
+        except exception.NotAuthorized:
+            # If the user is not authorized to access the runbook,
+            # check also, if the runbook is public
+            rpc_runbook = api_utils.check_and_retrieve_public_runbook(
+                runbook_ident)
+
+        api_utils.check_allowed_fields(fields)
+        return convert_with_links(rpc_runbook, fields=fields)
+
+    @METRICS.timer('RunbooksController.post')
+    @method.expose(status_code=http_client.CREATED)
+    @method.body('runbook')
+    @args.validate(runbook=RUNBOOK_VALIDATOR)
+    def post(self, runbook):
+        """Create a new runbook.
+
+        :param runbook: a runbook within the request body.
+        """
+        if not api_utils.allow_runbooks():
+            raise exception.NotFound()
+
+        context = api.request.context
+        api_utils.check_policy('baremetal:runbook:create')
+
+        cdict = context.to_policy_values()
+        if cdict.get('system_scope') != 'all':
+            project_id = None
+            requested_owner = runbook.get('owner', None)
+            if cdict.get('project_id', False):
+                project_id = cdict.get('project_id')
+
+            if requested_owner and requested_owner != project_id:
+                # Translation: If project scoped, and an owner has been
+                # requested, and that owner does not match the requester's
+                # project ID value.
+                msg = _("Cannot create a runbook as a project scoped admin "
+                        "with an owner other than your own project.")
+                raise exception.Invalid(msg)
+
+            if project_id and runbook.get('public', False):
+                msg = _("Cannot create a public runbook as a project scoped "
+                        "admin.")
+                raise exception.Invalid(msg)
+            # Finally, note the project ID
+            runbook['owner'] = project_id
+
+        if not runbook.get('uuid'):
+            runbook['uuid'] = uuidutils.generate_uuid()
+        new_runbook = objects.Runbook(context, **runbook)
+
+        notify.emit_start_notification(context, new_runbook, 'create')
+        with notify.handle_error_notification(context, new_runbook, 'create'):
+            new_runbook.create()
+
+        # Set the HTTP Location Header
+        api.response.location = link.build_url('runbooks', new_runbook.uuid)
+        api_runbook = convert_with_links(new_runbook)
+        notify.emit_end_notification(context, new_runbook, 'create')
+        return api_runbook
+
+    def _authorize_patch_and_get_runbook(self, runbook_ident, patch):
+        # deal with attribute-specific policy rules
+        policy_checks = []
+        generic_update = False
+
+        paths_to_policy = (
+            ('/owner', 'baremetal:runbook:update:owner'),
+            ('/public', 'baremetal:runbook:update:public'),
+        )
+        for p in patch:
+            # Process general direct path to policy map
+            rule_match_found = False
+            for check_path, policy_name in paths_to_policy:
+                if p['path'].startswith(check_path):
+                    policy_checks.append(policy_name)
+                    # Break, policy found
+                    rule_match_found = True
+                    break
+            if not rule_match_found:
+                generic_update = True
+
+        if generic_update or not policy_checks:
+            # If we couldn't find specific policy to apply,
+            # apply the update policy check.
+            policy_checks.append('baremetal:runbook:update')
+        return api_utils.check_multiple_runbook_policies_and_retrieve(
+            policy_checks, runbook_ident)
+
+    @METRICS.timer('RunbooksController.patch')
+    @method.expose()
+    @method.body('patch')
+    @args.validate(runbook_ident=args.uuid_or_name, patch=args.patch)
+    def patch(self, runbook_ident, patch=None):
+        """Update an existing runbook.
+
+        :param runbook_ident: UUID or logical name of a runbook.
+        :param patch: a json PATCH document to apply to this runbook.
+        """
+        if not api_utils.allow_runbooks():
+            raise exception.NotFound()
+
+        api_utils.patch_validate_allowed_fields(patch, PATCH_ALLOWED_FIELDS)
+
+        context = api.request.context
+
+        rpc_runbook = self._authorize_patch_and_get_runbook(runbook_ident,
+                                                            patch)
+        runbook = rpc_runbook.as_dict()
+
+        owner = api_utils.get_patch_values(patch, '/owner')
+        public = api_utils.get_patch_values(patch, '/public')
+
+        if owner:
+            # NOTE(cid): There should not be an owner for a public runbook,
+            # but an owned runbook can be set to non-public and assigned an
+            # owner atomically
+            public_value = public[0] if public else False
+            if runbook.get('public') and (not public) or public_value:
+                msg = _("There cannot be an owner for a public runbook")
+                raise exception.PatchError(patch=patch, reason=msg)
+
+        if public:
+            runbook['owner'] = None
+
+        # apply the patch
+        runbook = api_utils.apply_jsonpatch(runbook, patch)
+
+        # validate the result with the patch schema
+        for step in runbook.get('steps', []):
+            api_utils.patched_validate_with_schema(
+                step, api_utils.RUNBOOK_STEP_SCHEMA)
+        api_utils.patched_validate_with_schema(
+            runbook, RUNBOOK_SCHEMA, RUNBOOK_VALIDATOR)
+
+        api_utils.patch_update_changed_fields(
+            runbook, rpc_runbook, fields=objects.Runbook.fields,
+            schema=RUNBOOK_SCHEMA
+        )
+
+        notify.emit_start_notification(context, rpc_runbook, 'update')
+        with notify.handle_error_notification(context, rpc_runbook, 'update'):
+            rpc_runbook.save()
+
+        api_runbook = convert_with_links(rpc_runbook)
+        notify.emit_end_notification(context, rpc_runbook, 'update')
+
+        return api_runbook
+
+    @METRICS.timer('RunbooksController.delete')
+    @method.expose(status_code=http_client.NO_CONTENT)
+    @args.validate(runbook_ident=args.uuid_or_name)
+    def delete(self, runbook_ident):
+        """Delete a runbook.
+
+        :param runbook_ident: UUID or logical name of a runbook.
+        """
+        if not api_utils.allow_runbooks():
+            raise exception.NotFound()
+
+        rpc_runbook = api_utils.check_runbook_policy_and_retrieve(
+            policy_name='baremetal:runbook:delete',
+            runbook_ident=runbook_ident)
+
+        context = api.request.context
+        notify.emit_start_notification(context, rpc_runbook, 'delete')
+        with notify.handle_error_notification(context, rpc_runbook, 'delete'):
+            rpc_runbook.destroy()
+        notify.emit_end_notification(context, rpc_runbook, 'delete')
diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py
index b806888737..882d051420 100644
--- a/ironic/api/controllers/v1/utils.py
+++ b/ironic/api/controllers/v1/utils.py
@@ -13,6 +13,7 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 
+import collections
 import copy
 from http import client as http_client
 import inspect
@@ -158,6 +159,24 @@ DEPLOY_STEP_SCHEMA = {
     'additionalProperties': False,
 }
 
+RUNBOOK_STEP_SCHEMA = {
+    'type': 'object',
+    'properties': {
+        'args': {'type': 'object'},
+        'interface': {
+            'type': 'string',
+            'enum': list(conductor_steps.CLEANING_INTERFACE_PRIORITY)
+        },
+        'step': {'type': 'string', 'minLength': 1},
+        'order': {'anyOf': [
+            {'type': 'integer', 'minimum': 0},
+            {'type': 'string', 'minLength': 1, 'pattern': '^[0-9]+$'}
+        ]}
+    },
+    'required': ['interface', 'step', 'order'],
+    'additionalProperties': False,
+}
+
 
 def local_link_normalize(name, value):
     if not value:
@@ -685,6 +704,43 @@ def get_rpc_deploy_template_with_suffix(template_ident):
                             exception.DeployTemplateNotFound)
 
 
+def get_rpc_runbook(runbook_ident):
+    """Get the RPC runbook from the UUID or logical name.
+
+    :param runbook_ident: the UUID or logical name of a runbook.
+
+    :returns: The RPC runbook.
+    :raises: InvalidUuidOrName if the name or uuid provided is not valid.
+    :raises: RunbookNotFound if the runbook is not found.
+    """
+    # If runbook_ident is instead a valid UUID, treat it as a UUID.
+    if uuidutils.is_uuid_like(runbook_ident):
+        return objects.Runbook.get_by_uuid(api.request.context,
+                                           runbook_ident)
+
+    # Else, we can refer to runbooks by their name too
+    if utils.is_valid_logical_name(runbook_ident):
+        return objects.Runbook.get_by_name(api.request.context,
+                                           runbook_ident)
+    raise exception.InvalidUuidOrName(name=runbook_ident)
+
+
+def check_runbook_policy_and_retrieve(policy_name, runbook_ident):
+    """Check if the specified policy authorizes this request on a node.
+
+    :param: policy_name: Name of the policy to check.
+    :param: runbook_ident: the UUID or logical name of a runbook.
+
+    :raises: HTTPForbidden if the policy forbids access.
+    :raises: RunbookNotFound if the runbook is not found.
+    :return: a runbook object
+    """
+    rpc_runbook = get_rpc_runbook(runbook_ident)
+    check_owner_policy(object_type='runbook', policy_name=policy_name,
+                       owner=rpc_runbook['owner'])
+    return rpc_runbook
+
+
 def is_valid_node_name(name):
     """Determine if the provided name is a valid node name.
 
@@ -1517,6 +1573,53 @@ def check_policy_true(policy_name):
     return policy.check_policy(policy_name, cdict, api.request.context)
 
 
+def duplicate_steps(name, value):
+    """Argument validator to check template for duplicate steps"""
+    # TODO(mgoddard): Determine the consequences of allowing duplicate
+    # steps.
+    # * What if one step has zero priority and another non-zero?
+    # * What if a step that is enabled by default is included in a
+    #   template? Do we override the default or add a second invocation?
+
+    # Check for duplicate steps. Each interface/step combination can be
+    # specified at most once.
+    counter = collections.Counter((step['interface'], step['step'])
+                                  for step in value['steps'])
+    duplicates = {key for key, count in counter.items() if count > 1}
+    if duplicates:
+        duplicates = {"interface: %s, step: %s" % (interface, step)
+                      for interface, step in duplicates}
+        err = _("Duplicate deploy steps. A template cannot have multiple "
+                "deploy steps with the same interface and step. "
+                "Duplicates: %s") % "; ".join(duplicates)
+        raise exception.InvalidDeployTemplate(err=err)
+    return value
+
+
+def convert_steps(rpc_steps):
+    for step in rpc_steps:
+        result = {
+            'interface': step['interface'],
+            'step': step['step'],
+            'args': step['args'],
+        }
+
+        if 'priority' in step:
+            result['priority'] = step['priority']
+        elif 'order' in step:
+            result['order'] = step['order']
+
+        yield result
+
+
+def allow_runbooks():
+    """Check if accessing runbook endpoints is allowed.
+
+    Version 1.92 of the API exposed runbook endpoints.
+    """
+    return api.request.version.minor >= versions.MINOR_92_RUNBOOKS
+
+
 def check_owner_policy(object_type, policy_name, owner, lessee=None,
                        conceal_node=False):
     """Check if the policy authorizes this request on an object.
@@ -1547,6 +1650,19 @@ def check_owner_policy(object_type, policy_name, owner, lessee=None,
             raise
 
 
+def check_and_retrieve_public_runbook(runbook_ident):
+    """If policy authorization check fails, check if runbook is public.
+
+    :param: runbook_ident: the UUID or logical name of a runbook.
+    :raises: HTTPForbidden if runbook is not public.
+    :return: RPC runbook identified by runbook_ident
+    """
+    rpc_runbook = get_rpc_runbook(runbook_ident)
+    if not rpc_runbook.public:
+        raise exception.HTTPForbidden
+    return rpc_runbook
+
+
 def check_node_policy_and_retrieve(policy_name, node_ident,
                                    with_suffix=False):
     """Check if the specified policy authorizes this request on a node.
@@ -1635,6 +1751,27 @@ def check_multiple_node_policies_and_retrieve(policy_names,
     return rpc_node
 
 
+def check_multiple_runbook_policies_and_retrieve(policy_names,
+                                                 runbook_ident):
+    """Check if the specified policies authorize this request on a runbook.
+
+    :param: policy_names: List of policy names to check.
+    :param: runbook_ident: the UUID or logical name of a runbook.
+
+    :raises: HTTPForbidden if the policy forbids access.
+    :raises: RunbookNotFound if the runbook is not found.
+    :return: RPC runbook identified by runbook_ident
+    """
+    rpc_runbook = None
+    for policy_name in policy_names:
+        if rpc_runbook is None:
+            rpc_runbook = check_runbook_policy_and_retrieve(policy_names[0],
+                                                            runbook_ident)
+        else:
+            check_owner_policy('runbook', policy_name, rpc_runbook['owner'])
+    return rpc_runbook
+
+
 def check_list_policy(object_type, owner=None):
     """Check if the list policy authorizes this request on an object.
 
diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py
index 90d07ffcab..af51000e1c 100644
--- a/ironic/api/controllers/v1/versions.py
+++ b/ironic/api/controllers/v1/versions.py
@@ -129,6 +129,7 @@ BASE_VERSION = 1
 # v1.89: Add API for attaching/detaching virtual media
 # v1.90: Accept ovn vtep switch metadata schema to port.local_link_connection
 # v1.91: Remove special treatment of .json for API objects
+# v1.92: Add runbooks API
 
 MINOR_0_JUNO = 0
 MINOR_1_INITIAL_VERSION = 1
@@ -222,6 +223,7 @@ MINOR_88_PORT_NAME = 88
 MINOR_89_ATTACH_DETACH_VMEDIA = 89
 MINOR_90_OVN_VTEP = 90
 MINOR_91_DOT_JSON = 91
+MINOR_92_RUNBOOKS = 92
 
 # When adding another version, update:
 # - MINOR_MAX_VERSION
@@ -229,7 +231,7 @@ MINOR_91_DOT_JSON = 91
 #   explanation of what changed in the new version
 # - common/release_mappings.py, RELEASE_MAPPING['master']['api']
 
-MINOR_MAX_VERSION = MINOR_91_DOT_JSON
+MINOR_MAX_VERSION = MINOR_92_RUNBOOKS
 
 # String representations of the minor and maximum versions
 _MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)
diff --git a/ironic/common/exception.py b/ironic/common/exception.py
index e31b5eef2f..7754a27c2c 100644
--- a/ironic/common/exception.py
+++ b/ironic/common/exception.py
@@ -716,6 +716,22 @@ class InvalidDeployTemplate(Invalid):
     _msg_fmt = _("Deploy template invalid: %(err)s.")
 
 
+class RunbookDuplicateName(Conflict):
+    _msg_fmt = _("A runbook with name %(name)s already exists.")
+
+
+class RunbookAlreadyExists(Conflict):
+    _msg_fmt = _("A runbook with UUID %(uuid)s already exists.")
+
+
+class RunbookNotFound(NotFound):
+    _msg_fmt = _("Runbook %(runbook)s could not be found.")
+
+
+class InvalidRunbook(Invalid):
+    _msg_fmt = _("Runbook invalid: %(err)s.")
+
+
 class InvalidKickstartTemplate(Invalid):
     _msg_fmt = _("The kickstart template is missing required variables")
 
diff --git a/ironic/common/policy.py b/ironic/common/policy.py
index fc209b754b..3d9a6795ef 100644
--- a/ironic/common/policy.py
+++ b/ironic/common/policy.py
@@ -49,7 +49,7 @@ SYSTEM_ADMIN = 'role:admin and system_scope:all'
 
 # Generic policy check string for system users who don't require all the
 # authorization that system administrators typically have. This persona, or
-# check string, typically isn't used by default, but it's existence it useful
+# check string, typically isn't used by default, but it's existence is useful
 # in the event a deployment wants to offload some administrative action from
 # system administrator to system members.
 # The rule:service_role match here is to enable an elevated level of API
@@ -59,7 +59,7 @@ SYSTEM_MEMBER = '(role:member and system_scope:all) or rule:service_role'  # noq
 
 # Generic policy check string for read-only access to system-level
 # resources. This persona is useful for someone who needs access
-# for auditing or even support. These uses are also able to view
+# for auditing or even support. These users are also able to view
 # project-specific resources where applicable (e.g., listing all
 # volumes in the deployment, regardless of the project they belong to).
 # The rule:service_role match here is to enable an elevated level of API
@@ -126,6 +126,24 @@ ALLOCATION_OWNER_MANAGER = ('role:manager and project_id:%(allocation.owner)s')
 ALLOCATION_OWNER_MEMBER = ('role:member and project_id:%(allocation.owner)s')
 ALLOCATION_OWNER_READER = ('role:reader and project_id:%(allocation.owner)s')
 
+# Members can create/destroy their runbooks.
+RUNBOOK_OWNER_ADMIN = ('role:admin and project_id:%(runbook.owner)s')
+RUNBOOK_OWNER_MANAGER = ('role:manager and project_id:%(runbook.owner)s')
+RUNBOOK_OWNER_MEMBER = ('role:member and project_id:%(runbook.owner)s')
+RUNBOOK_OWNER_READER = ('role:reader and project_id:%(runbook.owner)s')
+
+RUNBOOK_ADMIN = (
+    '(' + SYSTEM_MEMBER + ') or (' + RUNBOOK_OWNER_MANAGER + ') or role:service' # noqa
+)
+
+RUNBOOK_READER = (
+    '(' + SYSTEM_READER + ') or (' + RUNBOOK_OWNER_READER + ') or role:service' # noqa
+)
+
+RUNBOOK_CREATOR = (
+    '(' + SYSTEM_MEMBER + ') or role:manager or role:service' # noqa
+)
+
 # Used for general operations like changing provision state.
 SYSTEM_OR_OWNER_MEMBER_AND_LESSEE_ADMIN = (
     '(' + SYSTEM_MEMBER + ') or (' + SYSTEM_SERVICE + ') or (' + PROJECT_OWNER_MEMBER + ') or (' + PROJECT_LESSEE_ADMIN + ') or (' + PROJECT_LESSEE_MANAGER + ') or (' + PROJECT_SERVICE + ')'  # noqa
@@ -862,6 +880,24 @@ node_policies = [
         ],
         deprecated_rule=deprecated_node_set_provision_state
     ),
+    policy.DocumentedRuleDefault(
+        name='baremetal:node:set_provision_state:clean_steps',
+        check_str=SYSTEM_OR_OWNER_MEMBER_AND_LESSEE_ADMIN,
+        scope_types=['system', 'project'],
+        description='Allow execution of arbitrary steps on a node',
+        operations=[
+            {'path': '/nodes/{node_ident}/states/provision', 'method': 'PUT'}
+        ],
+    ),
+    policy.DocumentedRuleDefault(
+        name='baremetal:node:set_provision_state:service_steps',
+        check_str=SYSTEM_OR_OWNER_MEMBER_AND_LESSEE_ADMIN,
+        scope_types=['system', 'project'],
+        description='Allow execution of arbitrary steps on a node',
+        operations=[
+            {'path': '/nodes/{node_ident}/states/provision', 'method': 'PUT'}
+        ],
+    ),
     policy.DocumentedRuleDefault(
         name='baremetal:node:set_raid_state',
         check_str=SYSTEM_MEMBER_OR_OWNER_MEMBER,
@@ -1880,6 +1916,89 @@ deploy_template_policies = [
     ),
 ]
 
+runbook_policies = [
+    policy.DocumentedRuleDefault(
+        name='baremetal:runbook:get',
+        check_str=RUNBOOK_READER,
+        scope_types=['system', 'project'],
+        description='Retrieve a single runbook record',
+        operations=[
+            {'path': '/runbooks/{runbook_ident}', 'method': 'GET'}
+        ],
+    ),
+    policy.DocumentedRuleDefault(
+        name='baremetal:runbook:list',
+        check_str=API_READER,
+        scope_types=['system', 'project'],
+        description='Retrieve multiple runbook records, filtered by '
+                    'an explicit owner or the client project_id',
+        operations=[
+            {'path': '/runbooks', 'method': 'GET'}
+        ],
+    ),
+    policy.DocumentedRuleDefault(
+        name='baremetal:runbook:list_all',
+        check_str=SYSTEM_READER,
+        scope_types=['system', 'project'],
+        description='Retrieve all runbook records',
+        operations=[
+            {'path': '/runbooks', 'method': 'GET'}
+        ],
+    ),
+    policy.DocumentedRuleDefault(
+        name='baremetal:runbook:create',
+        check_str=RUNBOOK_CREATOR,
+        scope_types=['system', 'project'],
+        description='Create Runbook records',
+        operations=[{'path': '/runbooks', 'method': 'POST'}],
+    ),
+    policy.DocumentedRuleDefault(
+        name='baremetal:runbook:delete',
+        check_str=RUNBOOK_ADMIN,
+        scope_types=['system', 'project'],
+        description='Delete a runbook record',
+        operations=[
+            {'path': '/runbooks/{runbook_ident}', 'method': 'DELETE'}
+        ],
+    ),
+    policy.DocumentedRuleDefault(
+        name='baremetal:runbook:update',
+        check_str=RUNBOOK_ADMIN,
+        scope_types=['system', 'project'],
+        description='Update a runbook record',
+        operations=[
+            {'path': '/runbooks/{runbook_ident}', 'method': 'PATCH'}
+        ],
+    ),
+    policy.DocumentedRuleDefault(
+        name='baremetal:runbook:update:public',
+        check_str=SYSTEM_MEMBER,
+        scope_types=['system', 'project'],
+        description='Set and unset a runbook as public',
+        operations=[
+            {'path': '/runbooks/{runbook_ident}/public', 'method': 'PATCH'}
+        ],
+    ),
+    policy.DocumentedRuleDefault(
+        name='baremetal:runbook:update:owner',
+        check_str=SYSTEM_MEMBER,
+        scope_types=['system', 'project'],
+        description='Set and unset the owner of a runbook',
+        operations=[
+            {'path': '/runbooks/{runbook_ident}/owner', 'method': 'PATCH'}
+        ],
+    ),
+    policy.DocumentedRuleDefault(
+        name='baremetal:runbook:use',
+        check_str=RUNBOOK_ADMIN,
+        scope_types=['system', 'project'],
+        description='Allowed to use a runbook for node operations',
+        operations=[
+            {'path': '/nodes/{node_ident}/states/provision', 'method': 'PUT'}
+        ],
+    )
+]
+
 
 def list_policies():
     policies = itertools.chain(
@@ -1896,6 +2015,7 @@ def list_policies():
         allocation_policies,
         event_policies,
         deploy_template_policies,
+        runbook_policies,
     )
     return policies
 
diff --git a/ironic/common/release_mappings.py b/ironic/common/release_mappings.py
index b45f09c8d8..9abbc9a546 100644
--- a/ironic/common/release_mappings.py
+++ b/ironic/common/release_mappings.py
@@ -709,7 +709,7 @@ RELEASE_MAPPING = {
     # make it below. To release, we will preserve a version matching
     # the release as a separate block of text, like above.
     'master': {
-        'api': '1.91',
+        'api': '1.92',
         'rpc': '1.60',
         'objects': {
             'Allocation': ['1.1'],
@@ -728,6 +728,7 @@ RELEASE_MAPPING = {
             'VolumeConnector': ['1.0'],
             'VolumeTarget': ['1.0'],
             'FirmwareComponent': ['1.0'],
+            'Runbook': ['1.0'],
         }
     },
 }
diff --git a/ironic/db/api.py b/ironic/db/api.py
index 0f21292767..b6f38a95d3 100644
--- a/ironic/db/api.py
+++ b/ironic/db/api.py
@@ -1347,6 +1347,101 @@ class Connection(object, metaclass=abc.ABCMeta):
         :returns: A list of deploy templates.
         """
 
+    @abc.abstractmethod
+    def create_runbook(self, values):
+        """Create a runbook.
+
+        :param values: A dict describing the runbook. For example:
+
+                     ::
+
+                      {
+                       'uuid': uuidutils.generate_uuid(),
+                       'name': 'CUSTOM_DT1',
+                      }
+        :raises: RunbookDuplicateName if a runbook with the same
+            name exists.
+        :raises: RunbookAlreadyExists if a runbook with the same
+            UUID exists.
+        :returns: A runbook.
+        """
+
+    @abc.abstractmethod
+    def update_runbook(self, runbook_id, values):
+        """Update a runbook.
+
+        :param runbook_id: ID of the runbook to update.
+        :param values: A dict describing the runbook. For example:
+
+                     ::
+
+                      {
+                       'uuid': uuidutils.generate_uuid(),
+                       'name': 'CUSTOM_DT1',
+                      }
+        :raises: RunbookDuplicateName if a runbook with the same
+            name exists.
+        :raises: RunbookNotFound if the runbook does not exist.
+        :returns: A runbook.
+        """
+
+    @abc.abstractmethod
+    def destroy_runbook(self, runbook_id):
+        """Destroy a runbook.
+
+        :param runbook_id: ID of the runbook to destroy.
+        :raises: RunbookNotFound if the runbook does not exist.
+        """
+
+    @abc.abstractmethod
+    def get_runbook_by_id(self, runbook_id):
+        """Retrieve a runbook by ID.
+
+        :param runbook_id: ID of the runbook to retrieve.
+        :raises: RunbookNotFound if the runbook does not exist.
+        :returns: A runbook.
+        """
+
+    @abc.abstractmethod
+    def get_runbook_by_uuid(self, runbook_uuid):
+        """Retrieve a runbook by UUID.
+
+        :param runbook_uuid: UUID of the runbook to retrieve.
+        :raises: RunbookNotFound if the runbook does not exist.
+        :returns: A runbook.
+        """
+
+    @abc.abstractmethod
+    def get_runbook_by_name(self, runbook_name):
+        """Retrieve a runbook by name.
+
+        :param runbook_name: name of the runbook to retrieve.
+        :raises: RunbookNotFound if the runbook does not exist.
+        :returns: A runbook.
+        """
+
+    @abc.abstractmethod
+    def get_runbook_list(self, limit=None, marker=None, filters=None,
+                         sort_key=None, sort_dir=None):
+        """Retrieve a list of runbooks.
+
+        :param limit: Maximum number of runbooks to return.
+        :param marker: The last item of the previous page; we return the next
+                       result set.
+        :param sort_key: Attribute by which results should be sorted.
+        :param sort_dir: Direction in which results should be sorted.
+                         (asc, desc)
+        :returns: A list of runbooks.
+        """
+
+    @abc.abstractmethod
+    def get_runbook_list_by_names(self, names):
+        """Return a list of runbooks with one of a list of names.
+
+        :param names: List of names to filter by.
+        :returns: A list of runbooks.
+        """
+
     @abc.abstractmethod
     def create_node_history(self, values):
         """Create a new history record.
diff --git a/ironic/db/sqlalchemy/alembic/versions/66bd9c5604d5_add_runbook_and_runbook_step.py b/ironic/db/sqlalchemy/alembic/versions/66bd9c5604d5_add_runbook_and_runbook_step.py
new file mode 100644
index 0000000000..430578851f
--- /dev/null
+++ b/ironic/db/sqlalchemy/alembic/versions/66bd9c5604d5_add_runbook_and_runbook_step.py
@@ -0,0 +1,70 @@
+#    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.
+
+"""Create runbooks and runbook_steps tables
+
+Revision ID: 66bd9c5604d5
+Revises: 01f21d5e5195
+Create Date: 2024-05-29 19:33:53.268794
+
+"""
+
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = '66bd9c5604d5'
+down_revision = '01f21d5e5195'
+
+
+def upgrade():
+    op.create_table(
+        'runbooks',
+        sa.Column('version', sa.String(length=15), nullable=True),
+        sa.Column('created_at', sa.DateTime(), nullable=True),
+        sa.Column('updated_at', sa.DateTime(), nullable=True),
+        sa.Column('id', sa.Integer(), nullable=False,
+                  autoincrement=True),
+        sa.Column('uuid', sa.String(length=36)),
+        sa.Column('name', sa.String(length=255), nullable=False),
+        sa.Column('disable_ramdisk', sa.Boolean, default=False),
+        sa.Column('public', sa.Boolean, default=False),
+        sa.Column('owner', sa.String(length=255), nullable=True),
+        sa.Column('extra', sa.Text(), nullable=True),
+        sa.PrimaryKeyConstraint('id'),
+        sa.UniqueConstraint('uuid', name='uniq_runbooks0uuid'),
+        sa.UniqueConstraint('name', name='uniq_runbooks0name'),
+        mysql_engine='InnoDB',
+        mysql_charset='UTF8MB3'
+    )
+    op.create_table(
+        'runbook_steps',
+        sa.Column('version', sa.String(length=15), nullable=True),
+        sa.Column('created_at', sa.DateTime(), nullable=True),
+        sa.Column('updated_at', sa.DateTime(), nullable=True),
+        sa.Column('id', sa.Integer(), nullable=False,
+                  autoincrement=True),
+        sa.Column('runbook_id', sa.Integer(), nullable=False,
+                  autoincrement=False),
+        sa.Column('interface', sa.String(length=255), nullable=False),
+        sa.Column('step', sa.String(length=255), nullable=False),
+        sa.Column('args', sa.Text, nullable=False),
+        sa.Column('order', sa.Integer, nullable=False),
+        sa.PrimaryKeyConstraint('id'),
+        sa.ForeignKeyConstraint(['runbook_id'],
+                                ['runbooks.id']),
+        sa.Index('runbook_id', 'runbook_id'),
+        sa.Index('runbook_steps_interface_idx', 'interface'),
+        sa.Index('runbook_steps_step_idx', 'step'),
+        mysql_engine='InnoDB',
+        mysql_charset='UTF8MB3'
+    )
diff --git a/ironic/db/sqlalchemy/api.py b/ironic/db/sqlalchemy/api.py
index 873d35c208..ddb48610f5 100644
--- a/ironic/db/sqlalchemy/api.py
+++ b/ironic/db/sqlalchemy/api.py
@@ -169,6 +169,16 @@ def _get_deploy_template_select_with_steps():
     ).options(selectinload(models.DeployTemplate.steps))
 
 
+def _get_runbook_select_with_steps():
+    """Return a select object for the Runbook joined with steps.
+
+    :returns: a select object.
+    """
+    return sa.select(
+        models.Runbook
+    ).options(selectinload(models.Runbook.steps))
+
+
 def model_query(model, *args, **kwargs):
     """Query helper for simpler session usage.
 
@@ -471,6 +481,13 @@ class Connection(api.Connection):
                      | set(_NODE_IN_QUERY_FIELDS)
                      | set(_NODE_NON_NULL_FILTERS))
 
+    _RUNBOOK_QUERY_FIELDS = {'id', 'uuid', 'name', 'public', 'owner',
+                             'disable_ramdisk'}
+    _RUNBOOK_IN_QUERY_FIELDS = {'%s_in' % field: field
+                                for field in ('id', 'uuid', 'name')}
+    _RUNBOOK_FILTERS = ({'project'} | _RUNBOOK_QUERY_FIELDS
+                        | set(_RUNBOOK_IN_QUERY_FIELDS))
+
     def __init__(self):
         pass
 
@@ -541,6 +558,31 @@ class Connection(api.Connection):
         # a full list of both parents and children being conveyed.
         return query
 
+    def _validate_runbooks_filters(self, filters):
+        if filters is None:
+            filters = dict()
+        unsupported_filters = set(filters).difference(self._RUNBOOK_FILTERS)
+        if unsupported_filters:
+            msg = _("SqlAlchemy API does not support "
+                    "filtering by %s") % ', '.join(unsupported_filters)
+            raise ValueError(msg)
+        return filters
+
+    def _add_runbooks_filters(self, query, filters):
+        filters = self._validate_runbooks_filters(filters)
+        for field in self._RUNBOOK_QUERY_FIELDS:
+            if field in filters:
+                query = query.filter_by(**{field: filters[field]})
+        for key, field in self._RUNBOOK_IN_QUERY_FIELDS.items():
+            if key in filters:
+                query = query.filter(
+                    getattr(models.Runbook, field).in_(filters[key]))
+        if 'project' in filters:
+            project = filters['project']
+            query = query.filter((models.Runbook.owner == project)
+                                 | (models.Runbook.public))
+        return query
+
     def _add_allocations_filters(self, query, filters):
         if filters is None:
             filters = dict()
@@ -2628,6 +2670,171 @@ class Connection(api.Connection):
             ).all()
             return [r[0] for r in res]
 
+    @staticmethod
+    def _get_runbook_steps(steps, runbook_id=None):
+        results = []
+        for values in steps:
+            step = models.RunbookStep()
+            step.update(values)
+            if runbook_id:
+                step['runbook_id'] = runbook_id
+            results.append(step)
+        return results
+
+    @oslo_db_api.retry_on_deadlock
+    def create_runbook(self, values):
+        steps = values.get('steps', [])
+        values['steps'] = self._get_runbook_steps(steps)
+
+        runbook = models.Runbook()
+        runbook.update(values)
+        with _session_for_write() as session:
+            try:
+                session.add(runbook)
+                session.flush()
+            except db_exc.DBDuplicateEntry as e:
+                if 'name' in e.columns:
+                    raise exception.RunbookDuplicateName(
+                        name=values['name'])
+                raise exception.RunbookAlreadyExists(
+                    uuid=values['uuid'])
+        return runbook
+
+    def _update_runbook_steps(self, session, runbook_id, steps):
+        """Update the steps for a runbook.
+
+        :param session: DB session object.
+        :param runbook_id: runbook ID.
+        :param steps: list of steps that should exist for the runbook.
+        """
+
+        def _step_key(step):
+            """Compare two runbook steps."""
+            # NOTE(mgoddard): In python 3, dicts are not orderable so cannot be
+            # used as a sort key. Serialise the step arguments to a JSON string
+            # for comparison. Taken from https://stackoverflow.com/a/22003440.
+            sortable_args = json.dumps(step.args, sort_keys=True)
+            return step.interface, step.step, sortable_args, step.order
+
+        # List all existing steps for the runbook.
+        current_steps = (session.query(models.RunbookStep)
+                         .filter_by(runbook_id=runbook_id))
+
+        # List the new steps for the runbook.
+        new_steps = self._get_runbook_steps(steps, runbook_id)
+
+        # The following is an efficient way to ensure that the steps in the
+        # database match those that have been requested. We compare the current
+        # and requested steps in a single pass using the _zip_matching
+        # function.
+        steps_to_create = []
+        step_ids_to_delete = []
+        for current_step, new_step in _zip_matching(current_steps, new_steps,
+                                                    _step_key):
+            if current_step is None:
+                # No matching current step found for this new step - create.
+                steps_to_create.append(new_step)
+            elif new_step is None:
+                # No matching new step found for this current step - delete.
+                step_ids_to_delete.append(current_step.id)
+            # else: steps match, no work required.
+
+        # Delete and create steps in bulk as necessary.
+        if step_ids_to_delete:
+            ((session.query(models.RunbookStep)
+              .filter(models.RunbookStep.id.in_(step_ids_to_delete)))
+             .delete(synchronize_session=False))
+        if steps_to_create:
+            session.bulk_save_objects(steps_to_create)
+
+    @oslo_db_api.retry_on_deadlock
+    def update_runbook(self, runbook_id, values):
+        if 'uuid' in values:
+            msg = _("Cannot overwrite UUID for an existing runbook.")
+            raise exception.InvalidParameterValue(err=msg)
+
+        try:
+            with _session_for_write() as session:
+                # NOTE(mgoddard): Don't issue a joined query for the update as
+                # this does not work with PostgreSQL.
+                query = session.query(models.Runbook)
+                query = add_identity_filter(query, runbook_id)
+                ref = query.with_for_update().one()
+                # First, update non-step columns.
+                steps = values.pop('steps', None)
+                ref.update(values)
+                # If necessary, update steps.
+                if steps is not None:
+                    self._update_runbook_steps(session, ref.id, steps)
+                session.flush()
+
+            with _session_for_read() as session:
+                # Return the updated runbook joined with all relevant fields.
+                query = _get_runbook_select_with_steps()
+                query = add_identity_filter(query, runbook_id)
+                res = session.execute(query).one()[0]
+            return res
+        except db_exc.DBDuplicateEntry as e:
+            if 'name' in e.columns:
+                raise exception.RunbookDuplicateName(
+                    name=values['name'])
+            raise
+        except NoResultFound:
+            # TODO(TheJulia): What would unified core raise?!?
+            raise exception.RunbookNotFound(
+                runbook=runbook_id)
+
+    @oslo_db_api.retry_on_deadlock
+    def destroy_runbook(self, runbook_id):
+        with _session_for_write() as session:
+            session.query(models.RunbookStep).filter_by(
+                runbook_id=runbook_id).delete()
+            count = session.query(models.Runbook).filter_by(
+                id=runbook_id).delete()
+            if count == 0:
+                raise exception.RunbookNotFound(runbook=runbook_id)
+
+    def _get_runbook(self, field, value):
+        """Helper method for retrieving a runbook."""
+        query = (_get_runbook_select_with_steps()
+                 .where(field == value))
+        try:
+            with _session_for_read() as session:
+                res = session.execute(query).one()[0]
+            return res
+        except NoResultFound:
+            raise exception.RunbookNotFound(runbook=value)
+
+    def get_runbook_by_id(self, runbook_id):
+        return self._get_runbook(models.Runbook.id,
+                                 runbook_id)
+
+    def get_runbook_by_uuid(self, runbook_uuid):
+        return self._get_runbook(models.Runbook.uuid,
+                                 runbook_uuid)
+
+    def get_runbook_by_name(self, runbook_name):
+        return self._get_runbook(models.Runbook.name,
+                                 runbook_name)
+
+    def get_runbook_list(self, limit=None, marker=None, filters=None,
+                         sort_key=None, sort_dir=None):
+        query = (sa.select(models.Runbook)
+                 .options(selectinload(models.Runbook.steps)))
+        query = self._add_runbooks_filters(query, filters)
+        return _paginate_query(models.Runbook, limit, marker,
+                               sort_key, sort_dir, query)
+
+    def get_runbook_list_by_names(self, names):
+        query = _get_runbook_select_with_steps()
+        with _session_for_read() as session:
+            res = session.execute(
+                query.where(
+                    models.Runbook.name.in_(names)
+                )
+            ).all()
+            return [r[0] for r in res]
+
     @oslo_db_api.retry_on_deadlock
     def create_node_history(self, values):
         values['uuid'] = uuidutils.generate_uuid()
diff --git a/ironic/db/sqlalchemy/models.py b/ironic/db/sqlalchemy/models.py
index ff4dcc522b..fbe5c1ff0a 100644
--- a/ironic/db/sqlalchemy/models.py
+++ b/ironic/db/sqlalchemy/models.py
@@ -516,6 +516,51 @@ class FirmwareComponent(Base):
     last_version_flashed = Column(String(255), nullable=True)
 
 
+class Runbook(Base):
+    """Represents a runbook."""
+
+    __tablename__ = 'runbooks'
+    __table_args__ = (
+        schema.UniqueConstraint('uuid', name='uniq_runbooks0uuid'),
+        schema.UniqueConstraint('name', name='uniq_runbooks0name'),
+        table_args())
+    id = Column(Integer, primary_key=True)
+    uuid = Column(String(36))
+    name = Column(String(255), nullable=False)
+    public = Column(Boolean, default=False)
+    owner = Column(String(255), nullable=True)
+    disable_ramdisk = Column(Boolean, default=False)
+    extra = Column(db_types.JsonEncodedDict)
+    steps: orm.Mapped[List['RunbookStep']] = orm.relationship(  # noqa
+        "RunbookStep",
+        back_populates="runbook",
+        lazy="selectin")
+
+
+class RunbookStep(Base):
+    """Represents a deployment step in a runbook."""
+
+    __tablename__ = 'runbook_steps'
+    __table_args__ = (
+        Index('runbook_id', 'runbook_id'),
+        Index('runbook_steps_interface_idx', 'interface'),
+        Index('runbook_steps_step_idx', 'step'),
+        table_args())
+    id = Column(Integer, primary_key=True)
+    runbook_id = Column(Integer, ForeignKey('runbooks.id'), nullable=False)
+    interface = Column(String(255), nullable=False)
+    step = Column(String(255), nullable=False)
+    args = Column(db_types.JsonEncodedDict, nullable=False)
+    order = Column(Integer, nullable=False)
+    runbook = orm.relationship(
+        "Runbook",
+        primaryjoin=(
+            'and_(RunbookStep.runbook_id == '
+            'Runbook.id)'),
+        foreign_keys=runbook_id
+    )
+
+
 def get_class(model_name):
     """Returns the model class with the specified name.
 
diff --git a/ironic/objects/__init__.py b/ironic/objects/__init__.py
index 83d1facc50..4d3ac2de84 100644
--- a/ironic/objects/__init__.py
+++ b/ironic/objects/__init__.py
@@ -36,6 +36,7 @@ def register_all():
     __import__('ironic.objects.node_inventory')
     __import__('ironic.objects.port')
     __import__('ironic.objects.portgroup')
+    __import__('ironic.objects.runbook')
     __import__('ironic.objects.trait')
     __import__('ironic.objects.volume_connector')
     __import__('ironic.objects.volume_target')
diff --git a/ironic/objects/runbook.py b/ironic/objects/runbook.py
new file mode 100644
index 0000000000..66a0656d9a
--- /dev/null
+++ b/ironic/objects/runbook.py
@@ -0,0 +1,252 @@
+#    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.
+
+from oslo_versionedobjects import base as object_base
+
+from ironic.db import api as db_api
+from ironic.objects import base
+from ironic.objects import fields as object_fields
+from ironic.objects import notification
+
+
+@base.IronicObjectRegistry.register
+class Runbook(base.IronicObject, object_base.VersionedObjectDictCompat):
+    # Version 1.0: Initial version
+    VERSION = '1.0'
+
+    dbapi = db_api.get_instance()
+
+    fields = {
+        'id': object_fields.IntegerField(),
+        'uuid': object_fields.UUIDField(nullable=False),
+        'name': object_fields.StringField(nullable=False),
+        'steps': object_fields.ListOfFlexibleDictsField(nullable=False),
+        'disable_ramdisk': object_fields.BooleanField(default=False),
+        'extra': object_fields.FlexibleDictField(nullable=True),
+        'public': object_fields.BooleanField(default=False),
+        'owner': object_fields.StringField(nullable=True),
+    }
+
+    def create(self, context=None):
+        """Create a Runbook record in the DB.
+
+        :param context: security context. NOTE: This should only
+                        be used internally by the indirection_api,
+                        but, RPC requires context as the first
+                        argument, even though we don't use it.
+                        A context should be set when instantiating the
+                        object, e.g.: Runbook(context).
+        :raises: RunbookDuplicateName if a runbook with the same
+            name exists.
+        :raises: RunbookAlreadyExists if a runbook with the same
+            UUID exists.
+        """
+        values = self.do_version_changes_for_db()
+        db_template = self.dbapi.create_runbook(values)
+        self._from_db_object(self._context, self, db_template)
+
+    def save(self, context=None):
+        """Save updates to this Runbook.
+
+        Column-wise updates will be made based on the result of
+        self.what_changed().
+
+        :param context: Security context. NOTE: This should only
+                        be used internally by the indirection_api,
+                        but, RPC requires context as the first
+                        argument, even though we don't use it.
+                        A context should be set when instantiating the
+                        object, e.g.: Runbook(context)
+        :raises: RunbookDuplicateName if a runbook with the same
+            name exists.
+        :raises: RunbookNotFound if the runbook does not exist.
+        """
+        updates = self.do_version_changes_for_db()
+        db_template = self.dbapi.update_runbook(self.uuid, updates)
+        self._from_db_object(self._context, self, db_template)
+
+    def destroy(self):
+        """Delete the Runbook from the DB.
+
+        :param context: security context. NOTE: This should only
+                        be used internally by the indirection_api,
+                        but, RPC requires context as the first
+                        argument, even though we don't use it.
+                        A context should be set when instantiating the
+                        object, e.g.: Runbook(context).
+        :raises: RunbookNotFound if the runbook no longer
+            appears in the database.
+        """
+        self.dbapi.destroy_runbook(self.id)
+        self.obj_reset_changes()
+
+    @classmethod
+    def get_by_id(cls, context, runbook_id):
+        """Find a runbook based on its integer ID.
+
+        :param context: security context. NOTE: This should only
+                        be used internally by the indirection_api,
+                        but, RPC requires context as the first
+                        argument, even though we don't use it.
+                        A context should be set when instantiating the
+                        object, e.g.: Runbook(context).
+        :param runbook_id: The ID of a runbook.
+        :raises: RunbookNotFound if the runbook no longer
+            appears in the database.
+        :returns: a :class:`Runbook` object.
+        """
+        db_template = cls.dbapi.get_runbook_by_id(runbook_id)
+        template = cls._from_db_object(context, cls(), db_template)
+        return template
+
+    @classmethod
+    def get_by_uuid(cls, context, uuid):
+        """Find a runbook based on its UUID.
+
+        :param context: security context. NOTE: This should only
+                        be used internally by the indirection_api,
+                        but, RPC requires context as the first
+                        argument, even though we don't use it.
+                        A context should be set when instantiating the
+                        object, e.g.: Runbook(context).
+        :param uuid: The UUID of a runbook.
+        :raises: RunbookNotFound if the runbook no longer
+            appears in the database.
+        :returns: a :class:`Runbook` object.
+        """
+        db_template = cls.dbapi.get_runbook_by_uuid(uuid)
+        template = cls._from_db_object(context, cls(), db_template)
+        return template
+
+    @classmethod
+    def get_by_name(cls, context, name):
+        """Find a runbook based on its name.
+
+        :param context: security context. NOTE: This should only
+                        be used internally by the indirection_api,
+                        but, RPC requires context as the first
+                        argument, even though we don't use it.
+                        A context should be set when instantiating the
+                        object, e.g.: Runbook(context).
+        :param name: The name of a runbook.
+        :raises: RunbookNotFound if the runbook no longer
+            appears in the database.
+        :returns: a :class:`Runbook` object.
+        """
+        db_template = cls.dbapi.get_runbook_by_name(name)
+        template = cls._from_db_object(context, cls(), db_template)
+        return template
+
+    @classmethod
+    def list(cls, context, limit=None, marker=None, sort_key=None,
+             sort_dir=None, filters=None):
+        """Return a list of Runbook objects.
+
+        :param context: security context. NOTE: This should only
+                        be used internally by the indirection_api,
+                        but, RPC requires context as the first
+                        argument, even though we don't use it.
+                        A context should be set when instantiating the
+                        object, e.g.: Runbook(context).
+        :param limit: maximum number of resources to return in a single result.
+        :param marker: pagination marker for large data sets.
+        :param sort_key: column to sort results by.
+        :param sort_dir: direction to sort. "asc" or "desc".
+        :param filters: Filters to apply.
+        :returns: a list of :class:`Runbook` objects.
+        """
+        db_templates = cls.dbapi.get_runbook_list(limit=limit, marker=marker,
+                                                  sort_key=sort_key,
+                                                  sort_dir=sort_dir,
+                                                  filters=filters)
+        return cls._from_db_object_list(context, db_templates)
+
+    @classmethod
+    def list_by_names(cls, context, names):
+        """Return a list of Runbook objects matching a set of names.
+
+        :param context: security context. NOTE: This should only
+                        be used internally by the indirection_api,
+                        but, RPC requires context as the first
+                        argument, even though we don't use it.
+                        A context should be set when instantiating the
+                        object, e.g.: Runbook(context).
+        :param names: a list of names to filter by.
+        :returns: a list of :class:`Runbook` objects.
+        """
+        db_templates = cls.dbapi.get_runbook_list_by_names(names)
+        return cls._from_db_object_list(context, db_templates)
+
+    def refresh(self, context=None):
+        """Loads updates for this runbook.
+
+        Loads a runbook with the same uuid from the database and
+        checks for updated attributes. Updates are applied from
+        the loaded template column by column, if there are any updates.
+
+        :param context: Security context. NOTE: This should only
+                        be used internally by the indirection_api,
+                        but, RPC requires context as the first
+                        argument, even though we don't use it.
+                        A context should be set when instantiating the
+                        object, e.g.: Port(context)
+        :raises: RunbookNotFound if the runbook no longer
+            appears in the database.
+        """
+        current = self.get_by_uuid(self._context, uuid=self.uuid)
+        self.obj_refresh(current)
+        self.obj_reset_changes()
+
+
+@base.IronicObjectRegistry.register
+class RunbookCRUDNotification(notification.NotificationBase):
+    """Notification emitted on runbook API operations."""
+    # Version 1.0: Initial version
+    VERSION = '1.0'
+
+    fields = {
+        'payload': object_fields.ObjectField('RunbookCRUDPayload')
+    }
+
+
+@base.IronicObjectRegistry.register
+class RunbookCRUDPayload(notification.NotificationPayloadBase):
+    # Version 1.0: Initial version
+    VERSION = '1.0'
+
+    SCHEMA = {
+        'created_at': ('runbook', 'created_at'),
+        'disable_ramdisk': ('runbook', 'disable_ramdisk'),
+        'extra': ('runbook', 'extra'),
+        'name': ('runbook', 'name'),
+        'owner': ('runbook', 'owner'),
+        'public': ('runbook', 'public'),
+        'steps': ('runbook', 'steps'),
+        'updated_at': ('runbook', 'updated_at'),
+        'uuid': ('runbook', 'uuid')
+    }
+
+    fields = {
+        'created_at': object_fields.DateTimeField(nullable=True),
+        'disable_ramdisk': object_fields.BooleanField(default=False),
+        'extra': object_fields.FlexibleDictField(nullable=True),
+        'name': object_fields.StringField(nullable=False),
+        'owner': object_fields.StringField(nullable=True),
+        'public': object_fields.BooleanField(default=False),
+        'steps': object_fields.ListOfFlexibleDictsField(nullable=False),
+        'updated_at': object_fields.DateTimeField(nullable=True),
+        'uuid': object_fields.UUIDField()
+    }
+
+    def __init__(self, runbook, **kwargs):
+        super(RunbookCRUDPayload, self).__init__(**kwargs)
+        self.populate_schema(runbook=runbook)
diff --git a/ironic/tests/unit/api/controllers/v1/test_node.py b/ironic/tests/unit/api/controllers/v1/test_node.py
index cfd0eb9bc7..8c7b396049 100644
--- a/ironic/tests/unit/api/controllers/v1/test_node.py
+++ b/ironic/tests/unit/api/controllers/v1/test_node.py
@@ -7019,6 +7019,113 @@ ORHMKeXMO8fcK0By7CiMKwHSXCoEQgfQhWwpMdSsO8LgHCjh87DQc= """
         self.assertEqual('application/json', ret.content_type)
         self.assertEqual(http_client.BAD_REQUEST, ret.status_code)
 
+    @mock.patch.object(api_utils, 'check_runbook_policy_and_retrieve',
+                       autospec=True)
+    @mock.patch.object(rpcapi.ConductorAPI, 'do_node_service',
+                       autospec=True)
+    def test_service_with_runbooks(self, mock_dns, mock_policy):
+        objects.TraitList.create(self.context, self.node.id, ['CUSTOM_1'])
+        self.node.refresh
+
+        self.node.provision_state = states.SERVICEHOLD
+        self.node.save()
+
+        runbook = mock.Mock()
+        runbook.name = 'CUSTOM_1'
+        runbook.steps = [{"step": "upgrade_firmware", "interface": "deploy",
+                          "args": {}}]
+        mock_policy.return_value = runbook
+        ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
+                            {'target': states.VERBS['service'],
+                             'runbook': runbook.name},
+                            headers={api_base.Version.string:
+                                     str(api_v1.max_version())})
+        self.assertEqual(http_client.ACCEPTED, ret.status_code)
+        self.assertEqual(b'', ret.body)
+        mock_policy.assert_has_calls([mock.call('baremetal:runbook:use',
+                                                runbook.name)]),
+        mock_dns.assert_called_once_with(mock.ANY, mock.ANY,
+                                         self.node.uuid, runbook.steps,
+                                         mock.ANY, topic='test-topic')
+
+    @mock.patch.object(api_utils, 'check_runbook_policy_and_retrieve',
+                       autospec=True)
+    @mock.patch.object(rpcapi.ConductorAPI, 'do_node_clean', autospec=True)
+    @mock.patch.object(api_node, '_check_clean_steps', autospec=True)
+    def test_clean_with_runbooks(self, mock_check, mock_rpcapi, mock_policy):
+        objects.TraitList.create(self.context, self.node.id, ['CUSTOM_1'])
+        self.node.refresh
+
+        self.node.provision_state = states.MANAGEABLE
+        self.node.save()
+
+        step = {"step": "configure raid", "interface": "raid", "args": {},
+                "order": 1}
+
+        runbook = mock.Mock()
+        runbook.name = 'CUSTOM_1'
+        runbook.steps = [step]
+        mock_policy.return_value = runbook
+        ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
+                            {'target': states.VERBS['clean'],
+                             'runbook': runbook.name},
+                            headers={api_base.Version.string:
+                                     str(api_v1.max_version())})
+        self.assertEqual(http_client.ACCEPTED, ret.status_code)
+        self.assertEqual(b'', ret.body)
+        mock_policy.assert_has_calls([mock.call('baremetal:runbook:use',
+                                                runbook.name)]),
+        mock_check.assert_called_once_with(runbook.steps)
+        mock_rpcapi.assert_called_once_with(mock.ANY, mock.ANY, self.node.uuid,
+                                            runbook.steps, mock.ANY,
+                                            topic='test-topic')
+
+    @mock.patch.object(api_utils, 'check_runbook_policy_and_retrieve',
+                       autospec=True)
+    def test_service_with_runbooks_unapproved(self, mock_policy):
+        objects.TraitList.create(self.context, self.node.id, ['CUSTOM_2'])
+        self.node.refresh
+
+        self.node.provision_state = states.SERVICEHOLD
+        self.node.save()
+
+        runbook = mock.Mock()
+        runbook.name = 'CUSTOM_1'
+        runbook.steps = [{'step': 'meow', 'interface': 'raid', 'args': {},
+                          'order': 1}]
+        mock_policy.return_value = runbook
+
+        ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
+                            {'target': states.VERBS['service'],
+                             'runbook': runbook.name},
+                            expect_errors=True,
+                            headers={api_base.Version.string:
+                                     str(api_v1.max_version())})
+        self.assertEqual(http_client.BAD_REQUEST, ret.status_int)
+
+    @mock.patch.object(api_utils, 'check_runbook_policy_and_retrieve',
+                       autospec=True)
+    def test_clean_with_runbooks_unapproved(self, mock_policy):
+        objects.TraitList.create(self.context, self.node.id, ['CUSTOM_2'])
+        self.node.refresh
+
+        self.node.provision_state = states.MANAGEABLE
+        self.node.save()
+
+        runbook = mock.Mock()
+        runbook.name = 'CUSTOM_1'
+        runbook.steps = [{'step': 'meow', 'interface': 'deploy', 'args': {},
+                          'order': 1}]
+        mock_policy.return_value = runbook
+
+        ret = self.put_json('/nodes/%s/states/provision' % self.node.uuid,
+                            {'target': states.VERBS['clean'],
+                             'runbook': runbook.name},
+                            expect_errors=True,
+                            headers={api_base.Version.string:
+                                     str(api_v1.max_version())})
+        self.assertEqual(http_client.BAD_REQUEST, ret.status_int)
+
 
 class TestCheckCleanSteps(db_base.DbTestCase):
     def test__check_clean_steps_not_list(self):
diff --git a/ironic/tests/unit/api/controllers/v1/test_root.py b/ironic/tests/unit/api/controllers/v1/test_root.py
index 903f8d8549..e00fe0a5aa 100644
--- a/ironic/tests/unit/api/controllers/v1/test_root.py
+++ b/ironic/tests/unit/api/controllers/v1/test_root.py
@@ -160,6 +160,12 @@ class TestV1Routing(api_base.BaseApiTest):
             'volume': [
                 {'href': 'http://localhost/v1/volume/', 'rel': 'self'},
                 {'href': 'http://localhost/volume/', 'rel': 'bookmark'}
+            ],
+            'runbooks': [
+                {'href': 'http://localhost/v1/runbooks/',
+                 'rel': 'self'},
+                {'href': 'http://localhost/runbooks/',
+                 'rel': 'bookmark'}
             ]
         }, response)
 
diff --git a/ironic/tests/unit/api/controllers/v1/test_runbook.py b/ironic/tests/unit/api/controllers/v1/test_runbook.py
new file mode 100644
index 0000000000..47af466795
--- /dev/null
+++ b/ironic/tests/unit/api/controllers/v1/test_runbook.py
@@ -0,0 +1,1126 @@
+#    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.
+"""
+Tests for the API /runbooks/ methods.
+"""
+
+import datetime
+from http import client as http_client
+from unittest import mock
+from urllib import parse as urlparse
+
+from oslo_config import cfg
+from oslo_utils import timeutils
+from oslo_utils import uuidutils
+
+from ironic.api.controllers import base as api_base
+from ironic.api.controllers import v1 as api_v1
+from ironic.api.controllers.v1 import notification_utils
+from ironic.common import exception
+from ironic import objects
+from ironic.objects import fields as obj_fields
+from ironic.tests.unit.api import base as test_api_base
+from ironic.tests.unit.api import utils as test_api_utils
+from ironic.tests.unit.objects import utils as obj_utils
+
+
+def _obj_to_api_step(obj_step):
+    """Convert a runbook step in 'object' form to one in 'API' form."""
+    return {
+        'interface': obj_step['interface'],
+        'step': obj_step['step'],
+        'args': obj_step['args'],
+        'order': obj_step['order'],
+    }
+
+
+class BaseRunbooksAPITest(test_api_base.BaseApiTest):
+    headers = {api_base.Version.string: str(api_v1.max_version())}
+    invalid_version_headers = {api_base.Version.string: '1.91'}
+
+
+class TestListRunbooks(BaseRunbooksAPITest):
+
+    def test_empty(self):
+        data = self.get_json('/runbooks', headers=self.headers)
+        self.assertEqual([], data['runbooks'])
+
+    def test_one(self):
+        runbook = obj_utils.create_test_runbook(self.context)
+        data = self.get_json('/runbooks', headers=self.headers)
+        self.assertEqual(1, len(data['runbooks']))
+        self.assertEqual(runbook.uuid, data['runbooks'][0]['uuid'])
+        self.assertEqual(runbook.name, data['runbooks'][0]['name'])
+        self.assertNotIn('steps', data['runbooks'][0])
+        self.assertNotIn('extra', data['runbooks'][0])
+
+    def test_get_one(self):
+        runbook = obj_utils.create_test_runbook(self.context)
+        data = self.get_json('/runbooks/%s' % runbook.uuid,
+                             headers=self.headers)
+        self.assertEqual(runbook.uuid, data['uuid'])
+        self.assertEqual(runbook.name, data['name'])
+        self.assertEqual(runbook.extra, data['extra'])
+        for t_dict_step, t_step in zip(data['steps'], runbook.steps):
+            self.assertEqual(t_dict_step['interface'], t_step['interface'])
+            self.assertEqual(t_dict_step['step'], t_step['step'])
+            self.assertEqual(t_dict_step['args'], t_step['args'])
+            self.assertEqual(t_dict_step['order'], t_step['order'])
+
+    def test_get_one_custom_fields(self):
+        runbook = obj_utils.create_test_runbook(self.context)
+        fields = 'name,steps'
+        data = self.get_json(
+            '/runbooks/%s?fields=%s' % (runbook.uuid, fields),
+            headers=self.headers)
+        # We always append "links"
+        self.assertCountEqual(['name', 'steps', 'links'], data)
+
+    def test_get_collection_custom_fields(self):
+        fields = 'uuid,steps'
+        for i in range(3):
+            obj_utils.create_test_runbook(
+                self.context,
+                uuid=uuidutils.generate_uuid(),
+                name='CUSTOM_DT%s' % i)
+
+        data = self.get_json(
+            '/runbooks?fields=%s' % fields,
+            headers=self.headers)
+
+        self.assertEqual(3, len(data['runbooks']))
+        for runbook in data['runbooks']:
+            # We always append "links"
+            self.assertCountEqual(['uuid', 'steps', 'links'], runbook)
+
+    def test_get_custom_fields_invalid_fields(self):
+        runbook = obj_utils.create_test_runbook(self.context)
+        fields = 'uuid,spongebob'
+        response = self.get_json(
+            '/runbooks/%s?fields=%s' % (runbook.uuid, fields),
+            headers=self.headers, expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+        self.assertEqual('application/json', response.content_type)
+        self.assertIn('spongebob', response.json['error_message'])
+
+    def test_get_all_invalid_api_version(self):
+        obj_utils.create_test_runbook(self.context)
+        response = self.get_json('/runbooks',
+                                 headers=self.invalid_version_headers,
+                                 expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_get_one_invalid_api_version(self):
+        runbook = obj_utils.create_test_runbook(self.context)
+        response = self.get_json(
+            '/runbooks/%s' % (runbook.uuid),
+            headers=self.invalid_version_headers,
+            expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_detail_query(self):
+        runbook = obj_utils.create_test_runbook(self.context)
+        data = self.get_json('/runbooks?detail=True',
+                             headers=self.headers)
+        self.assertEqual(runbook.uuid, data['runbooks'][0]['uuid'])
+        self.assertIn('name', data['runbooks'][0])
+        self.assertIn('steps', data['runbooks'][0])
+        self.assertIn('extra', data['runbooks'][0])
+
+    def test_detail_query_false(self):
+        obj_utils.create_test_runbook(self.context)
+        data1 = self.get_json('/runbooks', headers=self.headers)
+        data2 = self.get_json(
+            '/runbooks?detail=False', headers=self.headers)
+        self.assertEqual(data1['runbooks'], data2['runbooks'])
+
+    def test_detail_using_query_false_and_fields(self):
+        obj_utils.create_test_runbook(self.context)
+        data = self.get_json(
+            '/runbooks?detail=False&fields=steps',
+            headers=self.headers)
+        self.assertIn('steps', data['runbooks'][0])
+        self.assertNotIn('uuid', data['runbooks'][0])
+        self.assertNotIn('extra', data['runbooks'][0])
+
+    def test_detail_using_query_and_fields(self):
+        obj_utils.create_test_runbook(self.context)
+        response = self.get_json(
+            '/runbooks?detail=True&fields=name', headers=self.headers,
+            expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+    def test_many(self):
+        templates = []
+        for id_ in range(5):
+            runbook = obj_utils.create_test_runbook(
+                self.context, uuid=uuidutils.generate_uuid(),
+                name='CUSTOM_DT%s' % id_)
+            templates.append(runbook.uuid)
+        data = self.get_json('/runbooks', headers=self.headers)
+        self.assertEqual(len(templates), len(data['runbooks']))
+
+        uuids = [n['uuid'] for n in data['runbooks']]
+        self.assertCountEqual(templates, uuids)
+
+    def test_links(self):
+        uuid = uuidutils.generate_uuid()
+        obj_utils.create_test_runbook(self.context, uuid=uuid)
+        data = self.get_json('/runbooks/%s' % uuid,
+                             headers=self.headers)
+        self.assertIn('links', data)
+        self.assertEqual(2, len(data['links']))
+        self.assertIn(uuid, data['links'][0]['href'])
+        for link in data['links']:
+            bookmark = link['rel'] == 'bookmark'
+            self.assertTrue(self.validate_link(link['href'], bookmark=bookmark,
+                            headers=self.headers))
+
+    def test_collection_links(self):
+        templates = []
+        for id_ in range(5):
+            runbook = obj_utils.create_test_runbook(
+                self.context, uuid=uuidutils.generate_uuid(),
+                name='CUSTOM_DT%s' % id_)
+            templates.append(runbook.uuid)
+        data = self.get_json('/runbooks/?limit=3',
+                             headers=self.headers)
+        self.assertEqual(3, len(data['runbooks']))
+
+        next_marker = data['runbooks'][-1]['uuid']
+        self.assertIn('/runbooks', data['next'])
+        self.assertIn('limit=3', data['next'])
+        self.assertIn(f'marker={next_marker}', data['next'])
+
+    def test_collection_links_default_limit(self):
+        cfg.CONF.set_override('max_limit', 3, 'api')
+        templates = []
+        for id_ in range(5):
+            runbook = obj_utils.create_test_runbook(
+                self.context, uuid=uuidutils.generate_uuid(),
+                name='CUSTOM_DT%s' % id_)
+            templates.append(runbook.uuid)
+        data = self.get_json('/runbooks', headers=self.headers)
+        self.assertEqual(3, len(data['runbooks']))
+
+        next_marker = data['runbooks'][-1]['uuid']
+        self.assertIn('/runbooks', data['next'])
+        self.assertIn(f'marker={next_marker}', data['next'])
+
+    def test_collection_links_custom_fields(self):
+        cfg.CONF.set_override('max_limit', 3, 'api')
+        templates = []
+        fields = 'uuid,steps'
+        for i in range(5):
+            runbook = obj_utils.create_test_runbook(
+                self.context,
+                uuid=uuidutils.generate_uuid(),
+                name='CUSTOM_DT%s' % i)
+            templates.append(runbook.uuid)
+        data = self.get_json('/runbooks?fields=%s' % fields,
+                             headers=self.headers)
+        self.assertEqual(3, len(data['runbooks']))
+        next_marker = data['runbooks'][-1]['uuid']
+        self.assertIn('/runbooks', data['next'])
+        self.assertIn(f'marker={next_marker}', data['next'])
+        self.assertIn(f'fields={fields}', data['next'])
+
+    def test_get_collection_pagination_no_uuid(self):
+        fields = 'name'
+        limit = 2
+        templates = []
+        for id_ in range(3):
+            runbook = obj_utils.create_test_runbook(
+                self.context,
+                uuid=uuidutils.generate_uuid(),
+                name='CUSTOM_DT%s' % id_)
+            templates.append(runbook)
+
+        data = self.get_json(
+            '/runbooks?fields=%s&limit=%s' % (fields, limit),
+            headers=self.headers)
+
+        self.assertEqual(limit, len(data['runbooks']))
+        self.assertIn('/runbooks', data['next'])
+        self.assertIn('marker=%s' % templates[limit - 1].uuid, data['next'])
+
+    def test_sort_key(self):
+        templates = []
+        for id_ in range(3):
+            runbook = obj_utils.create_test_runbook(
+                self.context,
+                uuid=uuidutils.generate_uuid(),
+                name='CUSTOM_DT%s' % id_)
+            templates.append(runbook.uuid)
+        data = self.get_json('/runbooks?sort_key=uuid',
+                             headers=self.headers)
+        uuids = [n['uuid'] for n in data['runbooks']]
+        self.assertEqual(sorted(templates), uuids)
+
+    def test_sort_key_invalid(self):
+        invalid_keys_list = ['extra', 'foo', 'steps']
+        for invalid_key in invalid_keys_list:
+            path = '/runbooks?sort_key=%s' % invalid_key
+            response = self.get_json(path, expect_errors=True,
+                                     headers=self.headers)
+            self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+            self.assertEqual('application/json', response.content_type)
+            self.assertIn(invalid_key, response.json['error_message'])
+
+    def _test_sort_key_allowed(self, detail=False):
+        template_uuids = []
+        for id_ in range(3, 0, -1):
+            runbook = obj_utils.create_test_runbook(
+                self.context,
+                uuid=uuidutils.generate_uuid(),
+                name='CUSTOM_DT%s' % id_)
+            template_uuids.append(runbook.uuid)
+        template_uuids.reverse()
+        url = '/runbooks?sort_key=name&detail=%s' % str(detail)
+        data = self.get_json(url, headers=self.headers)
+        data_uuids = [p['uuid'] for p in data['runbooks']]
+        self.assertEqual(template_uuids, data_uuids)
+
+    def test_sort_key_allowed(self):
+        self._test_sort_key_allowed()
+
+    def test_detail_sort_key_allowed(self):
+        self._test_sort_key_allowed(detail=True)
+
+    def test_sensitive_data_masked(self):
+        runbook = obj_utils.get_test_runbook(self.context)
+        runbook.steps[0]['args']['password'] = 'correcthorsebatterystaple'
+        runbook.create()
+        data = self.get_json('/runbooks/%s' % runbook.uuid,
+                             headers=self.headers)
+
+        self.assertEqual("******", data['steps'][0]['args']['password'])
+
+
+@mock.patch.object(objects.Runbook, 'save', autospec=True)
+class TestPatch(BaseRunbooksAPITest):
+
+    def setUp(self):
+        super(TestPatch, self).setUp()
+        self.runbook = obj_utils.create_test_runbook(
+            self.context, name='CUSTOM_DT1')
+
+    def _test_update_ok(self, mock_save, patch):
+        response = self.patch_json('/runbooks/%s' % self.runbook.uuid,
+                                   patch, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        mock_save.assert_called_once_with(mock.ANY)
+        return response
+
+    def _test_update_bad_request(self, mock_save, patch, error_msg=None):
+        response = self.patch_json('/runbooks/%s' % self.runbook.uuid,
+                                   patch, expect_errors=True,
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertTrue(response.json['error_message'])
+        if error_msg:
+            self.assertIn(error_msg, response.json['error_message'])
+        self.assertFalse(mock_save.called)
+        return response
+
+    @mock.patch.object(notification_utils, '_emit_api_notification',
+                       autospec=True)
+    def test_update_by_id(self, mock_notify, mock_save):
+        name = 'CUSTOM_DT2'
+        patch = [{'path': '/name', 'value': name, 'op': 'add'}]
+        response = self._test_update_ok(mock_save, patch)
+        self.assertEqual(name, response.json['name'])
+
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START),
+                                      mock.call(mock.ANY, mock.ANY, 'update',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.END)])
+
+    def test_update_by_name(self, mock_save):
+        steps = [{
+            'interface': 'bios',
+            'step': 'apply_configuration',
+            'args': {'foo': 'bar'},
+            'order': 1
+        }]
+        patch = [{'path': '/steps', 'value': steps, 'op': 'replace'}]
+        response = self.patch_json('/runbooks/%s' % self.runbook.name,
+                                   patch, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        mock_save.assert_called_once_with(mock.ANY)
+        self.assertEqual(steps, response.json['steps'])
+
+    def test_update_name_standard_trait(self, mock_save):
+        name = 'HW_CPU_X86_VMX'
+        patch = [{'path': '/name', 'value': name, 'op': 'replace'}]
+        response = self._test_update_ok(mock_save, patch)
+        self.assertEqual(name, response.json['name'])
+
+    def test_update_by_id_invalid_api_version(self, mock_save):
+        name = 'CUSTOM_DT2'
+        headers = self.invalid_version_headers
+        response = self.patch_json('/runbooks/%s' % self.runbook.uuid,
+                                   [{'path': '/name',
+                                     'value': name,
+                                     'op': 'add'}],
+                                   headers=headers,
+                                   expect_errors=True)
+        self.assertEqual(http_client.METHOD_NOT_ALLOWED, response.status_int)
+        self.assertFalse(mock_save.called)
+
+    def test_update_by_name_old_api_version(self, mock_save):
+        name = 'CUSTOM_DT2'
+        response = self.patch_json('/runbooks/%s' % self.runbook.name,
+                                   [{'path': '/name',
+                                     'value': name,
+                                     'op': 'add'}],
+                                   expect_errors=True)
+        self.assertEqual(http_client.METHOD_NOT_ALLOWED, response.status_int)
+        self.assertFalse(mock_save.called)
+
+    def test_update_not_found(self, mock_save):
+        name = 'CUSTOM_DT2'
+        uuid = uuidutils.generate_uuid()
+        response = self.patch_json('/runbooks/%s' % uuid,
+                                   [{'path': '/name',
+                                     'value': name,
+                                     'op': 'add'}],
+                                   expect_errors=True,
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+        self.assertTrue(response.json['error_message'])
+        self.assertFalse(mock_save.called)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification',
+                       autospec=True)
+    def test_replace_name_already_exist(self, mock_notify, mock_save):
+        name = 'CUSTOM_DT2'
+        obj_utils.create_test_runbook(self.context,
+                                      uuid=uuidutils.generate_uuid(),
+                                      name=name)
+        mock_save.side_effect = exception.RunbookAlreadyExists(
+            uuid=self.runbook.uuid)
+        response = self.patch_json('/runbooks/%s' % self.runbook.uuid,
+                                   [{'path': '/name',
+                                     'value': name,
+                                     'op': 'replace'}],
+                                   expect_errors=True,
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.CONFLICT, response.status_code)
+        self.assertTrue(response.json['error_message'])
+        mock_save.assert_called_once_with(mock.ANY)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START),
+                                      mock.call(mock.ANY, mock.ANY, 'update',
+                                      obj_fields.NotificationLevel.ERROR,
+                                      obj_fields.NotificationStatus.ERROR)])
+
+    def test_replace_invalid_name_too_long(self, mock_save):
+        name = 'CUSTOM_' + 'X' * 249
+        patch = [{'path': '/name', 'op': 'replace', 'value': name}]
+        self._test_update_bad_request(
+            mock_save, patch, "'%s' is too long" % name)
+
+    def test_replace_invalid_name_none(self, mock_save):
+        patch = [{'path': '/name', 'op': 'replace', 'value': None}]
+        self._test_update_bad_request(
+            mock_save, patch, "None is not of type 'string'")
+
+    def test_replace_duplicate_step(self, mock_save):
+        # interface & step combination must be unique.
+        steps = [
+            {
+                'interface': 'raid',
+                'step': 'create_configuration',
+                'args': {'foo': '%d' % i},
+                'order': i,
+            }
+            for i in range(2)
+        ]
+        patch = [{'path': '/steps', 'op': 'replace', 'value': steps}]
+        self._test_update_bad_request(
+            mock_save, patch, "Duplicate deploy steps")
+
+    def test_replace_invalid_step_interface_fail(self, mock_save):
+        step = {
+            'interface': 'foo',
+            'step': 'apply_configuration',
+            'args': {'foo': 'bar'},
+            'order': 1
+        }
+        patch = [{'path': '/steps/0', 'op': 'replace', 'value': step}]
+        self._test_update_bad_request(
+            mock_save, patch, "'foo' is not one of")
+
+    def test_replace_non_existent_step_fail(self, mock_save):
+        step = {
+            'interface': 'bios',
+            'step': 'apply_configuration',
+            'args': {'foo': 'bar'},
+            'order': 1
+        }
+        patch = [{'path': '/steps/1', 'op': 'replace', 'value': step}]
+        self._test_update_bad_request(mock_save, patch)
+
+    def test_replace_empty_step_list_fail(self, mock_save):
+        patch = [{'path': '/steps', 'op': 'replace', 'value': []}]
+        try:
+            self._test_update_bad_request(
+                mock_save, patch, "[] is too short")
+        except Exception:
+            self._test_update_bad_request(
+                mock_save, patch, "[] should be non-empty")
+
+    def _test_remove_not_allowed(self, mock_save, field, error_msg=None):
+        patch = [{'path': '/%s' % field, 'op': 'remove'}]
+        self._test_update_bad_request(mock_save, patch, error_msg)
+
+    def test_remove_uuid(self, mock_save):
+        self._test_remove_not_allowed(
+            mock_save, 'uuid',
+            "Cannot patch /uuid")
+
+    def test_remove_name(self, mock_save):
+        self._test_remove_not_allowed(
+            mock_save, 'name',
+            "'name' is a required property")
+
+    def test_remove_steps(self, mock_save):
+        self._test_remove_not_allowed(
+            mock_save, 'steps',
+            "'steps' is a required property")
+
+    def test_remove_foo(self, mock_save):
+        self._test_remove_not_allowed(mock_save, 'foo')
+
+    def test_replace_step_invalid_interface(self, mock_save):
+        patch = [{'path': '/steps/0/interface', 'op': 'replace',
+                  'value': 'foo'}]
+        self._test_update_bad_request(
+            mock_save, patch, "'foo' is not one of")
+
+    def test_replace_multi(self, mock_save):
+        steps = [
+            {
+                'interface': 'raid',
+                'step': 'create_configuration%d' % i,
+                'args': {},
+                'order': 2,
+            }
+            for i in range(3)
+        ]
+        runbook = obj_utils.create_test_runbook(
+            self.context, uuid=uuidutils.generate_uuid(), name='CUSTOM_DT2',
+            steps=steps)
+
+        # mutate steps so we replace all of them
+        for step in steps:
+            step['order'] = step['order'] + 1
+
+        patch = []
+        for i, step in enumerate(steps):
+            patch.append({'path': '/steps/%s' % i,
+                          'value': step,
+                          'op': 'replace'})
+        response = self.patch_json('/runbooks/%s' % runbook.uuid,
+                                   patch, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(steps, response.json['steps'])
+        mock_save.assert_called_once_with(mock.ANY)
+
+    def test_remove_multi(self, mock_save):
+        steps = [
+            {
+                'interface': 'raid',
+                'step': 'create_configuration%d' % i,
+                'args': {},
+                'order': 2,
+            }
+            for i in range(3)
+        ]
+        runbook = obj_utils.create_test_runbook(
+            self.context, uuid=uuidutils.generate_uuid(), name='CUSTOM_DT2',
+            steps=steps)
+
+        # Removing one step from the collection
+        steps.pop(1)
+        response = self.patch_json('/runbooks/%s' % runbook.uuid,
+                                   [{'path': '/steps/1',
+                                     'op': 'remove'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(steps, response.json['steps'])
+        mock_save.assert_called_once_with(mock.ANY)
+
+    def test_remove_non_existent_property_fail(self, mock_save):
+        patch = [{'path': '/non-existent', 'op': 'remove'}]
+        self._test_update_bad_request(mock_save, patch)
+
+    def test_remove_non_existent_step_fail(self, mock_save):
+        patch = [{'path': '/steps/1', 'op': 'remove'}]
+        self._test_update_bad_request(mock_save, patch)
+
+    def test_remove_only_step_fail(self, mock_save):
+        patch = [{'path': '/steps/0', 'op': 'remove'}]
+        try:
+            self._test_update_bad_request(
+                mock_save, patch, "[] is too short")
+        except Exception:
+            self._test_update_bad_request(
+                mock_save, patch, "[] should be non-empty")
+
+    def test_remove_non_existent_step_property_fail(self, mock_save):
+        patch = [{'path': '/steps/0/non-existent', 'op': 'remove'}]
+        self._test_update_bad_request(mock_save, patch)
+
+    def test_add_root_non_existent(self, mock_save):
+        patch = [{'path': '/foo', 'value': 'bar', 'op': 'add'}]
+        self._test_update_bad_request(
+            mock_save, patch,
+            "Cannot patch /foo")
+
+    def test_add_too_high_index_step_fail(self, mock_save):
+        step = {
+            'interface': 'bios',
+            'step': 'apply_configuration',
+            'args': {'foo': 'bar'},
+            'order': 1
+        }
+        patch = [{'path': '/steps/2', 'op': 'add', 'value': step}]
+        self._test_update_bad_request(mock_save, patch)
+
+    def test_add_multi(self, mock_save):
+        steps = [
+            {
+                'interface': 'raid',
+                'step': 'create_configuration%d' % i,
+                'args': {},
+                'order': 2,
+            }
+            for i in range(3)
+        ]
+        patch = []
+        for i, step in enumerate(steps):
+            patch.append({'path': '/steps/%d' % i,
+                          'value': step,
+                          'op': 'add'})
+        response = self.patch_json('/runbooks/%s' % self.runbook.uuid,
+                                   patch, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(steps, response.json['steps'][:-1])
+        self.assertEqual(_obj_to_api_step(self.runbook.steps[0]),
+                         response.json['steps'][-1])
+        mock_save.assert_called_once_with(mock.ANY)
+
+    def test_update_project_scope(self, mock_save):
+        patch = [{'path': '/name', 'value': 'CUSTOM_NAME', 'op': 'replace'}]
+        response = self.patch_json('/runbooks/%s' % self.runbook.uuid,
+                                   patch,
+                                   headers={api_base.Version.string:
+                                            str(api_v1.max_version()),
+                                            'X-Project-Id': 'projectX'})
+
+        self.assertEqual(http_client.OK, response.status_code)
+
+    def test_update_system_scope(self, mock_save):
+        patch = [{'path': '/name', 'value': 'CUSTOM_NAME', 'op': 'replace'}]
+        response = self.patch_json('/runbooks/%s' % self.runbook.uuid,
+                                   patch,
+                                   headers={api_base.Version.string:
+                                            str(api_v1.max_version()),
+                                            'OpenStack-System-Scope': 'all',
+                                            'X-Roles': 'admin'})
+
+        self.assertEqual(http_client.OK, response.status_code)
+
+    def test_set_public_system_scope(self, mock_save):
+        patch = [{'path': '/public', 'value': True, 'op': 'replace'}]
+        response = self.patch_json('/runbooks/%s' % self.runbook.uuid,
+                                   patch,
+                                   headers={api_base.Version.string:
+                                            str(api_v1.max_version()),
+                                            'OpenStack-System-Scope': 'all',
+                                            'X-Roles': 'admin'})
+
+        self.assertTrue(response.json['public'])
+        self.assertIsNone(response.json['owner'])
+
+    def test_set_project_owned_runbook_public(self, mock_save):
+        tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE1')
+        runbook = self.post_json('/runbooks', tdict,
+                                 headers={api_base.Version.string:
+                                          str(api_v1.max_version()),
+                                          'X-Project-Id': 'projectX'})
+        self.assertEqual(http_client.CREATED, runbook.status_int)
+        self.assertEqual('projectX', runbook.json['owner'])
+
+        patch = [{'path': '/public', 'value': True, 'op': 'replace'}]
+        response = self.patch_json('/runbooks/%s' % runbook.json['uuid'],
+                                   patch,
+                                   headers={api_base.Version.string:
+                                            str(api_v1.max_version()),
+                                            'OpenStack-System-Scope': 'all',
+                                            'X-Roles': 'admin'})
+
+        self.assertTrue(response.json['public'])
+        self.assertIsNone(response.json['owner'])
+
+    def test_unset_public_system_scope(self, mock_save):
+        headers = {api_base.Version.string: str(api_v1.max_version()),
+                   'OpenStack-System-Scope': 'all',
+                   'X-Roles': 'admin'}
+
+        tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE2')
+        tdict['public'] = True
+        runbook = self.post_json('/runbooks', tdict, headers=headers)
+        self.assertEqual(http_client.CREATED, runbook.status_int)
+
+        patch = [{'path': '/public', 'value': False, 'op': 'replace'}]
+        response = self.patch_json('/runbooks/%s' % runbook.json['uuid'],
+                                   patch, headers=headers)
+        self.assertFalse(response.json['public'])
+        self.assertIsNone(response.json['owner'])
+
+    def test_set_owner_system_scope(self, mock_save):
+        headers = {api_base.Version.string: str(api_v1.max_version()),
+                   'OpenStack-System-Scope': 'all',
+                   'X-Roles': 'admin'}
+
+        tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE1')
+        runbook = self.post_json('/runbooks', tdict, headers=self.headers)
+        self.assertEqual(http_client.CREATED, runbook.status_int)
+
+        new_owner = 'projectX'
+        patch = [{'path': '/owner', 'value': new_owner, 'op': 'replace'}]
+        response = self.patch_json('/runbooks/%s' % runbook.json['uuid'],
+                                   patch, headers=headers)
+        self.assertEqual(new_owner, response.json['owner'])
+
+    def test_set_new_owner_for_project_owned_runbook(self, mock_save):
+        tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE1')
+        runbook = self.post_json('/runbooks', tdict,
+                                 headers={api_base.Version.string:
+                                          str(api_v1.max_version()),
+                                          'X-Project-Id': 'projectX'})
+        self.assertEqual(http_client.CREATED, runbook.status_int)
+        self.assertEqual('projectX', runbook.json['owner'])
+
+        headers = {api_base.Version.string: str(api_v1.max_version()),
+                   'OpenStack-System-Scope': 'all',
+                   'X-Roles': 'admin'}
+        new_owner = 'projectY'
+        patch = [{'path': '/owner', 'value': new_owner, 'op': 'replace'}]
+        response = self.patch_json('/runbooks/%s' % runbook.json['uuid'],
+                                   patch, headers=headers)
+        self.assertEqual(new_owner, response.json['owner'])
+
+    def test_set_owner_system_scope_fails_if_public(self, mock_save):
+        headers = {api_base.Version.string: str(api_v1.max_version()),
+                   'OpenStack-System-Scope': 'all',
+                   'X-Roles': 'admin'}
+
+        tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE1')
+        tdict['public'] = True
+        runbook = self.post_json('/runbooks', tdict, headers=headers)
+        self.assertEqual(http_client.CREATED, runbook.status_int)
+
+        new_owner = 'projectX'
+        patch = [{'path': '/owner', 'value': new_owner, 'op': 'replace'}]
+        response = self.patch_json('/runbooks/%s' % runbook.json['uuid'],
+                                   patch, headers=headers, expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+    def test_runbook_set_owner_public_system_scope(self, mock_save):
+        headers = {api_base.Version.string: str(api_v1.max_version()),
+                   'OpenStack-System-Scope': 'all',
+                   'X-Roles': 'admin'}
+
+        tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE1')
+        tdict['public'] = True
+        runbook = self.post_json('/runbooks', tdict, headers=headers)
+        self.assertEqual(http_client.CREATED, runbook.status_int)
+        self.assertTrue(runbook.json['public'])
+
+        new_owner = 'projectX'
+        patch = [{'path': '/owner', 'value': new_owner, 'op': 'replace'},
+                 {'path': '/public', 'value': False, 'op': 'replace'}]
+        response = self.patch_json('/runbooks/%s' % runbook.json['uuid'],
+                                   patch, headers=headers)
+
+        self.assertFalse(response.json['public'])
+        self.assertEqual(new_owner, response.json['owner'])
+
+    def test_runbook_set_owner_public_system_scope_fails(self, mock_save):
+        headers = {api_base.Version.string: str(api_v1.max_version()),
+                   'OpenStack-System-Scope': 'all',
+                   'X-Roles': 'admin'}
+
+        tdict = test_api_utils.post_get_test_runbook(name='CUSTOM_UNIQUE1')
+        tdict['public'] = True
+        runbook = self.post_json('/runbooks', tdict, headers=headers)
+        self.assertEqual(http_client.CREATED, runbook.status_int)
+        self.assertTrue(runbook.json['public'])
+
+        new_owner = 'projectX'
+        patch = [{'path': '/owner', 'value': new_owner, 'op': 'replace'},
+                 {'path': '/public', 'value': True, 'op': 'replace'}]
+        response = self.patch_json('/runbooks/%s' % runbook.json['uuid'],
+                                   patch, headers=headers, expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+
+class TestPost(BaseRunbooksAPITest):
+
+    @mock.patch.object(notification_utils, '_emit_api_notification',
+                       autospec=True)
+    @mock.patch.object(timeutils, 'utcnow', autospec=True)
+    def test_create(self, mock_utcnow, mock_notify):
+        tdict = test_api_utils.post_get_test_runbook()
+        test_time = datetime.datetime(2000, 1, 1, 0, 0)
+        mock_utcnow.return_value = test_time
+        response = self.post_json('/runbooks', tdict,
+                                  headers=self.headers)
+        self.assertEqual(http_client.CREATED, response.status_int)
+        result = self.get_json('/runbooks/%s' % tdict['uuid'],
+                               headers=self.headers)
+        self.assertEqual(tdict['uuid'], result['uuid'])
+        self.assertFalse(result['updated_at'])
+        return_created_at = timeutils.parse_isotime(
+            result['created_at']).replace(tzinfo=None)
+        self.assertEqual(test_time, return_created_at)
+        # Check location header
+        self.assertIsNotNone(response.location)
+        expected_location = '/v1/runbooks/%s' % tdict['uuid']
+        self.assertEqual(expected_location,
+                         urlparse.urlparse(response.location).path)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'create',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START),
+                                      mock.call(mock.ANY, mock.ANY, 'create',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.END)])
+
+    def test_create_invalid_api_version(self):
+        tdict = test_api_utils.post_get_test_runbook()
+        response = self.post_json(
+            '/runbooks', tdict, headers=self.invalid_version_headers,
+            expect_errors=True)
+        self.assertEqual(http_client.METHOD_NOT_ALLOWED, response.status_int)
+
+    def test_create_doesnt_contain_id(self):
+        with mock.patch.object(
+                self.dbapi, 'create_runbook',
+                wraps=self.dbapi.create_runbook) as mock_create:
+            tdict = test_api_utils.post_get_test_runbook()
+            self.post_json('/runbooks', tdict, headers=self.headers)
+            self.get_json('/runbooks/%s' % tdict['uuid'],
+                          headers=self.headers)
+            mock_create.assert_called_once_with(mock.ANY)
+            # Check that 'id' is not in first arg of positional args
+            self.assertNotIn('id', mock_create.call_args[0][0])
+
+    @mock.patch.object(notification_utils.LOG, 'exception', autospec=True)
+    @mock.patch.object(notification_utils.LOG, 'warning', autospec=True)
+    def test_create_generate_uuid(self, mock_warn, mock_except):
+        tdict = test_api_utils.post_get_test_runbook()
+        del tdict['uuid']
+        response = self.post_json('/runbooks', tdict,
+                                  headers=self.headers)
+        result = self.get_json('/runbooks/%s' % response.json['uuid'],
+                               headers=self.headers)
+        print(mock_warn.call_args)
+        self.assertTrue(uuidutils.is_uuid_like(result['uuid']))
+        self.assertFalse(mock_warn.called)
+        self.assertFalse(mock_except.called)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification',
+                       autospec=True)
+    @mock.patch.object(objects.Runbook, 'create', autospec=True)
+    def test_create_error(self, mock_create, mock_notify):
+        mock_create.side_effect = Exception()
+        tdict = test_api_utils.post_get_test_runbook()
+        self.post_json('/runbooks', tdict, headers=self.headers,
+                       expect_errors=True)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'create',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START),
+                                      mock.call(mock.ANY, mock.ANY, 'create',
+                                      obj_fields.NotificationLevel.ERROR,
+                                      obj_fields.NotificationStatus.ERROR)])
+
+    def _test_create_ok(self, tdict):
+        response = self.post_json('/runbooks', tdict,
+                                  headers=self.headers)
+        self.assertEqual(http_client.CREATED, response.status_int)
+
+    def _test_create_bad_request(self, tdict, error_msg):
+        response = self.post_json('/runbooks', tdict,
+                                  expect_errors=True, headers=self.headers)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+        self.assertEqual('application/json', response.content_type)
+        self.assertTrue(response.json['error_message'])
+        self.assertIn(error_msg, response.json['error_message'])
+
+    def test_create_long_name(self):
+        name = 'CUSTOM_' + 'X' * 248
+        tdict = test_api_utils.post_get_test_runbook(name=name)
+        self._test_create_ok(tdict)
+
+    def test_create_standard_trait_name(self):
+        name = 'HW_CPU_X86_VMX'
+        tdict = test_api_utils.post_get_test_runbook(name=name)
+        self._test_create_ok(tdict)
+
+    def test_create_name_invalid_too_long(self):
+        name = 'CUSTOM_' + 'X' * 249
+        tdict = test_api_utils.post_get_test_runbook(name=name)
+        self._test_create_bad_request(
+            tdict, "'%s' is too long" % name)
+
+    def test_create_steps_invalid_duplicate(self):
+        steps = [
+            {
+                'interface': 'raid',
+                'step': 'create_configuration',
+                'args': {'foo': '%d' % i},
+                'order': i,
+            }
+            for i in range(2)
+        ]
+        tdict = test_api_utils.post_get_test_runbook(steps=steps)
+        self._test_create_bad_request(tdict, "Duplicate deploy steps")
+
+    def _test_create_no_mandatory_field(self, field):
+        tdict = test_api_utils.post_get_test_runbook()
+        del tdict[field]
+        self._test_create_bad_request(tdict, "is a required property")
+
+    def test_create_no_mandatory_field_name(self):
+        self._test_create_no_mandatory_field('name')
+
+    def test_create_no_mandatory_field_steps(self):
+        self._test_create_no_mandatory_field('steps')
+
+    def _test_create_no_mandatory_step_field(self, field):
+        tdict = test_api_utils.post_get_test_runbook()
+        del tdict['steps'][0][field]
+        self._test_create_bad_request(tdict, "is a required property")
+
+    def test_create_no_mandatory_step_field_interface(self):
+        self._test_create_no_mandatory_step_field('interface')
+
+    def test_create_no_mandatory_step_field_step(self):
+        self._test_create_no_mandatory_step_field('step')
+
+    def test_create_no_mandatory_step_field_order(self):
+        self._test_create_no_mandatory_step_field('order')
+
+    def _test_create_invalid_field(self, field, value, error_msg):
+        tdict = test_api_utils.post_get_test_runbook()
+        tdict[field] = value
+        self._test_create_bad_request(tdict, error_msg)
+
+    def test_create_invalid_field_name(self):
+        self._test_create_invalid_field(
+            'name', 1, "1 is not of type 'string'")
+
+    def test_create_invalid_field_name_none(self):
+        self._test_create_invalid_field(
+            'name', None, "None is not of type 'string'")
+
+    def test_create_invalid_field_steps(self):
+        self._test_create_invalid_field(
+            'steps', {}, "{} is not of type 'array'")
+
+    def test_create_invalid_field_empty_steps(self):
+        try:
+            self._test_create_invalid_field(
+                'steps', [], "[] is too short")
+        except Exception:
+            self._test_create_invalid_field(
+                'steps', [], "[] should be non-empty")
+
+    def test_create_invalid_field_extra(self):
+        self._test_create_invalid_field(
+            'extra', 1, "1 is not of type 'object'")
+
+    def test_create_invalid_field_foo(self):
+        self._test_create_invalid_field(
+            'foo', 'bar',
+            "Additional properties are not allowed ('foo' was unexpected)")
+
+    def _test_create_invalid_step_field(self, field, value, error_msg=None):
+        tdict = test_api_utils.post_get_test_runbook()
+        tdict['steps'][0][field] = value
+        if error_msg is None:
+            error_msg = "Deploy runbook invalid: "
+        self._test_create_bad_request(tdict, error_msg)
+
+    def test_create_invalid_step_field_interface1(self):
+        self._test_create_invalid_step_field(
+            'interface', [3], "[3] is not of type 'string'")
+
+    def test_create_invalid_step_field_interface2(self):
+        self._test_create_invalid_step_field(
+            'interface', 'foo', "'foo' is not one of")
+
+    def test_create_invalid_step_field_step(self):
+        self._test_create_invalid_step_field(
+            'step', 1, "1 is not of type 'string'")
+
+    def test_create_invalid_step_field_args1(self):
+        self._test_create_invalid_step_field(
+            'args', 'not a dict', "'not a dict' is not of type 'object'")
+
+    def test_create_invalid_step_field_args2(self):
+        self._test_create_invalid_step_field(
+            'args', [], "[] is not of type 'object'")
+
+    def test_create_invalid_step_field_order(self):
+        self._test_create_invalid_step_field(
+            'order', 'not a number',
+            "'not a number'")  # differs between jsonschema versions
+
+    def test_create_invalid_step_field_negative_order(self):
+        self._test_create_invalid_step_field(
+            'order', -1, "-1 is less than the minimum of 0")
+
+    def test_create_invalid_step_field_foo(self):
+        self._test_create_invalid_step_field(
+            'foo', 'bar',
+            "Additional properties are not allowed ('foo' was unexpected)")
+
+    def test_create_step_string_order(self):
+        tdict = test_api_utils.post_get_test_runbook()
+        tdict['steps'][0]['order'] = '1'
+        self._test_create_ok(tdict)
+
+    def test_create_complex_step_args(self):
+        tdict = test_api_utils.post_get_test_runbook()
+        tdict['steps'][0]['args'] = {'foo': [{'bar': 'baz'}]}
+        self._test_create_ok(tdict)
+
+    def test_create_runbook_system_scope(self):
+        tdict = test_api_utils.post_get_test_runbook()
+        response = self.post_json('/runbooks', tdict,
+                                  headers={api_base.Version.string:
+                                           str(api_v1.max_version()),
+                                           'OpenStack-System-Scope': 'all',
+                                           'X-Roles': 'admin'})
+        self.assertEqual(http_client.CREATED, response.status_int)
+        result = self.get_json('/runbooks/%s' % tdict['uuid'],
+                               headers={api_base.Version.string:
+                                        str(api_v1.max_version())})
+        self.assertIsNone(result['owner'])
+        self.assertFalse(result['public'])
+
+    def test_create_runbook_owner_system_scope(self):
+        ndict = test_api_utils.post_get_test_runbook(owner='catsay')
+        response = self.post_json('/runbooks', ndict,
+                                  headers={api_base.Version.string:
+                                           str(api_v1.max_version()),
+                                           'OpenStack-System-Scope': 'all',
+                                           'X-Roles': 'admin'})
+        self.assertEqual(http_client.CREATED, response.status_int)
+        result = self.get_json('/runbooks/%s' % ndict['uuid'],
+                               headers={api_base.Version.string:
+                                        str(api_v1.max_version())})
+        self.assertEqual('catsay', result['owner'])
+
+    def test_create_runbook_project_scope(self):
+        tdict = test_api_utils.post_get_test_runbook()
+        response = self.post_json('/runbooks', tdict,
+                                  headers={api_base.Version.string:
+                                           str(api_v1.max_version()),
+                                           'X-Project-Id': 'projectX'})
+        self.assertEqual(http_client.CREATED, response.status_int)
+        result = self.get_json('/runbooks/%s' % tdict['uuid'],
+                               headers={api_base.Version.string:
+                                        str(api_v1.max_version())})
+        self.assertEqual(result['owner'], 'projectX')
+        self.assertFalse(result['public'])
+
+    def test_create_runbook_owner_project_scope_fails(self):
+        """In project scope, owner has to match the requester's project."""
+        ndict = test_api_utils.post_get_test_runbook(owner='catsay')
+        response = self.post_json('/runbooks', ndict,
+                                  headers={api_base.Version.string:
+                                           str(api_v1.max_version()),
+                                           'X-Project-Id': 'projectX'},
+                                  expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+    def test_create_public_runbook_project_scope_fails(self):
+        """A runbook cannot be public in project scope."""
+        ndict = test_api_utils.post_get_test_runbook(owner='catsay',
+                                                     public=True)
+        response = self.post_json('/runbooks', ndict,
+                                  headers={api_base.Version.string:
+                                           str(api_v1.max_version()),
+                                           'X-Project-Id': 'projectX'},
+                                  expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+
+@mock.patch.object(objects.Runbook, 'destroy', autospec=True)
+class TestDelete(BaseRunbooksAPITest):
+
+    def setUp(self):
+        super(TestDelete, self).setUp()
+        self.runbook = obj_utils.create_test_runbook(self.context)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification',
+                       autospec=True)
+    def test_delete_by_uuid(self, mock_notify, mock_destroy):
+        self.delete('/runbooks/%s' % self.runbook.uuid,
+                    headers=self.headers)
+        mock_destroy.assert_called_once_with(mock.ANY)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START),
+                                      mock.call(mock.ANY, mock.ANY, 'delete',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.END)])
+
+    def test_delete_by_name(self, mock_destroy):
+        self.delete('/runbooks/%s' % self.runbook.name,
+                    headers=self.headers)
+        mock_destroy.assert_called_once_with(mock.ANY)
+
+    def test_delete_invalid_api_version(self, mock_dpt):
+        response = self.delete('/runbooks/%s' % self.runbook.uuid,
+                               expect_errors=True,
+                               headers=self.invalid_version_headers)
+        self.assertEqual(http_client.METHOD_NOT_ALLOWED, response.status_int)
+
+    def test_delete_old_api_version(self, mock_dpt):
+        # Names like CUSTOM_1 were not valid in API 1.1, but the check should
+        # go after the microversion check.
+        response = self.delete('/runbooks/%s' % self.runbook.name,
+                               expect_errors=True)
+        self.assertEqual(http_client.METHOD_NOT_ALLOWED, response.status_int)
+
+    def test_delete_by_name_non_existent(self, mock_dpt):
+        res = self.delete('/runbooks/%s' % 'blah', expect_errors=True,
+                          headers=self.headers)
+        self.assertEqual(http_client.NOT_FOUND, res.status_code)
diff --git a/ironic/tests/unit/api/test_acl.py b/ironic/tests/unit/api/test_acl.py
index 3c85f3d0ea..d4e7e9669f 100644
--- a/ironic/tests/unit/api/test_acl.py
+++ b/ironic/tests/unit/api/test_acl.py
@@ -298,6 +298,7 @@ class TestRBACModelBeforeScopesBase(TestACLBase):
         # false positives with test runners.
         db_utils.create_test_node(
             uuid='18a552fb-dcd2-43bf-9302-e4c93287be11')
+        fake_db_runbook = db_utils.create_test_runbook()
         self.format_data.update({
             'node_ident': fake_db_node['uuid'],
             'allocated_node_ident': fake_db_node_alloced['uuid'],
@@ -314,6 +315,7 @@ class TestRBACModelBeforeScopesBase(TestACLBase):
             'driver_name': 'fake-driverz',
             'bios_setting': fake_setting,
             'trait': fake_trait,
+            'runbook_ident': fake_db_runbook['uuid'],
             'volume_target_ident': fake_db_volume_target['uuid'],
             'volume_connector_ident': fake_db_volume_connector['uuid'],
             'history_ident': fake_history['uuid'],
@@ -391,6 +393,9 @@ class TestRBACProjectScoped(TestACLBase):
         lessee_project_id = 'f11853c7-fa9c-4db3-a477-c9d8e0dbbf13'
         unowned_node = db_utils.create_test_node(chassis_id=None)
 
+        fake_db_runbook = db_utils.create_test_runbook(
+            owner='70e5e25a-2ca2-4cb1-8ae8-7d8739cee205')
+
         # owned node - since the tests use the same node for
         # owner/lesse checks
         owned_node = db_utils.create_test_node(
@@ -496,6 +501,7 @@ class TestRBACProjectScoped(TestACLBase):
             'vif_ident': fake_vif_port_id,
             'ind_component': 'component',
             'ind_ident': 'magic_light',
+            'runbook_ident': fake_db_runbook['uuid'],
             'owner_port_ident': owned_node_port['uuid'],
             'other_port_ident': other_port['uuid'],
             'owner_portgroup_ident': owner_pgroup['uuid'],
diff --git a/ironic/tests/unit/api/test_rbac_project_scoped.yaml b/ironic/tests/unit/api/test_rbac_project_scoped.yaml
index a675119677..83185f0936 100644
--- a/ironic/tests/unit/api/test_rbac_project_scoped.yaml
+++ b/ironic/tests/unit/api/test_rbac_project_scoped.yaml
@@ -3978,3 +3978,314 @@ service_cannot_get_firmware_components:
   method: get
   headers: *service_headers
   assert_status: 404
+
+# Runbooks - https://docs.openstack.org/api-ref/baremetal/#runbooks-templates
+
+runbooks_post_admin:
+  path: '/v1/runbooks'
+  method: post
+  body: &runbook_body
+    name: 'CUSTOM_NAME'
+    steps:
+      - interface: 'raid'
+        step: 'noop'
+        args: {}
+        order: 0
+  headers: *owner_admin_headers
+  assert_status: 201
+
+runbooks_post_manager:
+  path: '/v1/runbooks'
+  method: post
+  body: *runbook_body
+  headers: *owner_manager_headers
+  assert_status: 201
+
+service_post_runbook:
+  path: '/v1/runbooks'
+  method: post
+  body: *runbook_body
+  headers: *service_headers_owner_project
+  assert_status: 201
+
+third_party_admin_post_runbook:
+  path: '/v1/runbooks'
+  method: post
+  body: *runbook_body
+  headers: *third_party_admin_headers
+  assert_status: 201
+
+runbooks_post_public_admin:
+  path: '/v1/runbooks'
+  method: post
+  body: &runbook_body_public
+    name: 'CUSTOM_NAME'
+    public: true
+    steps:
+      - interface: 'raid'
+        step: 'noop'
+        args: {}
+        order: 0
+  headers: *owner_admin_headers
+  assert_status: 400
+
+runbooks_post_public_admin:
+  path: '/v1/runbooks'
+  method: post
+  body: *runbook_body_public
+  headers: *owner_manager_headers
+  assert_status: 400
+
+runbooks_post_public_service:
+  path: '/v1/runbooks'
+  method: post
+  body: *runbook_body_public
+  headers: *owner_admin_headers
+  assert_status: 400
+
+runbooks_patch_admin:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: &runbook_patch
+    - op: replace
+      path: /name
+      value: 'CUSTOM_NAME'
+  headers: *owner_admin_headers
+  assert_status: 200
+
+runbooks_patch_manager:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_patch
+  headers: *owner_manager_headers
+  assert_status: 200
+
+service_patch_runbook:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_patch
+  headers: *service_headers_owner_project
+  assert_status: 200
+
+project_admin_delete_runbook:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: delete
+  headers: *owner_admin_headers
+  assert_status: 204
+
+project_manager_delete_runbook:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: delete
+  headers: *owner_manager_headers
+  assert_status: 204
+
+service_get_runbooks:
+  path: '/v1/runbooks'
+  method: get
+  headers: *service_headers_owner_project
+  assert_status: 200
+
+service_patch_runbook:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_patch
+  headers: *service_headers_owner_project
+  assert_status: 200
+
+runbooks_project_admin:
+  path: '/v1/runbooks'
+  method: get
+  headers: *owner_admin_headers
+  assert_status: 200
+
+runbooks_runbook_id_get_project_admin:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: get
+  headers: *owner_admin_headers
+  assert_status: 200
+
+project_admin_patch_runbook:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_patch
+  headers: *owner_admin_headers
+  assert_status: 200
+
+runbooks_project_manager:
+  path: '/v1/runbooks'
+  method: get
+  headers: *owner_manager_headers
+  assert_status: 200
+
+runbooks_runbook_id_get_project_manager:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: get
+  headers: *owner_manager_headers
+  assert_status: 200
+
+project_manager_patch_runbook:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_patch
+  headers: *owner_manager_headers
+  assert_status: 200
+
+runbooks_project_member:
+  path: '/v1/runbooks'
+  method: get
+  headers: *owner_member_headers
+  assert_status: 200
+
+runbooks_runbook_id_get_project_member:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: get
+  headers: *owner_member_headers
+  assert_status: 200
+
+runbooks_list_project_reader:
+  path: '/v1/runbooks'
+  method: get
+  headers: *owner_reader_headers
+  assert_status: 200
+
+runbooks_runbook_id_get_project_reader:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: get
+  headers: *owner_reader_headers
+  assert_status: 200
+
+runbooks_list_third_party_admin:
+  path: '/v1/runbooks'
+  method: get
+  headers: *third_party_admin_headers
+  assert_status: 200
+
+project_reader_cannot_post_runbook:
+  path: '/v1/runbooks'
+  method: post
+  body: *runbook_body
+  headers: *owner_reader_headers
+  assert_status: 403
+
+project_reader_cannot_patch_runbook:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_patch
+  headers: *owner_reader_headers
+  assert_status: 403
+
+project_reader_cannot_set_runbook_owner:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: &runbook_owner_patch
+    - op: replace
+      path: /owner
+      value: 'new_owner'
+  headers: *owner_reader_headers
+  assert_status: 403
+
+project_reader_cannot_set_runbook_public:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: &runbook_public_patch
+    - op: replace
+      path: /public
+      value: true
+  headers: *owner_reader_headers
+  assert_status: 403
+
+project_reader_cannot_delete_runbook:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: delete
+  headers: *owner_reader_headers
+  assert_status: 403
+
+project_member_cannot_post_runbook:
+  path: '/v1/runbooks'
+  method: post
+  body: *runbook_body
+  headers: *owner_member_headers
+  assert_status: 403
+
+project_member_cannot_patch_runbook:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_patch
+  headers: *owner_member_headers
+  assert_status: 403
+
+project_member_cannot_set_runbook_owner:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_owner_patch
+  headers: *owner_member_headers
+  assert_status: 403
+
+project_member_cannot_set_runbook_public:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_public_patch
+  headers: *owner_member_headers
+  assert_status: 403
+
+project_member_cannot_delete_runbook:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: delete
+  headers: *owner_member_headers
+  assert_status: 403
+
+project_manager_cannot_set_runbook_owner:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_owner_patch
+  headers: *owner_manager_headers
+  assert_status: 403
+
+project_manager_cannot_set_runbook_public:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_public_patch
+  headers: *owner_manager_headers
+  assert_status: 403
+
+project_admin_cannot_set_runbook_owner:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_owner_patch
+  headers: *owner_admin_headers
+  assert_status: 403
+
+project_admin_cannot_set_runbook_public:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_public_patch
+  headers: *owner_admin_headers
+  assert_status: 403
+
+service_cannot_patch_runbook_owner:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_owner_patch
+  headers: *service_headers_owner_project
+  assert_status: 403
+
+service_cannot_patch_runbook_public:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_public_patch
+  headers: *service_headers_owner_project
+  assert_status: 403
+
+third_party_admin_cannot_patch_runbook_owner:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_owner_patch
+  headers: *third_party_admin_headers
+  assert_status: 403
+
+third_party_admin_cannot_patch_runbook_public:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_public_patch
+  headers: *third_party_admin_headers
+  assert_status: 403
diff --git a/ironic/tests/unit/api/test_rbac_system_scoped.yaml b/ironic/tests/unit/api/test_rbac_system_scoped.yaml
index 55465602dd..c8224c94e9 100644
--- a/ironic/tests/unit/api/test_rbac_system_scoped.yaml
+++ b/ironic/tests/unit/api/test_rbac_system_scoped.yaml
@@ -1,5 +1,10 @@
 values:
   skip_reason: "These are fake reference values for YAML templating"
+  # Project scoped admin token
+  project_admin_headers: &project_admin_headers
+    X-Auth-Token: 'owner-admin-token'
+    X-Roles: admin,manager,member,reader
+    X-Project-Id: 70e5e25a-2ca2-4cb1-8ae8-7d8739cee205
   # System scoped admin token
   admin_headers: &admin_headers
     X-Auth-Token: 'baremetal-admin-token'
@@ -2584,3 +2589,186 @@ nodes_firmware_component_get_reader:
   method: get
   headers: *reader_headers
   assert_status: 200
+
+# Runbooks - https://docs.openstack.org/api-ref/baremetal/#runbooks-templates
+
+runbooks_post_admin:
+  path: '/v1/runbooks'
+  method: post
+  body: &runbook_body
+    name: 'CUSTOM_NAME'
+    steps:
+      - interface: 'raid'
+        step: 'noop'
+        args: {}
+        order: 0
+  headers: *admin_headers
+  assert_status: 201
+
+runbooks_post_member:
+  path: '/v1/runbooks'
+  method: post
+  body: *runbook_body
+  headers: *scoped_member_headers
+  assert_status: 201
+
+runbooks_post_reader:
+  path: '/v1/runbooks'
+  method: post
+  body: *runbook_body
+  headers: *reader_headers
+  assert_status: 403
+
+runbooks_get_admin:
+  path: '/v1/runbooks'
+  method: get
+  headers: *admin_headers
+  assert_status: 200
+
+runbooks_get_member:
+  path: '/v1/runbooks'
+  method: get
+  headers: *scoped_member_headers
+  assert_status: 200
+
+runbooks_get_reader:
+  path: '/v1/runbooks'
+  method: get
+  headers: *reader_headers
+  assert_status: 200
+
+runbooks_runbook_id_get_admin:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: get
+  headers: *admin_headers
+  assert_status: 200
+
+runbooks_runbook_id_get_member:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: get
+  headers: *scoped_member_headers
+  assert_status: 200
+
+runbooks_runbook_id_get_reader:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: get
+  headers: *reader_headers
+  assert_status: 200
+
+runbooks_runbook_id_patch_admin:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: &runbook_name_patch
+    - op: replace
+      path: /name
+      value: 'CUSTOM_NAME'
+  headers: *admin_headers
+  assert_status: 200
+
+runbooks_runbook_id_patch_member:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_name_patch
+  headers: *scoped_member_headers
+  assert_status: 200
+
+runbooks_runbook_id_patch_reader:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_name_patch
+  headers: *reader_headers
+  assert_status: 403
+
+runbooks_runbook_id_patch_public_admin:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: &runbook_public_patch
+    - op: replace
+      path: /public
+      value: true
+  headers: *admin_headers
+  assert_status: 200
+
+runbooks_runbook_id_patch_public_member:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_public_patch
+  headers: *scoped_member_headers
+  assert_status: 200
+
+runbooks_runbook_id_patch_public_reader:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_public_patch
+  headers: *reader_headers
+  assert_status: 403
+
+runbooks_runbook_id_patch_owner_admin:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: &runbook_owner_patch
+    - op: replace
+      path: /owner
+      value: 'new_owner'
+  headers: *admin_headers
+  assert_status: 200
+
+runbooks_runbook_id_patch_owner_member:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_owner_patch
+  headers: *scoped_member_headers
+  assert_status: 200
+
+runbooks_runbook_id_patch_owner_reader:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_owner_patch
+  headers: *reader_headers
+  assert_status: 403
+
+runbooks_runbook_id_delete_admin:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: delete
+  headers: *admin_headers
+  assert_status: 204
+
+runbooks_runbook_id_delete_member:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: delete
+  headers: *scoped_member_headers
+  assert_status: 204
+
+runbooks_runbook_id_delete_reader:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: delete
+  headers: *reader_headers
+  assert_status: 403
+
+runbooks_post_project_admin:
+  path: '/v1/runbooks'
+  method: post
+  body: *runbook_body
+  headers: *project_admin_headers
+  assert_status: 201
+
+runbooks_runbook_id_patch_public_admin:
+  path: '/v1/runbooks/{runbook_ident}'
+  method: patch
+  body: *runbook_public_patch
+  headers: *admin_headers
+  assert_status: 200
+
+public_runbooks_post_admin:
+  path: '/v1/runbooks'
+  method: post
+  body: &runbook_body_public
+    name: 'CUSTOM_NAME'
+    public: true
+    steps:
+      - interface: 'raid'
+        step: 'noop'
+        args: {}
+        order: 0
+  headers: *admin_headers
+  assert_status: 201
diff --git a/ironic/tests/unit/api/utils.py b/ironic/tests/unit/api/utils.py
index 822325cfc4..f26905d090 100644
--- a/ironic/tests/unit/api/utils.py
+++ b/ironic/tests/unit/api/utils.py
@@ -27,6 +27,7 @@ from ironic.api.controllers.v1 import deploy_template as dt_controller
 from ironic.api.controllers.v1 import node as node_controller
 from ironic.api.controllers.v1 import port as port_controller
 from ironic.api.controllers.v1 import portgroup as portgroup_controller
+from ironic.api.controllers.v1 import runbook as rb_controller
 from ironic.api.controllers.v1 import utils as api_utils
 from ironic.api.controllers.v1 import volume_connector as vc_controller
 from ironic.api.controllers.v1 import volume_target as vt_controller
@@ -201,6 +202,25 @@ def deploy_template_post_data(**kw):
         template, dt_controller.TEMPLATE_SCHEMA['properties'])
 
 
+def runbook_post_data(**kw):
+    """Return a Runbook object without internal attributes."""
+    runbook = db_utils.get_test_runbook(**kw)
+    # These values are not part of the API object
+    runbook.pop('version')
+    # Remove internal attributes from each step.
+    step_internal = api_utils.RUNBOOK_STEP_SCHEMA['properties']
+    runbook['steps'] = [remove_other_fields(step, step_internal)
+                        for step in runbook['steps']]
+    # Remove internal attributes from the runbook.
+    return remove_other_fields(
+        runbook, rb_controller.RUNBOOK_SCHEMA['properties'])
+
+
 def post_get_test_deploy_template(**kw):
     """Return a DeployTemplate object with appropriate attributes."""
     return deploy_template_post_data(**kw)
+
+
+def post_get_test_runbook(**kw):
+    """Return a Runbook object with appropriate attributes."""
+    return runbook_post_data(**kw)
diff --git a/ironic/tests/unit/common/test_release_mappings.py b/ironic/tests/unit/common/test_release_mappings.py
index e6447eda0d..abb41d6567 100644
--- a/ironic/tests/unit/common/test_release_mappings.py
+++ b/ironic/tests/unit/common/test_release_mappings.py
@@ -101,7 +101,7 @@ class ReleaseMappingsTestCase(base.TestCase):
         # NodeBase is also excluded as it is covered by Node.
         exceptions = set(['NodeTag', 'ConductorHardwareInterfaces',
                           'NodeTrait', 'DeployTemplateStep',
-                          'NodeBase'])
+                          'NodeBase', 'RunbookStep'])
         model_names -= exceptions
         # NodeTrait maps to two objects
         model_names |= set(['Trait', 'TraitList'])
diff --git a/ironic/tests/unit/db/sqlalchemy/test_migrations.py b/ironic/tests/unit/db/sqlalchemy/test_migrations.py
index 416c7a5ba9..7b6b55aa4c 100644
--- a/ironic/tests/unit/db/sqlalchemy/test_migrations.py
+++ b/ironic/tests/unit/db/sqlalchemy/test_migrations.py
@@ -1169,6 +1169,152 @@ class MigrationCheckersMixin(object):
         self.assertIsInstance(deploy_templates.c.extra.type,
                               sqlalchemy.types.TEXT)
 
+    def _check_dada631878c4(self, engine, data):
+        # Runbooks.
+        runbooks = db_utils.get_table(engine, 'runbooks')
+        col_names = [column.name for column in runbooks.c]
+        expected = ['created_at', 'updated_at', 'version',
+                    'id', 'uuid', 'name']
+        self.assertEqual(sorted(expected), sorted(col_names))
+        self.assertIsInstance(runbooks.c.created_at.type,
+                              sqlalchemy.types.DateTime)
+        self.assertIsInstance(runbooks.c.updated_at.type,
+                              sqlalchemy.types.DateTime)
+        self.assertIsInstance(runbooks.c.version.type,
+                              sqlalchemy.types.String)
+        self.assertIsInstance(runbooks.c.id.type,
+                              sqlalchemy.types.Integer)
+        self.assertIsInstance(runbooks.c.uuid.type,
+                              sqlalchemy.types.String)
+        self.assertIsInstance(runbooks.c.name.type,
+                              sqlalchemy.types.String)
+
+        # Runbook steps.
+        runbook_steps = db_utils.get_table(engine, 'runbook_steps')
+        col_names = [column.name for column in runbook_steps.c]
+        expected = ['created_at', 'updated_at', 'version', 'id',
+                    'runbook_id', 'interface', 'step', 'args',
+                    'order']
+        self.assertEqual(sorted(expected), sorted(col_names))
+
+        self.assertIsInstance(runbook_steps.c.created_at.type,
+                              sqlalchemy.types.DateTime)
+        self.assertIsInstance(runbook_steps.c.updated_at.type,
+                              sqlalchemy.types.DateTime)
+        self.assertIsInstance(runbook_steps.c.version.type,
+                              sqlalchemy.types.String)
+        self.assertIsInstance(runbook_steps.c.id.type,
+                              sqlalchemy.types.Integer)
+        self.assertIsInstance(runbook_steps.c.runbook_id.type,
+                              sqlalchemy.types.Integer)
+        self.assertIsInstance(runbook_steps.c.interface.type,
+                              sqlalchemy.types.String)
+        self.assertIsInstance(runbook_steps.c.step.type,
+                              sqlalchemy.types.String)
+        self.assertIsInstance(runbook_steps.c.args.type,
+                              sqlalchemy.types.Text)
+        self.assertIsInstance(runbook_steps.c.order.type,
+                              sqlalchemy.types.Integer)
+
+        with engine.begin() as connection:
+            # Insert a Runbook.
+            uuid = uuidutils.generate_uuid()
+            name = 'CUSTOM_DT1'
+            runbook = {'name': name, 'uuid': uuid}
+            insert_dpt = runbooks.insert().values(runbook)
+            connection.execute(insert_dpt)
+            # Query by UUID.
+            dpt_uuid_stmt = sqlalchemy.select(
+                models.Runbook.id,
+                models.Runbook.name,
+            ).where(
+                models.Runbook.uuid == uuid
+            )
+            result = connection.execute(dpt_uuid_stmt).first()
+            runbook_id = result.id
+            self.assertEqual(name, result.name)
+            # Query by name.
+            dpt_name_stmt = sqlalchemy.select(
+                models.Runbook.id
+            ).where(
+                models.Runbook.name == name
+            )
+            result = connection.execute(dpt_name_stmt).first()
+            self.assertEqual(runbook_id, result.id)
+            # Query by ID.
+            dpt_id_stmt = sqlalchemy.select(
+                models.Runbook.uuid,
+                models.Runbook.name
+            ).where(
+                models.Runbook.id == runbook_id
+            )
+            result = connection.execute(dpt_id_stmt).first()
+            self.assertEqual(uuid, result.uuid)
+            self.assertEqual(name, result.name)
+            savepoint_uuid = connection.begin_nested()
+            # UUID is unique.
+            runbook = {'name': 'CUSTOM_DT2', 'uuid': uuid}
+            self.assertRaises(db_exc.DBDuplicateEntry, connection.execute,
+                              runbooks.insert(), runbook)
+            savepoint_uuid.rollback()
+            savepoint_uuid.close()
+            # Name is unique.
+            savepoint_name = connection.begin_nested()
+            runbook = {'name': name, 'uuid': uuidutils.generate_uuid()}
+            self.assertRaises(db_exc.DBDuplicateEntry, connection.execute,
+                              runbooks.insert(), runbook)
+            savepoint_name.rollback()
+            savepoint_name.close()
+
+            # Insert a Runbook step.
+            interface = 'raid'
+            step_name = 'create_configuration'
+            # The line below is JSON.
+            args = '{"logical_disks": []}'
+            order = 1
+            step = {'runbook_id': runbook_id, 'interface': interface,
+                    'step': step_name, 'args': args, 'order': order}
+            insert_dpts = runbook_steps.insert().values(step)
+            connection.execute(insert_dpts)
+            # Query by Runbook ID.
+            query_id_stmt = sqlalchemy.select(
+                models.RunbookStep.runbook_id,
+                models.RunbookStep.interface,
+                models.RunbookStep.step,
+                models.RunbookStep.args,
+                models.RunbookStep.order,
+            ).where(
+                models.RunbookStep.runbook_id == runbook_id
+            )
+            result = connection.execute(query_id_stmt).first()
+            self.assertEqual(runbook_id, result.runbook_id)
+            self.assertEqual(interface, result.interface)
+            self.assertEqual(step_name, result.step)
+            if isinstance(result.args, dict):
+                # Postgres testing results in a dict being returned
+                # at this level which if you str() it, you get a dict,
+                # so comparing string to string fails.
+                result_args = json.dumps(result.args)
+            else:
+                # Mysql/MariaDB appears to be actually hand us
+                # a string back so we should be able to compare it.
+                result_args = result.args
+            self.assertEqual(args, result_args)
+            self.assertEqual(order, result.order)
+            # Insert another step for the same runbook.
+            insert_step = runbook_steps.insert().values(step)
+            connection.execute(insert_step)
+
+    def _check_245c3e54b247(self, engine, data):
+        # Runbook 'extra' field.
+        runbooks = db_utils.get_table(engine, 'runbooks')
+        col_names = [column.name for column in runbooks.c]
+        expected = ['created_at', 'updated_at', 'version',
+                    'id', 'uuid', 'name', 'extra']
+        self.assertEqual(sorted(expected), sorted(col_names))
+        self.assertIsInstance(runbooks.c.extra.type,
+                              sqlalchemy.types.TEXT)
+
     def _check_ce6c4b3cf5a2(self, engine, data):
         allocations = db_utils.get_table(engine, 'allocations')
         col_names = [column.name for column in allocations.c]
diff --git a/ironic/tests/unit/db/test_runbooks.py b/ironic/tests/unit/db/test_runbooks.py
new file mode 100644
index 0000000000..ffabac7f7d
--- /dev/null
+++ b/ironic/tests/unit/db/test_runbooks.py
@@ -0,0 +1,207 @@
+#    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.
+
+"""Tests for manipulating Runbooks via the DB API"""
+
+from oslo_db import exception as db_exc
+from oslo_utils import uuidutils
+
+from ironic.common import exception
+from ironic.tests.unit.db import base
+from ironic.tests.unit.db import utils as db_utils
+
+
+class DbRunbookTestCase(base.DbTestCase):
+
+    def setUp(self):
+        super(DbRunbookTestCase, self).setUp()
+        self.runbook = db_utils.create_test_runbook()
+
+    def test_create(self):
+        self.assertEqual('CUSTOM_DT1', self.runbook.name)
+        self.assertEqual(1, len(self.runbook.steps))
+        step = self.runbook.steps[0]
+        self.assertEqual(self.runbook.id, step.runbook_id)
+        self.assertEqual('raid', step.interface)
+        self.assertEqual('create_configuration', step.step)
+        self.assertEqual({'logical_disks': []}, step.args)
+        self.assertEqual({}, self.runbook.extra)
+
+    def test_create_no_steps(self):
+        uuid = uuidutils.generate_uuid()
+        runbook = db_utils.create_test_runbook(
+            uuid=uuid, name='CUSTOM_DT2', steps=[])
+        self.assertEqual([], runbook.steps)
+
+    def test_create_duplicate_uuid(self):
+        self.assertRaises(exception.RunbookAlreadyExists,
+                          db_utils.create_test_runbook,
+                          uuid=self.runbook.uuid, name='CUSTOM_DT2')
+
+    def test_create_duplicate_name(self):
+        uuid = uuidutils.generate_uuid()
+        self.assertRaises(exception.RunbookDuplicateName,
+                          db_utils.create_test_runbook,
+                          uuid=uuid, name=self.runbook.name)
+
+    def test_create_invalid_step_no_interface(self):
+        uuid = uuidutils.generate_uuid()
+        runbook = db_utils.get_test_runbook(uuid=uuid,
+                                            name='CUSTOM_DT2')
+        del runbook['steps'][0]['interface']
+        self.assertRaises(db_exc.DBError,
+                          self.dbapi.create_runbook,
+                          runbook)
+
+    def test_update_name(self):
+        values = {'name': 'CUSTOM_DT2'}
+        runbook = self.dbapi.update_runbook(self.runbook.id, values)
+        self.assertEqual('CUSTOM_DT2', runbook.name)
+
+    def test_update_steps_replace(self):
+        step = {'interface': 'bios', 'step': 'apply_configuration',
+                'args': {}, 'order': 1}
+        values = {'steps': [step]}
+        runbook = self.dbapi.update_runbook(self.runbook.id, values)
+        self.assertEqual(1, len(runbook.steps))
+        step = runbook.steps[0]
+        self.assertEqual('bios', step.interface)
+        self.assertEqual('apply_configuration', step.step)
+        self.assertEqual({}, step.args)
+        self.assertEqual(1, step.order)
+
+    def test_update_steps_add(self):
+        step = {'interface': 'bios', 'step': 'apply_configuration',
+                'args': {}, 'order': 1}
+        values = {'steps': [self.runbook.steps[0], step]}
+        runbook = self.dbapi.update_runbook(self.runbook.id, values)
+        self.assertEqual(2, len(runbook.steps))
+        step0 = runbook.steps[0]
+        self.assertEqual(self.runbook.steps[0].id, step0.id)
+        self.assertEqual('raid', step0.interface)
+        self.assertEqual('create_configuration', step0.step)
+        self.assertEqual({'logical_disks': []}, step0.args)
+        step1 = runbook.steps[1]
+        self.assertNotEqual(self.runbook.steps[0].id, step1.id)
+        self.assertEqual('bios', step1.interface)
+        self.assertEqual('apply_configuration', step1.step)
+        self.assertEqual({}, step1.args)
+        self.assertEqual(1, step1.order)
+
+    def test_update_steps_replace_args(self):
+        step = self.runbook.steps[0]
+        step['args'] = {'foo': 'bar'}
+        values = {'steps': [step]}
+        runbook = self.dbapi.update_runbook(self.runbook.id, values)
+        self.assertEqual(1, len(runbook.steps))
+        step = runbook.steps[0]
+        self.assertEqual({'foo': 'bar'}, step.args)
+
+    def test_update_steps_remove_all(self):
+        values = {'steps': []}
+        runbook = self.dbapi.update_runbook(self.runbook.id, values)
+        self.assertEqual([], runbook.steps)
+
+    def test_update_extra(self):
+        values = {'extra': {'foo': 'bar'}}
+        runbook = self.dbapi.update_runbook(self.runbook.id, values)
+        self.assertEqual({'foo': 'bar'}, runbook.extra)
+
+    def test_update_duplicate_name(self):
+        uuid = uuidutils.generate_uuid()
+        runbook2 = db_utils.create_test_runbook(uuid=uuid,
+                                                name='CUSTOM_DT2')
+        values = {'name': self.runbook.name}
+        self.assertRaises(exception.RunbookDuplicateName,
+                          self.dbapi.update_runbook, runbook2.id,
+                          values)
+
+    def test_update_not_found(self):
+        self.assertRaises(exception.RunbookNotFound,
+                          self.dbapi.update_runbook, 123, {})
+
+    def test_update_uuid_not_allowed(self):
+        uuid = uuidutils.generate_uuid()
+        self.assertRaises(exception.InvalidParameterValue,
+                          self.dbapi.update_runbook,
+                          self.runbook.id, {'uuid': uuid})
+
+    def test_destroy(self):
+        self.dbapi.destroy_runbook(self.runbook.id)
+        # Attempt to retrieve the runbook to verify it is gone.
+        self.assertRaises(exception.RunbookNotFound,
+                          self.dbapi.get_runbook_by_id,
+                          self.runbook.id)
+        # Ensure that the destroy_runbook returns the
+        # expected exception.
+        self.assertRaises(exception.RunbookNotFound,
+                          self.dbapi.destroy_runbook,
+                          self.runbook.id)
+
+    def test_get_runbook_by_id(self):
+        res = self.dbapi.get_runbook_by_id(self.runbook.id)
+        self.assertEqual(self.runbook.id, res.id)
+        self.assertEqual(self.runbook.name, res.name)
+        self.assertEqual(1, len(res.steps))
+        self.assertEqual(self.runbook.id, res.steps[0].runbook_id)
+        self.assertRaises(exception.RunbookNotFound,
+                          self.dbapi.get_runbook_by_id, -1)
+
+    def test_get_runbook_by_uuid(self):
+        res = self.dbapi.get_runbook_by_uuid(self.runbook.uuid)
+        self.assertEqual(self.runbook.id, res.id)
+        invalid_uuid = uuidutils.generate_uuid()
+        self.assertRaises(exception.RunbookNotFound,
+                          self.dbapi.get_runbook_by_uuid, invalid_uuid)
+
+    def test_get_runbook_by_name(self):
+        res = self.dbapi.get_runbook_by_name(self.runbook.name)
+        self.assertEqual(self.runbook.id, res.id)
+        self.assertRaises(exception.RunbookNotFound,
+                          self.dbapi.get_runbook_by_name, 'bogus')
+
+    def _runbook_list_preparation(self):
+        uuids = [str(self.runbook.uuid)]
+        for i in range(1, 3):
+            runbook = db_utils.create_test_runbook(
+                uuid=uuidutils.generate_uuid(),
+                name='CUSTOM_DT%d' % (i + 1))
+            uuids.append(str(runbook.uuid))
+        return uuids
+
+    def test_get_runbook_list(self):
+        uuids = self._runbook_list_preparation()
+        res = self.dbapi.get_runbook_list()
+        res_uuids = [r.uuid for r in res]
+        self.assertCountEqual(uuids, res_uuids)
+
+    def test_get_runbook_list_sorted(self):
+        uuids = self._runbook_list_preparation()
+        res = self.dbapi.get_runbook_list(sort_key='uuid')
+        res_uuids = [r.uuid for r in res]
+        self.assertEqual(sorted(uuids), res_uuids)
+
+        self.assertRaises(exception.InvalidParameterValue,
+                          self.dbapi.get_runbook_list, sort_key='foo')
+
+    def test_get_runbook_list_by_names(self):
+        self._runbook_list_preparation()
+        names = ['CUSTOM_DT2', 'CUSTOM_DT3']
+        res = self.dbapi.get_runbook_list_by_names(names=names)
+        res_names = [r.name for r in res]
+        self.assertCountEqual(names, res_names)
+
+    def test_get_runbook_list_by_names_no_match(self):
+        self._runbook_list_preparation()
+        names = ['CUSTOM_FOO']
+        res = self.dbapi.get_runbook_list_by_names(names=names)
+        self.assertEqual([], res)
diff --git a/ironic/tests/unit/db/utils.py b/ironic/tests/unit/db/utils.py
index 910288a591..d3f13b7956 100644
--- a/ironic/tests/unit/db/utils.py
+++ b/ironic/tests/unit/db/utils.py
@@ -32,6 +32,7 @@ from ironic.objects import node_history
 from ironic.objects import node_inventory
 from ironic.objects import port
 from ironic.objects import portgroup
+from ironic.objects import runbook
 from ironic.objects import trait
 from ironic.objects import volume_connector
 from ironic.objects import volume_target
@@ -673,6 +674,59 @@ def create_test_deploy_template(**kw):
     return dbapi.create_deploy_template(template)
 
 
+def get_test_runbook(**kw):
+    default_uuid = uuidutils.generate_uuid()
+    return {
+        'version': kw.get('version', runbook.Runbook.VERSION),
+        'created_at': kw.get('created_at'),
+        'updated_at': kw.get('updated_at'),
+        'id': kw.get('id', 234),
+        'name': kw.get('name', u'CUSTOM_DT1'),
+        'uuid': kw.get('uuid', default_uuid),
+        'steps': kw.get('steps', [get_test_runbook_step(
+            runbook_id=kw.get('id', 234))]),
+        'disable_ramdisk': kw.get('disable_ramdisk', False),
+        'extra': kw.get('extra', {}),
+        'public': kw.get('public', False),
+        'owner': kw.get('owner', None),
+    }
+
+
+def get_test_runbook_step(**kw):
+    return {
+        'created_at': kw.get('created_at'),
+        'updated_at': kw.get('updated_at'),
+        'id': kw.get('id', 345),
+        'runbook_id': kw.get('runbook_id', 234),
+        'interface': kw.get('interface', 'raid'),
+        'step': kw.get('step', 'create_configuration'),
+        'args': kw.get('args', {'logical_disks': []}),
+        'order': kw.get('order', 1)
+    }
+
+
+def create_test_runbook(**kw):
+    """Create a runbook in the DB and return Runbook model.
+
+    :param kw: kwargs with overriding values for the runbook.
+    :returns: Test Runbook DB object.
+    """
+    runbook = get_test_runbook(**kw)
+    dbapi = db_api.get_instance()
+    # Let DB generate an ID if one isn't specified explicitly.
+    if 'id' not in kw:
+        del runbook['id']
+    if 'steps' not in kw:
+        for step in runbook['steps']:
+            del step['id']
+            del step['runbook_id']
+    else:
+        for kw_step, runbook_step in zip(kw['steps'], runbook['steps']):
+            if 'id' not in kw_step:
+                del runbook_step['id']
+    return dbapi.create_runbook(runbook)
+
+
 def get_test_history(**kw):
     return {
         'id': kw.get('id', 345),
diff --git a/ironic/tests/unit/objects/test_objects.py b/ironic/tests/unit/objects/test_objects.py
index 1222e347f4..1e2efce85e 100644
--- a/ironic/tests/unit/objects/test_objects.py
+++ b/ironic/tests/unit/objects/test_objects.py
@@ -724,6 +724,9 @@ expected_object_fingerprints = {
     'NodeInventory': '1.0-97692fec24e20ab02022b9db54e8f539',
     'FirmwareComponent': '1.0-0e0720dab959e20247bbcfd5f28958c5',
     'FirmwareComponentList': '1.0-33a2e1bb91ad4082f9f63429b77c1244',
+    'Runbook': '1.0-7a9c65b49b5f7b45686b6a674e703629',
+    'RunbookCRUDNotification': '1.0-59acc533c11d306f149846f922739c15',
+    'RunbookCRUDPayload': '1.0-f0c97f4ff29eb3401e53b34550a95e30',
 }
 
 
diff --git a/ironic/tests/unit/objects/utils.py b/ironic/tests/unit/objects/utils.py
index c84f9a51a2..a45f9ad064 100644
--- a/ironic/tests/unit/objects/utils.py
+++ b/ironic/tests/unit/objects/utils.py
@@ -358,6 +358,41 @@ def get_payloads_with_schemas(from_module):
     return payloads
 
 
+def get_test_runbook(ctxt, **kw):
+    """Return a Runbook object with appropriate attributes.
+
+    NOTE: The object leaves the attributes marked as changed, such
+    that a create() could be used to commit it to the DB.
+    """
+    db_runbook = db_utils.get_test_runbook(**kw)
+    # Let DB generate ID if it isn't specified explicitly
+    if 'id' not in kw:
+        del db_runbook['id']
+    if 'steps' not in kw:
+        for step in db_runbook['steps']:
+            del step['id']
+            del step['runbook_id']
+    else:
+        for kw_step, runbook_step in zip(kw['steps'], db_runbook['steps']):
+            if 'id' not in kw_step and 'id' in runbook_step:
+                del runbook_step['id']
+    runbook = objects.Runbook(ctxt)
+    for key in db_runbook:
+        setattr(runbook, key, db_runbook[key])
+    return runbook
+
+
+def create_test_runbook(ctxt, **kw):
+    """Create and return a test runbook object.
+
+    NOTE: The object leaves the attributes marked as changed, such
+    that a create() could be used to commit it to the DB.
+    """
+    runbook = get_test_runbook(ctxt, **kw)
+    runbook.create()
+    return runbook
+
+
 class SchemasTestMixIn(object):
     def _check_payload_schemas(self, from_module, fields):
         """Assert that the Payload SCHEMAs have the expected properties.
diff --git a/releasenotes/notes/add-runbooks-38c3efa97ace8c67.yaml b/releasenotes/notes/add-runbooks-38c3efa97ace8c67.yaml
new file mode 100644
index 0000000000..a1c451cae3
--- /dev/null
+++ b/releasenotes/notes/add-runbooks-38c3efa97ace8c67.yaml
@@ -0,0 +1,19 @@
+---
+features:
+  - |
+    Adds a new API concept, runbooks, to enable self-service of maintenance
+    items on nodes by project members.
+
+    Runbooks are curated lists of steps that can be run on nodes only
+    associated via traits and used in lieu of an explicit list of steps
+    for manual cleaning or servicing.
+  - |
+    Adds a new top-level REST API endpoint `/v1/runbooks/` with basic CRUD
+    support.
+  - |
+    Extends the `/v1/nodes/<node>/states/provision` API to accept a runbook
+    ident (name or UUID) instead of `clean_steps` or `service_steps` for
+    servicing or manual cleaning.
+  - |
+    Implements RBAC-aware lifecycle management for runbooks, allowing projects
+    to limit who can CRUD and use a runbook.