Browse Source

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
tags/9.0.0
Satoru Moriya 3 years ago
parent
commit
c380e05dbf

+ 82
- 0
doc/source/deploy/notifications.rst View File

@@ -251,6 +251,88 @@ Example of portgroup CRUD notification::
251 251
     "publisher_id":"ironic-api.hostname02"
252 252
    }
253 253
 
254
+List of CRUD notifications for volume connector:
255
+
256
+* ``baremetal.volumeconnector.create.start``
257
+* ``baremetal.volumeconnector.create.end``
258
+* ``baremetal.volumeconnector.create.error``
259
+* ``baremetal.volumeconnector.update.start``
260
+* ``baremetal.volumeconnector.update.end``
261
+* ``baremetal.volumeconnector.update.error``
262
+* ``baremetal.volumeconnector.delete.start``
263
+* ``baremetal.volumeconnector.delete.end``
264
+* ``baremetal.volumeconnector.delete.error``
265
+
266
+Example of volume connector CRUD notification::
267
+
268
+   {
269
+    "priority": "info",
270
+    "payload": {
271
+        "ironic_object.namespace": "ironic",
272
+        "ironic_object.name": "VolumeConnectorCRUDPayload",
273
+        "ironic_object.version": "1.0",
274
+        "ironic_object.data": {
275
+           "connector_id": "iqn.2017-05.org.openstack:01:d9a51732c3f",
276
+           "created_at": "2017-05-11T05:57:36+00:00",
277
+           "extra": {},
278
+           "node_uuid": "4dbb4e69-99a8-4e13-b6e8-dd2ad4a20caf",
279
+           "type": "iqn",
280
+           "updated_at": "2017-05-11T08:28:58+00:00",
281
+           "uuid": "19b9f3ab-4754-4725-a7a4-c43ea7e57360"
282
+        }
283
+    },
284
+    "event_type": "baremetal.volumeconnector.update.end",
285
+    "publisher_id":"ironic-api.hostname02"
286
+   }
287
+
288
+List of CRUD notifications for volume target:
289
+
290
+* ``baremetal.volumetarget.create.start``
291
+* ``baremetal.volumetarget.create.end``
292
+* ``baremetal.volumetarget.create.error``
293
+* ``baremetal.volumetarget.update.start``
294
+* ``baremetal.volumetarget.update.end``
295
+* ``baremetal.volumetarget.update.error``
296
+* ``baremetal.volumetarget.delete.start``
297
+* ``baremetal.volumetarget.delete.end``
298
+* ``baremetal.volumetarget.delete.error``
299
+
300
+Example of volume target CRUD notification::
301
+
302
+   {
303
+    "priority": "info",
304
+    "payload": {
305
+        "ironic_object.namespace": "ironic",
306
+        "ironic_object.version": "1.0",
307
+        "ironic_object.name": "VolumeTargetCRUDPayload"
308
+        "ironic_object.data": {
309
+            "boot_index": 0,
310
+            "created_at": "2017-05-11T09:38:59+00:00",
311
+            "extra": {},
312
+            "node_uuid": "4dbb4e69-99a8-4e13-b6e8-dd2ad4a20caf",
313
+            "properties": {
314
+                "access_mode": "rw",
315
+                "auth_method": "CHAP"
316
+                "auth_password": "***",
317
+                "auth_username": "urxhQCzAKr4sjyE8DivY",
318
+                "encrypted": false,
319
+                "qos_specs": null,
320
+                "target_discovered": false,
321
+                "target_iqn": "iqn.2010-10.org.openstack:volume-f0d9b0e6-b242-9105-91d4-a20331693ad8",
322
+                "target_lun": 1,
323
+                "target_portal": "192.168.12.34:3260",
324
+                "volume_id": "f0d9b0e6-b042-4105-91d4-a20331693ad8",
325
+            },
326
+            "updated_at": "2017-05-11T09:52:04+00:00",
327
+            "uuid": "82a45833-9c58-4ec1-943c-2091ab10e47b",
328
+            "volume_id": "f0d9b0e6-b242-9105-91d4-a20331693ad8",
329
+            "volume_type": "iscsi"
330
+        }
331
+    },
332
+    "event_type": "baremetal.volumetarget.update.end",
333
+    "publisher_id":"ironic-api.hostname02"
334
+   }
335
+
254 336
 Node maintenance notifications
255 337
 ------------------------------
256 338
 

+ 22
- 1
doc/source/dev/webapi-version-history.rst View File

@@ -2,6 +2,28 @@
2 2
 REST API Version History
3 3
 ========================
4 4
 
5
+**1.32** (Pike)
6
+
7
+    Added new endpoints for remote volume configuration:
8
+
9
+    * GET /v1/volume as a root for volume resources
10
+    * GET /v1/volume/connectors for listing volume connectors
11
+    * POST /v1/volume/connectors for creating a volume connector
12
+    * GET /v1/volume/connectors/<UUID> for showing a volume connector
13
+    * PATCH /v1/volume/connectors/<UUID> for updating a volume connector
14
+    * DELETE /v1/volume/connectors/<UUID> for deleting a volume connector
15
+    * GET /v1/volume/targets for listing volume targets
16
+    * POST /v1/volume/targets for creating a volume target
17
+    * GET /v1/volume/targets/<UUID> for showing a volume target
18
+    * PATCH /v1/volume/targets/<UUID> for updating a volume target
19
+    * DELETE /v1/volume/targets/<UUID> for deleting a volume target
20
+
21
+    Volume resources also can be listed as sub resources of nodes:
22
+
23
+    * GET /v1/nodes/<node identifier>/volume
24
+    * GET /v1/nodes/<node identifier>/volume/connectors
25
+    * GET /v1/nodes/<node identifier>/volume/targets
26
+
5 27
 **1.31** (Ocata)
6 28
 
7 29
     Added the following fields to the node object, to allow getting and
@@ -237,4 +259,3 @@ REST API Version History
237 259
     supported version in Kilo.
238 260
 
239 261
 .. _fully qualified domain name: https://en.wikipedia.org/wiki/Fully_qualified_domain_name
240
-

+ 12
- 0
etc/ironic/policy.json.sample View File

@@ -133,3 +133,15 @@
133 133
 # Access IPA ramdisk functions
134 134
 #"baremetal:driver:ipa_lookup": "rule:public_api"
135 135
 
136
+# Retrieve Volume connector and target records
137
+#"baremetal:volume:get": "rule:is_admin or rule:is_observer"
138
+
139
+# Create Volume connector and target records
140
+#"baremetal:volume:create": "rule:is_admin"
141
+
142
+# Delete Volume connetor and target records
143
+#"baremetal:volume:delete": "rule:is_admin"
144
+
145
+# Update Volume connector and target records
146
+#"baremetal:volume:update": "rule:is_admin"
147
+

+ 15
- 0
ironic/api/controllers/v1/__init__.py View File

@@ -33,6 +33,7 @@ from ironic.api.controllers.v1 import portgroup
33 33
 from ironic.api.controllers.v1 import ramdisk
34 34
 from ironic.api.controllers.v1 import utils
35 35
 from ironic.api.controllers.v1 import versions
36
+from ironic.api.controllers.v1 import volume
36 37
 from ironic.api import expose
37 38
 from ironic.common.i18n import _
38 39
 
@@ -84,6 +85,9 @@ class V1(base.APIBase):
84 85
     drivers = [link.Link]
85 86
     """Links to the drivers resource"""
86 87
 
88
+    volume = [link.Link]
89
+    """Links to the volume resource"""
90
+
87 91
     lookup = [link.Link]
88 92
     """Links to the lookup resource"""
89 93
 
@@ -139,6 +143,16 @@ class V1(base.APIBase):
139 143
                                           'drivers', '',
140 144
                                           bookmark=True)
141 145
                       ]
146
+        if utils.allow_volume():
147
+            v1.volume = [
148
+                link.Link.make_link('self',
149
+                                    pecan.request.public_url,
150
+                                    'volume', ''),
151
+                link.Link.make_link('bookmark',
152
+                                    pecan.request.public_url,
153
+                                    'volume', '',
154
+                                    bookmark=True)
155
+            ]
142 156
         if utils.allow_ramdisk_endpoints():
143 157
             v1.lookup = [link.Link.make_link('self', pecan.request.public_url,
144 158
                                              'lookup', ''),
@@ -166,6 +180,7 @@ class Controller(rest.RestController):
166 180
     portgroups = portgroup.PortgroupsController()
167 181
     chassis = chassis.ChassisController()
168 182
     drivers = driver.DriversController()
183
+    volume = volume.VolumeController()
169 184
     lookup = ramdisk.LookupController()
170 185
     heartbeat = ramdisk.HeartbeatController()
171 186
 

+ 17
- 2
ironic/api/controllers/v1/node.py View File

@@ -35,6 +35,7 @@ from ironic.api.controllers.v1 import portgroup
35 35
 from ironic.api.controllers.v1 import types
36 36
 from ironic.api.controllers.v1 import utils as api_utils
37 37
 from ironic.api.controllers.v1 import versions
38
+from ironic.api.controllers.v1 import volume
38 39
 from ironic.api import expose
39 40
 from ironic.common import exception
40 41
 from ironic.common.i18n import _
@@ -813,6 +814,9 @@ class Node(base.APIBase):
813 814
     portgroups = wsme.wsattr([link.Link], readonly=True)
814 815
     """Links to the collection of portgroups on this node"""
815 816
 
817
+    volume = wsme.wsattr([link.Link], readonly=True)
818
+    """Links to endpoint for retrieving volume resources on this node"""
819
+
816 820
     states = wsme.wsattr([link.Link], readonly=True)
817 821
     """Links to endpoint for retrieving and setting node states"""
818 822
 
@@ -869,7 +873,7 @@ class Node(base.APIBase):
869 873
 
870 874
     @staticmethod
871 875
     def _convert_with_links(node, url, fields=None, show_states_links=True,
872
-                            show_portgroups=True):
876
+                            show_portgroups=True, show_volume=True):
873 877
         # NOTE(lucasagomes): Since we are able to return a specified set of
874 878
         # fields the "uuid" can be unset, so we need to save it in another
875 879
         # variable to use when building the links
@@ -897,6 +901,14 @@ class Node(base.APIBase):
897 901
                                         node_uuid + "/portgroups",
898 902
                                         bookmark=True)]
899 903
 
904
+            if show_volume:
905
+                node.volume = [
906
+                    link.Link.make_link('self', url, 'nodes',
907
+                                        node_uuid + "/volume"),
908
+                    link.Link.make_link('bookmark', url, 'nodes',
909
+                                        node_uuid + "/volume",
910
+                                        bookmark=True)]
911
+
900 912
         # NOTE(lucasagomes): The numeric ID should not be exposed to
901 913
         #                    the user, it's internal only.
902 914
         node.chassis_id = wtypes.Unset
@@ -951,10 +963,12 @@ class Node(base.APIBase):
951 963
         show_states_links = (
952 964
             api_utils.allow_links_node_states_and_driver_properties())
953 965
         show_portgroups = api_utils.allow_portgroups_subcontrollers()
966
+        show_volume = api_utils.allow_volume()
954 967
         return cls._convert_with_links(node, pecan.request.public_url,
955 968
                                        fields=fields,
956 969
                                        show_states_links=show_states_links,
957
-                                       show_portgroups=show_portgroups)
970
+                                       show_portgroups=show_portgroups,
971
+                                       show_volume=show_volume)
958 972
 
959 973
     @classmethod
960 974
     def sample(cls, expand=True):
@@ -1247,6 +1261,7 @@ class NodesController(rest.RestController):
1247 1261
         'ports': port.PortsController,
1248 1262
         'portgroups': portgroup.PortgroupsController,
1249 1263
         'vifs': NodeVIFController,
1264
+        'volume': volume.VolumeController,
1250 1265
     }
1251 1266
 
1252 1267
     @pecan.expose()

+ 9
- 1
ironic/api/controllers/v1/notification_utils.py View File

@@ -27,6 +27,8 @@ from ironic.objects import node as node_objects
27 27
 from ironic.objects import notification
28 28
 from ironic.objects import port as port_objects
29 29
 from ironic.objects import portgroup as portgroup_objects
30
+from ironic.objects import volume_connector as volume_connector_objects
31
+from ironic.objects import volume_target as volume_target_objects
30 32
 
31 33
 LOG = log.getLogger(__name__)
32 34
 CONF = cfg.CONF
@@ -40,7 +42,13 @@ CRUD_NOTIFY_OBJ = {
40 42
     'port': (port_objects.PortCRUDNotification,
41 43
              port_objects.PortCRUDPayload),
42 44
     'portgroup': (portgroup_objects.PortgroupCRUDNotification,
43
-                  portgroup_objects.PortgroupCRUDPayload)
45
+                  portgroup_objects.PortgroupCRUDPayload),
46
+    'volumeconnector':
47
+        (volume_connector_objects.VolumeConnectorCRUDNotification,
48
+         volume_connector_objects.VolumeConnectorCRUDPayload),
49
+    'volumetarget':
50
+        (volume_target_objects.VolumeTargetCRUDNotification,
51
+         volume_target_objects.VolumeTargetCRUDPayload),
44 52
 }
45 53
 
46 54
 

+ 8
- 0
ironic/api/controllers/v1/utils.py View File

@@ -549,6 +549,14 @@ def allow_dynamic_interfaces():
549 549
             versions.MINOR_31_DYNAMIC_INTERFACES)
550 550
 
551 551
 
552
+def allow_volume():
553
+    """Check if volume connectors and targets are allowed.
554
+
555
+    Version 1.32 of the API added support for volume connectors and targets
556
+    """
557
+    return pecan.request.version.minor >= versions.MINOR_32_VOLUME
558
+
559
+
552 560
 def get_controller_reserved_names(cls):
