From c380e05dbf84079b01fb3b0ea099ef9b293b9b3d Mon Sep 17 00:00:00 2001
From: Satoru Moriya <satoru.moriya.br@hitachi.com>
Date: Wed, 10 Feb 2016 14:29:34 +0900
Subject: [PATCH] Add REST API for volume connector and volume target operation

This patch introduces following REST API endpoints to get/set volume
connector and volume target in Ironic.

- GET /v1/volume
- GET /v1/nodes/<node_uuid or name>/volume
- {GET, POST} /v1/volume/connectors
- {GET, PATCH, DELETE} /v1/volume/connectors/<volume_connector_uuid>
- GET /v1/nodes/<node_uuid or name>/volume/connectors
- {GET, POST} /v1/volume/targets
- {GET, PATCH, DELETE} /v1/volume/targets/<volume_target_uuid>
- GET /v1/nodes/<node_uuid or name>/volume/targets

This also adds CRUD notifications for volume connector and volume
target.

Co-Authored-By: Tomoki Sekiyama <tomoki.sekiyama.qu@hitachi.com>
Co-Authored-By: David Lenwell <dlenwell@gmail.com>
Co-Authored-By: Hironori Shiina <shiina.hironori@jp.fujitsu.com>
Change-Id: I328a698f2109841e1e122e17fea4b345c4179161
Partial-Bug: 1526231
---
 doc/source/deploy/notifications.rst           |  82 ++
 doc/source/dev/webapi-version-history.rst     |  23 +-
 etc/ironic/policy.json.sample                 |  12 +
 ironic/api/controllers/v1/__init__.py         |  15 +
 ironic/api/controllers/v1/node.py             |  19 +-
 .../api/controllers/v1/notification_utils.py  |  10 +-
 ironic/api/controllers/v1/utils.py            |   8 +
 ironic/api/controllers/v1/versions.py         |   4 +-
 ironic/api/controllers/v1/volume.py           | 103 ++
 ironic/api/controllers/v1/volume_connector.py | 480 +++++++++
 ironic/api/controllers/v1/volume_target.py    | 489 +++++++++
 ironic/common/policy.py                       |  22 +-
 ironic/tests/unit/api/test_root.py            |   7 +
 ironic/tests/unit/api/utils.py                |  20 +
 ironic/tests/unit/api/v1/test_nodes.py        | 207 ++++
 ironic/tests/unit/api/v1/test_utils.py        |   7 +
 ironic/tests/unit/api/v1/test_volume.py       |  55 +
 .../unit/api/v1/test_volume_connectors.py     | 943 ++++++++++++++++++
 .../tests/unit/api/v1/test_volume_targets.py  | 927 +++++++++++++++++
 ...ector-and-target-api-dd172f121ab3af8e.yaml |  54 +
 20 files changed, 3481 insertions(+), 6 deletions(-)
 create mode 100644 ironic/api/controllers/v1/volume.py
 create mode 100644 ironic/api/controllers/v1/volume_connector.py
 create mode 100644 ironic/api/controllers/v1/volume_target.py
 create mode 100644 ironic/tests/unit/api/v1/test_volume.py
 create mode 100644 ironic/tests/unit/api/v1/test_volume_connectors.py
 create mode 100644 ironic/tests/unit/api/v1/test_volume_targets.py
 create mode 100644 releasenotes/notes/volume-connector-and-target-api-dd172f121ab3af8e.yaml

diff --git a/doc/source/deploy/notifications.rst b/doc/source/deploy/notifications.rst
index b75705dc2c..f1c2fea461 100644
--- a/doc/source/deploy/notifications.rst
+++ b/doc/source/deploy/notifications.rst
@@ -251,6 +251,88 @@ Example of portgroup CRUD notification::
     "publisher_id":"ironic-api.hostname02"
    }
 
+List of CRUD notifications for volume connector:
+
+* ``baremetal.volumeconnector.create.start``
+* ``baremetal.volumeconnector.create.end``
+* ``baremetal.volumeconnector.create.error``
+* ``baremetal.volumeconnector.update.start``
+* ``baremetal.volumeconnector.update.end``
+* ``baremetal.volumeconnector.update.error``
+* ``baremetal.volumeconnector.delete.start``
+* ``baremetal.volumeconnector.delete.end``
+* ``baremetal.volumeconnector.delete.error``
+
+Example of volume connector CRUD notification::
+
+   {
+    "priority": "info",
+    "payload": {
+        "ironic_object.namespace": "ironic",
+        "ironic_object.name": "VolumeConnectorCRUDPayload",
+        "ironic_object.version": "1.0",
+        "ironic_object.data": {
+           "connector_id": "iqn.2017-05.org.openstack:01:d9a51732c3f",
+           "created_at": "2017-05-11T05:57:36+00:00",
+           "extra": {},
+           "node_uuid": "4dbb4e69-99a8-4e13-b6e8-dd2ad4a20caf",
+           "type": "iqn",
+           "updated_at": "2017-05-11T08:28:58+00:00",
+           "uuid": "19b9f3ab-4754-4725-a7a4-c43ea7e57360"
+        }
+    },
+    "event_type": "baremetal.volumeconnector.update.end",
+    "publisher_id":"ironic-api.hostname02"
+   }
+
+List of CRUD notifications for volume target:
+
+* ``baremetal.volumetarget.create.start``
+* ``baremetal.volumetarget.create.end``
+* ``baremetal.volumetarget.create.error``
+* ``baremetal.volumetarget.update.start``
+* ``baremetal.volumetarget.update.end``
+* ``baremetal.volumetarget.update.error``
+* ``baremetal.volumetarget.delete.start``
+* ``baremetal.volumetarget.delete.end``
+* ``baremetal.volumetarget.delete.error``
+
+Example of volume target CRUD notification::
+
+   {
+    "priority": "info",
+    "payload": {
+        "ironic_object.namespace": "ironic",
+        "ironic_object.version": "1.0",
+        "ironic_object.name": "VolumeTargetCRUDPayload"
+        "ironic_object.data": {
+            "boot_index": 0,
+            "created_at": "2017-05-11T09:38:59+00:00",
+            "extra": {},
+            "node_uuid": "4dbb4e69-99a8-4e13-b6e8-dd2ad4a20caf",
+            "properties": {
+                "access_mode": "rw",
+                "auth_method": "CHAP"
+                "auth_password": "***",
+                "auth_username": "urxhQCzAKr4sjyE8DivY",
+                "encrypted": false,
+                "qos_specs": null,
+                "target_discovered": false,
+                "target_iqn": "iqn.2010-10.org.openstack:volume-f0d9b0e6-b242-9105-91d4-a20331693ad8",
+                "target_lun": 1,
+                "target_portal": "192.168.12.34:3260",
+                "volume_id": "f0d9b0e6-b042-4105-91d4-a20331693ad8",
+            },
+            "updated_at": "2017-05-11T09:52:04+00:00",
+            "uuid": "82a45833-9c58-4ec1-943c-2091ab10e47b",
+            "volume_id": "f0d9b0e6-b242-9105-91d4-a20331693ad8",
+            "volume_type": "iscsi"
+        }
+    },
+    "event_type": "baremetal.volumetarget.update.end",
+    "publisher_id":"ironic-api.hostname02"
+   }
+
 Node maintenance notifications
 ------------------------------
 
diff --git a/doc/source/dev/webapi-version-history.rst b/doc/source/dev/webapi-version-history.rst
index 67f86874ed..61853ea79c 100644
--- a/doc/source/dev/webapi-version-history.rst
+++ b/doc/source/dev/webapi-version-history.rst
@@ -2,6 +2,28 @@
 REST API Version History
 ========================
 
+**1.32** (Pike)
+
+    Added new endpoints for remote volume configuration:
+
+    * GET /v1/volume as a root for volume resources
+    * GET /v1/volume/connectors for listing volume connectors
+    * POST /v1/volume/connectors for creating a volume connector
+    * GET /v1/volume/connectors/<UUID> for showing a volume connector
+    * PATCH /v1/volume/connectors/<UUID> for updating a volume connector
+    * DELETE /v1/volume/connectors/<UUID> for deleting a volume connector
+    * GET /v1/volume/targets for listing volume targets
+    * POST /v1/volume/targets for creating a volume target
+    * GET /v1/volume/targets/<UUID> for showing a volume target
+    * PATCH /v1/volume/targets/<UUID> for updating a volume target
+    * DELETE /v1/volume/targets/<UUID> for deleting a volume target
+
+    Volume resources also can be listed as sub resources of nodes:
+
+    * GET /v1/nodes/<node identifier>/volume
+    * GET /v1/nodes/<node identifier>/volume/connectors
+    * GET /v1/nodes/<node identifier>/volume/targets
+
 **1.31** (Ocata)
 
     Added the following fields to the node object, to allow getting and
@@ -237,4 +259,3 @@ REST API Version History
     supported version in Kilo.
 
 .. _fully qualified domain name: https://en.wikipedia.org/wiki/Fully_qualified_domain_name
-
diff --git a/etc/ironic/policy.json.sample b/etc/ironic/policy.json.sample
index 8a418774d4..8857fdf8bd 100644
--- a/etc/ironic/policy.json.sample
+++ b/etc/ironic/policy.json.sample
@@ -133,3 +133,15 @@
 # Access IPA ramdisk functions
 #"baremetal:driver:ipa_lookup": "rule:public_api"
 
+# Retrieve Volume connector and target records
+#"baremetal:volume:get": "rule:is_admin or rule:is_observer"
+
+# Create Volume connector and target records
+#"baremetal:volume:create": "rule:is_admin"
+
+# Delete Volume connetor and target records
+#"baremetal:volume:delete": "rule:is_admin"
+
+# Update Volume connector and target records
+#"baremetal:volume:update": "rule:is_admin"
+
diff --git a/ironic/api/controllers/v1/__init__.py b/ironic/api/controllers/v1/__init__.py
index 059b796dbc..431b89840d 100644
--- a/ironic/api/controllers/v1/__init__.py
+++ b/ironic/api/controllers/v1/__init__.py
@@ -33,6 +33,7 @@ from ironic.api.controllers.v1 import portgroup
 from ironic.api.controllers.v1 import ramdisk
 from ironic.api.controllers.v1 import utils
 from ironic.api.controllers.v1 import versions
+from ironic.api.controllers.v1 import volume
 from ironic.api import expose
 from ironic.common.i18n import _
 
@@ -84,6 +85,9 @@ class V1(base.APIBase):
     drivers = [link.Link]
     """Links to the drivers resource"""
 
+    volume = [link.Link]
+    """Links to the volume resource"""
+
     lookup = [link.Link]
     """Links to the lookup resource"""
 
@@ -139,6 +143,16 @@ class V1(base.APIBase):
                                           'drivers', '',
                                           bookmark=True)
                       ]
+        if utils.allow_volume():
+            v1.volume = [
+                link.Link.make_link('self',
+                                    pecan.request.public_url,
+                                    'volume', ''),
+                link.Link.make_link('bookmark',
+                                    pecan.request.public_url,
+                                    'volume', '',
+                                    bookmark=True)
+            ]
         if utils.allow_ramdisk_endpoints():
             v1.lookup = [link.Link.make_link('self', pecan.request.public_url,
                                              'lookup', ''),
@@ -166,6 +180,7 @@ class Controller(rest.RestController):
     portgroups = portgroup.PortgroupsController()
     chassis = chassis.ChassisController()
     drivers = driver.DriversController()
+    volume = volume.VolumeController()
     lookup = ramdisk.LookupController()
     heartbeat = ramdisk.HeartbeatController()
 
diff --git a/ironic/api/controllers/v1/node.py b/ironic/api/controllers/v1/node.py
index 9e5929d1ad..5f0bcc6195 100644
--- a/ironic/api/controllers/v1/node.py
+++ b/ironic/api/controllers/v1/node.py
@@ -35,6 +35,7 @@ from ironic.api.controllers.v1 import portgroup
 from ironic.api.controllers.v1 import types
 from ironic.api.controllers.v1 import utils as api_utils
 from ironic.api.controllers.v1 import versions
+from ironic.api.controllers.v1 import volume
 from ironic.api import expose
 from ironic.common import exception
 from ironic.common.i18n import _
@@ -813,6 +814,9 @@ class Node(base.APIBase):
     portgroups = wsme.wsattr([link.Link], readonly=True)
     """Links to the collection of portgroups on this node"""
 
+    volume = wsme.wsattr([link.Link], readonly=True)
+    """Links to endpoint for retrieving volume resources on this node"""
+
     states = wsme.wsattr([link.Link], readonly=True)
     """Links to endpoint for retrieving and setting node states"""
 
@@ -869,7 +873,7 @@ class Node(base.APIBase):
 
     @staticmethod
     def _convert_with_links(node, url, fields=None, show_states_links=True,
-                            show_portgroups=True):
+                            show_portgroups=True, show_volume=True):
         # NOTE(lucasagomes): Since we are able to return a specified set of
         # fields the "uuid" can be unset, so we need to save it in another
         # variable to use when building the links
@@ -897,6 +901,14 @@ class Node(base.APIBase):
                                         node_uuid + "/portgroups",
                                         bookmark=True)]
 
+            if show_volume:
+                node.volume = [
+                    link.Link.make_link('self', url, 'nodes',
+                                        node_uuid + "/volume"),
+                    link.Link.make_link('bookmark', url, 'nodes',
+                                        node_uuid + "/volume",
+                                        bookmark=True)]
+
         # NOTE(lucasagomes): The numeric ID should not be exposed to
         #                    the user, it's internal only.
         node.chassis_id = wtypes.Unset
@@ -951,10 +963,12 @@ class Node(base.APIBase):
         show_states_links = (
             api_utils.allow_links_node_states_and_driver_properties())
         show_portgroups = api_utils.allow_portgroups_subcontrollers()
+        show_volume = api_utils.allow_volume()
         return cls._convert_with_links(node, pecan.request.public_url,
                                        fields=fields,
                                        show_states_links=show_states_links,
-                                       show_portgroups=show_portgroups)
+                                       show_portgroups=show_portgroups,
+                                       show_volume=show_volume)
 
     @classmethod
     def sample(cls, expand=True):
@@ -1247,6 +1261,7 @@ class NodesController(rest.RestController):
         'ports': port.PortsController,
         'portgroups': portgroup.PortgroupsController,
         'vifs': NodeVIFController,
+        'volume': volume.VolumeController,
     }
 
     @pecan.expose()
diff --git a/ironic/api/controllers/v1/notification_utils.py b/ironic/api/controllers/v1/notification_utils.py
index 8f058aa378..5506238e0e 100644
--- a/ironic/api/controllers/v1/notification_utils.py
+++ b/ironic/api/controllers/v1/notification_utils.py
@@ -27,6 +27,8 @@ 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 volume_connector as volume_connector_objects
+from ironic.objects import volume_target as volume_target_objects
 
 LOG = log.getLogger(__name__)
 CONF = cfg.CONF
@@ -40,7 +42,13 @@ CRUD_NOTIFY_OBJ = {
     'port': (port_objects.PortCRUDNotification,
              port_objects.PortCRUDPayload),
     'portgroup': (portgroup_objects.PortgroupCRUDNotification,
-                  portgroup_objects.PortgroupCRUDPayload)
+                  portgroup_objects.PortgroupCRUDPayload),
+    'volumeconnector':
+        (volume_connector_objects.VolumeConnectorCRUDNotification,
+         volume_connector_objects.VolumeConnectorCRUDPayload),
+    'volumetarget':
+        (volume_target_objects.VolumeTargetCRUDNotification,
+         volume_target_objects.VolumeTargetCRUDPayload),
 }
 
 
diff --git a/ironic/api/controllers/v1/utils.py b/ironic/api/controllers/v1/utils.py
index da5b5ec0b0..b0c005be3f 100644
--- a/ironic/api/controllers/v1/utils.py
+++ b/ironic/api/controllers/v1/utils.py
@@ -549,6 +549,14 @@ def allow_dynamic_interfaces():
             versions.MINOR_31_DYNAMIC_INTERFACES)
 
 
