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