553 561
     """Get reserved names for a given controller.
554 562
 

+ 3
- 1
ironic/api/controllers/v1/versions.py View File

@@ -62,6 +62,7 @@ BASE_VERSION = 1
62 62
 # v1.29: Add inject nmi.
63 63
 # v1.30: Add dynamic driver interactions.
64 64
 # v1.31: Add dynamic interfaces fields to node.
65
+# v1.32: Add volume support.
65 66
 
66 67
 MINOR_0_JUNO = 0
67 68
 MINOR_1_INITIAL_VERSION = 1
@@ -95,11 +96,12 @@ MINOR_28_VIFS_SUBCONTROLLER = 28
95 96
 MINOR_29_INJECT_NMI = 29
96 97
 MINOR_30_DYNAMIC_DRIVERS = 30
97 98
 MINOR_31_DYNAMIC_INTERFACES = 31
99
+MINOR_32_VOLUME = 32
98 100
 
99 101
 # When adding another version, update MINOR_MAX_VERSION and also update
100 102
 # doc/source/dev/webapi-version-history.rst with a detailed explanation of
101 103
 # what the version has changed.
102
-MINOR_MAX_VERSION = MINOR_31_DYNAMIC_INTERFACES
104
+MINOR_MAX_VERSION = MINOR_32_VOLUME
103 105
 
104 106
 # String representations of the minor and maximum versions
105 107
 MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

+ 103
- 0
ironic/api/controllers/v1/volume.py View File

@@ -0,0 +1,103 @@
1
+# Copyright (c) 2017 Hitachi, Ltd.
2
+#
3
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+#    not use this file except in compliance with the License. You may obtain
5
+#    a copy of the License at
6
+#
7
+#         http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+#    Unless required by applicable law or agreed to in writing, software
10
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+#    License for the specific language governing permissions and limitations
13
+#    under the License.
14
+
15
+import pecan
16
+from pecan import rest
17
+from six.moves import http_client
18
+import wsme
19
+
20
+from ironic.api.controllers import base
21
+from ironic.api.controllers import link
22
+from ironic.api.controllers.v1 import utils as api_utils
23
+from ironic.api.controllers.v1 import volume_connector
24
+from ironic.api.controllers.v1 import volume_target
25
+from ironic.api import expose
26
+from ironic.common import exception
27
+from ironic.common import policy
28
+
29
+
30
+class Volume(base.APIBase):
31
+    """API representation of a volume root.
32
+
33
+    This class exists as a root class for the volume connectors and volume
34
+    targets controllers.
35
+    """
36
+
37
+    links = wsme.wsattr([link.Link], readonly=True)
38
+    """A list containing a self link and associated volume links"""
39
+
40
+    connectors = wsme.wsattr([link.Link], readonly=True)
41
+    """Links to the volume connectors resource"""
42
+
43
+    targets = wsme.wsattr([link.Link], readonly=True)
44
+    """Links to the volume targets resource"""
45
+
46
+    @staticmethod
47
+    def convert(node_ident=None):
48
+        url = pecan.request.public_url
49
+        volume = Volume()
50
+        if node_ident:
51
+            resource = 'nodes'
52
+            args = '%s/volume/' % node_ident
53
+        else:
54
+            resource = 'volume'
55
+            args = ''
56
+
57
+        volume.links = [
58
+            link.Link.make_link('self', url, resource, args),
59
+            link.Link.make_link('bookmark', url, resource, args,
60
+                                bookmark=True)]
61
+
62
+        volume.connectors = [
63
+            link.Link.make_link('self', url, resource, args + 'connectors'),
64
+            link.Link.make_link('bookmark', url, resource, args + 'connectors',
65
+                                bookmark=True)]
66
+
67
+        volume.targets = [
68
+            link.Link.make_link('self', url, resource, args + 'targets'),
69
+            link.Link.make_link('bookmark', url, resource, args + 'targets',
70
+                                bookmark=True)]
71
+
72
+        return volume
73
+
74
+
75
+class VolumeController(rest.RestController):
76
+    """REST controller for volume root"""
77
+
78
+    _subcontroller_map = {
79
+        'connectors': volume_connector.VolumeConnectorsController,
80
+        'targets': volume_target.VolumeTargetsController
81
+    }
82
+
83
+    def __init__(self, node_ident=None):
84
+        super(VolumeController, self).__init__()
85
+        self.parent_node_ident = node_ident
86
+
87
+    @expose.expose(Volume)
88
+    def get(self):
89
+        if not api_utils.allow_volume():
90
+            raise exception.NotFound()
91
+
92
+        cdict = pecan.request.context.to_policy_values()
93
+        policy.authorize('baremetal:volume:get', cdict, cdict)
94
+
95
+        return Volume.convert(self.parent_node_ident)
96
+
97
+    @pecan.expose()
98
+    def _lookup(self, subres, *remainder):
99
+        if not api_utils.allow_volume():
100
+            pecan.abort(http_client.NOT_FOUND)
101
+        subcontroller = self._subcontroller_map.get(subres)
102
+        if subcontroller:
103
+            return subcontroller(node_ident=self.parent_node_ident), remainder

+ 480
- 0
ironic/api/controllers/v1/volume_connector.py View File

@@ -0,0 +1,480 @@
1
+# Copyright (c) 2017 Hitachi, Ltd.
2
+#
3
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+#    not use this file except in compliance with the License. You may obtain
5
+#    a copy of the License at
6
+#
7
+#         http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+#    Unless required by applicable law or agreed to in writing, software
10
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+#    License for the specific language governing permissions and limitations
13
+#    under the License.
14
+
15
+import datetime
16
+
17
+from ironic_lib import metrics_utils
18
+from oslo_utils import uuidutils
19
+import pecan
20
+from pecan import rest
21
+import six
22
+from six.moves import http_client
23
+import wsme
24
+from wsme import types as wtypes
25
+
26
+from ironic.api.controllers import base
27
+from ironic.api.controllers import link
28
+from ironic.api.controllers.v1 import collection
29
+from ironic.api.controllers.v1 import notification_utils as notify
30
+from ironic.api.controllers.v1 import types
31
+from ironic.api.controllers.v1 import utils as api_utils
32
+from ironic.api import expose
33
+from ironic.common import exception
34
+from ironic.common.i18n import _
35
+from ironic.common import policy
36
+from ironic import objects
37
+
38
+METRICS = metrics_utils.get_metrics_logger(__name__)
39
+
40
+_DEFAULT_RETURN_FIELDS = ('uuid', 'node_uuid', 'type', 'connector_id')
41
+
42
+
43
+class VolumeConnector(base.APIBase):
44
+    """API representation of a volume connector.
45
+
46
+    This class enforces type checking and value constraints, and converts
47
+    between the internal object model and the API representation of a volume
48
+    connector.
49
+    """
50
+
51
+    _node_uuid = None
52
+
53
+    def _get_node_uuid(self):
54
+        return self._node_uuid
55
+
56
+    def _set_node_identifiers(self, value):
57
+        """Set both UUID and ID of a node for VolumeConnector object
58
+
59
+        :param value: UUID, ID of a node, or wtypes.Unset
60
+        """
61
+        if value == wtypes.Unset:
62
+            self._node_uuid = wtypes.Unset
63
+        elif value and self._node_uuid != value:
64
+            try:
65
+                node = objects.Node.get(pecan.request.context, value)
66
+                self._node_uuid = node.uuid
67
+                # NOTE(smoriya): Create the node_id attribute on-the-fly
68
+                #                to satisfy the api -> rpc object conversion.
69
+                self.node_id = node.id
70
+            except exception.NodeNotFound as e:
71
+                # Change error code because 404 (NotFound) is inappropriate
72
+                # response for a POST request to create a VolumeConnector
73
+                e.code = http_client.BAD_REQUEST  # BadRequest
74
+                raise
75
+
76
+    uuid = types.uuid
77
+    """Unique UUID for this volume connector"""
78
+
79
+    type = wsme.wsattr(wtypes.text, mandatory=True)
80
+    """The type of volume connector"""
81
+
82
+    connector_id = wsme.wsattr(wtypes.text, mandatory=True)
83
+    """The connector_id for this volume connector"""
84
+
85
+    extra = {wtypes.text: types.jsontype}
86
+    """The metadata for this volume connector"""
87
+
88
+    node_uuid = wsme.wsproperty(types.uuid, _get_node_uuid,
89
+                                _set_node_identifiers, mandatory=True)
90
+    """The UUID of the node this volume connector belongs to"""
91
+
92
+    links = wsme.wsattr([link.Link], readonly=True)
93
+    """A list containing a self link and associated volume connector links"""
94
+
95
+    def __init__(self, **kwargs):
96
+        self.fields = []
97
+        fields = list(objects.VolumeConnector.fields)
98
+        for field in fields:
99
+            # Skip fields we do not expose.
100
+            if not hasattr(self, field):
101
+                continue
102
+            self.fields.append(field)
103
+            setattr(self, field, kwargs.get(field, wtypes.Unset))
104
+
105
+        # NOTE(smoriya): node_id is an attribute created on-the-fly
106
+        # by _set_node_uuid(), it needs to be present in the fields so
107
+        # that as_dict() will contain node_id field when converting it
108
+        # before saving it in the database.
109
+        self.fields.append('node_id')
110
+        # NOTE(smoriya): node_uuid is not part of objects.VolumeConnector.-
111
+        #                fields because it's an API-only attribute
112
+        self.fields.append('node_uuid')
113
+        # NOTE(jtaryma): Additionally to node_uuid, node_id is handled as a
114
+        # secondary identifier in case RPC volume connector object dictionary
115
+        # was passed to the constructor.
116
+        self.node_uuid = kwargs.get('node_uuid') or kwargs.get('node_id',
117
+                                                               wtypes.Unset)
118
+
119
+    @staticmethod
120
+    def _convert_with_links(connector, url, fields=None):
121
+        # NOTE(lucasagomes): Since we are able to return a specified set of
122
+        # fields the "uuid" can be unset, so we need to save it in another
123
+        # variable to use when building the links
124
+        connector_uuid = connector.uuid
125
+        if fields is not None:
126
+            connector.unset_fields_except(fields)
127
+
128
+        # never expose the node_id attribute
129
+        connector.node_id = wtypes.Unset
130
+        connector.links = [link.Link.make_link('self', url,
131
+                                               'volume/connectors',
132
+                                               connector_uuid),
133
+                           link.Link.make_link('bookmark', url,
134
+                                               'volume/connectors',
135
+                                               connector_uuid,
136
+                                               bookmark=True)
137
+                           ]
138
+        return connector
139
+
140
+    @classmethod
141
+    def convert_with_links(cls, rpc_connector, fields=None):
142
+        connector = VolumeConnector(**rpc_connector.as_dict())
143
+
144
+        if fields is not None:
145
+            api_utils.check_for_invalid_fields(fields, connector.as_dict())
146
+
147
+        return cls._convert_with_links(connector, pecan.request.public_url,
148
+                                       fields=fields)
149
+
150
+    @classmethod
151
+    def sample(cls, expand=True):
152
+        sample = cls(uuid='86cfd480-0842-4abb-8386-e46149beb82f',
153
+                     type='iqn',
154
+                     connector_id='iqn.2010-10.org.openstack:51332b70524',
155
+                     extra={'foo': 'bar'},
156
+                     created_at=datetime.datetime.utcnow(),
157
+                     updated_at=datetime.datetime.utcnow())
158
+        sample._node_uuid = '7ae81bb3-dec3-4289-8d6c-da80bd8001ae'
159
+        fields = None if expand else _DEFAULT_RETURN_FIELDS
160
+        return cls._convert_with_links(sample, 'http://localhost:6385',
161
+                                       fields=fields)
162
+
163
+
164
+class VolumeConnectorPatchType(types.JsonPatchType):
165
+
166
+    _api_base = VolumeConnector
167
+
168
+
169
+class VolumeConnectorCollection(collection.Collection):
170
+    """API representation of a collection of volume connectors."""
171
+
172
+    connectors = [VolumeConnector]
173
+    """A list containing volume connector objects"""
174
+
175
+    def __init__(self, **kwargs):
176
+        self._type = 'connectors'
177
+
178
+    @staticmethod
179
+    def convert_with_links(rpc_connectors, limit, url=None, fields=None,
180
+                           detail=None, **kwargs):
181
+        collection = VolumeConnectorCollection()
182
+        collection.connectors = [
183
+            VolumeConnector.convert_with_links(p, fields=fields)
184
+            for p in rpc_connectors]
185
+        if detail:
186
+            kwargs['detail'] = detail
187
+        collection.next = collection.get_next(limit, url=url, **kwargs)
188
+        return collection
189
+
190
+    @classmethod
191
+    def sample(cls):
192
+        sample = cls()
193
+        sample.connectors = [VolumeConnector.sample(expand=False)]
194
+        return sample
195
+
196
+
197
+class VolumeConnectorsController(rest.RestController):
198
+    """REST controller for VolumeConnectors."""
199
+
200
+    invalid_sort_key_list = ['extra']
201
+
202
+    def __init__(self, node_ident=None):
203
+        super(VolumeConnectorsController, self).__init__()
204
+        self.parent_node_ident = node_ident
205
+
206
+    def _get_volume_connectors_collection(self, node_ident, marker, limit,
207
+                                          sort_key, sort_dir,
208
+                                          resource_url=None,
209
+                                          fields=None, detail=None):
210
+        limit = api_utils.validate_limit(limit)
211
+        sort_dir = api_utils.validate_sort_dir(sort_dir)
212
+
213
+        marker_obj = None
214
+        if marker:
215
+            marker_obj = objects.VolumeConnector.get_by_uuid(
216
+                pecan.request.context, marker)
217
+
218
+        if sort_key in self.invalid_sort_key_list:
219
+            raise exception.InvalidParameterValue(
220
+                _("The sort_key value %(key)s is an invalid field for "
221
+                  "sorting") % {'key': sort_key})
222
+
223
+        node_ident = self.parent_node_ident or node_ident
224
+
225
+        if node_ident:
226
+            # FIXME(comstud): Since all we need is the node ID, we can
227
+            #                 make this more efficient by only querying
228
+            #                 for that column. This will get cleaned up
229
+            #                 as we move to the object interface.
230
+            node = api_utils.get_rpc_node(node_ident)
231
+            connectors = objects.VolumeConnector.list_by_node_id(
232
+                pecan.request.context, node.id, limit, marker_obj,
233
+                sort_key=sort_key, sort_dir=sort_dir)
234
+        else:
235
+            connectors = objects.VolumeConnector.list(pecan.request.context,
236
+                                                      limit,
237
+                                                      marker_obj,
238
+                                                      sort_key=sort_key,
239
+                                                      sort_dir=sort_dir)
240
+        return VolumeConnectorCollection.convert_with_links(connectors, limit,
241
+                                                            url=resource_url,
242
+                                                            fields=fields,
243
+                                                            sort_key=sort_key,
244
+                                                            sort_dir=sort_dir,
245
+                                                            detail=detail)
246
+
247
+    @METRICS.timer('VolumeConnectorsController.get_all')
248
+    @expose.expose(VolumeConnectorCollection, types.uuid_or_name, types.uuid,
249
+                   int, wtypes.text, wtypes.text, types.listtype,
250
+                   types.boolean)
251
+    def get_all(self, node=None, marker=None, limit=None, sort_key='id',
252
+                sort_dir='asc', fields=None, detail=None):
253
+        """Retrieve a list of volume connectors.
254
+
255
+        :param node: UUID or name of a node, to get only volume connectors
256
+                     for that node.
257
+        :param marker: pagination marker for large data sets.
258
+        :param limit: maximum number of resources to return in a single result.
259
+                      This value cannot be larger than the value of max_limit
260
+                      in the [api] section of the ironic configuration, or only
261
+                      max_limit resources will be returned.
262
+        :param sort_key: column to sort results by. Default: id.
263
+        :param sort_dir: direction to sort. "asc" or "desc". Default: "asc".
264
+        :param fields: Optional, a list with a specified set of fields
265
+                       of the resource to be returned.
266
+        :param detail: Optional, whether to retrieve with detail.
267
+
268
+        :returns: a list of volume connectors, or an empty list if no volume
269
+                  connector is found.
270
+
271
+        :raises: InvalidParameterValue if sort_key does not exist
272
+        :raises: InvalidParameterValue if sort key is invalid for sorting.
273
+        :raises: InvalidParameterValue if both fields and detail are specified.
274
+        """
275
+        cdict = pecan.request.context.to_policy_values()
276
+        policy.authorize('baremetal:volume:get', cdict, cdict)
277
+
278
+        if fields is None and not detail:
279
+            fields = _DEFAULT_RETURN_FIELDS
280
+
281
+        if fields and detail:
282
+            raise exception.InvalidParameterValue(
283
+                _("Can't fetch a subset of fields with 'detail' set"))
284
+
285
+        resource_url = 'volume/connectors'
286
+        return self._get_volume_connectors_collection(
287
+            node, marker, limit, sort_key, sort_dir, resource_url=resource_url,
288
+            fields=fields, detail=detail)
289
+
290
+    @METRICS.timer('VolumeConnectorsController.get_one')
291
+    @expose.expose(VolumeConnector, types.uuid, types.listtype)
292
+    def get_one(self, connector_uuid, fields=None):
293
+        """Retrieve information about the given volume connector.
294
+
295
+        :param connector_uuid: UUID of a volume connector.
296
+        :param fields: Optional, a list with a specified set of fields
297
+            of the resource to be returned.
298
+
299
+        :returns: API-serializable volume connector object.
300
+
301
+        :raises: OperationNotPermitted if accessed with specifying a parent
302
+                 node.
303
+        :raises: VolumeConnectorNotFound if no volume connector exists with
304
+                 the specified UUID.
305
+        """
306
+        cdict = pecan.request.context.to_policy_values()
307
+        policy.authorize('baremetal:volume:get', cdict, cdict)
308
+
309
+        if self.parent_node_ident:
310
+            raise exception.OperationNotPermitted()
311
+
312
+        rpc_connector = objects.VolumeConnector.get_by_uuid(
313
+            pecan.request.context, connector_uuid)
314
+        return VolumeConnector.convert_with_links(rpc_connector, fields=fields)
315
+
316
+    @METRICS.timer('VolumeConnectorsController.post')
317
+    @expose.expose(VolumeConnector, body=VolumeConnector,
318
+                   status_code=http_client.CREATED)
319
+    def post(self, connector):
320
+        """Create a new volume connector.
321
+
322
+        :param connector: a volume connector within the request body.
323
+
324
+        :returns: API-serializable volume connector object.
325
+
326
+        :raises: OperationNotPermitted if accessed with specifying a parent
327
+                 node.
328
+        :raises: VolumeConnectorTypeAndIdAlreadyExists if a volume
329
+                 connector already exists with the same type and connector_id
330
+        :raises: VolumeConnectorAlreadyExists if a volume connector with the
331
+                 same UUID already exists
332
+        """
333
+        context = pecan.request.context
334
+        cdict = context.to_policy_values()
335
+        policy.authorize('baremetal:volume:create', cdict, cdict)
336
+
337
+        if self.parent_node_ident:
338
+            raise exception.OperationNotPermitted()
339
+
340
+        connector_dict = connector.as_dict()
341
+        # NOTE(hshiina): UUID is mandatory for notification payload
342
+        if not connector_dict.get('uuid'):
343
+            connector_dict['uuid'] = uuidutils.generate_uuid()
344
+
345
+        new_connector = objects.VolumeConnector(context, **connector_dict)
346
+
347
+        notify.emit_start_notification(context, new_connector, 'create',
348
+                                       node_uuid=connector.node_uuid)
349
+        with notify.handle_error_notification(context, new_connector,
350
+                                              'create',
351
+                                              node_uuid=connector.node_uuid):
352
+            new_connector.create()
353
+        notify.emit_end_notification(context, new_connector, 'create',
354
+                                     node_uuid=connector.node_uuid)
355
+        # Set the HTTP Location Header
356
+        pecan.response.location = link.build_url('volume/connectors',
357
+                                                 new_connector.uuid)
358
+        return VolumeConnector.convert_with_links(new_connector)
359
+
360
+    @METRICS.timer('VolumeConnectorsController.patch')
361
+    @wsme.validate(types.uuid, [VolumeConnectorPatchType])
362
+    @expose.expose(VolumeConnector, types.uuid,
363
+                   body=[VolumeConnectorPatchType])
364
+    def patch(self, connector_uuid, patch):
365
+        """Update an existing volume connector.
366
+
367
+        :param connector_uuid: UUID of a volume connector.
368
+        :param patch: a json PATCH document to apply to this volume connector.
369
+
370
+        :returns: API-serializable volume connector object.
371
+
372
+        :raises: OperationNotPermitted if accessed with specifying a
373
+                 parent node.
374
+        :raises: PatchError if a given patch can not be applied.
375
+        :raises: VolumeConnectorNotFound if no volume connector exists with
376
+                 the specified UUID.
377
+        :raises: InvalidParameterValue if the volume connector's UUID is being
378
+                 changed
379
+        :raises: NodeLocked if node is locked by another conductor
380
+        :raises: NodeNotFound if the node associated with the connector does
381
+                 not exist
382
+        :raises: VolumeConnectorTypeAndIdAlreadyExists if another connector
383
+                 already exists with the same values for type and connector_id
384
+                 fields
385
+        :raises: InvalidUUID if invalid node UUID is passed in the patch.
386
+        :raises: InvalidStateRequested If a node associated with the
387
+                 volume connector is not powered off.
388
+        """
389
+        context = pecan.request.context
390
+        cdict = context.to_policy_values()
391
+        policy.authorize('baremetal:volume:update', cdict, cdict)
392
+
393
+        if self.parent_node_ident:
394
+            raise exception.OperationNotPermitted()
395
+
396
+        values = api_utils.get_patch_values(patch, '/node_uuid')
397
+        for value in values:
398
+            if not uuidutils.is_uuid_like(value):
399
+                message = _("Expected a UUID for node_uuid, but received "
400
+                            "%(uuid)s.") % {'uuid': six.text_type(value)}
401
+                raise exception.InvalidUUID(message=message)
402
+
403
+        rpc_connector = objects.VolumeConnector.get_by_uuid(context,
404
+                                                            connector_uuid)
405
+        try:
406
+            connector_dict = rpc_connector.as_dict()
407
+            # NOTE(smoriya):
408
+            # 1) Remove node_id because it's an internal value and
409
+            #    not present in the API object
410
+            # 2) Add node_uuid
411
+            connector_dict['node_uuid'] = connector_dict.pop('node_id', None)
412
+            connector = VolumeConnector(
413
+                **api_utils.apply_jsonpatch(connector_dict, patch))
414
+        except api_utils.JSONPATCH_EXCEPTIONS as e:
415
+            raise exception.PatchError(patch=patch, reason=e)
416
+
417
+        # Update only the fields that have changed.
418
+        for field in objects.VolumeConnector.fields:
419
+            try:
420
+                patch_val = getattr(connector, field)
421
+            except AttributeError:
422
+                # Ignore fields that aren't exposed in the API
423
+                continue
424
+            if patch_val == wtypes.Unset:
425
+                patch_val = None
426
+            if rpc_connector[field] != patch_val:
427
+                rpc_connector[field] = patch_val
428
+
429
+        rpc_node = objects.Node.get_by_id(context,
430
+                                          rpc_connector.node_id)
431
+        notify.emit_start_notification(context, rpc_connector, 'update',
432
+                                       node_uuid=rpc_node.uuid)
433
+        with notify.handle_error_notification(context, rpc_connector, 'update',
434
+                                              node_uuid=rpc_node.uuid):
435
+            topic = pecan.request.rpcapi.get_topic_for(rpc_node)
436
+            new_connector = pecan.request.rpcapi.update_volume_connector(
437
+                context, rpc_connector, topic)
438
+
439
+        api_connector = VolumeConnector.convert_with_links(new_connector)
440
+        notify.emit_end_notification(context, new_connector, 'update',
441
+                                     node_uuid=rpc_node.uuid)
442
+        return api_connector
443
+
444
+    @METRICS.timer('VolumeConnectorsController.delete')
445
+    @expose.expose(None, types.uuid, status_code=http_client.NO_CONTENT)
446
+    def delete(self, connector_uuid):
447
+        """Delete a volume connector.
448
+
449
+        :param connector_uuid: UUID of a volume connector.
450
+
451
+        :raises: OperationNotPermitted if accessed with specifying a
452
+                 parent node.
453
+        :raises: NodeLocked if node is locked by another conductor
454
+        :raises: NodeNotFound if the node associated with the connector does
455
+                 not exist
456
+        :raises: VolumeConnectorNotFound if the volume connector cannot be
457
+                 found
458
+        :raises: InvalidStateRequested If a node associated with the
459
+                 volume connector is not powered off.
460
+        """
461
+        context = pecan.request.context
462
+        cdict = context.to_policy_values()
463
+        policy.authorize('baremetal:volume:delete', cdict, cdict)
464
+
465
+        if self.parent_node_ident:
466
+            raise exception.OperationNotPermitted()
467
+
468
+        rpc_connector = objects.VolumeConnector.get_by_uuid(context,
469
+                                                            connector_uuid)
470
+        rpc_node = objects.Node.get_by_id(context, rpc_connector.node_id)
471
+        notify.emit_start_notification(context, rpc_connector, 'delete',
472
+                                       node_uuid=rpc_node.uuid)
473
+        with notify.handle_error_notification(context, rpc_connector,
474
+                                              'delete',
475
+                                              node_uuid=rpc_node.uuid):
476
+            topic = pecan.request.rpcapi.get_topic_for(rpc_node)
477
+            pecan.request.rpcapi.destroy_volume_connector(context,
478
+                                                          rpc_connector, topic)
479
+        notify.emit_end_notification(context, rpc_connector, 'delete',
480
+                                     node_uuid=rpc_node.uuid)

+ 489
- 0
ironic/api/controllers/v1/volume_target.py View File

@@ -0,0 +1,489 @@
1
+# Copyright (c) 2017 Hitachi, Ltd.
2
+#
3
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+#    not use this file except in compliance with the License. You may obtain
5
+#    a copy of the License at
6
+#
7
+#         http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+#    Unless required by applicable law or agreed to in writing, software
10
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+#    License for the specific language governing permissions and limitations
13
+#    under the License.
14
+
15
+import datetime
16
+
17
+from ironic_lib import metrics_utils
18
+from oslo_utils import uuidutils
19
+import pecan
20
+from pecan import rest
21
+import six
22
+from six.moves import http_client
23
+import wsme
24
+from wsme import types as wtypes
25
+
26
+from ironic.api.controllers import base
27
+from ironic.api.controllers import link
28
+from ironic.api.controllers.v1 import collection
29
+from ironic.api.controllers.v1 import notification_utils as notify
30
+from ironic.api.controllers.v1 import types
31
+from ironic.api.controllers.v1 import utils as api_utils
32
+from ironic.api import expose
33
+from ironic.common import exception
34
+from ironic.common.i18n import _
35
+from ironic.common import policy
36
+from ironic import objects
37
+
38
+METRICS = metrics_utils.get_metrics_logger(__name__)
39
+
40
+_DEFAULT_RETURN_FIELDS = ('uuid', 'node_uuid', 'volume_type',
41
+                          'boot_index', 'volume_id')
42
+
43
+
44
+class VolumeTarget(base.APIBase):
45
+    """API representation of a volume target.
46
+
47
+    This class enforces type checking and value constraints, and converts
48
+    between the internal object model and the API representation of a volume
49
+    target.
50
+    """
51
+
52
+    _node_uuid = None
53
+
54
+    def _get_node_uuid(self):
55
+        return self._node_uuid
56
+
57
+    def _set_node_identifiers(self, value):
58
+        """Set both UUID and ID of a node for VolumeTarget object
59
+
60
+        :param value: UUID, ID of a node, or wtypes.Unset
61
+        """
62
+        if value == wtypes.Unset:
63
+            self._node_uuid = wtypes.Unset
64
+        elif value and self._node_uuid != value:
65
+            try:
66
+                node = objects.Node.get(pecan.request.context, value)
67
+                self._node_uuid = node.uuid
68
+                # NOTE(smoriya): Create the node_id attribute on-the-fly
69
+                #                to satisfy the api -> rpc object conversion.
70
+                self.node_id = node.id
71
+            except exception.NodeNotFound as e:
72
+                # Change error code because 404 (NotFound) is inappropriate
73
+                # response for a POST request to create a VolumeTarget
74
+                e.code = http_client.BAD_REQUEST  # BadRequest
75
+                raise
76
+
77
+    uuid = types.uuid
78
+    """Unique UUID for this volume target"""
79
+
80
+    volume_type = wsme.wsattr(wtypes.text, mandatory=True)
81
+    """The volume_type of volume target"""
82
+
83
+    properties = {wtypes.text: types.jsontype}
84
+    """The properties for this volume target"""
85
+
86
+    boot_index = wsme.wsattr(int, mandatory=True)
87
+    """The boot_index of volume target"""
88
+
89
+    volume_id = wsme.wsattr(wtypes.text, mandatory=True)
90
+    """The volume_id for this volume target"""
91
+
92
+    extra = {wtypes.text: types.jsontype}
93
+    """The metadata for this volume target"""
94
+
95
+    node_uuid = wsme.wsproperty(types.uuid, _get_node_uuid,
96
+                                _set_node_identifiers, mandatory=True)
97
+    """The UUID of the node this volume target belongs to"""
98
+
99
+    links = wsme.wsattr([link.Link], readonly=True)
100
+    """A list containing a self link and associated volume target links"""
101
+
102
+    def __init__(self, **kwargs):
103
+        self.fields = []
104
+        fields = list(objects.VolumeTarget.fields)
105
+        for field in fields:
106
+            # Skip fields we do not expose.
107
+            if not hasattr(self, field):
108
+                continue
109
+            self.fields.append(field)
110
+            setattr(self, field, kwargs.get(field, wtypes.Unset))
111
+
112
+        # NOTE(smoriya): node_id is an attribute created on-the-fly
113
+        # by _set_node_uuid(), it needs to be present in the fields so
114
+        # that as_dict() will contain node_id field when converting it
115
+        # before saving it in the database.
116
+        self.fields.append('node_id')
117
+        # NOTE(smoriya): node_uuid is not part of objects.VolumeTarget.-
118
+        #                fields because it's an API-only attribute
119
+        self.fields.append('node_uuid')
120
+        # NOTE(jtaryma): Additionally to node_uuid, node_id is handled as a
121
+        # secondary identifier in case RPC volume target object dictionary
122
+        # was passed to the constructor.
123
+        self.node_uuid = kwargs.get('node_uuid') or kwargs.get('node_id',
124
+                                                               wtypes.Unset)
125
+
126
+    @staticmethod
127
+    def _convert_with_links(target, url, fields=None):
128
+        # NOTE(lucasagomes): Since we are able to return a specified set of
129
+        # fields the "uuid" can be unset, so we need to save it in another
130
+        # variable to use when building the links
131
+        target_uuid = target.uuid
132
+        if fields is not None:
133
+            target.unset_fields_except(fields)
134
+
135
+        # never expose the node_id attribute
136
+        target.node_id = wtypes.Unset
137
+        target.links = [link.Link.make_link('self', url,
138
+                                            'volume/targets',
139
+                                            target_uuid),
140
+                        link.Link.make_link('bookmark', url,
141
+                                            'volume/targets',
142
+                                            target_uuid,
143
+                                            bookmark=True)
144
+                        ]
145
+        return target
146
+
147
+    @classmethod
148
+    def convert_with_links(cls, rpc_target, fields=None):
149
+        target = VolumeTarget(**rpc_target.as_dict())
150
+
151
+        if fields is not None:
152
+            api_utils.check_for_invalid_fields(fields, target.as_dict())
153
+
154
+        return cls._convert_with_links(target, pecan.request.public_url,
155
+                                       fields=fields)
156
+
157
+    @classmethod
158
+    def sample(cls, expand=True):
159
+        properties = {"auth_method": "CHAP",
160
+                      "auth_username": "XXX",
161
+                      "auth_password": "XXX",
162
+                      "target_iqn": "iqn.2010-10.com.example:vol-X",
163
+                      "target_portal": "192.168.0.123:3260",
164
+                      "volume_id": "a2f3ff15-b3ea-4656-ab90-acbaa1a07607",
165
+                      "target_lun": 0,
166
+                      "access_mode": "rw"}
167
+
168
+        sample = cls(uuid='667808d4-622f-4629-b629-07753a19e633',
169
+                     volume_type='iscsi',
170
+                     boot_index=0,
171
+                     volume_id='a2f3ff15-b3ea-4656-ab90-acbaa1a07607',
172
+                     properties=properties,
173
+                     extra={'foo': 'bar'},
174
+                     created_at=datetime.datetime.utcnow(),
175
+                     updated_at=datetime.datetime.utcnow())
176
+        sample._node_uuid = '7ae81bb3-dec3-4289-8d6c-da80bd8001ae'
177
+        fields = None if expand else _DEFAULT_RETURN_FIELDS
178
+        return cls._convert_with_links(sample, 'http://localhost:6385',
179
+                                       fields=fields)
180
+
181
+
182
+class VolumeTargetPatchType(types.JsonPatchType):
183
+
184
+    _api_base = VolumeTarget
185
+
186
+
187
+class VolumeTargetCollection(collection.Collection):
188
+    """API representation of a collection of volume targets."""
189
+
190
+    targets = [VolumeTarget]
191
+    """A list containing volume target objects"""
192
+
193
+    def __init__(self, **kwargs):
194
+        self._type = 'targets'
195
+
196
+    @staticmethod
197
+    def convert_with_links(rpc_targets, limit, url=None, fields=None,
198
+                           detail=None, **kwargs):
199
+        collection = VolumeTargetCollection()
200
+        collection.targets = [
201
+            VolumeTarget.convert_with_links(p, fields=fields)
202
+            for p in rpc_targets]
203
+        if detail:
204
+            kwargs['detail'] = detail
205
+        collection.next = collection.get_next(limit, url=url, **kwargs)
206
+        return collection
207
+
208
+    @classmethod
209
+    def sample(cls):
210
+        sample = cls()
211
+        sample.targets = [VolumeTarget.sample(expand=False)]
212
+        return sample
213
+
214
+
215
+class VolumeTargetsController(rest.RestController):
216
+    """REST controller for VolumeTargets."""
217
+
218
+    invalid_sort_key_list = ['extra', 'properties']
219
+
220
+    def __init__(self, node_ident=None):
221
+        super(VolumeTargetsController, self).__init__()
222
+        self.parent_node_ident = node_ident
223
+
224
+    def _get_volume_targets_collection(self, node_ident, marker, limit,
225
+                                       sort_key, sort_dir, resource_url=None,
226
+                                       fields=None, detail=None):
227
+        limit = api_utils.validate_limit(limit)
228
+        sort_dir = api_utils.validate_sort_dir(sort_dir)
229
+
230
+        marker_obj = None
231
+        if marker:
232
+            marker_obj = objects.VolumeTarget.get_by_uuid(
233
+                pecan.request.context, marker)
234
+
235
+        if sort_key in self.invalid_sort_key_list:
236
+            raise exception.InvalidParameterValue(
237
+                _("The sort_key value %(key)s is an invalid field for "
238
+                  "sorting") % {'key': sort_key})
239
+
240
+        node_ident = self.parent_node_ident or node_ident
241
+
242
+        if node_ident:
243
+            # FIXME(comstud): Since all we need is the node ID, we can
244
+            #                 make this more efficient by only querying
245
+            #                 for that column. This will get cleaned up
246
+            #                 as we move to the object interface.
247
+            node = api_utils.get_rpc_node(node_ident)
248
+            targets = objects.VolumeTarget.list_by_node_id(
249
+                pecan.request.context, node.id, limit, marker_obj,
250
+                sort_key=sort_key, sort_dir=sort_dir)
251
+        else:
252
+            targets = objects.VolumeTarget.list(pecan.request.context,
253
+                                                limit, marker_obj,
254
+                                                sort_key=sort_key,
255
+                                                sort_dir=sort_dir)
256
+        return VolumeTargetCollection.convert_with_links(targets, limit,
257
+                                                         url=resource_url,
258
+                                                         fields=fields,
259
+                                                         sort_key=sort_key,
260
+                                                         sort_dir=sort_dir,
261
+                                                         detail=detail)
262
+
263
+    @METRICS.timer('VolumeTargetsController.get_all')
264
+    @expose.expose(VolumeTargetCollection, types.uuid_or_name, types.uuid,
265
+                   int, wtypes.text, wtypes.text, types.listtype,
266
+                   types.boolean)
267
+    def get_all(self, node=None, marker=None, limit=None, sort_key='id',
268
+                sort_dir='asc', fields=None, detail=None):
269
+        """Retrieve a list of volume targets.
270
+
271
+        :param node: UUID or name of a node, to get only volume targets
272
+                     for that node.
273
+        :param marker: pagination marker for large data sets.
274
+        :param limit: maximum number of resources to return in a single result.
275
+                      This value cannot be larger than the value of max_limit
276
+                      in the [api] section of the ironic configuration, or only
277
+                      max_limit resources will be returned.
278
+        :param sort_key: column to sort results by. Default: id.
279
+        :param sort_dir: direction to sort. "asc" or "desc". Default: "asc".
280
+        :param fields: Optional, a list with a specified set of fields
281
+                       of the resource to be returned.
282
+        :param detail: Optional, whether to retrieve with detail.
283
+
284
+        :returns: a list of volume targets, or an empty list if no volume
285
+                  target is found.
286
+
287
+        :raises: InvalidParameterValue if sort_key does not exist
288
+        :raises: InvalidParameterValue if sort key is invalid for sorting.
289
+        :raises: InvalidParameterValue if both fields and detail are specified.
290
+        """
291
+        cdict = pecan.request.context.to_policy_values()
292
+        policy.authorize('baremetal:volume:get', cdict, cdict)
293
+
294
+        if fields is None and not detail:
295
+            fields = _DEFAULT_RETURN_FIELDS
296
+
297
+        if fields and detail:
298
+            raise exception.InvalidParameterValue(
299
+                _("Can't fetch a subset of fields with 'detail' set"))
300
+
301
+        resource_url = 'volume/targets'
302
+        return self._get_volume_targets_collection(node, marker, limit,
303
+                                                   sort_key, sort_dir,
304
+                                                   resource_url=resource_url,
305
+                                                   fields=fields,
306
+                                                   detail=detail)
307
+
308
+    @METRICS.timer('VolumeTargetsController.get_one')
309
+    @expose.expose(VolumeTarget, types.uuid, types.listtype)
310
+    def get_one(self, target_uuid, fields=None):
311
+        """Retrieve information about the given volume target.
312
+
313
+        :param target_uuid: UUID of a volume target.
314
+        :param fields: Optional, a list with a specified set of fields
315
+               of the resource to be returned.
316
+
317
+        :returns: API-serializable volume target object.
318
+
319
+        :raises: OperationNotPermitted if accessed with specifying a parent
320
+                 node.
321
+        :raises: VolumeTargetNotFound if no volume target with this UUID exists
322
+        """
323
+        cdict = pecan.request.context.to_policy_values()
324
+        policy.authorize('baremetal:volume:get', cdict, cdict)
325
+
326
+        if self.parent_node_ident:
327
+            raise exception.OperationNotPermitted()
328
+
329
+        rpc_target = objects.VolumeTarget.get_by_uuid(
330
+            pecan.request.context, target_uuid)
331
+        return VolumeTarget.convert_with_links(rpc_target, fields=fields)
332
+
333
+    @METRICS.timer('VolumeTargetsController.post')
334
+    @expose.expose(VolumeTarget, body=VolumeTarget,
335
+                   status_code=http_client.CREATED)
336
+    def post(self, target):
337
+        """Create a new volume target.
338
+
339
+        :param target: a volume target within the request body.
340
+
341
+        :returns: API-serializable volume target object.
342
+
343
+        :raises: OperationNotPermitted if accessed with specifying a parent
344
+                 node.
345
+        :raises: VolumeTargetBootIndexAlreadyExists if a volume target already
346
+                 exists with the same node ID and boot index
347
+        :raises: VolumeTargetAlreadyExists if a volume target with the same
348
+                 UUID exists
349
+        """
350
+        context = pecan.request.context
351
+        cdict = context.to_policy_values()
352
+        policy.authorize('baremetal:volume:create', cdict, cdict)
353
+
354
+        if self.parent_node_ident:
355
+            raise exception.OperationNotPermitted()
356
+
357
+        target_dict = target.as_dict()
358
+        # NOTE(hshiina): UUID is mandatory for notification payload
359
+        if not target_dict.get('uuid'):
360
+            target_dict['uuid'] = uuidutils.generate_uuid()
361
+
362
+        new_target = objects.VolumeTarget(context, **target_dict)
363
+
364
+        notify.emit_start_notification(context, new_target, 'create',
365
+                                       node_uuid=target.node_uuid)
366
+        with notify.handle_error_notification(context, new_target, 'create',
367
+                                              node_uuid=target.node_uuid):
368
+            new_target.create()
369
+        notify.emit_end_notification(context, new_target, 'create',
370
+                                     node_uuid=target.node_uuid)
371
+        # Set the HTTP Location Header
372
+        pecan.response.location = link.build_url('volume/targets',
373
+                                                 new_target.uuid)
374
+        return VolumeTarget.convert_with_links(new_target)
375
+
376
+    @METRICS.timer('VolumeTargetsController.patch')
377
+    @wsme.validate(types.uuid, [VolumeTargetPatchType])
378
+    @expose.expose(VolumeTarget, types.uuid,
379
+                   body=[VolumeTargetPatchType])
380
+    def patch(self, target_uuid, patch):
381
+        """Update an existing volume target.
382
+
383
+        :param target_uuid: UUID of a volume target.
384
+        :param patch: a json PATCH document to apply to this volume target.
385
+
386
+        :returns: API-serializable volume target object.
387
+
388
+        :raises: OperationNotPermitted if accessed with specifying a
389
+                 parent node.
390
+        :raises: PatchError if a given patch can not be applied.
391
+        :raises: InvalidParameterValue if the volume target's UUID is being
392
+                 changed
393
+        :raises: NodeLocked if the node is already locked
394
+        :raises: NodeNotFound if the node associated with the volume target
395
+                 does not exist
396
+        :raises: VolumeTargetNotFound if the volume target cannot be found
397
+        :raises: VolumeTargetBootIndexAlreadyExists if a volume target already
398
+                 exists with the same node ID and boot index values
399
+        :raises: InvalidUUID if invalid node UUID is passed in the patch.
400
+        :raises: InvalidStateRequested If a node associated with the
401
+                 volume target is not powered off.
402
+        """
403
+        context = pecan.request.context
404
+        cdict = context.to_policy_values()
405
+        policy.authorize('baremetal:volume:update', cdict, cdict)
406
+
407
+        if self.parent_node_ident:
408
+            raise exception.OperationNotPermitted()
409
+
410
+        values = api_utils.get_patch_values(patch, '/node_uuid')
411
+        for value in values:
412
+            if not uuidutils.is_uuid_like(value):
413
+                message = _("Expected a UUID for node_uuid, but received "
414
+                            "%(uuid)s.") % {'uuid': six.text_type(value)}
415
+                raise exception.InvalidUUID(message=message)
416
+
417
+        rpc_target = objects.VolumeTarget.get_by_uuid(context, target_uuid)
418
+        try:
419
+            target_dict = rpc_target.as_dict()
420
+            # NOTE(smoriya):
421
+            # 1) Remove node_id because it's an internal value and
422
+            #    not present in the API object
423
+            # 2) Add node_uuid
424
+            target_dict['node_uuid'] = target_dict.pop('node_id', None)
425
+            target = VolumeTarget(
426
+                **api_utils.apply_jsonpatch(target_dict, patch))
427
+        except api_utils.JSONPATCH_EXCEPTIONS as e:
428
+            raise exception.PatchError(patch=patch, reason=e)
429
+
430
+        # Update only the fields that have changed.
431
+        for field in objects.VolumeTarget.fields:
432
+            try:
433
+                patch_val = getattr(target, field)
434
+            except AttributeError:
435
+                # Ignore fields that aren't exposed in the API
436
+                continue
437
+            if patch_val == wtypes.Unset:
438
+                patch_val = None
439
+            if rpc_target[field] != patch_val:
440
+                rpc_target[field] = patch_val
441
+
442
+        rpc_node = objects.Node.get_by_id(context, rpc_target.node_id)
443
+        notify.emit_start_notification(context, rpc_target, 'update',
444
+                                       node_uuid=rpc_node.uuid)
445
+        with notify.handle_error_notification(context, rpc_target, 'update',
446
+                                              node_uuid=rpc_node.uuid):
447
+            topic = pecan.request.rpcapi.get_topic_for(rpc_node)
448
+            new_target = pecan.request.rpcapi.update_volume_target(
449
+                context, rpc_target, topic)
450
+
451
+        api_target = VolumeTarget.convert_with_links(new_target)
452
+        notify.emit_end_notification(context, new_target, 'update',
453
+                                     node_uuid=rpc_node.uuid)
454
+        return api_target
455
+
456
+    @METRICS.timer('VolumeTargetsController.delete')
457
+    @expose.expose(None, types.uuid, status_code=http_client.NO_CONTENT)
458
+    def delete(self, target_uuid):
459
+        """Delete a volume target.
460
+
461
+        :param target_uuid: UUID of a volume target.
462
+
463
+        :raises: OperationNotPermitted if accessed with specifying a
464
+                 parent node.
465
+        :raises: NodeLocked if node is locked by another conductor
466
+        :raises: NodeNotFound if the node associated with the target does
467
+                 not exist
468
+        :raises: VolumeTargetNotFound if the volume target cannot be found
469
+        :raises: InvalidStateRequested If a node associated with the
470
+                 volume target is not powered off.
471
+        """
472
+        context = pecan.request.context
473
+        cdict = context.to_policy_values()
474
+        policy.authorize('baremetal:volume:delete', cdict, cdict)
475
+
476
+        if self.parent_node_ident:
477
+            raise exception.OperationNotPermitted()
478
+
479
+        rpc_target = objects.VolumeTarget.get_by_uuid(context, target_uuid)
480
+        rpc_node = objects.Node.get_by_id(context, rpc_target.node_id)
481
+        notify.emit_start_notification(context, rpc_target, 'delete',
482
+                                       node_uuid=rpc_node.uuid)
483
+        with notify.handle_error_notification(context, rpc_target, 'delete',
484
+                                              node_uuid=rpc_node.uuid):
485
+            topic = pecan.request.rpcapi.get_topic_for(rpc_node)
486
+            pecan.request.rpcapi.destroy_volume_target(context,
487
+                                                       rpc_target, topic)
488
+        notify.emit_end_notification(context, rpc_target, 'delete',
489
+                                     node_uuid=rpc_node.uuid)

+ 21
- 1
ironic/common/policy.py View File

@@ -204,6 +204,25 @@ extra_policies = [
204 204
                        description='Access IPA ramdisk functions'),
205 205
 ]
206 206
 
207
+volume_policies = [
208
+    policy.RuleDefault('baremetal:volume:get',
209
+                       'rule:is_admin or rule:is_observer',
210
+                       description='Retrieve Volume connector and target '
211
+                                   'records'),
212
+    policy.RuleDefault('baremetal:volume:create',
213
+                       'rule:is_admin',
214
+                       description='Create Volume connector and target '
215
+                                   'records'),
216
+    policy.RuleDefault('baremetal:volume:delete',
217
+                       'rule:is_admin',
218
+                       description='Delete Volume connetor and target '
219
+                                   'records'),
220
+    policy.RuleDefault('baremetal:volume:update',
221
+                       'rule:is_admin',
222
+                       description='Update Volume connector and target '
223
+                                   'records'),
224
+]
225
+
207 226
 
208 227
 def list_policies():
209 228
     policies = (default_policies
@@ -212,7 +231,8 @@ def list_policies():
212 231
                 + portgroup_policies
213 232
                 + chassis_policies
214 233
                 + driver_policies
215
-                + extra_policies)
234
+                + extra_policies
235
+                + volume_policies)
216 236
     return policies
217 237
 
218 238
 

+ 7
- 0
ironic/tests/unit/api/test_root.py View File

@@ -69,3 +69,10 @@ class TestV1Root(base.BaseApiTest):
69 69
                             additional_expected_resources=['heartbeat',
70 70
                                                            'lookup',
71 71
                                                            'portgroups'])
72
+
73
+    def test_get_v1_32_root(self):
74
+        self._test_get_root(headers={'X-OpenStack-Ironic-API-Version': '1.32'},
75
+                            additional_expected_resources=['heartbeat',
76
+                                                           'lookup',
77
+                                                           'portgroups',
78
+                                                           'volume'])

+ 20
- 0
ironic/tests/unit/api/utils.py View File

@@ -23,6 +23,8 @@ from ironic.api.controllers.v1 import chassis as chassis_controller
23 23
 from ironic.api.controllers.v1 import node as node_controller
24 24
 from ironic.api.controllers.v1 import port as port_controller
25 25
 from ironic.api.controllers.v1 import portgroup as portgroup_controller
26
+from ironic.api.controllers.v1 import volume_connector as vc_controller
27
+from ironic.api.controllers.v1 import volume_target as vt_controller
26 28
 from ironic.drivers import base as drivers_base
27 29
 from ironic.tests.unit.db import utils as db_utils
28 30
 
@@ -124,6 +126,24 @@ def port_post_data(**kw):
124 126
     return remove_internal(port, internal)
125 127
 
126 128
 
129
+def volume_connector_post_data(**kw):
130
+    connector = db_utils.get_test_volume_connector(**kw)
131
+    # These values are not part of the API object
132
+    connector.pop('node_id')
133
+    connector.pop('version')
134
+    internal = vc_controller.VolumeConnectorPatchType.internal_attrs()
135
+    return remove_internal(connector, internal)
136
+
137
+
138
+def volume_target_post_data(**kw):
139
+    target = db_utils.get_test_volume_target(**kw)
140
+    # These values are not part of the API object
141
+    target.pop('node_id')
142
+    target.pop('version')
143
+    internal = vt_controller.VolumeTargetPatchType.internal_attrs()
144
+    return remove_internal(target, internal)
145
+
146
+
127 147
 def chassis_post_data(**kw):
128 148
     chassis = db_utils.get_test_chassis(**kw)
129 149
     # version is not part of the API object

+ 207
- 0
ironic/tests/unit/api/v1/test_nodes.py View File

@@ -402,6 +402,17 @@ class TestListNodes(test_api_base.BaseApiTest):
402 402
             self.assertEqual(getattr(node, field),
403 403
                              new_data['nodes'][0][field])
404 404
 
405
+    def test_hide_fields_in_newer_versions_volume(self):
406
+        node = obj_utils.create_test_node(self.context)
407
+        data = self.get_json(
408
+            '/nodes/%s' % node.uuid,
409
+            headers={api_base.Version.string: '1.31'})
410
+        self.assertNotIn('volume', data)
411
+
412
+        data = self.get_json('/nodes/%s' % node.uuid,
413
+                             headers={api_base.Version.string: "1.32"})
414
+        self.assertIn('volume', data)
415
+
405 416
     def test_many(self):
406 417
         nodes = []
407 418
         for id in range(5):
@@ -630,6 +641,113 @@ class TestListNodes(test_api_base.BaseApiTest):
630 641
             headers={api_base.Version.string: '1.24'})
631 642
         self.assertEqual(http_client.FORBIDDEN, response.status_int)
632 643
 
644
+    def test_volume_subresource_link(self):
645
+        node = obj_utils.create_test_node(self.context)
646
+        data = self.get_json(
647
+            '/nodes/%s' % node.uuid,
648
+            headers={api_base.Version.string: '1.32'})
649
+        self.assertIn('volume', data)
650
+
651
+    def test_volume_subresource(self):
652
+        node = obj_utils.create_test_node(self.context)
653
+        data = self.get_json('/nodes/%s/volume' % node.uuid,
654
+                             headers={api_base.Version.string: '1.32'})
655
+        self.assertIn('connectors', data)
656
+        self.assertIn('targets', data)
657
+        self.assertIn('/volume/connectors',
658
+                      data['connectors'][0]['href'])
659
+        self.assertIn('/volume/connectors',
660
+                      data['connectors'][1]['href'])
661
+        self.assertIn('/volume/targets',
662
+                      data['targets'][0]['href'])
663
+        self.assertIn('/volume/targets',
664
+                      data['targets'][1]['href'])
665
+
666
+    def test_volume_subresource_invalid_api_version(self):
667
+        node = obj_utils.create_test_node(self.context)
668
+        response = self.get_json('/nodes/%s/volume' % node.uuid,
669
+                                 headers={api_base.Version.string: '1.31'},
670
+                                 expect_errors=True)
671
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
672
+
673
+    def test_volume_connectors_subresource(self):
674
+        node = obj_utils.create_test_node(self.context)
675
+
676
+        for id_ in range(2):
677
+            obj_utils.create_test_volume_connector(
678
+                self.context, node_id=node.id, uuid=uuidutils.generate_uuid(),
679
+                connector_id='test-connector_id-%s' % id_)
680
+
681
+        data = self.get_json(
682
+            '/nodes/%s/volume/connectors' % node.uuid,
683
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
684
+        self.assertEqual(2, len(data['connectors']))
685
+        self.assertNotIn('next', data)
686
+
687
+        # Test collection pagination
688
+        data = self.get_json(
689
+            '/nodes/%s/volume/connectors?limit=1' % node.uuid,
690
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
691
+        self.assertEqual(1, len(data['connectors']))
692
+        self.assertIn('next', data)
693
+
694
+    def test_volume_connectors_subresource_noid(self):
695
+        node = obj_utils.create_test_node(self.context)
696
+        obj_utils.create_test_volume_connector(self.context, node_id=node.id)
697
+        # No node_id specified.
698
+        response = self.get_json(
699
+            '/nodes/volume/connectors',
700
+            expect_errors=True,
701
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
702
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
703
+
704
+    def test_volume_connectors_subresource_node_not_found(self):
705
+        non_existent_uuid = 'eeeeeeee-cccc-aaaa-bbbb-cccccccccccc'
706
+        response = self.get_json(
707
+            '/nodes/%s/volume/connectors' % non_existent_uuid,
708
+            expect_errors=True,
709
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
710
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
711
+
712
+    def test_volume_targets_subresource(self):
713
+        node = obj_utils.create_test_node(self.context)
714
+
715
+        for id_ in range(2):
716
+            obj_utils.create_test_volume_target(
717
+                self.context, node_id=node.id, uuid=uuidutils.generate_uuid(),
718
+                boot_index=id_)
719
+
720
+        data = self.get_json(
721
+            '/nodes/%s/volume/targets' % node.uuid,
722
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
723
+        self.assertEqual(2, len(data['targets']))
724
+        self.assertNotIn('next', data)
725
+
726
+        # Test collection pagination
727
+        data = self.get_json(
728
+            '/nodes/%s/volume/targets?limit=1' % node.uuid,
729
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
730
+        self.assertEqual(1, len(data['targets']))
731
+        self.assertIn('next', data)
732
+
733
+    def test_volume_targets_subresource_noid(self):
734
+        node = obj_utils.create_test_node(self.context)
735
+        obj_utils.create_test_volume_target(self.context, node_id=node.id)
736
+        # No node_id specified.
737
+        response = self.get_json(
738
+            '/nodes/volume/targets',
739
+            expect_errors=True,
740
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
741
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
742
+
743
+    def test_volume_targets_subresource_node_not_found(self):
744
+        non_existent_uuid = 'eeeeeeee-cccc-aaaa-bbbb-cccccccccccc'
745
+        response = self.get_json(
746
+            '/nodes/%s/volume/targets' % non_existent_uuid,
747
+            expect_errors=True,
748
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
749
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
750
+
633 751
     @mock.patch.object(timeutils, 'utcnow')
634 752
     def _test_node_states(self, mock_utcnow, api_version=None):
635 753
         fake_state = 'fake-state'
@@ -1382,6 +1500,37 @@ class TestPatch(test_api_base.BaseApiTest):
1382 1500
             headers={'X-OpenStack-Ironic-API-Version': '1.24'})
1383 1501
         self.assertEqual(http_client.FORBIDDEN, response.status_int)
1384 1502
 
1503
+    def test_patch_volume_connectors_subresource_no_connector_id(self):
1504
+        response = self.patch_json(
1505
+            '/nodes/%s/volume/connectors' % self.node.uuid,
1506
+            [{'path': '/extra/foo', 'value': 'bar', 'op': 'add'}],
1507
+            expect_errors=True,
1508
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
1509
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
1510
+
1511
+    def test_patch_volume_connectors_subresource(self):
1512
+        connector = (
1513
+            obj_utils.create_test_volume_connector(self.context,
1514
+                                                   node_id=self.node.id))
1515
+        response = self.patch_json(
1516
+            '/nodes/%s/volume/connectors/%s' % (self.node.uuid,
1517
+                                                connector.uuid),
1518
+            [{'path': '/extra/foo', 'value': 'bar', 'op': 'add'}],
1519
+            expect_errors=True,
1520
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
1521
+        self.assertEqual(http_client.FORBIDDEN, response.status_int)
1522
+
1523
+    def test_patch_volume_targets_subresource(self):
1524
+        target = obj_utils.create_test_volume_target(self.context,
1525
+                                                     node_id=self.node.id)
1526
+        response = self.patch_json(
1527
+            '/nodes/%s/volume/targets/%s' % (self.node.uuid,
1528
+                                             target.uuid),
1529
+            [{'path': '/extra/foo', 'value': 'bar', 'op': 'add'}],
1530
+            expect_errors=True,
1531
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
1532
+        self.assertEqual(http_client.FORBIDDEN, response.status_int)
1533
+
1385 1534
     def test_remove_uuid(self):
1386 1535
         response = self.patch_json('/nodes/%s' % self.node.uuid,
1387 1536
                                    [{'path': '/uuid', 'op': 'remove'}],
@@ -2194,6 +2343,36 @@ class TestPost(test_api_base.BaseApiTest):
2194 2343
             headers={'X-OpenStack-Ironic-API-Version': '1.24'})
2195 2344
         self.assertEqual(http_client.FORBIDDEN, response.status_int)
2196 2345
 
2346
+    def test_post_volume_connectors_subresource_no_node_id(self):
2347
+        node = obj_utils.create_test_node(self.context)
2348
+        pdict = test_api_utils.volume_connector_post_data(node_id=None)
2349
+        pdict['node_uuid'] = node.uuid
2350
+        response = self.post_json(
2351
+            '/nodes/volume/connectors', pdict,
2352
+            expect_errors=True,
2353
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
2354
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
2355
+
2356
+    def test_post_volume_connectors_subresource(self):
2357
+        node = obj_utils.create_test_node(self.context)
2358
+        pdict = test_api_utils.volume_connector_post_data(node_id=None)
2359
+        pdict['node_uuid'] = node.uuid
2360
+        response = self.post_json(
2361
+            '/nodes/%s/volume/connectors' % node.uuid, pdict,
2362
+            expect_errors=True,
2363
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
2364
+        self.assertEqual(http_client.FORBIDDEN, response.status_int)
2365
+
2366
+    def test_post_volume_targets_subresource(self):
2367
+        node = obj_utils.create_test_node(self.context)
2368
+        pdict = test_api_utils.volume_target_post_data(node_id=None)
2369
+        pdict['node_uuid'] = node.uuid
2370
+        response = self.post_json(
2371
+            '/nodes/%s/volume/targets' % node.uuid, pdict,
2372
+            expect_errors=True,
2373
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
2374
+        self.assertEqual(http_client.FORBIDDEN, response.status_int)
2375
+
2197 2376
     def test_create_node_no_mandatory_field_driver(self):
2198 2377
         ndict = test_api_utils.post_get_test_node()
2199 2378
         del ndict['driver']
@@ -2427,6 +2606,34 @@ class TestDelete(test_api_base.BaseApiTest):
2427 2606
             headers={'X-OpenStack-Ironic-API-Version': '1.24'})
2428 2607
         self.assertEqual(http_client.FORBIDDEN, response.status_int)
2429 2608
 
2609
+    def test_delete_volume_connectors_subresource_no_connector_id(self):
2610
+        node = obj_utils.create_test_node(self.context)
2611
+        response = self.delete(
2612
+            '/nodes/%s/volume/connectors' % node.uuid,
2613
+            expect_errors=True,
2614
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
2615
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
2616
+
2617
+    def test_delete_volume_connectors_subresource(self):
2618
+        node = obj_utils.create_test_node(self.context)
2619
+        connector = obj_utils.create_test_volume_connector(self.context,
2620
+                                                           node_id=node.id)
2621
+        response = self.delete(
2622
+            '/nodes/%s/volume/connectors/%s' % (node.uuid, connector.uuid),
2623
+            expect_errors=True,
2624
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
2625
+        self.assertEqual(http_client.FORBIDDEN, response.status_int)
2626
+
2627
+    def test_delete_volume_targets_subresource(self):
2628
+        node = obj_utils.create_test_node(self.context)
2629
+        target = obj_utils.create_test_volume_target(self.context,
2630
+                                                     node_id=node.id)
2631
+        response = self.delete(
2632
+            '/nodes/%s/volume/targets/%s' % (node.uuid, target.uuid),
2633
+            expect_errors=True,
2634
+            headers={api_base.Version.string: str(api_v1.MAX_VER)})
2635
+        self.assertEqual(http_client.FORBIDDEN, response.status_int)
2636
+
2430 2637
     @mock.patch.object(notification_utils, '_emit_api_notification')
2431 2638
     @mock.patch.object(rpcapi.ConductorAPI, 'destroy_node')
2432 2639
     def test_delete_associated(self, mock_dn, mock_notify):

+ 7
- 0
ironic/tests/unit/api/v1/test_utils.py View File

@@ -412,6 +412,13 @@ class TestApiUtils(base.TestCase):
412 412
         mock_request.version.minor = 29
413 413
         self.assertFalse(utils.allow_dynamic_drivers())
414 414
 
415
+    @mock.patch.object(pecan, 'request', spec_set=['version'])
416
+    def test_allow_volume(self, mock_request):
417
+        mock_request.version.minor = 32
418
+        self.assertTrue(utils.allow_volume())
419
+        mock_request.version.minor = 31
420
+        self.assertFalse(utils.allow_volume())
421
+
415 422
 
416 423
 class TestNodeIdent(base.TestCase):
417 424
 

+ 55
- 0
ironic/tests/unit/api/v1/test_volume.py View File

@@ -0,0 +1,55 @@
1
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
2
+#    not use this file except in compliance with the License. You may obtain
3
+#    a copy of the License at
4
+#
5
+#         http://www.apache.org/licenses/LICENSE-2.0
6
+#
7
+#    Unless required by applicable law or agreed to in writing, software
8
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
9
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
10
+#    License for the specific language governing permissions and limitations
11
+#    under the License.
12
+"""
13
+Tests for the API /volume/ methods.
14
+"""
15
+
16
+from six.moves import http_client
17
+
18
+from ironic.api.controllers import base as api_base
19
+from ironic.api.controllers import v1 as api_v1
20
+from ironic.tests.unit.api import base as test_api_base
21
+
22
+
23
+class TestGetVolume(test_api_base.BaseApiTest):
24
+
25
+    def setUp(self):
26
+        super(TestGetVolume, self).setUp()
27
+
28
+    def _test_links(self, data, key, headers):
29
+        self.assertIn(key, data)
30
+        self.assertEqual(2, len(data[key]))
31
+        for l in data[key]:
32
+            bookmark = (l['rel'] == 'bookmark')
33
+            self.assertTrue(self.validate_link(l['href'],
34
+                                               bookmark=bookmark,
35
+                                               headers=headers))
36
+
37
+    def test_get_volume(self):
38
+        headers = {api_base.Version.string: str(api_v1.MAX_VER)}
39
+        data = self.get_json('/volume/', headers=headers)
40
+        for key in ['links', 'connectors', 'targets']:
41
+            self._test_links(data, key, headers)
42
+        self.assertIn('/volume/connectors',
43
+                      data['connectors'][0]['href'])
44
+        self.assertIn('/volume/connectors',
45
+                      data['connectors'][1]['href'])
46
+        self.assertIn('/volume/targets',
47
+                      data['targets'][0]['href'])
48
+        self.assertIn('/volume/targets',
49
+                      data['targets'][1]['href'])
50
+
51
+    def test_get_volume_invalid_api_version(self):
52
+        headers = {api_base.Version.string: str(api_v1.MIN_VER)}
53
+        response = self.get_json('/volume/', headers=headers,
54
+                                 expect_errors=True)
55
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)

+ 943
- 0
ironic/tests/unit/api/v1/test_volume_connectors.py View File

@@ -0,0 +1,943 @@
1
+# -*- encoding: utf-8 -*-
2
+#
3
+#    Licensed under the Apache License, Version 2.0 (the "License"); you may
4
+#    not use this file except in compliance with the License. You may obtain
5
+#    a copy of the License at
6
+#
7
+#         http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+#    Unless required by applicable law or agreed to in writing, software
10
+#    distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11
+#    WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12
+#    License for the specific language governing permissions and limitations
13
+#    under the License.
14
+"""
15
+Tests for the API /volume connectors/ methods.
16
+"""
17
+
18
+import datetime
19
+
20
+import mock
21
+from oslo_config import cfg
22
+from oslo_utils import timeutils
23
+from oslo_utils import uuidutils
24
+import six
25
+from six.moves import http_client
26
+from six.moves.urllib import parse as urlparse
27
+from wsme import types as wtypes
28
+
29
+from ironic.api.controllers import base as api_base
30
+from ironic.api.controllers import v1 as api_v1
31
+from ironic.api.controllers.v1 import notification_utils
32
+from ironic.api.controllers.v1 import utils as api_utils
33
+from ironic.api.controllers.v1 import volume_connector as api_volume_connector
34
+from ironic.common import exception
35
+from ironic.conductor import rpcapi
36
+from ironic import objects
37
+from ironic.objects import fields as obj_fields
38
+from ironic.tests import base
39
+from ironic.tests.unit.api import base as test_api_base
40
+from ironic.tests.unit.api import utils as apiutils
41
+from ironic.tests.unit.db import utils as dbutils
42
+from ironic.tests.unit.objects import utils as obj_utils
43
+
44
+
45
+def post_get_test_volume_connector(**kw):
46
+    connector = apiutils.volume_connector_post_data(**kw)
47
+    node = dbutils.get_test_node()
48
+    connector['node_uuid'] = kw.get('node_uuid', node['uuid'])
49
+    return connector
50
+
51
+
52
+class TestVolumeConnectorObject(base.TestCase):
53
+
54
+    def test_volume_connector_init(self):
55
+        connector_dict = apiutils.volume_connector_post_data(node_id=None)
56
+        del connector_dict['extra']
57
+        connector = api_volume_connector.VolumeConnector(**connector_dict)
58
+        self.assertEqual(wtypes.Unset, connector.extra)
59
+
60
+
61
+class TestListVolumeConnectors(test_api_base.BaseApiTest):
62
+    headers = {api_base.Version.string: str(api_v1.MAX_VER)}
63
+
64
+    def setUp(self):
65
+        super(TestListVolumeConnectors, self).setUp()
66
+        self.node = obj_utils.create_test_node(self.context)
67
+
68
+    def test_empty(self):
69
+        data = self.get_json('/volume/connectors', headers=self.headers)
70
+        self.assertEqual([], data['connectors'])
71
+
72
+    def test_one(self):
73
+        connector = obj_utils.create_test_volume_connector(
74
+            self.context, node_id=self.node.id)
75
+        data = self.get_json('/volume/connectors', headers=self.headers)
76
+        self.assertEqual(connector.uuid, data['connectors'][0]["uuid"])
77
+        self.assertNotIn('extra', data['connectors'][0])
78
+        # never expose the node_id
79
+        self.assertNotIn('node_id', data['connectors'][0])
80
+
81
+    def test_one_invalid_api_version(self):
82
+        obj_utils.create_test_volume_connector(self.context,
83
+                                               node_id=self.node.id)
84
+        response = self.get_json(
85
+            '/volume/connectors',
86
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
87
+            expect_errors=True)
88
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
89
+
90
+    def test_get_one(self):
91
+        connector = obj_utils.create_test_volume_connector(
92
+            self.context, node_id=self.node.id)
93
+        data = self.get_json('/volume/connectors/%s' % connector.uuid,
94
+                             headers=self.headers)
95
+        self.assertEqual(connector.uuid, data['uuid'])
96
+        self.assertIn('extra', data)
97
+        self.assertIn('node_uuid', data)
98
+        # never expose the node_id
99
+        self.assertNotIn('node_id', data)
100
+
101
+    def test_get_one_invalid_api_version(self):
102
+        connector = obj_utils.create_test_volume_connector(
103
+            self.context, node_id=self.node.id)
104
+        response = self.get_json(
105
+            '/volume/connectors/%s' % connector.uuid,
106
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
107
+            expect_errors=True)
108
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
109
+
110
+    def test_get_one_custom_fields(self):
111
+        connector = obj_utils.create_test_volume_connector(
112
+            self.context, node_id=self.node.id)
113
+        fields = 'connector_id,extra'
114
+        data = self.get_json(
115
+            '/volume/connectors/%s?fields=%s' % (connector.uuid, fields),
116
+            headers=self.headers)
117
+        # We always append "links"
118
+        self.assertItemsEqual(['connector_id', 'extra', 'links'], data)
119
+
120
+    def test_get_collection_custom_fields(self):
121
+        fields = 'uuid,extra'
122
+        for i in range(3):
123
+            obj_utils.create_test_volume_connector(
124
+                self.context, node_id=self.node.id,
125
+                uuid=uuidutils.generate_uuid(),
126
+                connector_id='test-connector_id-%s' % i)
127
+
128
+        data = self.get_json(
129
+            '/volume/connectors?fields=%s' % fields,
130
+            headers=self.headers)
131
+
132
+        self.assertEqual(3, len(data['connectors']))
133
+        for connector in data['connectors']:
134
+            # We always append "links"
135
+            self.assertItemsEqual(['uuid', 'extra', 'links'], connector)
136
+
137
+    def test_get_custom_fields_invalid_fields(self):
138
+        connector = obj_utils.create_test_volume_connector(
139
+            self.context, node_id=self.node.id)
140
+        fields = 'uuid,spongebob'
141
+        response = self.get_json(
142
+            '/volume/connectors/%s?fields=%s' % (connector.uuid, fields),
143
+            headers=self.headers, expect_errors=True)
144
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
145
+        self.assertEqual('application/json', response.content_type)
146
+        self.assertIn('spongebob', response.json['error_message'])
147
+
148
+    def test_get_custom_fields_invalid_api_version(self):
149
+        connector = obj_utils.create_test_volume_connector(
150
+            self.context, node_id=self.node.id)
151
+        fields = 'uuid,extra'
152
+        response = self.get_json(
153
+            '/volume/connectors/%s?fields=%s' % (connector.uuid, fields),
154
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
155
+            expect_errors=True)
156
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
157
+
158
+    def test_detail(self):
159
+        connector = obj_utils.create_test_volume_connector(
160
+            self.context, node_id=self.node.id)
161
+        data = self.get_json('/volume/connectors?detail=True',
162
+                             headers=self.headers)
163
+        self.assertEqual(connector.uuid, data['connectors'][0]["uuid"])
164
+        self.assertIn('extra', data['connectors'][0])
165
+        self.assertIn('node_uuid', data['connectors'][0])
166
+        # never expose the node_id
167
+        self.assertNotIn('node_id', data['connectors'][0])
168
+
169
+    def test_detail_false(self):
170
+        connector = obj_utils.create_test_volume_connector(
171
+            self.context, node_id=self.node.id)
172
+        data = self.get_json('/volume/connectors?detail=False',
173
+                             headers=self.headers)
174
+        self.assertEqual(connector.uuid, data['connectors'][0]["uuid"])
175
+        self.assertNotIn('extra', data['connectors'][0])
176
+        # never expose the node_id
177
+        self.assertNotIn('node_id', data['connectors'][0])
178
+
179
+    def test_detail_invalid_api_version(self):
180
+        obj_utils.create_test_volume_connector(self.context,
181
+                                               node_id=self.node.id)
182
+        response = self.get_json(
183
+            '/volume/connectors?detail=True',
184
+            headers={api_base.Version.string: str(api_v1.MIN_VER)},
185
+            expect_errors=True)
186
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
187
+
188
+    def test_detail_sepecified_by_path(self):
189
+        obj_utils.create_test_volume_connector(self.context,
190
+                                               node_id=self.node.id)
191
+        response = self.get_json(
192
+            '/volume/connectors/detail', headers=self.headers,
193
+            expect_errors=True)
194
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
195
+
196
+    def test_detail_against_single(self):
197
+        connector = obj_utils.create_test_volume_connector(
198
+            self.context, node_id=self.node.id)
199
+        response = self.get_json('/volume/connectors/%s?detail=True'
200
+                                 % connector.uuid,
201
+                                 expect_errors=True, headers=self.headers)
202
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
203
+
204
+    def test_detail_and_fields(self):
205
+        connector = obj_utils.create_test_volume_connector(
206
+            self.context, node_id=self.node.id)
207
+        fields = 'connector_id,extra'
208
+        response = self.get_json('/volume/connectors/%s?detail=True&fields=%s'
209
+                                 % (connector.uuid, fields),
210
+                                 expect_errors=True, headers=self.headers)
211
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
212
+
213
+    def test_many(self):
214
+        connectors = []
215
+        for id_ in range(5):
216
+            connector = obj_utils.create_test_volume_connector(
217
+                self.context, node_id=self.node.id,
218
+                uuid=uuidutils.generate_uuid(),
219
+                connector_id='test-connector_id-%s' % id_)
220
+            connectors.append(connector.uuid)
221
+        data = self.get_json('/volume/connectors', headers=self.headers)
222
+        self.assertEqual(len(connectors), len(data['connectors']))
223
+
224
+        uuids = [n['uuid'] for n in data['connectors']]
225
+        six.assertCountEqual(self, connectors, uuids)
226
+
227
+    def test_links(self):
228
+        uuid = uuidutils.generate_uuid()
229
+        obj_utils.create_test_volume_connector(self.context,
230
+                                               uuid=uuid,
231
+                                               node_id=self.node.id)
232
+        data = self.get_json('/volume/connectors/%s' % uuid,
233
+                             headers=self.headers)
234
+        self.assertIn('links', data.keys())
235
+        self.assertEqual(2, len(data['links']))
236
+        self.assertIn(uuid, data['links'][0]['href'])
237
+        for l in data['links']:
238
+            bookmark = l['rel'] == 'bookmark'
239
+            self.assertTrue(self.validate_link(l['href'], bookmark=bookmark,
240
+                                               headers=self.headers))
241
+
242
+    def test_collection_links(self):
243
+        connectors = []
244
+        for id_ in range(5):
245
+            connector = obj_utils.create_test_volume_connector(
246
+                self.context,
247
+                node_id=self.node.id,
248
+                uuid=uuidutils.generate_uuid(),
249
+                connector_id='test-connector_id-%s' % id_)
250
+            connectors.append(connector.uuid)
251
+        data = self.get_json('/volume/connectors/?limit=3',
252
+                             headers=self.headers)
253
+        self.assertEqual(3, len(data['connectors']))
254
+
255
+        next_marker = data['connectors'][-1]['uuid']
256
+        self.assertIn(next_marker, data['next'])
257
+        self.assertIn('volume/connectors', data['next'])
258
+
259
+    def test_collection_links_default_limit(self):
260
+        cfg.CONF.set_override('max_limit', 3, 'api')
261
+        connectors = []
262
+        for id_ in range(5):
263
+            connector = obj_utils.create_test_volume_connector(
264
+                self.context,
265
+                node_id=self.node.id,
266
+                uuid=uuidutils.generate_uuid(),
267
+                connector_id='test-connector_id-%s' % id_)
268
+            connectors.append(connector.uuid)
269
+        data = self.get_json('/volume/connectors', headers=self.headers)
270
+        self.assertEqual(3, len(data['connectors']))
271
+        self.assertIn('volume/connectors', data['next'])
272
+
273
+        next_marker = data['connectors'][-1]['uuid']
274
+        self.assertIn(next_marker, data['next'])
275
+
276
+    def test_collection_links_detail(self):
277
+        connectors = []
278
+        for id_ in range(5):
279
+            connector = obj_utils.create_test_volume_connector(
280
+                self.context,
281
+                node_id=self.node.id,
282
+                uuid=uuidutils.generate_uuid(),
283
+                connector_id='test-connector_id-%s' % id_)
284
+            connectors.append(connector.uuid)
285
+        data = self.get_json('/volume/connectors?detail=True&limit=3',
286
+                             headers=self.headers)
287
+        self.assertEqual(3, len(data['connectors']))
288
+
289
+        next_marker = data['connectors'][-1]['uuid']
290
+        self.assertIn(next_marker, data['next'])
291
+        self.assertIn('volume/connectors', data['next'])
292
+        self.assertIn('detail=True', data['next'])
293
+
294
+    def test_sort_key(self):
295
+        connectors = []
296
+        for id_ in range(3):
297
+            connector = obj_utils.create_test_volume_connector(
298
+                self.context,
299
+                node_id=self.node.id,
300
+                uuid=uuidutils.generate_uuid(),
301
+                connector_id='test-connector_id-%s' % id_)
302
+            connectors.append(connector.uuid)
303
+        data = self.get_json('/volume/connectors?sort_key=uuid',
304
+                             headers=self.headers)
305
+        uuids = [n['uuid'] for n in data['connectors']]
306
+        self.assertEqual(sorted(connectors), uuids)
307
+
308
+    def test_sort_key_invalid(self):
309
+        invalid_keys_list = ['foo', 'extra']
310
+        for invalid_key in invalid_keys_list:
311
+            response = self.get_json('/volume/connectors?sort_key=%s'
312
+                                     % invalid_key,
313
+                                     expect_errors=True,
314
+                                     headers=self.headers)
315
+            self.assertEqual(http_client.BAD_REQUEST, response.status_int)
316
+            self.assertEqual('application/json', response.content_type)
317
+            self.assertIn(invalid_key, response.json['error_message'])
318
+
319
+    @mock.patch.object(api_utils, 'get_rpc_node')
320
+    def test_get_all_by_node_name_ok(self, mock_get_rpc_node):
321
+        # GET /v1/volume/connectors specifying node_name - success
322
+        mock_get_rpc_node.return_value = self.node
323
+        for i in range(5):
324
+            if i < 3:
325
+                node_id = self.node.id
326
+            else:
327
+                node_id = 100000 + i
328
+            obj_utils.create_test_volume_connector(
329
+                self.context, node_id=node_id,
330
+                uuid=uuidutils.generate_uuid(),
331
+                connector_id='test-value-%s' % i)
332
+        data = self.get_json("/volume/connectors?node=%s" % 'test-node',
333
+                             headers=self.headers)
334
+        self.assertEqual(3, len(data['connectors']))
335
+
336
+    @mock.patch.object(api_utils, 'get_rpc_node')
337
+    def test_detail_by_node_name_ok(self, mock_get_rpc_node):
338
+        # GET /v1/volume/connectors?detail=True specifying node_name - success
339
+        mock_get_rpc_node.return_value = self.node
340
+        connector = obj_utils.create_test_volume_connector(
341
+            self.context, node_id=self.node.id)
342
+        data = self.get_json('/volume/connectors?detail=True&node=%s' %
343
+                             'test-node',
344
+                             headers=self.headers)
345
+        self.assertEqual(connector.uuid, data['connectors'][0]['uuid'])
346
+        self.assertEqual(self.node.uuid, data['connectors'][0]['node_uuid'])
347
+
348
+
349
+@mock.patch.object(rpcapi.ConductorAPI, 'update_volume_connector')
350
+class TestPatch(test_api_base.BaseApiTest):
351
+    headers = {api_base.Version.string: str(api_v1.MAX_VER)}
352
+
353
+    def setUp(self):
354
+        super(TestPatch, self).setUp()
355
+        self.node = obj_utils.create_test_node(self.context)
356
+        self.connector = obj_utils.create_test_volume_connector(
357
+            self.context, node_id=self.node.id)
358
+
359
+        p = mock.patch.object(rpcapi.ConductorAPI, 'get_topic_for')
360
+        self.mock_gtf = p.start()
361
+        self.mock_gtf.return_value = 'test-topic'
362
+        self.addCleanup(p.stop)
363
+
364
+    @mock.patch.object(notification_utils, '_emit_api_notification')
365
+    def test_update_byid(self, mock_notify, mock_upd):
366
+        extra = {'foo': 'bar'}
367
+        mock_upd.return_value = self.connector
368
+        mock_upd.return_value.extra = extra
369
+        response = self.patch_json('/volume/connectors/%s'
370
+                                   % self.connector.uuid,
371
+                                   [{'path': '/extra/foo',
372
+                                     'value': 'bar',
373
+                                     'op': 'add'}],
374
+                                   headers=self.headers)
375
+        self.assertEqual('application/json', response.content_type)
376
+        self.assertEqual(http_client.OK, response.status_code)
377
+        self.assertEqual(extra, response.json['extra'])
378
+
379
+        kargs = mock_upd.call_args[0][1]
380
+        self.assertEqual(extra, kargs.extra)
381
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update',
382
+                                      obj_fields.NotificationLevel.INFO,
383
+                                      obj_fields.NotificationStatus.START,
384
+                                      node_uuid=self.node.uuid),
385
+                                      mock.call(mock.ANY, mock.ANY, 'update',
386
+                                      obj_fields.NotificationLevel.INFO,
387
+                                      obj_fields.NotificationStatus.END,
388
+                                      node_uuid=self.node.uuid)])
389
+
390
+    def test_update_invalid_api_version(self, mock_upd):
391
+        headers = {api_base.Version.string: str(api_v1.MIN_VER)}
392
+        response = self.patch_json('/volume/connectors/%s'
393
+                                   % self.connector.uuid,
394
+                                   [{'path': '/extra/foo',
395
+                                     'value': 'bar',
396
+                                     'op': 'add'}],
397
+                                   headers=headers,
398
+                                   expect_errors=True)
399
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
400
+
401
+    def test_update_not_found(self, mock_upd):
402
+        uuid = uuidutils.generate_uuid()
403
+        response = self.patch_json('/volume/connectors/%s' % uuid,
404
+                                   [{'path': '/extra/foo',
405
+                                     'value': 'bar',
406
+                                     'op': 'add'}],
407
+                                   expect_errors=True, headers=self.headers)
408
+        self.assertEqual('application/json', response.content_type)
409
+        self.assertEqual(http_client.NOT_FOUND, response.status_int)
410
+        self.assertTrue(response.json['error_message'])
411
+        self.assertFalse(mock_upd.called)
412
+
413
+    def test_replace_singular(self, mock_upd):
414
+        connector_id = 'test-connector-id-999'
415
+        mock_upd.return_value = self.connector
416
+        mock_upd.return_value.connector_id = connector_id
417
+        response = self.patch_json('/volume/connectors/%s'
418
+                                   % self.connector.uuid,
419
+                                   [{'path': '/connector_id',
420
+                                     'value': connector_id,
421
+                                     'op': 'replace'}],
422
+                                   headers=self.headers)
423
+        self.assertEqual('application/json', response.content_type)
424
+        self.assertEqual(http_client.OK, response.status_code)
425
+        self.assertEqual(connector_id, response.json['connector_id'])
426
+        self.assertTrue(mock_upd.called)
427
+
428
+        kargs = mock_upd.call_args[0][1]
429
+        self.assertEqual(connector_id, kargs.connector_id)
430
+
431
+    @mock.patch.object(notification_utils, '_emit_api_notification')
432
+    def test_replace_connector_id_already_exist(self, mock_notify, mock_upd):
433
+        connector_id = 'test-connector-id-123'
434
+        mock_upd.side_effect = \
435
+            exception.VolumeConnectorTypeAndIdAlreadyExists(
436
+                type=None, connector_id=connector_id)
437
+        response = self.patch_json('/volume/connectors/%s'
438
+                                   % self.connector.uuid,
439
+                                   [{'path': '/connector_id',
440
+                                     'value': connector_id,
441
+                                     'op': 'replace'}],
442
+                                   expect_errors=True, headers=self.headers)
443
+        self.assertEqual('application/json', response.content_type)
444
+        self.assertEqual(http_client.CONFLICT, response.status_code)
445
+        self.assertTrue(response.json['error_message'])
446
+        self.assertTrue(mock_upd.called)
447
+
448
+        kargs = mock_upd.call_args[0][1]
449
+        self.assertEqual(connector_id, kargs.connector_id)
450
+        mock_notify.assert_has_calls([mock.call(mock.ANY, mock.ANY, 'update',
451
+                                      obj_fields.NotificationLevel.INFO,
452
+                                      obj_fields.NotificationStatus.START,
453
+                                      node_uuid=self.node.uuid),
454
+                                      mock.call(mock.ANY, mock.ANY, 'update',
455
+                                      obj_fields.NotificationLevel.ERROR,
456
+                                      obj_fields.NotificationStatus.ERROR,
457
+                                      node_uuid=self.node.uuid)])
458
+
459
+    def test_replace_invalid_power_state(self, mock_upd):
460
+        connector_id = 'test-connector-id-123'
461
+        mock_upd.side_effect = \
462
+            exception.InvalidStateRequested(
463
+                action='volume connector update', node=self.node.uuid,
464
+                state='power on')
465
+        response = self.patch_json('/volume/connectors/%s'
466
+                                   % self.connector.uuid,
467
+                                   [{'path': '/connector_id',
468
+                                     'value': connector_id,
469
+                                     'op': 'replace'}],
470
+                                   expect_errors=True, headers=self.headers)
471
+        self.assertEqual('application/json', response.content_type)
472
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
473
+        self.assertTrue(response.json['error_message'])
474
+        self.assertTrue(mock_upd.called)
475
+
476
+        kargs = mock_upd.call_args[0][1]
477
+        self.assertEqual(connector_id, kargs.connector_id)
478
+
479
+    def test_replace_node_uuid(self, mock_upd):
480
+        mock_upd.return_value = self.connector
481
+        response = self.patch_json('/volume/connectors/%s'
482
+                                   % self.connector.uuid,
483
+                                   [{'path': '/node_uuid',
484
+                                     'value': self.node.uuid,
485
+                                     'op': 'replace'}],
486
+                                   headers=self.headers)
487
+        self.assertEqual('application/json', response.content_type)
488
+        self.assertEqual(http_client.OK, response.status_code)
489
+
490
+    def test_replace_node_uuid_invalid_type(self, mock_upd):
491
+        response = self.patch_json('/volume/connectors/%s'
492
+                                   % self.connector.uuid,
493
+                                   [{'path': '/node_uuid',
494
+                                     'value': 123,
495
+                                     'op': 'replace'}],
496
+                                   expect_errors=True, headers=self.headers)
497
+        self.assertEqual('application/json', response.content_type)
498
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
499
+        self.assertIn(b'Expected a UUID for node_uuid, but received 123.',
500
+                      response.body)
501
+        self.assertFalse(mock_upd.called)
502
+
503
+    def test_add_node_uuid(self, mock_upd):
504
+        mock_upd.return_value = self.connector
505
+        response = self.patch_json('/volume/connectors/%s'
506
+                                   % self.connector.uuid,
507
+                                   [{'path': '/node_uuid',
508
+                                     'value': self.node.uuid,
509
+                                     'op': 'add'}],
510
+                                   headers=self.headers)
511
+        self.assertEqual('application/json', response.content_type)
512
+        self.assertEqual(http_client.OK, response.status_code)
513
+
514
+    def test_add_node_uuid_invalid_type(self, mock_upd):
515
+        response = self.patch_json('/volume/connectors/%s'
516
+                                   % self.connector.uuid,
517
+                                   [{'path': '/node_uuid',
518
+                                     'value': 123,
519
+                                     'op': 'add'}],
520
+                                   expect_errors=True, headers=self.headers)
521
+        self.assertEqual('application/json', response.content_type)
522
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
523
+        self.assertIn(b'Expected a UUID for node_uuid, but received 123.',
524
+                      response.body)
525
+        self.assertFalse(mock_upd.called)
526
+
527
+    def test_add_node_id(self, mock_upd):
528
+        response = self.patch_json('/volume/connectors/%s'
529
+                                   % self.connector.uuid,
530
+                                   [{'path': '/node_id',
531
+                                     'value': '1',
532
+                                     'op': 'add'}],
533
+                                   expect_errors=True, headers=self.headers)
534
+        self.assertEqual('application/json', response.content_type)
535
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
536
+        self.assertFalse(mock_upd.called)
537
+
538
+    def test_replace_node_id(self, mock_upd):
539
+        response = self.patch_json('/volume/connectors/%s'
540
+                                   % self.connector.uuid,
541
+                                   [{'path': '/node_id',
542
+                                     'value': '1',
543
+                                     'op': 'replace'}],
544
+                                   expect_errors=True, headers=self.headers)
545
+        self.assertEqual('application/json', response.content_type)
546
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
547
+        self.assertFalse(mock_upd.called)
548
+
549
+    def test_remove_node_id(self, mock_upd):
550
+        response = self.patch_json('/volume/connectors/%s'
551
+                                   % self.connector.uuid,
552
+                                   [{'path': '/node_id',
553
+                                     'op': 'remove'}],
554
+                                   expect_errors=True, headers=self.headers)
555
+        self.assertEqual('application/json', response.content_type)
556
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
557
+        self.assertFalse(mock_upd.called)
558
+
559
+    def test_replace_non_existent_node_uuid(self, mock_upd):
560
+        node_uuid = '12506333-a81c-4d59-9987-889ed5f8687b'
561
+        response = self.patch_json('/volume/connectors/%s'
562
+                                   % self.connector.uuid,
563
+                                   [{'path': '/node_uuid',
564
+                                     'value': node_uuid,
565
+                                     'op': 'replace'}],
566
+                                   expect_errors=True, headers=self.headers)
567
+        self.assertEqual('application/json', response.content_type)
568
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
569
+        self.assertIn(node_uuid, response.json['error_message'])
570
+        self.assertFalse(mock_upd.called)
571
+
572
+    def test_replace_multi(self, mock_upd):
573
+        extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
574
+        self.connector.extra = extra
575
+        self.connector.save()
576
+
577
+        # mutate extra so we replace all of them
578
+        extra = dict((k, extra[k] + 'x') for k in extra.keys())
579
+
580
+        patch = []
581
+        for k in extra.keys():
582
+            patch.append({'path': '/extra/%s' % k,
583
+                          'value': extra[k],
584
+                          'op': 'replace'})
585
+        mock_upd.return_value = self.connector
586
+        mock_upd.return_value.extra = extra
587
+        response = self.patch_json('/volume/connectors/%s'
588
+                                   % self.connector.uuid,
589
+                                   patch, headers=self.headers)
590
+        self.assertEqual('application/json', response.content_type)
591
+        self.assertEqual(http_client.OK, response.status_code)
592
+        self.assertEqual(extra, response.json['extra'])
593
+        kargs = mock_upd.call_args[0][1]
594
+        self.assertEqual(extra, kargs.extra)
595
+
596
+    def test_remove_multi(self, mock_upd):
597
+        extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
598
+        self.connector.extra = extra
599
+        self.connector.save()
600
+
601
+        # Remove one item from the collection.
602
+        extra.pop('foo1')
603
+        mock_upd.return_value = self.connector
604
+        mock_upd.return_value.extra = extra
605
+        response = self.patch_json('/volume/connectors/%s'
606
+                                   % self.connector.uuid,
607
+                                   [{'path': '/extra/foo1',
608
+                                     'op': 'remove'}],
609
+                                   headers=self.headers)
610
+        self.assertEqual('application/json', response.content_type)
611
+        self.assertEqual(http_client.OK, response.status_code)
612
+        self.assertEqual(extra, response.json['extra'])
613
+        kargs = mock_upd.call_args[0][1]
614
+        self.assertEqual(extra, kargs.extra)
615
+
616
+        # Remove the collection.
617
+        extra = {}
618
+        mock_upd.return_value.extra = extra
619
+        response = self.patch_json('/volume/connectors/%s'
620
+                                   % self.connector.uuid,
621
+                                   [{'path': '/extra', 'op': 'remove'}],
622
+                                   headers=self.headers)
623
+        self.assertEqual('application/json', response.content_type)
624
+        self.assertEqual(http_client.OK, response.status_code)
625
+        self.assertEqual({}, response.json['extra'])
626
+        kargs = mock_upd.call_args[0][1]
627
+        self.assertEqual(extra, kargs.extra)
628
+
629
+        # Assert nothing else was changed.
630
+        self.assertEqual(self.connector.uuid, response.json['uuid'])
631
+        self.assertEqual(self.connector.type, response.json['type'])
632
+        self.assertEqual(self.connector.connector_id,
633
+                         response.json['connector_id'])
634
+
635
+    def test_remove_non_existent_property_fail(self, mock_upd):
636
+        response = self.patch_json('/volume/connectors/%s'
637
+                                   % self.connector.uuid,
638
+                                   [{'path': '/extra/non-existent',
639
+                                     'op': 'remove'}],
640
+                                   expect_errors=True, headers=self.headers)
641
+        self.assertEqual('application/json', response.content_type)
642
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
643
+        self.assertTrue(response.json['error_message'])
644
+        self.assertFalse(mock_upd.called)
645
+
646
+    def test_remove_mandatory_field(self, mock_upd):
647
+        response = self.patch_json('/volume/connectors/%s'
648
+                                   % self.connector.uuid,
649
+                                   [{'path': '/value',
650
+                                     'op': 'remove'}],
651
+                                   expect_errors=True, headers=self.headers)
652
+        self.assertEqual('application/json', response.content_type)
653
+        self.assertEqual(http_client.BAD_REQUEST, response.status_code)
654
+        self.assertTrue(response.json['error_message'])
655
+        self.assertFalse(mock_upd.called)
656
+
657
+    def test_add_root(self, mock_upd):
658
+        connector_id = 'test-connector-id-123'
659
+        mock_upd.return_value = self.connector
660
+        mock_upd.return_value.connector_id = connector_id
661
+        response = self.patch_json('/volume/connectors/%s'
662
+                                   % self.connector.uuid,
663
+                                   [{'path': '/connector_id',
664
+                                     'value': connector_id,
665
+                                     'op': 'add'}],
666
+                                   headers=self.headers)
667
+        self.assertEqual('application/json', response.content_type)
668
+        self.assertEqual(http_client.OK, response.status_code)
669
+        self.assertEqual(connector_id, response.json['connector_id'])
670
+        self.assertTrue(mock_upd.called)
671
+        kargs = mock_upd.call_args[0][1]
672
+        self.assertEqual(connector_id, kargs.connector_id)
673
+
674
+    def test_add_root_non_existent(self, mock_upd):
675
+        response = self.patch_json('/volume/connectors/%s'
676
+                                   % self.connector.uuid,
677
+                                   [{'path': '/foo',
678
+                                     'value': 'bar',
679
+                                     'op': 'add'}],
680
+                                   expect_errors=True, headers=self.headers)
681
+        self.assertEqual('application/json', response.content_type)
682
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
683
+        self.assertTrue(response.json['error_message'])
684
+        self.assertFalse(mock_upd.called)
685
+
686
+    def test_add_multi(self, mock_upd):
687
+        extra = {"foo1": "bar1", "foo2": "bar2", "foo3": "bar3"}
688
+        patch = []
689
+        for k in extra.keys():
690
+            patch.append({'path': '/extra/%s' % k,
691
+                          'value': extra[k],
692
+                          'op': 'add'})
693
+        mock_upd.return_value = self.connector
694
+        mock_upd.return_value.extra = extra
695
+        response = self.patch_json('/volume/connectors/%s'
696
+                                   % self.connector.uuid,
697
+                                   patch, headers=self.headers)
698
+        self.assertEqual('application/json', response.content_type)
699
+        self.assertEqual(http_client.OK, response.status_code)
700
+        self.assertEqual(extra, response.json['extra'])
701
+        kargs = mock_upd.call_args[0][1]
702
+        self.assertEqual(extra, kargs.extra)
703
+
704
+    def test_remove_uuid(self, mock_upd):
705
+        response = self.patch_json('/volume/connectors/%s'
706
+                                   % self.connector.uuid,
707
+                                   [{'path': '/uuid',
708
+                                     'op': 'remove'}],
709
+                                   expect_errors=True, headers=self.headers)
710
+        self.assertEqual(http_client.BAD_REQUEST, response.status_int)
711
+        self.assertEqual('application/json', response.content_type)
712
+        self.assertTrue(response.json['error_message'])
713
+        self.assertFalse(mock_upd.called)
714
+
715
+
716
+class TestPost(test_api_base.BaseApiTest):
717
+    headers = {api_base.Version.string: str(api_v1.MAX_VER)}
718
+
719
+    def setUp(self):
720
+        super(TestPost, self).setUp()
721
+        self.node = obj_utils.create_test_node(self.context)
722
+
723
+    @mock.patch.object(notification_utils, '_emit_api_notification')
724
+    @mock.patch.object(timeutils, 'utcnow')
725
+    def test_create_volume_connector(self, mock_utcnow, mock_notify):
726
+        pdict = post_get_test_volume_connector()
727
+        test_time = datetime.datetime(2000, 1, 1, 0, 0)
728
+        mock_utcnow.return_value = test_time
729
+        response = self.post_json('/volume/connectors', pdict,
730
+                                  headers=self.headers)
731
+        self.assertEqual(http_client.CREATED, response.status_int)
732
+        result = self.get_json('/volume/connectors/%s' % pdict['uuid'],
733
+                               headers=self.headers)
734
+        self.assertEqual(pdict['uuid'], result['uuid'])
735
+        self.assertFalse(result['updated_at'])