+def allow_volume():
+    """Check if volume connectors and targets are allowed.
+
+    Version 1.32 of the API added support for volume connectors and targets
+    """
+    return pecan.request.version.minor >= versions.MINOR_32_VOLUME
+
+
 def get_controller_reserved_names(cls):
     """Get reserved names for a given controller.
 
diff --git a/ironic/api/controllers/v1/versions.py b/ironic/api/controllers/v1/versions.py
index 79a76d4826..46e08fcd40 100644
--- a/ironic/api/controllers/v1/versions.py
+++ b/ironic/api/controllers/v1/versions.py
@@ -62,6 +62,7 @@ BASE_VERSION = 1
 # v1.29: Add inject nmi.
 # v1.30: Add dynamic driver interactions.
 # v1.31: Add dynamic interfaces fields to node.
+# v1.32: Add volume support.
 
 MINOR_0_JUNO = 0
 MINOR_1_INITIAL_VERSION = 1
@@ -95,11 +96,12 @@ MINOR_28_VIFS_SUBCONTROLLER = 28
 MINOR_29_INJECT_NMI = 29
 MINOR_30_DYNAMIC_DRIVERS = 30
 MINOR_31_DYNAMIC_INTERFACES = 31
+MINOR_32_VOLUME = 32
 
 # When adding another version, update MINOR_MAX_VERSION and also update
 # doc/source/dev/webapi-version-history.rst with a detailed explanation of
 # what the version has changed.
-MINOR_MAX_VERSION = MINOR_31_DYNAMIC_INTERFACES
+MINOR_MAX_VERSION = MINOR_32_VOLUME
 
 # String representations of the minor and maximum versions
 MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)
diff --git a/ironic/api/controllers/v1/volume.py b/ironic/api/controllers/v1/volume.py
new file mode 100644
index 0000000000..a8adcfc23d
--- /dev/null
+++ b/ironic/api/controllers/v1/volume.py
@@ -0,0 +1,103 @@
+# Copyright (c) 2017 Hitachi, Ltd.
+#
+#    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.
+
+import pecan
+from pecan import rest
+from six.moves import http_client
+import wsme
+
+from ironic.api.controllers import base
+from ironic.api.controllers import link
+from ironic.api.controllers.v1 import utils as api_utils
+from ironic.api.controllers.v1 import volume_connector
+from ironic.api.controllers.v1 import volume_target
+from ironic.api import expose
+from ironic.common import exception
+from ironic.common import policy
+
+
+class Volume(base.APIBase):
+    """API representation of a volume root.
+
+    This class exists as a root class for the volume connectors and volume
+    targets controllers.
+    """
+
+    links = wsme.wsattr([link.Link], readonly=True)
+    """A list containing a self link and associated volume links"""
+
+    connectors = wsme.wsattr([link.Link], readonly=True)
+    """Links to the volume connectors resource"""
+
+    targets = wsme.wsattr([link.Link], readonly=True)
+    """Links to the volume targets resource"""
+
+    @staticmethod
+    def convert(node_ident=None):
+        url = pecan.request.public_url
+        volume = Volume()
+        if node_ident:
+            resource = 'nodes'
+            args = '%s/volume/' % node_ident
+        else:
+            resource = 'volume'
+            args = ''
+
+        volume.links = [
+            link.Link.make_link('self', url, resource, args),
+            link.Link.make_link('bookmark', url, resource, args,
+                                bookmark=True)]
+
+        volume.connectors = [
+            link.Link.make_link('self', url, resource, args + 'connectors'),
+            link.Link.make_link('bookmark', url, resource, args + 'connectors',
+                                bookmark=True)]
+
+        volume.targets = [
+            link.Link.make_link('self', url, resource, args + 'targets'),
+            link.Link.make_link('bookmark', url, resource, args + 'targets',
+                                bookmark=True)]
+
+        return volume
+
+
+class VolumeController(rest.RestController):
+    """REST controller for volume root"""
+
+    _subcontroller_map = {
+        'connectors': volume_connector.VolumeConnectorsController,
+        'targets': volume_target.VolumeTargetsController
+    }
+
+    def __init__(self, node_ident=None):
+        super(VolumeController, self).__init__()
+        self.parent_node_ident = node_ident
+
+    @expose.expose(Volume)
+    def get(self):
+        if not api_utils.allow_volume():
+            raise exception.NotFound()
+
+        cdict = pecan.request.context.to_policy_values()
+        policy.authorize('baremetal:volume:get', cdict, cdict)
+
+        return Volume.convert(self.parent_node_ident)
+
+    @pecan.expose()
+    def _lookup(self, subres, *remainder):
+        if not api_utils.allow_volume():
+            pecan.abort(http_client.NOT_FOUND)
+        subcontroller = self._subcontroller_map.get(subres)
+        if subcontroller:
+            return subcontroller(node_ident=self.parent_node_ident), remainder
diff --git a/ironic/api/controllers/v1/volume_connector.py b/ironic/api/controllers/v1/volume_connector.py
new file mode 100644
index 0000000000..cb96249ee1
--- /dev/null
+++ b/ironic/api/controllers/v1/volume_connector.py
@@ -0,0 +1,480 @@
+# Copyright (c) 2017 Hitachi, Ltd.
+#
+#    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.
+
+import datetime
+
+from ironic_lib import metrics_utils
+from oslo_utils import uuidutils
+import pecan
+from pecan import rest
+import six
+from six.moves import http_client
+import wsme
+from wsme import types as wtypes
+
+from ironic.api.controllers import base
+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 types
+from ironic.api.controllers.v1 import utils as api_utils
+from ironic.api import expose
+from ironic.common import exception
+from ironic.common.i18n import _
+from ironic.common import policy
+from ironic import objects
+
+METRICS = metrics_utils.get_metrics_logger(__name__)
+
+_DEFAULT_RETURN_FIELDS = ('uuid', 'node_uuid', 'type', 'connector_id')
+
+
+class VolumeConnector(base.APIBase):
+    """API representation of a volume connector.
+
+    This class enforces type checking and value constraints, and converts
+    between the internal object model and the API representation of a volume
+    connector.
+    """
+
+    _node_uuid = None
+
+    def _get_node_uuid(self):
+        return self._node_uuid
+
+    def _set_node_identifiers(self, value):
+        """Set both UUID and ID of a node for VolumeConnector object
+
+        :param value: UUID, ID of a node, or wtypes.Unset
+        """
+        if value == wtypes.Unset:
+            self._node_uuid = wtypes.Unset
+        elif value and self._node_uuid != value:
+            try:
+                node = objects.Node.get(pecan.request.context, value)
+                self._node_uuid = node.uuid
+                # NOTE(smoriya): Create the node_id attribute on-the-fly
+                #                to satisfy the api -> rpc object conversion.
+                self.node_id = node.id
+            except exception.NodeNotFound as e:
+                # Change error code because 404 (NotFound) is inappropriate
+                # response for a POST request to create a VolumeConnector
+                e.code = http_client.BAD_REQUEST  # BadRequest
+                raise
+
+    uuid = types.uuid
+    """Unique UUID for this volume connector"""
+
+    type = wsme.wsattr(wtypes.text, mandatory=True)
+    """The type of volume connector"""
+
+    connector_id = wsme.wsattr(wtypes.text, mandatory=True)
+    """The connector_id for this volume connector"""
+
+    extra = {wtypes.text: types.jsontype}
+    """The metadata for this volume connector"""
+
+    node_uuid = wsme.wsproperty(types.uuid, _get_node_uuid,
+                                _set_node_identifiers, mandatory=True)
+    """The UUID of the node this volume connector belongs to"""
+
+    links = wsme.wsattr([link.Link], readonly=True)
+    """A list containing a self link and associated volume connector links"""
+
+    def __init__(self, **kwargs):
+        self.fields = []
+        fields = list(objects.VolumeConnector.fields)
+        for field in fields:
+            # Skip fields we do not expose.
+            if not hasattr(self, field):
+                continue
+            self.fields.append(field)
+            setattr(self, field, kwargs.get(field, wtypes.Unset))
+
+        # NOTE(smoriya): node_id is an attribute created on-the-fly
+        # by _set_node_uuid(), it needs to be present in the fields so
+        # that as_dict() will contain node_id field when converting it
+        # before saving it in the database.
+        self.fields.append('node_id')
+        # NOTE(smoriya): node_uuid is not part of objects.VolumeConnector.-
+        #                fields because it's an API-only attribute
+        self.fields.append('node_uuid')
+        # NOTE(jtaryma): Additionally to node_uuid, node_id is handled as a
+        # secondary identifier in case RPC volume connector object dictionary
+        # was passed to the constructor.
+        self.node_uuid = kwargs.get('node_uuid') or kwargs.get('node_id',
+                                                               wtypes.Unset)
+
+    @staticmethod
+    def _convert_with_links(connector, url, fields=None):
+        # NOTE(lucasagomes): Since we are able to return a specified set of
+        # fields the "uuid" can be unset, so we need to save it in another
+        # variable to use when building the links
+        connector_uuid = connector.uuid
+        if fields is not None:
+            connector.unset_fields_except(fields)
+
+        # never expose the node_id attribute
+        connector.node_id = wtypes.Unset
+        connector.links = [link.Link.make_link('self', url,
+                                               'volume/connectors',
+                                               connector_uuid),
+                           link.Link.make_link('bookmark', url,
+                                               'volume/connectors',
+                                               connector_uuid,
+                                               bookmark=True)
+                           ]
+        return connector
+
+    @classmethod
+    def convert_with_links(cls, rpc_connector, fields=None):
+        connector = VolumeConnector(**rpc_connector.as_dict())
+
+        if fields is not None:
+            api_utils.check_for_invalid_fields(fields, connector.as_dict())
+
+        return cls._convert_with_links(connector, pecan.request.public_url,
+                                       fields=fields)
+
+    @classmethod
+    def sample(cls, expand=True):
+        sample = cls(uuid='86cfd480-0842-4abb-8386-e46149beb82f',
+                     type='iqn',
+                     connector_id='iqn.2010-10.org.openstack:51332b70524',
+                     extra={'foo': 'bar'},
+                     created_at=datetime.datetime.utcnow(),
+                     updated_at=datetime.datetime.utcnow())
+        sample._node_uuid = '7ae81bb3-dec3-4289-8d6c-da80bd8001ae'
+        fields = None if expand else _DEFAULT_RETURN_FIELDS
+        return cls._convert_with_links(sample, 'http://localhost:6385',
+                                       fields=fields)
+
+
+class VolumeConnectorPatchType(types.JsonPatchType):
+
+    _api_base = VolumeConnector
+
+
+class VolumeConnectorCollection(collection.Collection):
+    """API representation of a collection of volume connectors."""
+
+    connectors = [VolumeConnector]
+    """A list containing volume connector objects"""
+
+    def __init__(self, **kwargs):
+        self._type = 'connectors'
+
+    @staticmethod
+    def convert_with_links(rpc_connectors, limit, url=None, fields=None,
+                           detail=None, **kwargs):
+        collection = VolumeConnectorCollection()
+        collection.connectors = [
+            VolumeConnector.convert_with_links(p, fields=fields)
+            for p in rpc_connectors]
+        if detail:
+            kwargs['detail'] = detail
+        collection.next = collection.get_next(limit, url=url, **kwargs)
+        return collection
+
+    @classmethod
+    def sample(cls):
+        sample = cls()
+        sample.connectors = [VolumeConnector.sample(expand=False)]
+        return sample
+
+
+class VolumeConnectorsController(rest.RestController):
+    """REST controller for VolumeConnectors."""
+
+    invalid_sort_key_list = ['extra']
+
+    def __init__(self, node_ident=None):
+        super(VolumeConnectorsController, self).__init__()
+        self.parent_node_ident = node_ident
+
+    def _get_volume_connectors_collection(self, node_ident, marker, limit,
+                                          sort_key, sort_dir,
+                                          resource_url=None,
+                                          fields=None, detail=None):
+        limit = api_utils.validate_limit(limit)
+        sort_dir = api_utils.validate_sort_dir(sort_dir)
+
+        marker_obj = None
+        if marker:
+            marker_obj = objects.VolumeConnector.get_by_uuid(
+                pecan.request.context, marker)
+
+        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})
+
+        node_ident = self.parent_node_ident or node_ident
+
+        if node_ident:
+            # FIXME(comstud): Since all we need is the node ID, we can
+            #                 make this more efficient by only querying
+            #                 for that column. This will get cleaned up
+            #                 as we move to the object interface.
+            node = api_utils.get_rpc_node(node_ident)
+            connectors = objects.VolumeConnector.list_by_node_id(
+                pecan.request.context, node.id, limit, marker_obj,
+                sort_key=sort_key, sort_dir=sort_dir)
+        else:
+            connectors = objects.VolumeConnector.list(pecan.request.context,
+                                                      limit,
+                                                      marker_obj,
+                                                      sort_key=sort_key,
+                                                      sort_dir=sort_dir)
+        return VolumeConnectorCollection.convert_with_links(connectors, limit,
+                                                            url=resource_url,
+                                                            fields=fields,
+                                                            sort_key=sort_key,
+                                                            sort_dir=sort_dir,
+                                                            detail=detail)
+
+    @METRICS.timer('VolumeConnectorsController.get_all')
+    @expose.expose(VolumeConnectorCollection, types.uuid_or_name, types.uuid,
+                   int, wtypes.text, wtypes.text, types.listtype,
+                   types.boolean)
+    def get_all(self, node=None, marker=None, limit=None, sort_key='id',
+                sort_dir='asc', fields=None, detail=None):
+        """Retrieve a list of volume connectors.
+
+        :param node: UUID or name of a node, to get only volume connectors
+                     for that node.
+        :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 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, whether to retrieve with detail.
+
+        :returns: a list of volume connectors, or an empty list if no volume
+                  connector is found.
+
+        :raises: InvalidParameterValue if sort_key does not exist
+        :raises: InvalidParameterValue if sort key is invalid for sorting.
+        :raises: InvalidParameterValue if both fields and detail are specified.
+        """
+        cdict = pecan.request.context.to_policy_values()
+        policy.authorize('baremetal:volume:get', cdict, cdict)
+
+        if fields is None and not detail:
+            fields = _DEFAULT_RETURN_FIELDS
+
+        if fields and detail:
+            raise exception.InvalidParameterValue(
+                _("Can't fetch a subset of fields with 'detail' set"))
+
+        resource_url = 'volume/connectors'
+        return self._get_volume_connectors_collection(
+            node, marker, limit, sort_key, sort_dir, resource_url=resource_url,
+            fields=fields, detail=detail)
+
+    @METRICS.timer('VolumeConnectorsController.get_one')
+    @expose.expose(VolumeConnector, types.uuid, types.listtype)
+    def get_one(self, connector_uuid, fields=None):
+        """Retrieve information about the given volume connector.
+
+        :param connector_uuid: UUID of a volume connector.
+        :param fields: Optional, a list with a specified set of fields
+            of the resource to be returned.
+
+        :returns: API-serializable volume connector object.
+
+        :raises: OperationNotPermitted if accessed with specifying a parent
+                 node.
+        :raises: VolumeConnectorNotFound if no volume connector exists with
+                 the specified UUID.
+        """
+        cdict = pecan.request.context.to_policy_values()
+        policy.authorize('baremetal:volume:get', cdict, cdict)
+
+        if self.parent_node_ident:
+            raise exception.OperationNotPermitted()
+
+        rpc_connector = objects.VolumeConnector.get_by_uuid(
+            pecan.request.context, connector_uuid)
+        return VolumeConnector.convert_with_links(rpc_connector, fields=fields)
+
+    @METRICS.timer('VolumeConnectorsController.post')
+    @expose.expose(VolumeConnector, body=VolumeConnector,
+                   status_code=http_client.CREATED)
+    def post(self, connector):
+        """Create a new volume connector.
+
+        :param connector: a volume connector within the request body.
+
+        :returns: API-serializable volume connector object.
+
+        :raises: OperationNotPermitted if accessed with specifying a parent
+                 node.
+        :raises: VolumeConnectorTypeAndIdAlreadyExists if a volume
+                 connector already exists with the same type and connector_id
+        :raises: VolumeConnectorAlreadyExists if a volume connector with the
+                 same UUID already exists
+        """
+        context = pecan.request.context
+        cdict = context.to_policy_values()
+        policy.authorize('baremetal:volume:create', cdict, cdict)
+
+        if self.parent_node_ident:
+            raise exception.OperationNotPermitted()
+
+        connector_dict = connector.as_dict()
+        # NOTE(hshiina): UUID is mandatory for notification payload
+        if not connector_dict.get('uuid'):
+            connector_dict['uuid'] = uuidutils.generate_uuid()
+
+        new_connector = objects.VolumeConnector(context, **connector_dict)
+
+        notify.emit_start_notification(context, new_connector, 'create',
+                                       node_uuid=connector.node_uuid)
+        with notify.handle_error_notification(context, new_connector,
+                                              'create',
+                                              node_uuid=connector.node_uuid):
+            new_connector.create()
+        notify.emit_end_notification(context, new_connector, 'create',
+                                     node_uuid=connector.node_uuid)
+        # Set the HTTP Location Header
+        pecan.response.location = link.build_url('volume/connectors',
+                                                 new_connector.uuid)
+        return VolumeConnector.convert_with_links(new_connector)
+
+    @METRICS.timer('VolumeConnectorsController.patch')
+    @wsme.validate(types.uuid, [VolumeConnectorPatchType])
+    @expose.expose(VolumeConnector, types.uuid,
+                   body=[VolumeConnectorPatchType])
+    def patch(self, connector_uuid, patch):
+        """Update an existing volume connector.
+
+        :param connector_uuid: UUID of a volume connector.
+        :param patch: a json PATCH document to apply to this volume connector.
+
+        :returns: API-serializable volume connector object.
+
+        :raises: OperationNotPermitted if accessed with specifying a
+                 parent node.
+        :raises: PatchError if a given patch can not be applied.
+        :raises: VolumeConnectorNotFound if no volume connector exists with
+                 the specified UUID.
+        :raises: InvalidParameterValue if the volume connector's UUID is being
+                 changed
+        :raises: NodeLocked if node is locked by another conductor
+        :raises: NodeNotFound if the node associated with the connector does
+                 not exist
+        :raises: VolumeConnectorTypeAndIdAlreadyExists if another connector
+                 already exists with the same values for type and connector_id
+                 fields
+        :raises: InvalidUUID if invalid node UUID is passed in the patch.
+        :raises: InvalidStateRequested If a node associated with the
+                 volume connector is not powered off.
+        """
+        context = pecan.request.context
+        cdict = context.to_policy_values()
+        policy.authorize('baremetal:volume:update', cdict, cdict)
+
+        if self.parent_node_ident:
+            raise exception.OperationNotPermitted()
+
+        values = api_utils.get_patch_values(patch, '/node_uuid')
+        for value in values:
+            if not uuidutils.is_uuid_like(value):
+                message = _("Expected a UUID for node_uuid, but received "
+                            "%(uuid)s.") % {'uuid': six.text_type(value)}
+                raise exception.InvalidUUID(message=message)
+
+        rpc_connector = objects.VolumeConnector.get_by_uuid(context,
+                                                            connector_uuid)
+        try:
+            connector_dict = rpc_connector.as_dict()
+            # NOTE(smoriya):
+            # 1) Remove node_id because it's an internal value and
+            #    not present in the API object
+            # 2) Add node_uuid
+            connector_dict['node_uuid'] = connector_dict.pop('node_id', None)
+            connector = VolumeConnector(
+                **api_utils.apply_jsonpatch(connector_dict, patch))
+        except api_utils.JSONPATCH_EXCEPTIONS as e:
+            raise exception.PatchError(patch=patch, reason=e)
+
+        # Update only the fields that have changed.
+        for field in objects.VolumeConnector.fields:
+            try:
+                patch_val = getattr(connector, field)
+            except AttributeError:
+                # Ignore fields that aren't exposed in the API
+                continue
+            if patch_val == wtypes.Unset:
+                patch_val = None
+            if rpc_connector[field] != patch_val:
+                rpc_connector[field] = patch_val
+
+        rpc_node = objects.Node.get_by_id(context,
+                                          rpc_connector.node_id)
+        notify.emit_start_notification(context, rpc_connector, 'update',
+                                       node_uuid=rpc_node.uuid)
+        with notify.handle_error_notification(context, rpc_connector, 'update',
+                                              node_uuid=rpc_node.uuid):
+            topic = pecan.request.rpcapi.get_topic_for(rpc_node)
+            new_connector = pecan.request.rpcapi.update_volume_connector(
+                context, rpc_connector, topic)
+
+        api_connector = VolumeConnector.convert_with_links(new_connector)
+        notify.emit_end_notification(context, new_connector, 'update',
+                                     node_uuid=rpc_node.uuid)
+        return api_connector
+
+    @METRICS.timer('VolumeConnectorsController.delete')
+    @expose.expose(None, types.uuid, status_code=http_client.NO_CONTENT)
+    def delete(self, connector_uuid):
+        """Delete a volume connector.
+
+        :param connector_uuid: UUID of a volume connector.
+
+        :raises: OperationNotPermitted if accessed with specifying a
+                 parent node.
+        :raises: NodeLocked if node is locked by another conductor
+        :raises: NodeNotFound if the node associated with the connector does
+                 not exist
+        :raises: VolumeConnectorNotFound if the volume connector cannot be
+                 found
+        :raises: InvalidStateRequested If a node associated with the
+                 volume connector is not powered off.
+        """
+        context = pecan.request.context
+        cdict = context.to_policy_values()
+        policy.authorize('baremetal:volume:delete', cdict, cdict)
+
+        if self.parent_node_ident:
+            raise exception.OperationNotPermitted()
+
+        rpc_connector = objects.VolumeConnector.get_by_uuid(context,
+                                                            connector_uuid)
+        rpc_node = objects.Node.get_by_id(context, rpc_connector.node_id)
+        notify.emit_start_notification(context, rpc_connector, 'delete',
+                                       node_uuid=rpc_node.uuid)
+        with notify.handle_error_notification(context, rpc_connector,
+                                              'delete',
+                                              node_uuid=rpc_node.uuid):
+            topic = pecan.request.rpcapi.get_topic_for(rpc_node)
+            pecan.request.rpcapi.destroy_volume_connector(context,
+                                                          rpc_connector, topic)
+        notify.emit_end_notification(context, rpc_connector, 'delete',
+                                     node_uuid=rpc_node.uuid)
diff --git a/ironic/api/controllers/v1/volume_target.py b/ironic/api/controllers/v1/volume_target.py
new file mode 100644
index 0000000000..8bddac636d
--- /dev/null
+++ b/ironic/api/controllers/v1/volume_target.py
@@ -0,0 +1,489 @@
+# Copyright (c) 2017 Hitachi, Ltd.
+#
+#    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.
+
+import datetime
+
+from ironic_lib import metrics_utils
+from oslo_utils import uuidutils
+import pecan
+from pecan import rest
+import six
+from six.moves import http_client
+import wsme
+from wsme import types as wtypes
+
+from ironic.api.controllers import base
+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 types
+from ironic.api.controllers.v1 import utils as api_utils
+from ironic.api import expose
+from ironic.common import exception
+from ironic.common.i18n import _
+from ironic.common import policy
+from ironic import objects
+
+METRICS = metrics_utils.get_metrics_logger(__name__)
+
+_DEFAULT_RETURN_FIELDS = ('uuid', 'node_uuid', 'volume_type',
+                          'boot_index', 'volume_id')
+
+
+class VolumeTarget(base.APIBase):
+    """API representation of a volume target.
+
+    This class enforces type checking and value constraints, and converts
+    between the internal object model and the API representation of a volume
+    target.
+    """
+
+    _node_uuid = None
+
+    def _get_node_uuid(self):
+        return self._node_uuid
+
+    def _set_node_identifiers(self, value):
+        """Set both UUID and ID of a node for VolumeTarget object
+
+        :param value: UUID, ID of a node, or wtypes.Unset
+        """
+        if value == wtypes.Unset:
+            self._node_uuid = wtypes.Unset
+        elif value and self._node_uuid != value:
+            try:
+                node = objects.Node.get(pecan.request.context, value)
+                self._node_uuid = node.uuid
+                # NOTE(smoriya): Create the node_id attribute on-the-fly
+                #                to satisfy the api -> rpc object conversion.
+                self.node_id = node.id
+            except exception.NodeNotFound as e:
+                # Change error code because 404 (NotFound) is inappropriate
+                # response for a POST request to create a VolumeTarget
+                e.code = http_client.BAD_REQUEST  # BadRequest
+                raise
+
+    uuid = types.uuid
+    """Unique UUID for this volume target"""
+
+    volume_type = wsme.wsattr(wtypes.text, mandatory=True)
+    """The volume_type of volume target"""
+
+    properties = {wtypes.text: types.jsontype}
+    """The properties for this volume target"""
+
+    boot_index = wsme.wsattr(int, mandatory=True)
+    """The boot_index of volume target"""
+
+    volume_id = wsme.wsattr(wtypes.text, mandatory=True)
+    """The volume_id for this volume target"""
+
+    extra = {wtypes.text: types.jsontype}
+    """The metadata for this volume target"""
+
+    node_uuid = wsme.wsproperty(types.uuid, _get_node_uuid,
+                                _set_node_identifiers, mandatory=True)
+    """The UUID of the node this volume target belongs to"""
+
+    links = wsme.wsattr([link.Link], readonly=True)
+    """A list containing a self link and associated volume target links"""
+
+    def __init__(self, **kwargs):
+        self.fields = []
+        fields = list(objects.VolumeTarget.fields)
+        for field in fields:
+            # Skip fields we do not expose.
+            if not hasattr(self, field):
+                continue
+            self.fields.append(field)
+            setattr(self, field, kwargs.get(field, wtypes.Unset))
+
+        # NOTE(smoriya): node_id is an attribute created on-the-fly
+        # by _set_node_uuid(), it needs to be present in the fields so
+        # that as_dict() will contain node_id field when converting it
+        # before saving it in the database.
+        self.fields.append('node_id')
+        # NOTE(smoriya): node_uuid is not part of objects.VolumeTarget.-
+        #                fields because it's an API-only attribute
+        self.fields.append('node_uuid')
+        # NOTE(jtaryma): Additionally to node_uuid, node_id is handled as a
+        # secondary identifier in case RPC volume target object dictionary
+        # was passed to the constructor.
+        self.node_uuid = kwargs.get('node_uuid') or kwargs.get('node_id',
+                                                               wtypes.Unset)
+
+    @staticmethod
+    def _convert_with_links(target, url, fields=None):
+        # NOTE(lucasagomes): Since we are able to return a specified set of
+        # fields the "uuid" can be unset, so we need to save it in another
+        # variable to use when building the links
+        target_uuid = target.uuid
+        if fields is not None:
+            target.unset_fields_except(fields)
+
+        # never expose the node_id attribute
+        target.node_id = wtypes.Unset
+        target.links = [link.Link.make_link('self', url,
+                                            'volume/targets',
+                                            target_uuid),
+                        link.Link.make_link('bookmark', url,
+                                            'volume/targets',
+                                            target_uuid,
+                                            bookmark=True)
+                        ]
+        return target
+
+    @classmethod
+    def convert_with_links(cls, rpc_target, fields=None):
+        target = VolumeTarget(**rpc_target.as_dict())
+
+        if fields is not None:
+            api_utils.check_for_invalid_fields(fields, target.as_dict())
+
+        return cls._convert_with_links(target, pecan.request.public_url,
+                                       fields=fields)
+
+    @classmethod
+    def sample(cls, expand=True):
+        properties = {"auth_method": "CHAP",
+                      "auth_username": "XXX",
+                      "auth_password": "XXX",
+                      "target_iqn": "iqn.2010-10.com.example:vol-X",
+                      "target_portal": "192.168.0.123:3260",
+                      "volume_id": "a2f3ff15-b3ea-4656-ab90-acbaa1a07607",
+                      "target_lun": 0,
+                      "access_mode": "rw"}
+
+        sample = cls(uuid='667808d4-622f-4629-b629-07753a19e633',
+                     volume_type='iscsi',
+                     boot_index=0,
+                     volume_id='a2f3ff15-b3ea-4656-ab90-acbaa1a07607',
+                     properties=properties,
+                     extra={'foo': 'bar'},
+                     created_at=datetime.datetime.utcnow(),
+                     updated_at=datetime.datetime.utcnow())
+        sample._node_uuid = '7ae81bb3-dec3-4289-8d6c-da80bd8001ae'
+        fields = None if expand else _DEFAULT_RETURN_FIELDS
+        return cls._convert_with_links(sample, 'http://localhost:6385',
+                                       fields=fields)
+
+
+class VolumeTargetPatchType(types.JsonPatchType):
+
+    _api_base = VolumeTarget
+
+
+class VolumeTargetCollection(collection.Collection):
+    """API representation of a collection of volume targets."""
+
+    targets = [VolumeTarget]
+    """A list containing volume target objects"""
+
+    def __init__(self, **kwargs):
+        self._type = 'targets'
+
+    @staticmethod
+    def convert_with_links(rpc_targets, limit, url=None, fields=None,
+                           detail=None, **kwargs):
+        collection = VolumeTargetCollection()
+        collection.targets = [
+            VolumeTarget.convert_with_links(p, fields=fields)
+            for p in rpc_targets]
+        if detail:
+            kwargs['detail'] = detail
+        collection.next = collection.get_next(limit, url=url, **kwargs)
+        return collection
+
+    @classmethod
+    def sample(cls):
+        sample = cls()
+        sample.targets = [VolumeTarget.sample(expand=False)]
+        return sample
+
+
+class VolumeTargetsController(rest.RestController):
+    """REST controller for VolumeTargets."""
+
+    invalid_sort_key_list = ['extra', 'properties']
+
+    def __init__(self, node_ident=None):
+        super(VolumeTargetsController, self).__init__()
+        self.parent_node_ident = node_ident
+
+    def _get_volume_targets_collection(self, node_ident, marker, limit,
+                                       sort_key, sort_dir, resource_url=None,
+                                       fields=None, detail=None):
+        limit = api_utils.validate_limit(limit)
+        sort_dir = api_utils.validate_sort_dir(sort_dir)
+
+        marker_obj = None
+        if marker:
+            marker_obj = objects.VolumeTarget.get_by_uuid(
+                pecan.request.context, marker)
+
+        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})
+
+        node_ident = self.parent_node_ident or node_ident
+
+        if node_ident:
+            # FIXME(comstud): Since all we need is the node ID, we can
+            #                 make this more efficient by only querying
+            #                 for that column. This will get cleaned up
+            #                 as we move to the object interface.
+            node = api_utils.get_rpc_node(node_ident)
+            targets = objects.VolumeTarget.list_by_node_id(
+                pecan.request.context, node.id, limit, marker_obj,
+                sort_key=sort_key, sort_dir=sort_dir)
+        else:
+            targets = objects.VolumeTarget.list(pecan.request.context,
+                                                limit, marker_obj,
+                                                sort_key=sort_key,
+                                                sort_dir=sort_dir)
+        return VolumeTargetCollection.convert_with_links(targets, limit,
+                                                         url=resource_url,
+                                                         fields=fields,
+                                                         sort_key=sort_key,
+                                                         sort_dir=sort_dir,
+                                                         detail=detail)
+
+    @METRICS.timer('VolumeTargetsController.get_all')
+    @expose.expose(VolumeTargetCollection, types.uuid_or_name, types.uuid,
+                   int, wtypes.text, wtypes.text, types.listtype,
+                   types.boolean)
+    def get_all(self, node=None, marker=None, limit=None, sort_key='id',
+                sort_dir='asc', fields=None, detail=None):
+        """Retrieve a list of volume targets.
+
+        :param node: UUID or name of a node, to get only volume targets
+                     for that node.
+        :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 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, whether to retrieve with detail.
+
+        :returns: a list of volume targets, or an empty list if no volume
+                  target is found.
+
+        :raises: InvalidParameterValue if sort_key does not exist
+        :raises: InvalidParameterValue if sort key is invalid for sorting.
+        :raises: InvalidParameterValue if both fields and detail are specified.
+        """
+        cdict = pecan.request.context.to_policy_values()
+        policy.authorize('baremetal:volume:get', cdict, cdict)
+
+        if fields is None and not detail:
+            fields = _DEFAULT_RETURN_FIELDS
+
+        if fields and detail:
+            raise exception.InvalidParameterValue(
+                _("Can't fetch a subset of fields with 'detail' set"))
+
+        resource_url = 'volume/targets'
+        return self._get_volume_targets_collection(node, marker, limit,
+                                                   sort_key, sort_dir,
+                                                   resource_url=resource_url,
+                                                   fields=fields,
+                                                   detail=detail)
+
+    @METRICS.timer('VolumeTargetsController.get_one')
+    @expose.expose(VolumeTarget, types.uuid, types.listtype)
+    def get_one(self, target_uuid, fields=None):
+        """Retrieve information about the given volume target.
+
+        :param target_uuid: UUID of a volume target.
+        :param fields: Optional, a list with a specified set of fields
+               of the resource to be returned.
+
+        :returns: API-serializable volume target object.
+
+        :raises: OperationNotPermitted if accessed with specifying a parent
+                 node.
+        :raises: VolumeTargetNotFound if no volume target with this UUID exists
+        """
+        cdict = pecan.request.context.to_policy_values()
+        policy.authorize('baremetal:volume:get', cdict, cdict)
+
+        if self.parent_node_ident:
+            raise exception.OperationNotPermitted()
+
+        rpc_target = objects.VolumeTarget.get_by_uuid(
+            pecan.request.context, target_uuid)
+        return VolumeTarget.convert_with_links(rpc_target, fields=fields)
+
+    @METRICS.timer('VolumeTargetsController.post')
+    @expose.expose(VolumeTarget, body=VolumeTarget,
+                   status_code=http_client.CREATED)
+    def post(self, target):
+        """Create a new volume target.
+
+        :param target: a volume target within the request body.
+
+        :returns: API-serializable volume target object.
+
+        :raises: OperationNotPermitted if accessed with specifying a parent
+                 node.
+        :raises: VolumeTargetBootIndexAlreadyExists if a volume target already
+                 exists with the same node ID and boot index
+        :raises: VolumeTargetAlreadyExists if a volume target with the same
+                 UUID exists
+        """
+        context = pecan.request.context
+        cdict = context.to_policy_values()
+        policy.authorize('baremetal:volume:create', cdict, cdict)
+
+        if self.parent_node_ident:
+            raise exception.OperationNotPermitted()
+
+        target_dict = target.as_dict()
+        # NOTE(hshiina): UUID is mandatory for notification payload
+        if not target_dict.get('uuid'):
+            target_dict['uuid'] = uuidutils.generate_uuid()
+
+        new_target = objects.VolumeTarget(context, **target_dict)
+
+        notify.emit_start_notification(context, new_target, 'create',
+                                       node_uuid=target.node_uuid)
+        with notify.handle_error_notification(context, new_target, 'create',
+                                              node_uuid=target.node_uuid):
+            new_target.create()
+        notify.emit_end_notification(context, new_target, 'create',
+                                     node_uuid=target.node_uuid)
+        # Set the HTTP Location Header
+        pecan.response.location = link.build_url('volume/targets',
+                                                 new_target.uuid)
+        return VolumeTarget.convert_with_links(new_target)
+
+    @METRICS.timer('VolumeTargetsController.patch')
+    @wsme.validate(types.uuid, [VolumeTargetPatchType])
+    @expose.expose(VolumeTarget, types.uuid,
+                   body=[VolumeTargetPatchType])
+    def patch(self, target_uuid, patch):
+        """Update an existing volume target.
+
+        :param target_uuid: UUID of a volume target.
+        :param patch: a json PATCH document to apply to this volume target.
+
+        :returns: API-serializable volume target object.
+
+        :raises: OperationNotPermitted if accessed with specifying a
+                 parent node.
+        :raises: PatchError if a given patch can not be applied.
+        :raises: InvalidParameterValue if the volume target's UUID is being
+                 changed
+        :raises: NodeLocked if the node is already locked
+        :raises: NodeNotFound if the node associated with the volume target
+                 does not exist
+        :raises: VolumeTargetNotFound if the volume target cannot be found
+        :raises: VolumeTargetBootIndexAlreadyExists if a volume target already
+                 exists with the same node ID and boot index values
+        :raises: InvalidUUID if invalid node UUID is passed in the patch.
+        :raises: InvalidStateRequested If a node associated with the
+                 volume target is not powered off.
+        """
+        context = pecan.request.context
+        cdict = context.to_policy_values()
+        policy.authorize('baremetal:volume:update', cdict, cdict)
+
+        if self.parent_node_ident:
+            raise exception.OperationNotPermitted()
+
+        values = api_utils.get_patch_values(patch, '/node_uuid')
+        for value in values:
+            if not uuidutils.is_uuid_like(value):
+                message = _("Expected a UUID for node_uuid, but received "
+                            "%(uuid)s.") % {'uuid': six.text_type(value)}
+                raise exception.InvalidUUID(message=message)
+
+        rpc_target = objects.VolumeTarget.get_by_uuid(context, target_uuid)
+        try:
+            target_dict = rpc_target.as_dict()
+            # NOTE(smoriya):
+            # 1) Remove node_id because it's an internal value and
+            #    not present in the API object
+            # 2) Add node_uuid
+            target_dict['node_uuid'] = target_dict.pop('node_id', None)
+            target = VolumeTarget(
+                **api_utils.apply_jsonpatch(target_dict, patch))
+        except api_utils.JSONPATCH_EXCEPTIONS as e:
+            raise exception.PatchError(patch=patch, reason=e)
+
+        # Update only the fields that have changed.
+        for field in objects.VolumeTarget.fields:
+            try:
+                patch_val = getattr(target, field)
+            except AttributeError:
+                # Ignore fields that aren't exposed in the API
+                continue
+            if patch_val == wtypes.Unset:
+                patch_val = None
+            if rpc_target[field] != patch_val:
+                rpc_target[field] = patch_val
+
+        rpc_node = objects.Node.get_by_id(context, rpc_target.node_id)
+        notify.emit_start_notification(context, rpc_target, 'update',
+                                       node_uuid=rpc_node.uuid)
+        with notify.handle_error_notification(context, rpc_target, 'update',
+                                              node_uuid=rpc_node.uuid):
+            topic = pecan.request.rpcapi.get_topic_for(rpc_node)
+            new_target = pecan.request.rpcapi.update_volume_target(
+                context, rpc_target, topic)
+
+        api_target = VolumeTarget.convert_with_links(new_target)
+        notify.emit_end_notification(context, new_target, 'update',
+                                     node_uuid=rpc_node.uuid)
+        return api_target
+
+    @METRICS.timer('VolumeTargetsController.delete')
+    @expose.expose(None, types.uuid, status_code=http_client.NO_CONTENT)
+    def delete(self, target_uuid):
+        """Delete a volume target.
+
+        :param target_uuid: UUID of a volume target.
+
+        :raises: OperationNotPermitted if accessed with specifying a
+                 parent node.
+        :raises: NodeLocked if node is locked by another conductor
+        :raises: NodeNotFound if the node associated with the target does
+                 not exist
+        :raises: VolumeTargetNotFound if the volume target cannot be found
+        :raises: InvalidStateRequested If a node associated with the
+                 volume target is not powered off.
+        """
+        context = pecan.request.context
+        cdict = context.to_policy_values()
+        policy.authorize('baremetal:volume:delete', cdict, cdict)
+
+        if self.parent_node_ident:
+            raise exception.OperationNotPermitted()
+
+        rpc_target = objects.VolumeTarget.get_by_uuid(context, target_uuid)
+        rpc_node = objects.Node.get_by_id(context, rpc_target.node_id)
+        notify.emit_start_notification(context, rpc_target, 'delete',
+                                       node_uuid=rpc_node.uuid)
+        with notify.handle_error_notification(context, rpc_target, 'delete',
+                                              node_uuid=rpc_node.uuid):
+            topic = pecan.request.rpcapi.get_topic_for(rpc_node)
+            pecan.request.rpcapi.destroy_volume_target(context,
+                                                       rpc_target, topic)
+        notify.emit_end_notification(context, rpc_target, 'delete',
+                                     node_uuid=rpc_node.uuid)
diff --git a/ironic/common/policy.py b/ironic/common/policy.py
index aad52d52f1..3bdc40f4ca 100644
--- a/ironic/common/policy.py
+++ b/ironic/common/policy.py
@@ -204,6 +204,25 @@ extra_policies = [
                        description='Access IPA ramdisk functions'),
 ]
 
+volume_policies = [
+    policy.RuleDefault('baremetal:volume:get',
+                       'rule:is_admin or rule:is_observer',
+                       description='Retrieve Volume connector and target '
+                                   'records'),
+    policy.RuleDefault('baremetal:volume:create',
+                       'rule:is_admin',
+                       description='Create Volume connector and target '
+                                   'records'),
+    policy.RuleDefault('baremetal:volume:delete',
+                       'rule:is_admin',
+                       description='Delete Volume connetor and target '
+                                   'records'),
+    policy.RuleDefault('baremetal:volume:update',
+                       'rule:is_admin',
+                       description='Update Volume connector and target '
+                                   'records'),
+]
+
 
 def list_policies():
     policies = (default_policies
@@ -212,7 +231,8 @@ def list_policies():
                 + portgroup_policies
                 + chassis_policies
                 + driver_policies
-                + extra_policies)
+                + extra_policies
+                + volume_policies)
     return policies
 
 
diff --git a/ironic/tests/unit/api/test_root.py b/ironic/tests/unit/api/test_root.py
index f55fed07ba..a4dade70ab 100644
--- a/ironic/tests/unit/api/test_root.py
+++ b/ironic/tests/unit/api/test_root.py
@@ -69,3 +69,10 @@ class TestV1Root(base.BaseApiTest):
                             additional_expected_resources=['heartbeat',
                                                            'lookup',
                                                            'portgroups'])
+
+    def test_get_v1_32_root(self):
+        self._test_get_root(headers={'X-OpenStack-Ironic-API-Version': '1.32'},
+                            additional_expected_resources=['heartbeat',
+                                                           'lookup',
+                                                           'portgroups',
+                                                           'volume'])
diff --git a/ironic/tests/unit/api/utils.py b/ironic/tests/unit/api/utils.py
index 7e49983fc1..d26864c607 100644
--- a/ironic/tests/unit/api/utils.py
+++ b/ironic/tests/unit/api/utils.py
@@ -23,6 +23,8 @@ from ironic.api.controllers.v1 import chassis as chassis_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 volume_connector as vc_controller
+from ironic.api.controllers.v1 import volume_target as vt_controller
 from ironic.drivers import base as drivers_base
 from ironic.tests.unit.db import utils as db_utils
 
@@ -124,6 +126,24 @@ def port_post_data(**kw):
     return remove_internal(port, internal)
 
 
+def volume_connector_post_data(**kw):
+    connector = db_utils.get_test_volume_connector(**kw)
+    # These values are not part of the API object
+    connector.pop('node_id')
+    connector.pop('version')
+    internal = vc_controller.VolumeConnectorPatchType.internal_attrs()
+    return remove_internal(connector, internal)
+
+
+def volume_target_post_data(**kw):
+    target = db_utils.get_test_volume_target(**kw)
+    # These values are not part of the API object
+    target.pop('node_id')
+    target.pop('version')
+    internal = vt_controller.VolumeTargetPatchType.internal_attrs()
+    return remove_internal(target, internal)
+
+
 def chassis_post_data(**kw):
     chassis = db_utils.get_test_chassis(**kw)
     # version is not part of the API object
diff --git a/ironic/tests/unit/api/v1/test_nodes.py b/ironic/tests/unit/api/v1/test_nodes.py
index a061c37514..be5a1a7f27 100644
--- a/ironic/tests/unit/api/v1/test_nodes.py
+++ b/ironic/tests/unit/api/v1/test_nodes.py
@@ -402,6 +402,17 @@ class TestListNodes(test_api_base.BaseApiTest):
             self.assertEqual(getattr(node, field),
                              new_data['nodes'][0][field])
 
+    def test_hide_fields_in_newer_versions_volume(self):
+        node = obj_utils.create_test_node(self.context)
+        data = self.get_json(
+            '/nodes/%s' % node.uuid,
+            headers={api_base.Version.string: '1.31'})
+        self.assertNotIn('volume', data)
+
+        data = self.get_json('/nodes/%s' % node.uuid,
+                             headers={api_base.Version.string: "1.32"})
+        self.assertIn('volume', data)
+
     def test_many(self):
         nodes = []
         for id in range(5):
@@ -630,6 +641,113 @@ class TestListNodes(test_api_base.BaseApiTest):
             headers={api_base.Version.string: '1.24'})
         self.assertEqual(http_client.FORBIDDEN, response.status_int)
 
+    def test_volume_subresource_link(self):
+        node = obj_utils.create_test_node(self.context)
+        data = self.get_json(
+            '/nodes/%s' % node.uuid,
+            headers={api_base.Version.string: '1.32'})
+        self.assertIn('volume', data)
+
+    def test_volume_subresource(self):
+        node = obj_utils.create_test_node(self.context)
+        data = self.get_json('/nodes/%s/volume' % node.uuid,
+                             headers={api_base.Version.string: '1.32'})
+        self.assertIn('connectors', data)
+        self.assertIn('targets', data)
+        self.assertIn('/volume/connectors',
+                      data['connectors'][0]['href'])
+        self.assertIn('/volume/connectors',
+                      data['connectors'][1]['href'])
+        self.assertIn('/volume/targets',
+                      data['targets'][0]['href'])
+        self.assertIn('/volume/targets',
+                      data['targets'][1]['href'])
+
+    def test_volume_subresource_invalid_api_version(self):
+        node = obj_utils.create_test_node(self.context)
+        response = self.get_json('/nodes/%s/volume' % node.uuid,
+                                 headers={api_base.Version.string: '1.31'},
+                                 expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_volume_connectors_subresource(self):
+        node = obj_utils.create_test_node(self.context)
+
+        for id_ in range(2):
+            obj_utils.create_test_volume_connector(
+                self.context, node_id=node.id, uuid=uuidutils.generate_uuid(),
+                connector_id='test-connector_id-%s' % id_)
+
+        data = self.get_json(
+            '/nodes/%s/volume/connectors' % node.uuid,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(2, len(data['connectors']))
+        self.assertNotIn('next', data)
+
+        # Test collection pagination
+        data = self.get_json(
+            '/nodes/%s/volume/connectors?limit=1' % node.uuid,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(1, len(data['connectors']))
+        self.assertIn('next', data)
+
+    def test_volume_connectors_subresource_noid(self):
+        node = obj_utils.create_test_node(self.context)
+        obj_utils.create_test_volume_connector(self.context, node_id=node.id)
+        # No node_id specified.
+        response = self.get_json(
+            '/nodes/volume/connectors',
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_volume_connectors_subresource_node_not_found(self):
+        non_existent_uuid = 'eeeeeeee-cccc-aaaa-bbbb-cccccccccccc'
+        response = self.get_json(
+            '/nodes/%s/volume/connectors' % non_existent_uuid,
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_volume_targets_subresource(self):
+        node = obj_utils.create_test_node(self.context)
+
+        for id_ in range(2):
+            obj_utils.create_test_volume_target(
+                self.context, node_id=node.id, uuid=uuidutils.generate_uuid(),
+                boot_index=id_)
+
+        data = self.get_json(
+            '/nodes/%s/volume/targets' % node.uuid,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(2, len(data['targets']))
+        self.assertNotIn('next', data)
+
+        # Test collection pagination
+        data = self.get_json(
+            '/nodes/%s/volume/targets?limit=1' % node.uuid,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(1, len(data['targets']))
+        self.assertIn('next', data)
+
+    def test_volume_targets_subresource_noid(self):
+        node = obj_utils.create_test_node(self.context)
+        obj_utils.create_test_volume_target(self.context, node_id=node.id)
+        # No node_id specified.
+        response = self.get_json(
+            '/nodes/volume/targets',
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_volume_targets_subresource_node_not_found(self):
+        non_existent_uuid = 'eeeeeeee-cccc-aaaa-bbbb-cccccccccccc'
+        response = self.get_json(
+            '/nodes/%s/volume/targets' % non_existent_uuid,
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
     @mock.patch.object(timeutils, 'utcnow')
     def _test_node_states(self, mock_utcnow, api_version=None):
         fake_state = 'fake-state'
@@ -1382,6 +1500,37 @@ class TestPatch(test_api_base.BaseApiTest):
             headers={'X-OpenStack-Ironic-API-Version': '1.24'})
         self.assertEqual(http_client.FORBIDDEN, response.status_int)
 
+    def test_patch_volume_connectors_subresource_no_connector_id(self):
+        response = self.patch_json(
+            '/nodes/%s/volume/connectors' % self.node.uuid,
+            [{'path': '/extra/foo', 'value': 'bar', 'op': 'add'}],
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+    def test_patch_volume_connectors_subresource(self):
+        connector = (
+            obj_utils.create_test_volume_connector(self.context,
+                                                   node_id=self.node.id))
+        response = self.patch_json(
+            '/nodes/%s/volume/connectors/%s' % (self.node.uuid,
+                                                connector.uuid),
+            [{'path': '/extra/foo', 'value': 'bar', 'op': 'add'}],
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.FORBIDDEN, response.status_int)
+
+    def test_patch_volume_targets_subresource(self):
+        target = obj_utils.create_test_volume_target(self.context,
+                                                     node_id=self.node.id)
+        response = self.patch_json(
+            '/nodes/%s/volume/targets/%s' % (self.node.uuid,
+                                             target.uuid),
+            [{'path': '/extra/foo', 'value': 'bar', 'op': 'add'}],
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.FORBIDDEN, response.status_int)
+
     def test_remove_uuid(self):
         response = self.patch_json('/nodes/%s' % self.node.uuid,
                                    [{'path': '/uuid', 'op': 'remove'}],
@@ -2194,6 +2343,36 @@ class TestPost(test_api_base.BaseApiTest):
             headers={'X-OpenStack-Ironic-API-Version': '1.24'})
         self.assertEqual(http_client.FORBIDDEN, response.status_int)
 
+    def test_post_volume_connectors_subresource_no_node_id(self):
+        node = obj_utils.create_test_node(self.context)
+        pdict = test_api_utils.volume_connector_post_data(node_id=None)
+        pdict['node_uuid'] = node.uuid
+        response = self.post_json(
+            '/nodes/volume/connectors', pdict,
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_post_volume_connectors_subresource(self):
+        node = obj_utils.create_test_node(self.context)
+        pdict = test_api_utils.volume_connector_post_data(node_id=None)
+        pdict['node_uuid'] = node.uuid
+        response = self.post_json(
+            '/nodes/%s/volume/connectors' % node.uuid, pdict,
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.FORBIDDEN, response.status_int)
+
+    def test_post_volume_targets_subresource(self):
+        node = obj_utils.create_test_node(self.context)
+        pdict = test_api_utils.volume_target_post_data(node_id=None)
+        pdict['node_uuid'] = node.uuid
+        response = self.post_json(
+            '/nodes/%s/volume/targets' % node.uuid, pdict,
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.FORBIDDEN, response.status_int)
+
     def test_create_node_no_mandatory_field_driver(self):
         ndict = test_api_utils.post_get_test_node()
         del ndict['driver']
@@ -2427,6 +2606,34 @@ class TestDelete(test_api_base.BaseApiTest):
             headers={'X-OpenStack-Ironic-API-Version': '1.24'})
         self.assertEqual(http_client.FORBIDDEN, response.status_int)
 
+    def test_delete_volume_connectors_subresource_no_connector_id(self):
+        node = obj_utils.create_test_node(self.context)
+        response = self.delete(
+            '/nodes/%s/volume/connectors' % node.uuid,
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+    def test_delete_volume_connectors_subresource(self):
+        node = obj_utils.create_test_node(self.context)
+        connector = obj_utils.create_test_volume_connector(self.context,
+                                                           node_id=node.id)
+        response = self.delete(
+            '/nodes/%s/volume/connectors/%s' % (node.uuid, connector.uuid),
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.FORBIDDEN, response.status_int)
+
+    def test_delete_volume_targets_subresource(self):
+        node = obj_utils.create_test_node(self.context)
+        target = obj_utils.create_test_volume_target(self.context,
+                                                     node_id=node.id)
+        response = self.delete(
+            '/nodes/%s/volume/targets/%s' % (node.uuid, target.uuid),
+            expect_errors=True,
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
+        self.assertEqual(http_client.FORBIDDEN, response.status_int)
+
     @mock.patch.object(notification_utils, '_emit_api_notification')
     @mock.patch.object(rpcapi.ConductorAPI, 'destroy_node')
     def test_delete_associated(self, mock_dn, mock_notify):
diff --git a/ironic/tests/unit/api/v1/test_utils.py b/ironic/tests/unit/api/v1/test_utils.py
index 6a2fa77032..c0a4f77400 100644
--- a/ironic/tests/unit/api/v1/test_utils.py
+++ b/ironic/tests/unit/api/v1/test_utils.py
@@ -412,6 +412,13 @@ class TestApiUtils(base.TestCase):
         mock_request.version.minor = 29
         self.assertFalse(utils.allow_dynamic_drivers())
 
+    @mock.patch.object(pecan, 'request', spec_set=['version'])
+    def test_allow_volume(self, mock_request):
+        mock_request.version.minor = 32
+        self.assertTrue(utils.allow_volume())
+        mock_request.version.minor = 31
+        self.assertFalse(utils.allow_volume())
+
 
 class TestNodeIdent(base.TestCase):
 
diff --git a/ironic/tests/unit/api/v1/test_volume.py b/ironic/tests/unit/api/v1/test_volume.py
new file mode 100644
index 0000000000..69c888353b
--- /dev/null
+++ b/ironic/tests/unit/api/v1/test_volume.py
@@ -0,0 +1,55 @@
+#    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 /volume/ methods.
+"""
+
+from six.moves import http_client
+
+from ironic.api.controllers import base as api_base
+from ironic.api.controllers import v1 as api_v1
+from ironic.tests.unit.api import base as test_api_base
+
+
+class TestGetVolume(test_api_base.BaseApiTest):
+
+    def setUp(self):
+        super(TestGetVolume, self).setUp()
+
+    def _test_links(self, data, key, headers):
+        self.assertIn(key, data)
+        self.assertEqual(2, len(data[key]))
+        for l in data[key]:
+            bookmark = (l['rel'] == 'bookmark')
+            self.assertTrue(self.validate_link(l['href'],
+                                               bookmark=bookmark,
+                                               headers=headers))
+
+    def test_get_volume(self):
+        headers = {api_base.Version.string: str(api_v1.MAX_VER)}
+        data = self.get_json('/volume/', headers=headers)
+        for key in ['links', 'connectors', 'targets']:
+            self._test_links(data, key, headers)
+        self.assertIn('/volume/connectors',
+                      data['connectors'][0]['href'])
+        self.assertIn('/volume/connectors',
+                      data['connectors'][1]['href'])
+        self.assertIn('/volume/targets',
+                      data['targets'][0]['href'])
+        self.assertIn('/volume/targets',
+                      data['targets'][1]['href'])
+
+    def test_get_volume_invalid_api_version(self):
+        headers = {api_base.Version.string: str(api_v1.MIN_VER)}
+        response = self.get_json('/volume/', headers=headers,
+                                 expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
diff --git a/ironic/tests/unit/api/v1/test_volume_connectors.py b/ironic/tests/unit/api/v1/test_volume_connectors.py
new file mode 100644
index 0000000000..bdeef82dce
--- /dev/null
+++ b/ironic/tests/unit/api/v1/test_volume_connectors.py
@@ -0,0 +1,943 @@
+# -*- encoding: utf-8 -*-
+#
+#    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 /volume connectors/ methods.
+"""
+
+import datetime
+
+import mock
+from oslo_config import cfg
+from oslo_utils import timeutils
+from oslo_utils import uuidutils
+import six
+from six.moves import http_client
+from six.moves.urllib import parse as urlparse
+from wsme import types as wtypes
+
+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.api.controllers.v1 import utils as api_utils
+from ironic.api.controllers.v1 import volume_connector as api_volume_connector
+from ironic.common import exception
+from ironic.conductor import rpcapi
+from ironic import objects
+from ironic.objects import fields as obj_fields
+from ironic.tests import base
+from ironic.tests.unit.api import base as test_api_base
+from ironic.tests.unit.api import utils as apiutils
+from ironic.tests.unit.db import utils as dbutils
+from ironic.tests.unit.objects import utils as obj_utils
+
+
+def post_get_test_volume_connector(**kw):
+    connector = apiutils.volume_connector_post_data(**kw)
+    node = dbutils.get_test_node()
+    connector['node_uuid'] = kw.get('node_uuid', node['uuid'])
+    return connector
+
+
+class TestVolumeConnectorObject(base.TestCase):
+
+    def test_volume_connector_init(self):
+        connector_dict = apiutils.volume_connector_post_data(node_id=None)
+        del connector_dict['extra']
+        connector = api_volume_connector.VolumeConnector(**connector_dict)
+        self.assertEqual(wtypes.Unset, connector.extra)
+
+
+class TestListVolumeConnectors(test_api_base.BaseApiTest):
+    headers = {api_base.Version.string: str(api_v1.MAX_VER)}
+
+    def setUp(self):
+        super(TestListVolumeConnectors, self).setUp()
+        self.node = obj_utils.create_test_node(self.context)
+
+    def test_empty(self):
+        data = self.get_json('/volume/connectors', headers=self.headers)
+        self.assertEqual([], data['connectors'])
+
+    def test_one(self):
+        connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+        data = self.get_json('/volume/connectors', headers=self.headers)
+        self.assertEqual(connector.uuid, data['connectors'][0]["uuid"])
+        self.assertNotIn('extra', data['connectors'][0])
+        # never expose the node_id
+        self.assertNotIn('node_id', data['connectors'][0])
+
+    def test_one_invalid_api_version(self):
+        obj_utils.create_test_volume_connector(self.context,
+                                               node_id=self.node.id)
+        response = self.get_json(
+            '/volume/connectors',
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
+            expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_get_one(self):
+        connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+        data = self.get_json('/volume/connectors/%s' % connector.uuid,
+                             headers=self.headers)
+        self.assertEqual(connector.uuid, data['uuid'])
+        self.assertIn('extra', data)
+        self.assertIn('node_uuid', data)
+        # never expose the node_id
+        self.assertNotIn('node_id', data)
+
+    def test_get_one_invalid_api_version(self):
+        connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+        response = self.get_json(
+            '/volume/connectors/%s' % connector.uuid,
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
+            expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_get_one_custom_fields(self):
+        connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+        fields = 'connector_id,extra'
+        data = self.get_json(
+            '/volume/connectors/%s?fields=%s' % (connector.uuid, fields),
+            headers=self.headers)
+        # We always append "links"
+        self.assertItemsEqual(['connector_id', 'extra', 'links'], data)
+
+    def test_get_collection_custom_fields(self):
+        fields = 'uuid,extra'
+        for i in range(3):
+            obj_utils.create_test_volume_connector(
+                self.context, node_id=self.node.id,
+                uuid=uuidutils.generate_uuid(),
+                connector_id='test-connector_id-%s' % i)
+
+        data = self.get_json(
+            '/volume/connectors?fields=%s' % fields,
+            headers=self.headers)
+
+        self.assertEqual(3, len(data['connectors']))
+        for connector in data['connectors']:
+            # We always append "links"
+            self.assertItemsEqual(['uuid', 'extra', 'links'], connector)
+
+    def test_get_custom_fields_invalid_fields(self):
+        connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+        fields = 'uuid,spongebob'
+        response = self.get_json(
+            '/volume/connectors/%s?fields=%s' % (connector.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_custom_fields_invalid_api_version(self):
+        connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+        fields = 'uuid,extra'
+        response = self.get_json(
+            '/volume/connectors/%s?fields=%s' % (connector.uuid, fields),
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
+            expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_detail(self):
+        connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+        data = self.get_json('/volume/connectors?detail=True',
+                             headers=self.headers)
+        self.assertEqual(connector.uuid, data['connectors'][0]["uuid"])
+        self.assertIn('extra', data['connectors'][0])
+        self.assertIn('node_uuid', data['connectors'][0])
+        # never expose the node_id
+        self.assertNotIn('node_id', data['connectors'][0])
+
+    def test_detail_false(self):
+        connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+        data = self.get_json('/volume/connectors?detail=False',
+                             headers=self.headers)
+        self.assertEqual(connector.uuid, data['connectors'][0]["uuid"])
+        self.assertNotIn('extra', data['connectors'][0])
+        # never expose the node_id
+        self.assertNotIn('node_id', data['connectors'][0])
+
+    def test_detail_invalid_api_version(self):
+        obj_utils.create_test_volume_connector(self.context,
+                                               node_id=self.node.id)
+        response = self.get_json(
+            '/volume/connectors?detail=True',
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
+            expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_detail_sepecified_by_path(self):
+        obj_utils.create_test_volume_connector(self.context,
+                                               node_id=self.node.id)
+        response = self.get_json(
+            '/volume/connectors/detail', headers=self.headers,
+            expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+    def test_detail_against_single(self):
+        connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+        response = self.get_json('/volume/connectors/%s?detail=True'
+                                 % connector.uuid,
+                                 expect_errors=True, headers=self.headers)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+    def test_detail_and_fields(self):
+        connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+        fields = 'connector_id,extra'
+        response = self.get_json('/volume/connectors/%s?detail=True&fields=%s'
+                                 % (connector.uuid, fields),
+                                 expect_errors=True, headers=self.headers)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+    def test_many(self):
+        connectors = []
+        for id_ in range(5):
+            connector = obj_utils.create_test_volume_connector(
+                self.context, node_id=self.node.id,
+                uuid=uuidutils.generate_uuid(),
+                connector_id='test-connector_id-%s' % id_)
+            connectors.append(connector.uuid)
+        data = self.get_json('/volume/connectors', headers=self.headers)
+        self.assertEqual(len(connectors), len(data['connectors']))
+
+        uuids = [n['uuid'] for n in data['connectors']]
+        six.assertCountEqual(self, connectors, uuids)
+
+    def test_links(self):
+        uuid = uuidutils.generate_uuid()
+        obj_utils.create_test_volume_connector(self.context,
+                                               uuid=uuid,
+                                               node_id=self.node.id)
+        data = self.get_json('/volume/connectors/%s' % uuid,
+                             headers=self.headers)
+        self.assertIn('links', data.keys())
+        self.assertEqual(2, len(data['links']))
+        self.assertIn(uuid, data['links'][0]['href'])
+        for l in data['links']:
+            bookmark = l['rel'] == 'bookmark'
+            self.assertTrue(self.validate_link(l['href'], bookmark=bookmark,
+                                               headers=self.headers))
+
+    def test_collection_links(self):
+        connectors = []
+        for id_ in range(5):
+            connector = obj_utils.create_test_volume_connector(
+                self.context,
+                node_id=self.node.id,
+                uuid=uuidutils.generate_uuid(),
+                connector_id='test-connector_id-%s' % id_)
+            connectors.append(connector.uuid)
+        data = self.get_json('/volume/connectors/?limit=3',
+                             headers=self.headers)
+        self.assertEqual(3, len(data['connectors']))
+
+        next_marker = data['connectors'][-1]['uuid']
+        self.assertIn(next_marker, data['next'])
+        self.assertIn('volume/connectors', data['next'])
+
+    def test_collection_links_default_limit(self):
+        cfg.CONF.set_override('max_limit', 3, 'api')
+        connectors = []
+        for id_ in range(5):
+            connector = obj_utils.create_test_volume_connector(
+                self.context,
+                node_id=self.node.id,
+                uuid=uuidutils.generate_uuid(),
+                connector_id='test-connector_id-%s' % id_)
+            connectors.append(connector.uuid)
+        data = self.get_json('/volume/connectors', headers=self.headers)
+        self.assertEqual(3, len(data['connectors']))
+        self.assertIn('volume/connectors', data['next'])
+
+        next_marker = data['connectors'][-1]['uuid']
+        self.assertIn(next_marker, data['next'])
+
+    def test_collection_links_detail(self):
+        connectors = []
+        for id_ in range(5):
+            connector = obj_utils.create_test_volume_connector(
+                self.context,
+                node_id=self.node.id,
+                uuid=uuidutils.generate_uuid(),
+                connector_id='test-connector_id-%s' % id_)
+            connectors.append(connector.uuid)
+        data = self.get_json('/volume/connectors?detail=True&limit=3',
+                             headers=self.headers)
+        self.assertEqual(3, len(data['connectors']))
+
+        next_marker = data['connectors'][-1]['uuid']
+        self.assertIn(next_marker, data['next'])
+        self.assertIn('volume/connectors', data['next'])
+        self.assertIn('detail=True', data['next'])
+
+    def test_sort_key(self):
+        connectors = []
+        for id_ in range(3):
+            connector = obj_utils.create_test_volume_connector(
+                self.context,
+                node_id=self.node.id,
+                uuid=uuidutils.generate_uuid(),
+                connector_id='test-connector_id-%s' % id_)
+            connectors.append(connector.uuid)
+        data = self.get_json('/volume/connectors?sort_key=uuid',
+                             headers=self.headers)
+        uuids = [n['uuid'] for n in data['connectors']]
+        self.assertEqual(sorted(connectors), uuids)
+
+    def test_sort_key_invalid(self):
+        invalid_keys_list = ['foo', 'extra']
+        for invalid_key in invalid_keys_list:
+            response = self.get_json('/volume/connectors?sort_key=%s'
+                                     % invalid_key,
+                                     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'])
+
+    @mock.patch.object(api_utils, 'get_rpc_node')
+    def test_get_all_by_node_name_ok(self, mock_get_rpc_node):
+        # GET /v1/volume/connectors specifying node_name - success
+        mock_get_rpc_node.return_value = self.node
+        for i in range(5):
+            if i < 3:
+                node_id = self.node.id
+            else:
+                node_id = 100000 + i
+            obj_utils.create_test_volume_connector(
+                self.context, node_id=node_id,
+                uuid=uuidutils.generate_uuid(),
+                connector_id='test-value-%s' % i)
+        data = self.get_json("/volume/connectors?node=%s" % 'test-node',
+                             headers=self.headers)
+        self.assertEqual(3, len(data['connectors']))
+
+    @mock.patch.object(api_utils, 'get_rpc_node')
+    def test_detail_by_node_name_ok(self, mock_get_rpc_node):
+        # GET /v1/volume/connectors?detail=True specifying node_name - success
+        mock_get_rpc_node.return_value = self.node
+        connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+        data = self.get_json('/volume/connectors?detail=True&node=%s' %
+                             'test-node',
+                             headers=self.headers)
+        self.assertEqual(connector.uuid, data['connectors'][0]['uuid'])
+        self.assertEqual(self.node.uuid, data['connectors'][0]['node_uuid'])
+
+
+@mock.patch.object(rpcapi.ConductorAPI, 'update_volume_connector')
+class TestPatch(test_api_base.BaseApiTest):
+    headers = {api_base.Version.string: str(api_v1.MAX_VER)}
+
+    def setUp(self):
+        super(TestPatch, self).setUp()
+        self.node = obj_utils.create_test_node(self.context)
+        self.connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+
+        p = mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for')
+        self.mock_gtf = p.start()
+        self.mock_gtf.return_value = 'test-topic'
+        self.addCleanup(p.stop)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification')
+    def test_update_byid(self, mock_notify, mock_upd):
+        extra = {'foo': 'bar'}
+        mock_upd.return_value = self.connector
+        mock_upd.return_value.extra = extra
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/extra/foo',
+                                     'value': 'bar',
+                                     'op': 'add'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(extra, response.json['extra'])
+
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(extra, kargs.extra)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START,
+                                      node_uuid=self.node.uuid),
+                                      mock.call(mock.ANY, mock.ANY, 'update',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.END,
+                                      node_uuid=self.node.uuid)])
+
+    def test_update_invalid_api_version(self, mock_upd):
+        headers = {api_base.Version.string: str(api_v1.MIN_VER)}
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/extra/foo',
+                                     'value': 'bar',
+                                     'op': 'add'}],
+                                   headers=headers,
+                                   expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_update_not_found(self, mock_upd):
+        uuid = uuidutils.generate_uuid()
+        response = self.patch_json('/volume/connectors/%s' % uuid,
+                                   [{'path': '/extra/foo',
+                                     'value': 'bar',
+                                     '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_upd.called)
+
+    def test_replace_singular(self, mock_upd):
+        connector_id = 'test-connector-id-999'
+        mock_upd.return_value = self.connector
+        mock_upd.return_value.connector_id = connector_id
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/connector_id',
+                                     'value': connector_id,
+                                     'op': 'replace'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(connector_id, response.json['connector_id'])
+        self.assertTrue(mock_upd.called)
+
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(connector_id, kargs.connector_id)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification')
+    def test_replace_connector_id_already_exist(self, mock_notify, mock_upd):
+        connector_id = 'test-connector-id-123'
+        mock_upd.side_effect = \
+            exception.VolumeConnectorTypeAndIdAlreadyExists(
+                type=None, connector_id=connector_id)
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/connector_id',
+                                     'value': connector_id,
+                                     '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'])
+        self.assertTrue(mock_upd.called)
+
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(connector_id, kargs.connector_id)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START,
+                                      node_uuid=self.node.uuid),
+                                      mock.call(mock.ANY, mock.ANY, 'update',
+                                      obj_fields.NotificationLevel.ERROR,
+                                      obj_fields.NotificationStatus.ERROR,
+                                      node_uuid=self.node.uuid)])
+
+    def test_replace_invalid_power_state(self, mock_upd):
+        connector_id = 'test-connector-id-123'
+        mock_upd.side_effect = \
+            exception.InvalidStateRequested(
+                action='volume connector update', node=self.node.uuid,
+                state='power on')
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/connector_id',
+                                     'value': connector_id,
+                                     'op': 'replace'}],
+                                   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'])
+        self.assertTrue(mock_upd.called)
+
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(connector_id, kargs.connector_id)
+
+    def test_replace_node_uuid(self, mock_upd):
+        mock_upd.return_value = self.connector
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/node_uuid',
+                                     'value': self.node.uuid,
+                                     'op': 'replace'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+
+    def test_replace_node_uuid_invalid_type(self, mock_upd):
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/node_uuid',
+                                     'value': 123,
+                                     'op': 'replace'}],
+                                   expect_errors=True, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertIn(b'Expected a UUID for node_uuid, but received 123.',
+                      response.body)
+        self.assertFalse(mock_upd.called)
+
+    def test_add_node_uuid(self, mock_upd):
+        mock_upd.return_value = self.connector
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/node_uuid',
+                                     'value': self.node.uuid,
+                                     'op': 'add'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+
+    def test_add_node_uuid_invalid_type(self, mock_upd):
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/node_uuid',
+                                     'value': 123,
+                                     'op': 'add'}],
+                                   expect_errors=True, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertIn(b'Expected a UUID for node_uuid, but received 123.',
+                      response.body)
+        self.assertFalse(mock_upd.called)
+
+    def test_add_node_id(self, mock_upd):
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/node_id',
+                                     'value': '1',
+                                     'op': 'add'}],
+                                   expect_errors=True, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertFalse(mock_upd.called)
+
+    def test_replace_node_id(self, mock_upd):
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/node_id',
+                                     'value': '1',
+                                     'op': 'replace'}],
+                                   expect_errors=True, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertFalse(mock_upd.called)
+
+    def test_remove_node_id(self, mock_upd):
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/node_id',
+                                     'op': 'remove'}],
+                                   expect_errors=True, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertFalse(mock_upd.called)
+
+    def test_replace_non_existent_node_uuid(self, mock_upd):
+        node_uuid = '12506333-a81c-4d59-9987-889ed5f8687b'
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/node_uuid',
+                                     'value': node_uuid,
+                                     'op': 'replace'}],
+                                   expect_errors=True, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertIn(node_uuid, response.json['error_message'])
+        self.assertFalse(mock_upd.called)
+
+    def test_replace_multi(self, mock_upd):
+        extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
+        self.connector.extra = extra
+        self.connector.save()
+
+        # mutate extra so we replace all of them
+        extra = dict((k, extra[k] + 'x') for k in extra.keys())
+
+        patch = []
+        for k in extra.keys():
+            patch.append({'path': '/extra/%s' % k,
+                          'value': extra[k],
+                          'op': 'replace'})
+        mock_upd.return_value = self.connector
+        mock_upd.return_value.extra = extra
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   patch, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(extra, response.json['extra'])
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(extra, kargs.extra)
+
+    def test_remove_multi(self, mock_upd):
+        extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
+        self.connector.extra = extra
+        self.connector.save()
+
+        # Remove one item from the collection.
+        extra.pop('foo1')
+        mock_upd.return_value = self.connector
+        mock_upd.return_value.extra = extra
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/extra/foo1',
+                                     'op': 'remove'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(extra, response.json['extra'])
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(extra, kargs.extra)
+
+        # Remove the collection.
+        extra = {}
+        mock_upd.return_value.extra = extra
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/extra', 'op': 'remove'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual({}, response.json['extra'])
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(extra, kargs.extra)
+
+        # Assert nothing else was changed.
+        self.assertEqual(self.connector.uuid, response.json['uuid'])
+        self.assertEqual(self.connector.type, response.json['type'])
+        self.assertEqual(self.connector.connector_id,
+                         response.json['connector_id'])
+
+    def test_remove_non_existent_property_fail(self, mock_upd):
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/extra/non-existent',
+                                     'op': 'remove'}],
+                                   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'])
+        self.assertFalse(mock_upd.called)
+
+    def test_remove_mandatory_field(self, mock_upd):
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/value',
+                                     'op': 'remove'}],
+                                   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'])
+        self.assertFalse(mock_upd.called)
+
+    def test_add_root(self, mock_upd):
+        connector_id = 'test-connector-id-123'
+        mock_upd.return_value = self.connector
+        mock_upd.return_value.connector_id = connector_id
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/connector_id',
+                                     'value': connector_id,
+                                     'op': 'add'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(connector_id, response.json['connector_id'])
+        self.assertTrue(mock_upd.called)
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(connector_id, kargs.connector_id)
+
+    def test_add_root_non_existent(self, mock_upd):
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/foo',
+                                     'value': 'bar',
+                                     'op': 'add'}],
+                                   expect_errors=True, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+        self.assertTrue(response.json['error_message'])
+        self.assertFalse(mock_upd.called)
+
+    def test_add_multi(self, mock_upd):
+        extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
+        patch = []
+        for k in extra.keys():
+            patch.append({'path': '/extra/%s' % k,
+                          'value': extra[k],
+                          'op': 'add'})
+        mock_upd.return_value = self.connector
+        mock_upd.return_value.extra = extra
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   patch, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(extra, response.json['extra'])
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(extra, kargs.extra)
+
+    def test_remove_uuid(self, mock_upd):
+        response = self.patch_json('/volume/connectors/%s'
+                                   % self.connector.uuid,
+                                   [{'path': '/uuid',
+                                     'op': 'remove'}],
+                                   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.assertFalse(mock_upd.called)
+
+
+class TestPost(test_api_base.BaseApiTest):
+    headers = {api_base.Version.string: str(api_v1.MAX_VER)}
+
+    def setUp(self):
+        super(TestPost, self).setUp()
+        self.node = obj_utils.create_test_node(self.context)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification')
+    @mock.patch.object(timeutils, 'utcnow')
+    def test_create_volume_connector(self, mock_utcnow, mock_notify):
+        pdict = post_get_test_volume_connector()
+        test_time = datetime.datetime(2000, 1, 1, 0, 0)
+        mock_utcnow.return_value = test_time
+        response = self.post_json('/volume/connectors', pdict,
+                                  headers=self.headers)
+        self.assertEqual(http_client.CREATED, response.status_int)
+        result = self.get_json('/volume/connectors/%s' % pdict['uuid'],
+                               headers=self.headers)
+        self.assertEqual(pdict['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/volume/connectors/%s' % pdict['uuid']
+        self.assertEqual(urlparse.urlparse(response.location).path,
+                         expected_location)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'create',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START,
+                                      node_uuid=self.node.uuid),
+                                      mock.call(mock.ANY, mock.ANY, 'create',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.END,
+                                      node_uuid=self.node.uuid)])
+
+    def test_create_volume_connector_invalid_api_version(self):
+        pdict = post_get_test_volume_connector()
+        response = self.post_json(
+            '/volume/connectors', pdict,
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
+            expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_create_volume_connector_doesnt_contain_id(self):
+        with mock.patch.object(
+                self.dbapi, 'create_volume_connector',
+                wraps=self.dbapi.create_volume_connector) as cp_mock:
+            pdict = post_get_test_volume_connector(extra={'foo': 123})
+            self.post_json('/volume/connectors', pdict, headers=self.headers)
+            result = self.get_json('/volume/connectors/%s' % pdict['uuid'],
+                                   headers=self.headers)
+            self.assertEqual(pdict['extra'], result['extra'])
+            cp_mock.assert_called_once_with(mock.ANY)
+            # Check that 'id' is not in first arg of positional args.
+            self.assertNotIn('id', cp_mock.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_volume_connector_generate_uuid(self, mock_warning,
+                                                   mock_exception):
+        pdict = post_get_test_volume_connector()
+        del pdict['uuid']
+        response = self.post_json('/volume/connectors', pdict,
+                                  headers=self.headers)
+        result = self.get_json('/volume/connectors/%s' % response.json['uuid'],
+                               headers=self.headers)
+        self.assertEqual(pdict['connector_id'], result['connector_id'])
+        self.assertTrue(uuidutils.is_uuid_like(result['uuid']))
+        self.assertFalse(mock_warning.called)
+        self.assertFalse(mock_exception.called)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification')
+    @mock.patch.object(objects.VolumeConnector, 'create')
+    def test_create_volume_connector_error(self, mock_create, mock_notify):
+        mock_create.side_effect = Exception()
+        cdict = post_get_test_volume_connector()
+        self.post_json('/volume/connectors', cdict, 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,
+                                      node_uuid=self.node.uuid),
+                                      mock.call(mock.ANY, mock.ANY, 'create',
+                                      obj_fields.NotificationLevel.ERROR,
+                                      obj_fields.NotificationStatus.ERROR,
+                                      node_uuid=self.node.uuid)])
+
+    def test_create_volume_connector_valid_extra(self):
+        pdict = post_get_test_volume_connector(
+            extra={'str': 'foo', 'int': 123, 'float': 0.1, 'bool': True,
+                   'list': [1, 2], 'none': None, 'dict': {'cat': 'meow'}})
+        self.post_json('/volume/connectors', pdict, headers=self.headers)
+        result = self.get_json('/volume/connectors/%s' % pdict['uuid'],
+                               headers=self.headers)
+        self.assertEqual(pdict['extra'], result['extra'])
+
+    def test_create_volume_connector_no_mandatory_field_type(self):
+        pdict = post_get_test_volume_connector()
+        del pdict['type']
+        response = self.post_json('/volume/connectors', pdict,
+                                  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'])
+
+    def test_create_volume_connector_no_mandatory_field_connector_id(self):
+        pdict = post_get_test_volume_connector()
+        del pdict['connector_id']
+        response = self.post_json('/volume/connectors', pdict,
+                                  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'])
+
+    def test_create_volume_connector_no_mandatory_field_node_uuid(self):
+        pdict = post_get_test_volume_connector()
+        del pdict['node_uuid']
+        response = self.post_json('/volume/connectors', pdict,
+                                  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'])
+
+    def test_create_volume_connector_invalid_node_uuid_format(self):
+        pdict = post_get_test_volume_connector(node_uuid=123)
+        response = self.post_json('/volume/connectors', pdict,
+                                  expect_errors=True, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+        self.assertTrue(response.json['error_message'])
+        self.assertIn(b'Expected a UUID but received 123.', response.body)
+
+    def test_node_uuid_to_node_id_mapping(self):
+        pdict = post_get_test_volume_connector(node_uuid=self.node['uuid'])
+        self.post_json('/volume/connectors', pdict, headers=self.headers)
+        # GET doesn't return the node_id it's an internal value
+        connector = self.dbapi.get_volume_connector_by_uuid(pdict['uuid'])
+        self.assertEqual(self.node['id'], connector.node_id)
+
+    def test_create_volume_connector_node_uuid_not_found(self):
+        pdict = post_get_test_volume_connector(
+            node_uuid='1a1a1a1a-2b2b-3c3c-4d4d-5e5e5e5e5e5e')
+        response = self.post_json('/volume/connectors', pdict,
+                                  expect_errors=True, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+        self.assertTrue(response.json['error_message'])
+
+    def test_create_volume_connector_type_value_already_exist(self):
+        connector_id = 'test-connector-id-456'
+        pdict = post_get_test_volume_connector(connector_id=connector_id)
+        self.post_json('/volume/connectors', pdict, headers=self.headers)
+        pdict['uuid'] = uuidutils.generate_uuid()
+        response = self.post_json('/volume/connectors',
+                                  pdict,
+                                  expect_errors=True, headers=self.headers)
+        self.assertEqual(http_client.CONFLICT, response.status_int)
+        self.assertEqual('application/json', response.content_type)
+        self.assertTrue(response.json['error_message'])
+        self.assertIn(connector_id, response.json['error_message'])
+
+
+@mock.patch.object(rpcapi.ConductorAPI, 'destroy_volume_connector')
+class TestDelete(test_api_base.BaseApiTest):
+    headers = {api_base.Version.string: str(api_v1.MAX_VER)}
+
+    def setUp(self):
+        super(TestDelete, self).setUp()
+        self.node = obj_utils.create_test_node(self.context)
+        self.connector = obj_utils.create_test_volume_connector(
+            self.context, node_id=self.node.id)
+
+        gtf = mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for')
+        self.mock_gtf = gtf.start()
+        self.mock_gtf.return_value = 'test-topic'
+        self.addCleanup(gtf.stop)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification')
+    def test_delete_volume_connector_byid(self, mock_notify, mock_dvc):
+        self.delete('/volume/connectors/%s' % self.connector.uuid,
+                    expect_errors=True, headers=self.headers)
+        self.assertTrue(mock_dvc.called)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START,
+                                      node_uuid=self.node.uuid),
+                                      mock.call(mock.ANY, mock.ANY, 'delete',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.END,
+                                      node_uuid=self.node.uuid)])
+
+    def test_delete_volume_connector_byid_invalid_api_version(self, mock_dvc):
+        headers = {api_base.Version.string: str(api_v1.MIN_VER)}
+        response = self.delete('/volume/connectors/%s' % self.connector.uuid,
+                               expect_errors=True, headers=headers)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification')
+    def test_delete_volume_connector_node_locked(self, mock_notify, mock_dvc):
+        self.node.reserve(self.context, 'fake', self.node.uuid)
+        mock_dvc.side_effect = exception.NodeLocked(node='fake-node',
+                                                    host='fake-host')
+        ret = self.delete('/volume/connectors/%s' % self.connector.uuid,
+                          expect_errors=True, headers=self.headers)
+        self.assertEqual(http_client.CONFLICT, ret.status_code)
+        self.assertTrue(ret.json['error_message'])
+        self.assertTrue(mock_dvc.called)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START,
+                                      node_uuid=self.node.uuid),
+                                      mock.call(mock.ANY, mock.ANY, 'delete',
+                                      obj_fields.NotificationLevel.ERROR,
+                                      obj_fields.NotificationStatus.ERROR,
+                                      node_uuid=self.node.uuid)])
+
+    def test_delete_volume_connector_invalid_power_state(self, mock_dvc):
+        self.node.reserve(self.context, 'fake', self.node.uuid)
+        mock_dvc.side_effect = exception.InvalidStateRequested(
+            action='volume connector deletion', node=self.node.uuid,
+            state='power on')
+        ret = self.delete('/volume/connectors/%s' % self.connector.uuid,
+                          expect_errors=True, headers=self.headers)
+        self.assertEqual(http_client.BAD_REQUEST, ret.status_code)
+        self.assertTrue(ret.json['error_message'])
+        self.assertTrue(mock_dvc.called)
diff --git a/ironic/tests/unit/api/v1/test_volume_targets.py b/ironic/tests/unit/api/v1/test_volume_targets.py
new file mode 100644
index 0000000000..fa1028694b
--- /dev/null
+++ b/ironic/tests/unit/api/v1/test_volume_targets.py
@@ -0,0 +1,927 @@
+# -*- encoding: utf-8 -*-
+#
+#    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 /volume targets/ methods.
+"""
+
+import datetime
+
+import mock
+from oslo_config import cfg
+from oslo_utils import timeutils
+from oslo_utils import uuidutils
+import six
+from six.moves import http_client
+from six.moves.urllib import parse as urlparse
+from wsme import types as wtypes
+
+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.api.controllers.v1 import utils as api_utils
+from ironic.api.controllers.v1 import volume_target as api_volume_target
+from ironic.common import exception
+from ironic.conductor import rpcapi
+from ironic import objects
+from ironic.objects import fields as obj_fields
+from ironic.tests import base
+from ironic.tests.unit.api import base as test_api_base
+from ironic.tests.unit.api import utils as apiutils
+from ironic.tests.unit.db import utils as dbutils
+from ironic.tests.unit.objects import utils as obj_utils
+
+
+def post_get_test_volume_target(**kw):
+    target = apiutils.volume_target_post_data(**kw)
+    node = dbutils.get_test_node()
+    target['node_uuid'] = kw.get('node_uuid', node['uuid'])
+    return target
+
+
+class TestVolumeTargetObject(base.TestCase):
+
+    def test_volume_target_init(self):
+        target_dict = apiutils.volume_target_post_data(node_id=None)
+        del target_dict['extra']
+        target = api_volume_target.VolumeTarget(**target_dict)
+        self.assertEqual(wtypes.Unset, target.extra)
+
+
+class TestListVolumeTargets(test_api_base.BaseApiTest):
+    headers = {api_base.Version.string: str(api_v1.MAX_VER)}
+
+    def setUp(self):
+        super(TestListVolumeTargets, self).setUp()
+        self.node = obj_utils.create_test_node(self.context)
+
+    def test_empty(self):
+        data = self.get_json('/volume/targets', headers=self.headers)
+        self.assertEqual([], data['targets'])
+
+    def test_one(self):
+        target = obj_utils.create_test_volume_target(
+            self.context, node_id=self.node.id)
+        data = self.get_json('/volume/targets', headers=self.headers)
+        self.assertEqual(target.uuid, data['targets'][0]["uuid"])
+        self.assertNotIn('extra', data['targets'][0])
+        # never expose the node_id
+        self.assertNotIn('node_id', data['targets'][0])
+
+    def test_one_invalid_api_version(self):
+        obj_utils.create_test_volume_target(
+            self.context, node_id=self.node.id)
+        response = self.get_json(
+            '/volume/targets',
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
+            expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_get_one(self):
+        target = obj_utils.create_test_volume_target(
+            self.context, node_id=self.node.id)
+        data = self.get_json('/volume/targets/%s' % target.uuid,
+                             headers=self.headers)
+        self.assertEqual(target.uuid, data['uuid'])
+        self.assertIn('extra', data)
+        self.assertIn('node_uuid', data)
+        # never expose the node_id
+        self.assertNotIn('node_id', data)
+
+    def test_get_one_invalid_api_version(self):
+        target = obj_utils.create_test_volume_target(self.context,
+                                                     node_id=self.node.id)
+        response = self.get_json(
+            '/volume/targets/%s' % target.uuid,
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
+            expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_get_one_custom_fields(self):
+        target = obj_utils.create_test_volume_target(
+            self.context, node_id=self.node.id)
+        fields = 'boot_index,extra'
+        data = self.get_json(
+            '/volume/targets/%s?fields=%s' % (target.uuid, fields),
+            headers=self.headers)
+        # We always append "links"
+        self.assertItemsEqual(['boot_index', 'extra', 'links'], data)
+
+    def test_get_collection_custom_fields(self):
+        fields = 'uuid,extra'
+        for i in range(3):
+            obj_utils.create_test_volume_target(
+                self.context, node_id=self.node.id,
+                uuid=uuidutils.generate_uuid(), boot_index=i)
+
+        data = self.get_json(
+            '/volume/targets?fields=%s' % fields,
+            headers=self.headers)
+
+        self.assertEqual(3, len(data['targets']))
+        for target in data['targets']:
+            # We always append "links"
+            self.assertItemsEqual(['uuid', 'extra', 'links'], target)
+
+    def test_get_custom_fields_invalid_fields(self):
+        target = obj_utils.create_test_volume_target(
+            self.context, node_id=self.node.id)
+        fields = 'uuid,spongebob'
+        response = self.get_json(
+            '/volume/targets/%s?fields=%s' % (target.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_detail(self):
+        target = obj_utils.create_test_volume_target(
+            self.context, node_id=self.node.id)
+        data = self.get_json('/volume/targets?detail=True',
+                             headers=self.headers)
+        self.assertEqual(target.uuid, data['targets'][0]["uuid"])
+        self.assertIn('extra', data['targets'][0])
+        self.assertIn('node_uuid', data['targets'][0])
+        # never expose the node_id
+        self.assertNotIn('node_id', data['targets'][0])
+
+    def test_detail_false(self):
+        target = obj_utils.create_test_volume_target(
+            self.context, node_id=self.node.id)
+        data = self.get_json('/volume/targets?detail=False',
+                             headers=self.headers)
+        self.assertEqual(target.uuid, data['targets'][0]["uuid"])
+        self.assertNotIn('extra', data['targets'][0])
+        # never expose the node_id
+        self.assertNotIn('node_id', data['targets'][0])
+
+    def test_detail_invalid_api_version(self):
+        obj_utils.create_test_volume_target(self.context,
+                                            node_id=self.node.id)
+        response = self.get_json(
+            '/volume/targets?detail=True',
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
+            expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_detail_sepecified_by_path(self):
+        obj_utils.create_test_volume_target(self.context,
+                                            node_id=self.node.id)
+        response = self.get_json(
+            '/volume/targets/detail', headers=self.headers,
+            expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+    def test_detail_against_single(self):
+        target = obj_utils.create_test_volume_target(
+            self.context, node_id=self.node.id)
+        response = self.get_json('/volume/targets/%s?detail=True'
+                                 % target.uuid,
+                                 headers=self.headers,
+                                 expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+    def test_detail_and_fields(self):
+        target = obj_utils.create_test_volume_target(
+            self.context, node_id=self.node.id)
+        fields = 'boot_index,extra'
+        response = self.get_json('/volume/targets/%s?detail=True&fields=%s'
+                                 % (target.uuid, fields),
+                                 headers=self.headers,
+                                 expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+
+    def test_many(self):
+        targets = []
+        for id_ in range(5):
+            target = obj_utils.create_test_volume_target(
+                self.context, node_id=self.node.id,
+                uuid=uuidutils.generate_uuid(), boot_index=id_)
+            targets.append(target.uuid)
+        data = self.get_json('/volume/targets', headers=self.headers)
+        self.assertEqual(len(targets), len(data['targets']))
+
+        uuids = [n['uuid'] for n in data['targets']]
+        six.assertCountEqual(self, targets, uuids)
+
+    def test_links(self):
+        uuid = uuidutils.generate_uuid()
+        obj_utils.create_test_volume_target(self.context,
+                                            uuid=uuid,
+                                            node_id=self.node.id)
+        data = self.get_json('/volume/targets/%s' % uuid,
+                             headers=self.headers)
+        self.assertIn('links', data.keys())
+        self.assertEqual(2, len(data['links']))
+        self.assertIn(uuid, data['links'][0]['href'])
+        for l in data['links']:
+            bookmark = l['rel'] == 'bookmark'
+            self.assertTrue(self.validate_link(l['href'], bookmark=bookmark,
+                                               headers=self.headers))
+
+    def test_collection_links(self):
+        targets = []
+        for id_ in range(5):
+            target = obj_utils.create_test_volume_target(
+                self.context, node_id=self.node.id,
+                uuid=uuidutils.generate_uuid(), boot_index=id_)
+            targets.append(target.uuid)
+        data = self.get_json('/volume/targets/?limit=3', headers=self.headers)
+        self.assertEqual(3, len(data['targets']))
+
+        next_marker = data['targets'][-1]['uuid']
+        self.assertIn(next_marker, data['next'])
+        self.assertIn('volume/targets', data['next'])
+
+    def test_collection_links_default_limit(self):
+        cfg.CONF.set_override('max_limit', 3, 'api')
+        targets = []
+        for id_ in range(5):
+            target = obj_utils.create_test_volume_target(
+                self.context, node_id=self.node.id,
+                uuid=uuidutils.generate_uuid(), boot_index=id_)
+            targets.append(target.uuid)
+        data = self.get_json('/volume/targets', headers=self.headers)
+        self.assertEqual(3, len(data['targets']))
+
+        next_marker = data['targets'][-1]['uuid']
+        self.assertIn(next_marker, data['next'])
+        self.assertIn('volume/targets', data['next'])
+
+    def test_collection_links_detail(self):
+        targets = []
+        for id_ in range(5):
+            target = obj_utils.create_test_volume_target(
+                self.context, node_id=self.node.id,
+                uuid=uuidutils.generate_uuid(), boot_index=id_)
+            targets.append(target.uuid)
+        data = self.get_json('/volume/targets?detail=True&limit=3',
+                             headers=self.headers)
+        self.assertEqual(3, len(data['targets']))
+
+        next_marker = data['targets'][-1]['uuid']
+        self.assertIn(next_marker, data['next'])
+        self.assertIn('volume/targets', data['next'])
+        self.assertIn('detail=True', data['next'])
+
+    def test_sort_key(self):
+        targets = []
+        for id_ in range(3):
+            target = obj_utils.create_test_volume_target(
+                self.context, node_id=self.node.id,
+                uuid=uuidutils.generate_uuid(), boot_index=id_)
+            targets.append(target.uuid)
+        data = self.get_json('/volume/targets?sort_key=uuid',
+                             headers=self.headers)
+        uuids = [n['uuid'] for n in data['targets']]
+        self.assertEqual(sorted(targets), uuids)
+
+    def test_sort_key_invalid(self):
+        invalid_keys_list = ['foo', 'extra', 'properties']
+        for invalid_key in invalid_keys_list:
+            response = self.get_json('/volume/targets?sort_key=%s'
+                                     % invalid_key,
+                                     headers=self.headers,
+                                     expect_errors=True)
+            self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+            self.assertEqual('application/json', response.content_type)
+            self.assertIn(invalid_key, response.json['error_message'])
+
+    @mock.patch.object(api_utils, 'get_rpc_node')
+    def test_get_all_by_node_name_ok(self, mock_get_rpc_node):
+        # GET /v1/volume/targets specifying node_name - success
+        mock_get_rpc_node.return_value = self.node
+        for i in range(5):
+            if i < 3:
+                node_id = self.node.id
+            else:
+                node_id = 100000 + i
+            obj_utils.create_test_volume_target(
+                self.context, node_id=node_id,
+                uuid=uuidutils.generate_uuid(), boot_index=i)
+        data = self.get_json("/volume/targets?node=%s" % 'test-node',
+                             headers=self.headers)
+        self.assertEqual(3, len(data['targets']))
+
+    @mock.patch.object(api_utils, 'get_rpc_node')
+    def test_detail_by_node_name_ok(self, mock_get_rpc_node):
+        # GET /v1/volume/targets/?detail=True specifying node_name - success
+        mock_get_rpc_node.return_value = self.node
+        target = obj_utils.create_test_volume_target(
+            self.context, node_id=self.node.id)
+        data = self.get_json('/volume/targets?detail=True&node=%s' %
+                             'test-node',
+                             headers=self.headers)
+        self.assertEqual(target.uuid, data['targets'][0]['uuid'])
+        self.assertEqual(self.node.uuid, data['targets'][0]['node_uuid'])
+
+
+@mock.patch.object(rpcapi.ConductorAPI, 'update_volume_target')
+class TestPatch(test_api_base.BaseApiTest):
+    headers = {api_base.Version.string: str(api_v1.MAX_VER)}
+
+    def setUp(self):
+        super(TestPatch, self).setUp()
+        self.node = obj_utils.create_test_node(self.context)
+        self.target = obj_utils.create_test_volume_target(
+            self.context, node_id=self.node.id)
+
+        p = mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for')
+        self.mock_gtf = p.start()
+        self.mock_gtf.return_value = 'test-topic'
+        self.addCleanup(p.stop)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification')
+    def test_update_byid(self, mock_notify, mock_upd):
+        extra = {'foo': 'bar'}
+        mock_upd.return_value = self.target
+        mock_upd.return_value.extra = extra
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/extra/foo',
+                                     'value': 'bar',
+                                     'op': 'add'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(extra, response.json['extra'])
+
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(extra, kargs.extra)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START,
+                                      node_uuid=self.node.uuid),
+                                      mock.call(mock.ANY, mock.ANY, 'update',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.END,
+                                      node_uuid=self.node.uuid)])
+
+    def test_update_byid_invalid_api_version(self, mock_upd):
+        headers = {api_base.Version.string: str(api_v1.MIN_VER)}
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/extra/foo',
+                                     'value': 'bar',
+                                     'op': 'add'}],
+                                   headers=headers,
+                                   expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_update_not_found(self, mock_upd):
+        uuid = uuidutils.generate_uuid()
+        response = self.patch_json('/volume/targets/%s' % uuid,
+                                   [{'path': '/extra/foo',
+                                     'value': 'bar',
+                                     'op': 'add'}],
+                                   headers=self.headers,
+                                   expect_errors=True)
+        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_upd.called)
+
+    def test_replace_singular(self, mock_upd):
+        boot_index = 100
+        mock_upd.return_value = self.target
+        mock_upd.return_value.boot_index = boot_index
+        response = self.patch_json('/volume/targets/%s' % self.target.uuid,
+                                   [{'path': '/boot_index',
+                                     'value': boot_index,
+                                     'op': 'replace'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(boot_index, response.json['boot_index'])
+        self.assertTrue(mock_upd.called)
+
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(boot_index, kargs.boot_index)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification')
+    def test_replace_boot_index_already_exist(self, mock_notify, mock_upd):
+        boot_index = 100
+        mock_upd.side_effect = \
+            exception.VolumeTargetBootIndexAlreadyExists(boot_index=boot_index)
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/boot_index',
+                                     'value': boot_index,
+                                     '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'])
+        self.assertTrue(mock_upd.called)
+
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(boot_index, kargs.boot_index)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START,
+                                      node_uuid=self.node.uuid),
+                                      mock.call(mock.ANY, mock.ANY, 'update',
+                                      obj_fields.NotificationLevel.ERROR,
+                                      obj_fields.NotificationStatus.ERROR,
+                                      node_uuid=self.node.uuid)])
+
+    def test_replace_invalid_power_state(self, mock_upd):
+        mock_upd.side_effect = \
+            exception.InvalidStateRequested(
+                action='volume target update', node=self.node.uuid,
+                state='power on')
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/boot_index',
+                                     'value': 0,
+                                     'op': 'replace'}],
+                                   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'])
+        self.assertTrue(mock_upd.called)
+
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(0, kargs.boot_index)
+
+    def test_replace_node_uuid(self, mock_upd):
+        mock_upd.return_value = self.target
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/node_uuid',
+                                     'value': self.node.uuid,
+                                     'op': 'replace'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+
+    def test_replace_node_uuid_inalid_type(self, mock_upd):
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/node_uuid',
+                                     'value': 123,
+                                     'op': 'replace'}],
+                                   expect_errors=True, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertIn(b'Expected a UUID for node_uuid, but received 123.',
+                      response.body)
+        self.assertFalse(mock_upd.called)
+
+    def test_add_node_uuid(self, mock_upd):
+        mock_upd.return_value = self.target
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/node_uuid',
+                                     'value': self.node.uuid,
+                                     'op': 'add'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+
+    def test_add_node_uuid_invalid_type(self, mock_upd):
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/node_uuid',
+                                     'value': 123,
+                                     'op': 'add'}],
+                                   expect_errors=True, headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertIn(b'Expected a UUID for node_uuid, but received 123.',
+                      response.body)
+        self.assertFalse(mock_upd.called)
+
+    def test_add_node_id(self, mock_upd):
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/node_id',
+                                     'value': '1',
+                                     'op': 'add'}],
+                                   headers=self.headers,
+                                   expect_errors=True)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertFalse(mock_upd.called)
+
+    def test_replace_node_id(self, mock_upd):
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/node_id',
+                                     'value': '1',
+                                     'op': 'replace'}],
+                                   headers=self.headers,
+                                   expect_errors=True)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertFalse(mock_upd.called)
+
+    def test_remove_node_id(self, mock_upd):
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/node_id',
+                                     'op': 'remove'}],
+                                   headers=self.headers,
+                                   expect_errors=True)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertFalse(mock_upd.called)
+
+    def test_replace_non_existent_node_uuid(self, mock_upd):
+        node_uuid = '12506333-a81c-4d59-9987-889ed5f8687b'
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/node_uuid',
+                                     'value': node_uuid,
+                                     'op': 'replace'}],
+                                   headers=self.headers,
+                                   expect_errors=True)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertIn(node_uuid, response.json['error_message'])
+        self.assertFalse(mock_upd.called)
+
+    def test_replace_multi(self, mock_upd):
+        extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
+        self.target.extra = extra
+        self.target.save()
+
+        # mutate extra so we replace all of them
+        extra = dict((k, extra[k] + 'x') for k in extra.keys())
+
+        patch = []
+        for k in extra.keys():
+            patch.append({'path': '/extra/%s' % k,
+                          'value': extra[k],
+                          'op': 'replace'})
+        mock_upd.return_value = self.target
+        mock_upd.return_value.extra = extra
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   patch,
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(extra, response.json['extra'])
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(extra, kargs.extra)
+
+    def test_remove_multi(self, mock_upd):
+        extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
+        self.target.extra = extra
+        self.target.save()
+
+        # Remove one item from the collection.
+        extra.pop('foo1')
+        mock_upd.return_value = self.target
+        mock_upd.return_value.extra = extra
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/extra/foo1',
+                                     'op': 'remove'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(extra, response.json['extra'])
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(extra, kargs.extra)
+
+        # Remove the collection.
+        extra = {}
+        mock_upd.return_value.extra = extra
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/extra', 'op': 'remove'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual({}, response.json['extra'])
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(extra, kargs.extra)
+
+        # Assert nothing else was changed.
+        self.assertEqual(self.target.uuid, response.json['uuid'])
+        self.assertEqual(self.target.volume_type,
+                         response.json['volume_type'])
+        self.assertEqual(self.target.boot_index, response.json['boot_index'])
+
+    def test_remove_non_existent_property_fail(self, mock_upd):
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/extra/non-existent',
+                                     'op': 'remove'}],
+                                   headers=self.headers,
+                                   expect_errors=True)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertTrue(response.json['error_message'])
+        self.assertFalse(mock_upd.called)
+
+    def test_remove_mandatory_field(self, mock_upd):
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/boot_index',
+                                     'op': 'remove'}],
+                                   headers=self.headers,
+                                   expect_errors=True)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
+        self.assertTrue(response.json['error_message'])
+        self.assertFalse(mock_upd.called)
+
+    def test_add_root(self, mock_upd):
+        boot_index = 100
+        mock_upd.return_value = self.target
+        mock_upd.return_value.boot_index = boot_index
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/boot_index',
+                                     'value': boot_index,
+                                     'op': 'add'}],
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(boot_index, response.json['boot_index'])
+        self.assertTrue(mock_upd.called)
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(boot_index, kargs.boot_index)
+
+    def test_add_root_non_existent(self, mock_upd):
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/foo',
+                                     'value': 'bar',
+                                     'op': 'add'}],
+                                   headers=self.headers,
+                                   expect_errors=True)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+        self.assertTrue(response.json['error_message'])
+        self.assertFalse(mock_upd.called)
+
+    def test_add_multi(self, mock_upd):
+        extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
+        patch = []
+        for k in extra.keys():
+            patch.append({'path': '/extra/%s' % k,
+                          'value': extra[k],
+                          'op': 'add'})
+        mock_upd.return_value = self.target
+        mock_upd.return_value.extra = extra
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   patch,
+                                   headers=self.headers)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.OK, response.status_code)
+        self.assertEqual(extra, response.json['extra'])
+        kargs = mock_upd.call_args[0][1]
+        self.assertEqual(extra, kargs.extra)
+
+    def test_remove_uuid(self, mock_upd):
+        response = self.patch_json('/volume/targets/%s'
+                                   % self.target.uuid,
+                                   [{'path': '/uuid',
+                                     'op': 'remove'}],
+                                   headers=self.headers,
+                                   expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+        self.assertEqual('application/json', response.content_type)
+        self.assertTrue(response.json['error_message'])
+        self.assertFalse(mock_upd.called)
+
+
+class TestPost(test_api_base.BaseApiTest):
+    headers = {api_base.Version.string: str(api_v1.MAX_VER)}
+
+    def setUp(self):
+        super(TestPost, self).setUp()
+        self.node = obj_utils.create_test_node(self.context)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification')
+    @mock.patch.object(timeutils, 'utcnow')
+    def test_create_volume_target(self, mock_utcnow, mock_notify):
+        pdict = post_get_test_volume_target()
+        test_time = datetime.datetime(2000, 1, 1, 0, 0)
+        mock_utcnow.return_value = test_time
+        response = self.post_json('/volume/targets', pdict,
+                                  headers=self.headers)
+        self.assertEqual(http_client.CREATED, response.status_int)
+        result = self.get_json('/volume/targets/%s' % pdict['uuid'],
+                               headers=self.headers)
+        self.assertEqual(pdict['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/volume/targets/%s' % pdict['uuid']
+        self.assertEqual(urlparse.urlparse(response.location).path,
+                         expected_location)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'create',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START,
+                                      node_uuid=self.node.uuid),
+                                      mock.call(mock.ANY, mock.ANY, 'create',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.END,
+                                      node_uuid=self.node.uuid)])
+
+    def test_create_volume_target_invalid_api_version(self):
+        pdict = post_get_test_volume_target()
+        response = self.post_json(
+            '/volume/targets', pdict,
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
+            expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    def test_create_volume_target_doesnt_contain_id(self):
+        with mock.patch.object(
+                self.dbapi, 'create_volume_target',
+                wraps=self.dbapi.create_volume_target) as cp_mock:
+            pdict = post_get_test_volume_target(extra={'foo': 123})
+            self.post_json('/volume/targets', pdict,
+                           headers=self.headers)
+            result = self.get_json('/volume/targets/%s' % pdict['uuid'],
+                                   headers=self.headers)
+            self.assertEqual(pdict['extra'], result['extra'])
+            cp_mock.assert_called_once_with(mock.ANY)
+            # Check that 'id' is not in first arg of positional args.
+            self.assertNotIn('id', cp_mock.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_volume_target_generate_uuid(self, mock_warning,
+                                                mock_exception):
+        pdict = post_get_test_volume_target()
+        del pdict['uuid']
+        response = self.post_json('/volume/targets', pdict,
+                                  headers=self.headers)
+        result = self.get_json('/volume/targets/%s' % response.json['uuid'],
+                               headers=self.headers)
+        self.assertEqual(pdict['boot_index'], result['boot_index'])
+        self.assertTrue(uuidutils.is_uuid_like(result['uuid']))
+        self.assertFalse(mock_warning.called)
+        self.assertFalse(mock_exception.called)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification')
+    @mock.patch.object(objects.VolumeTarget, 'create')
+    def test_create_volume_target_error(self, mock_create, mock_notify):
+        mock_create.side_effect = Exception()
+        tdict = post_get_test_volume_target()
+        self.post_json('/volume/targets', 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,
+                                      node_uuid=self.node.uuid),
+                                      mock.call(mock.ANY, mock.ANY, 'create',
+                                      obj_fields.NotificationLevel.ERROR,
+                                      obj_fields.NotificationStatus.ERROR,
+                                      node_uuid=self.node.uuid)])
+
+    def test_create_volume_target_valid_extra(self):
+        pdict = post_get_test_volume_target(
+            extra={'str': 'foo', 'int': 123, 'float': 0.1, 'bool': True,
+                   'list': [1, 2], 'none': None, 'dict': {'cat': 'meow'}})
+        self.post_json('/volume/targets', pdict, headers=self.headers)
+        result = self.get_json('/volume/targets/%s' % pdict['uuid'],
+                               headers=self.headers)
+        self.assertEqual(pdict['extra'], result['extra'])
+
+    def test_create_volume_target_no_mandatory_field_type(self):
+        pdict = post_get_test_volume_target()
+        del pdict['volume_type']
+        response = self.post_json('/volume/targets', pdict,
+                                  headers=self.headers,
+                                  expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+        self.assertEqual('application/json', response.content_type)
+        self.assertTrue(response.json['error_message'])
+
+    def test_create_volume_target_no_mandatory_field_value(self):
+        pdict = post_get_test_volume_target()
+        del pdict['boot_index']
+        response = self.post_json('/volume/targets', pdict,
+                                  headers=self.headers,
+                                  expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+        self.assertEqual('application/json', response.content_type)
+        self.assertTrue(response.json['error_message'])
+
+    def test_create_volume_target_no_mandatory_field_node_uuid(self):
+        pdict = post_get_test_volume_target()
+        del pdict['node_uuid']
+        response = self.post_json('/volume/targets', pdict,
+                                  headers=self.headers,
+                                  expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+        self.assertEqual('application/json', response.content_type)
+        self.assertTrue(response.json['error_message'])
+
+    def test_create_volume_target_invalid_node_uuid_format(self):
+        pdict = post_get_test_volume_target(node_uuid=123)
+        response = self.post_json('/volume/targets', pdict,
+                                  headers=self.headers,
+                                  expect_errors=True)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+        self.assertTrue(response.json['error_message'])
+        self.assertIn(b'Expected a UUID but received 123.', response.body)
+
+    def test_node_uuid_to_node_id_mapping(self):
+        pdict = post_get_test_volume_target(node_uuid=self.node['uuid'])
+        self.post_json('/volume/targets', pdict, headers=self.headers)
+        # GET doesn't return the node_id it's an internal value
+        target = self.dbapi.get_volume_target_by_uuid(pdict['uuid'])
+        self.assertEqual(self.node['id'], target.node_id)
+
+    def test_create_volume_target_node_uuid_not_found(self):
+        pdict = post_get_test_volume_target(
+            node_uuid='1a1a1a1a-2b2b-3c3c-4d4d-5e5e5e5e5e5e')
+        response = self.post_json('/volume/targets', pdict,
+                                  headers=self.headers,
+                                  expect_errors=True)
+        self.assertEqual('application/json', response.content_type)
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
+        self.assertTrue(response.json['error_message'])
+
+
+@mock.patch.object(rpcapi.ConductorAPI, 'destroy_volume_target')
+class TestDelete(test_api_base.BaseApiTest):
+    headers = {api_base.Version.string: str(api_v1.MAX_VER)}
+
+    def setUp(self):
+        super(TestDelete, self).setUp()
+        self.node = obj_utils.create_test_node(self.context)
+        self.target = obj_utils.create_test_volume_target(
+            self.context, node_id=self.node.id)
+
+        gtf = mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for')
+        self.mock_gtf = gtf.start()
+        self.mock_gtf.return_value = 'test-topic'
+        self.addCleanup(gtf.stop)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification')
+    def test_delete_volume_target_byid(self, mock_notify, mock_dvc):
+        self.delete('/volume/targets/%s' % self.target.uuid,
+                    headers=self.headers,
+                    expect_errors=True)
+        self.assertTrue(mock_dvc.called)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START,
+                                      node_uuid=self.node.uuid),
+                                      mock.call(mock.ANY, mock.ANY, 'delete',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.END,
+                                      node_uuid=self.node.uuid)])
+
+    def test_delete_volume_target_byid_invalid_api_version(self, mock_dvc):
+        headers = {api_base.Version.string: str(api_v1.MIN_VER)}
+        response = self.delete('/volume/targets/%s' % self.target.uuid,
+                               headers=headers,
+                               expect_errors=True)
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
+
+    @mock.patch.object(notification_utils, '_emit_api_notification')
+    def test_delete_volume_target_node_locked(self, mock_notify, mock_dvc):
+        self.node.reserve(self.context, 'fake', self.node.uuid)
+        mock_dvc.side_effect = exception.NodeLocked(node='fake-node',
+                                                    host='fake-host')
+        ret = self.delete('/volume/targets/%s' % self.target.uuid,
+                          headers=self.headers,
+                          expect_errors=True)
+        self.assertEqual(http_client.CONFLICT, ret.status_code)
+        self.assertTrue(ret.json['error_message'])
+        self.assertTrue(mock_dvc.called)
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'delete',
+                                      obj_fields.NotificationLevel.INFO,
+                                      obj_fields.NotificationStatus.START,
+                                      node_uuid=self.node.uuid),
+                                      mock.call(mock.ANY, mock.ANY, 'delete',
+                                      obj_fields.NotificationLevel.ERROR,
+                                      obj_fields.NotificationStatus.ERROR,
+                                      node_uuid=self.node.uuid)])
+
+    def test_delete_volume_target_invalid_power_state(self, mock_dvc):
+        mock_dvc.side_effect = exception.InvalidStateRequested(
+            action='volume target deletion', node=self.node.uuid,
+            state='power on')
+        ret = self.delete('/volume/targets/%s' % self.target.uuid,
+                          headers=self.headers,
+                          expect_errors=True)
+        self.assertEqual(http_client.BAD_REQUEST, ret.status_code)
+        self.assertTrue(ret.json['error_message'])
+        self.assertTrue(mock_dvc.called)
diff --git a/releasenotes/notes/volume-connector-and-target-api-dd172f121ab3af8e.yaml b/releasenotes/notes/volume-connector-and-target-api-dd172f121ab3af8e.yaml
new file mode 100644
index 0000000000..032039953f
--- /dev/null
+++ b/releasenotes/notes/volume-connector-and-target-api-dd172f121ab3af8e.yaml
@@ -0,0 +1,54 @@
+---
+features:
+  - |
+    Adds support for volume connectors and volume targets with new API
+    endpoints ``/v1/volume/connectors`` and ``/v1/volume/targets``. These
+    endpoints are available with API version 1.32 or later. These new
+    resources are used to connect a node to a volume. A volume connector
+    represents connector information of a node such as an iSCSI initiator. A
+    volume target provides volume information such as an iSCSI target. These
+    endpoints are available:
+
+    * ``GET /v1/volume/connectors`` for listing volume connectors
+    * ``POST /v1/volume/connectors`` for creating a volume connector
+    * ``GET /v1/volume/connectors/<UUID>`` for showing a volume connector
+    * ``PATCH /v1/volume/connectors/<UUID>`` for updating a volume connector
+    * ``DELETE /v1/volume/connectors/<UUID>`` for deleting a volume connector
+    * ``GET /v1/volume/targets`` for listing volume targets
+    * ``POST /v1/volume/targets`` for creating a volume target
+    * ``GET /v1/volume/targets/<UUID>`` for showing a volume target
+    * ``PATCH /v1/volume/targets/<UUID>`` for updating a volume target
+    * ``DELETE /v1/volume/targets/<UUID>`` for deleting a volume target
+
+    The Volume resources also can be listed as sub resources of nodes:
+
+    * ``GET /v1/nodes/<node>/volume/connectors``
+    * ``GET /v1/nodes/<node>/volume/targets``
+
+    Root endpoints of volume resources are also added. These endpoints provide
+    links to volume connectors and volume targets:
+
+    * ``GET /v1/volume``
+    * ``GET /v1/node/<node>/volume``
+
+    When a volume connector or a volume target is created, updated, or
+    deleted, these CRUD notifications can be emitted:
+
+    * ``baremetal.volumeconnector.create.start``
+    * ``baremetal.volumeconnector.create.end``
+    * ``baremetal.volumeconnector.create.error``
+    * ``baremetal.volumeconnector.update.start``
+    * ``baremetal.volumeconnector.update.end``
+    * ``baremetal.volumeconnector.update.error``
+    * ``baremetal.volumeconnector.delete.start``
+    * ``baremetal.volumeconnector.delete.end``
+    * ``baremetal.volumeconnector.delete.error``
+    * ``baremetal.volumetarget.create.start``
+    * ``baremetal.volumetarget.create.end``
+    * ``baremetal.volumetarget.create.error``
+    * ``baremetal.volumetarget.update.start``
+    * ``baremetal.volumetarget.update.end``
+    * ``baremetal.volumetarget.update.error``
+    * ``baremetal.volumetarget.delete.start``
+    * ``baremetal.volumetarget.delete.end``
+    * ``baremetal.volumetarget.delete.error``