Add REST API for volume connector and volume target operation

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

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

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

Co-Authored-By: Tomoki Sekiyama <tomoki.sekiyama.qu@hitachi.com>
Co-Authored-By: David Lenwell <dlenwell@gmail.com>
Co-Authored-By: Hironori Shiina <shiina.hironori@jp.fujitsu.com>
Change-Id: I328a698f2109841e1e122e17fea4b345c4179161
Partial-Bug: 1526231
This commit is contained in:
Satoru Moriya 2016-02-10 14:29:34 +09:00 committed by Hironori Shiina
parent ddcd97714c
commit c380e05dbf
20 changed files with 3481 additions and 6 deletions

View File

@ -251,6 +251,88 @@ Example of portgroup CRUD notification::
"publisher_id":"ironic-api.hostname02" "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 Node maintenance notifications
------------------------------ ------------------------------

View File

@ -2,6 +2,28 @@
REST API Version History REST API Version History
======================== ========================
**1.32** (Pike)
Added new endpoints for remote volume configuration:
* GET /v1/volume as a root for volume resources
* GET /v1/volume/connectors for listing volume connectors
* POST /v1/volume/connectors for creating a volume connector
* GET /v1/volume/connectors/<UUID> for showing a volume connector
* PATCH /v1/volume/connectors/<UUID> for updating a volume connector
* DELETE /v1/volume/connectors/<UUID> for deleting a volume connector
* GET /v1/volume/targets for listing volume targets
* POST /v1/volume/targets for creating a volume target
* GET /v1/volume/targets/<UUID> for showing a volume target
* PATCH /v1/volume/targets/<UUID> for updating a volume target
* DELETE /v1/volume/targets/<UUID> for deleting a volume target
Volume resources also can be listed as sub resources of nodes:
* GET /v1/nodes/<node identifier>/volume
* GET /v1/nodes/<node identifier>/volume/connectors
* GET /v1/nodes/<node identifier>/volume/targets
**1.31** (Ocata) **1.31** (Ocata)
Added the following fields to the node object, to allow getting and Added the following fields to the node object, to allow getting and
@ -237,4 +259,3 @@ REST API Version History
supported version in Kilo. supported version in Kilo.
.. _fully qualified domain name: https://en.wikipedia.org/wiki/Fully_qualified_domain_name .. _fully qualified domain name: https://en.wikipedia.org/wiki/Fully_qualified_domain_name

View File

@ -133,3 +133,15 @@
# Access IPA ramdisk functions # Access IPA ramdisk functions
#"baremetal:driver:ipa_lookup": "rule:public_api" #"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"

View File

@ -33,6 +33,7 @@ from ironic.api.controllers.v1 import portgroup
from ironic.api.controllers.v1 import ramdisk from ironic.api.controllers.v1 import ramdisk
from ironic.api.controllers.v1 import utils from ironic.api.controllers.v1 import utils
from ironic.api.controllers.v1 import versions from ironic.api.controllers.v1 import versions
from ironic.api.controllers.v1 import volume
from ironic.api import expose from ironic.api import expose
from ironic.common.i18n import _ from ironic.common.i18n import _
@ -84,6 +85,9 @@ class V1(base.APIBase):
drivers = [link.Link] drivers = [link.Link]
"""Links to the drivers resource""" """Links to the drivers resource"""
volume = [link.Link]
"""Links to the volume resource"""
lookup = [link.Link] lookup = [link.Link]
"""Links to the lookup resource""" """Links to the lookup resource"""
@ -139,6 +143,16 @@ class V1(base.APIBase):
'drivers', '', 'drivers', '',
bookmark=True) 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(): if utils.allow_ramdisk_endpoints():
v1.lookup = [link.Link.make_link('self', pecan.request.public_url, v1.lookup = [link.Link.make_link('self', pecan.request.public_url,
'lookup', ''), 'lookup', ''),
@ -166,6 +180,7 @@ class Controller(rest.RestController):
portgroups = portgroup.PortgroupsController() portgroups = portgroup.PortgroupsController()
chassis = chassis.ChassisController() chassis = chassis.ChassisController()
drivers = driver.DriversController() drivers = driver.DriversController()
volume = volume.VolumeController()
lookup = ramdisk.LookupController() lookup = ramdisk.LookupController()
heartbeat = ramdisk.HeartbeatController() heartbeat = ramdisk.HeartbeatController()

View File

@ -35,6 +35,7 @@ from ironic.api.controllers.v1 import portgroup
from ironic.api.controllers.v1 import types from ironic.api.controllers.v1 import types
from ironic.api.controllers.v1 import utils as api_utils from ironic.api.controllers.v1 import utils as api_utils
from ironic.api.controllers.v1 import versions from ironic.api.controllers.v1 import versions
from ironic.api.controllers.v1 import volume
from ironic.api import expose from ironic.api import expose
from ironic.common import exception from ironic.common import exception
from ironic.common.i18n import _ from ironic.common.i18n import _
@ -813,6 +814,9 @@ class Node(base.APIBase):
portgroups = wsme.wsattr([link.Link], readonly=True) portgroups = wsme.wsattr([link.Link], readonly=True)
"""Links to the collection of portgroups on this node""" """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) states = wsme.wsattr([link.Link], readonly=True)
"""Links to endpoint for retrieving and setting node states""" """Links to endpoint for retrieving and setting node states"""
@ -869,7 +873,7 @@ class Node(base.APIBase):
@staticmethod @staticmethod
def _convert_with_links(node, url, fields=None, show_states_links=True, 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 # 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 # fields the "uuid" can be unset, so we need to save it in another
# variable to use when building the links # variable to use when building the links
@ -897,6 +901,14 @@ class Node(base.APIBase):
node_uuid + "/portgroups", node_uuid + "/portgroups",
bookmark=True)] 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 # NOTE(lucasagomes): The numeric ID should not be exposed to
# the user, it's internal only. # the user, it's internal only.
node.chassis_id = wtypes.Unset node.chassis_id = wtypes.Unset
@ -951,10 +963,12 @@ class Node(base.APIBase):
show_states_links = ( show_states_links = (
api_utils.allow_links_node_states_and_driver_properties()) api_utils.allow_links_node_states_and_driver_properties())
show_portgroups = api_utils.allow_portgroups_subcontrollers() show_portgroups = api_utils.allow_portgroups_subcontrollers()
show_volume = api_utils.allow_volume()
return cls._convert_with_links(node, pecan.request.public_url, return cls._convert_with_links(node, pecan.request.public_url,
fields=fields, fields=fields,
show_states_links=show_states_links, show_states_links=show_states_links,
show_portgroups=show_portgroups) show_portgroups=show_portgroups,
show_volume=show_volume)
@classmethod @classmethod
def sample(cls, expand=True): def sample(cls, expand=True):
@ -1247,6 +1261,7 @@ class NodesController(rest.RestController):
'ports': port.PortsController, 'ports': port.PortsController,
'portgroups': portgroup.PortgroupsController, 'portgroups': portgroup.PortgroupsController,
'vifs': NodeVIFController, 'vifs': NodeVIFController,
'volume': volume.VolumeController,
} }
@pecan.expose() @pecan.expose()

View File

@ -27,6 +27,8 @@ from ironic.objects import node as node_objects
from ironic.objects import notification from ironic.objects import notification
from ironic.objects import port as port_objects from ironic.objects import port as port_objects
from ironic.objects import portgroup as portgroup_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__) LOG = log.getLogger(__name__)
CONF = cfg.CONF CONF = cfg.CONF
@ -40,7 +42,13 @@ CRUD_NOTIFY_OBJ = {
'port': (port_objects.PortCRUDNotification, 'port': (port_objects.PortCRUDNotification,
port_objects.PortCRUDPayload), port_objects.PortCRUDPayload),
'portgroup': (portgroup_objects.PortgroupCRUDNotification, '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),
} }

View File

@ -549,6 +549,14 @@ def allow_dynamic_interfaces():
versions.MINOR_31_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): def get_controller_reserved_names(cls):
"""Get reserved names for a given controller. """Get reserved names for a given controller.

View File

@ -62,6 +62,7 @@ BASE_VERSION = 1
# v1.29: Add inject nmi. # v1.29: Add inject nmi.
# v1.30: Add dynamic driver interactions. # v1.30: Add dynamic driver interactions.
# v1.31: Add dynamic interfaces fields to node. # v1.31: Add dynamic interfaces fields to node.
# v1.32: Add volume support.
MINOR_0_JUNO = 0 MINOR_0_JUNO = 0
MINOR_1_INITIAL_VERSION = 1 MINOR_1_INITIAL_VERSION = 1
@ -95,11 +96,12 @@ MINOR_28_VIFS_SUBCONTROLLER = 28
MINOR_29_INJECT_NMI = 29 MINOR_29_INJECT_NMI = 29
MINOR_30_DYNAMIC_DRIVERS = 30 MINOR_30_DYNAMIC_DRIVERS = 30
MINOR_31_DYNAMIC_INTERFACES = 31 MINOR_31_DYNAMIC_INTERFACES = 31
MINOR_32_VOLUME = 32
# When adding another version, update MINOR_MAX_VERSION and also update # When adding another version, update MINOR_MAX_VERSION and also update
# doc/source/dev/webapi-version-history.rst with a detailed explanation of # doc/source/dev/webapi-version-history.rst with a detailed explanation of
# what the version has changed. # 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 # String representations of the minor and maximum versions
MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION) MIN_VERSION_STRING = '{}.{}'.format(BASE_VERSION, MINOR_1_INITIAL_VERSION)

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -204,6 +204,25 @@ extra_policies = [
description='Access IPA ramdisk functions'), 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(): def list_policies():
policies = (default_policies policies = (default_policies
@ -212,7 +231,8 @@ def list_policies():
+ portgroup_policies + portgroup_policies
+ chassis_policies + chassis_policies
+ driver_policies + driver_policies
+ extra_policies) + extra_policies
+ volume_policies)
return policies return policies

View File

@ -69,3 +69,10 @@ class TestV1Root(base.BaseApiTest):
additional_expected_resources=['heartbeat', additional_expected_resources=['heartbeat',
'lookup', 'lookup',
'portgroups']) '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'])

View File

@ -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 node as node_controller
from ironic.api.controllers.v1 import port as port_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 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.drivers import base as drivers_base
from ironic.tests.unit.db import utils as db_utils from ironic.tests.unit.db import utils as db_utils
@ -124,6 +126,24 @@ def port_post_data(**kw):
return remove_internal(port, internal) 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): def chassis_post_data(**kw):
chassis = db_utils.get_test_chassis(**kw) chassis = db_utils.get_test_chassis(**kw)
# version is not part of the API object # version is not part of the API object

View File

@ -402,6 +402,17 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertEqual(getattr(node, field), self.assertEqual(getattr(node, field),
new_data['nodes'][0][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): def test_many(self):
nodes = [] nodes = []
for id in range(5): for id in range(5):
@ -630,6 +641,113 @@ class TestListNodes(test_api_base.BaseApiTest):
headers={api_base.Version.string: '1.24'}) headers={api_base.Version.string: '1.24'})
self.assertEqual(http_client.FORBIDDEN, response.status_int) 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') @mock.patch.object(timeutils, 'utcnow')
def _test_node_states(self, mock_utcnow, api_version=None): def _test_node_states(self, mock_utcnow, api_version=None):
fake_state = 'fake-state' fake_state = 'fake-state'
@ -1382,6 +1500,37 @@ class TestPatch(test_api_base.BaseApiTest):
headers={'X-OpenStack-Ironic-API-Version': '1.24'}) headers={'X-OpenStack-Ironic-API-Version': '1.24'})
self.assertEqual(http_client.FORBIDDEN, response.status_int) 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): def test_remove_uuid(self):
response = self.patch_json('/nodes/%s' % self.node.uuid, response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/uuid', 'op': 'remove'}], [{'path': '/uuid', 'op': 'remove'}],
@ -2194,6 +2343,36 @@ class TestPost(test_api_base.BaseApiTest):
headers={'X-OpenStack-Ironic-API-Version': '1.24'}) headers={'X-OpenStack-Ironic-API-Version': '1.24'})
self.assertEqual(http_client.FORBIDDEN, response.status_int) 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): def test_create_node_no_mandatory_field_driver(self):
ndict = test_api_utils.post_get_test_node() ndict = test_api_utils.post_get_test_node()
del ndict['driver'] del ndict['driver']
@ -2427,6 +2606,34 @@ class TestDelete(test_api_base.BaseApiTest):
headers={'X-OpenStack-Ironic-API-Version': '1.24'}) headers={'X-OpenStack-Ironic-API-Version': '1.24'})
self.assertEqual(http_client.FORBIDDEN, response.status_int) 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(notification_utils, '_emit_api_notification')
@mock.patch.object(rpcapi.ConductorAPI, 'destroy_node') @mock.patch.object(rpcapi.ConductorAPI, 'destroy_node')
def test_delete_associated(self, mock_dn, mock_notify): def test_delete_associated(self, mock_dn, mock_notify):

View File

@ -412,6 +412,13 @@ class TestApiUtils(base.TestCase):
mock_request.version.minor = 29 mock_request.version.minor = 29
self.assertFalse(utils.allow_dynamic_drivers()) 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): class TestNodeIdent(base.TestCase):

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -0,0 +1,54 @@
---
features:
- |
Adds support for volume connectors and volume targets with new API
endpoints ``/v1/volume/connectors`` and ``/v1/volume/targets``. These
endpoints are available with API version 1.32 or later. These new
resources are used to connect a node to a volume. A volume connector
represents connector information of a node such as an iSCSI initiator. A
volume target provides volume information such as an iSCSI target. These
endpoints are available:
* ``GET /v1/volume/connectors`` for listing volume connectors
* ``POST /v1/volume/connectors`` for creating a volume connector
* ``GET /v1/volume/connectors/<UUID>`` for showing a volume connector
* ``PATCH /v1/volume/connectors/<UUID>`` for updating a volume connector
* ``DELETE /v1/volume/connectors/<UUID>`` for deleting a volume connector
* ``GET /v1/volume/targets`` for listing volume targets
* ``POST /v1/volume/targets`` for creating a volume target
* ``GET /v1/volume/targets/<UUID>`` for showing a volume target
* ``PATCH /v1/volume/targets/<UUID>`` for updating a volume target
* ``DELETE /v1/volume/targets/<UUID>`` for deleting a volume target
The Volume resources also can be listed as sub resources of nodes:
* ``GET /v1/nodes/<node>/volume/connectors``
* ``GET /v1/nodes/<node>/volume/targets``
Root endpoints of volume resources are also added. These endpoints provide
links to volume connectors and volume targets:
* ``GET /v1/volume``
* ``GET /v1/node/<node>/volume``
When a volume connector or a volume target is created, updated, or
deleted, these CRUD notifications can be emitted:
* ``baremetal.volumeconnector.create.start``
* ``baremetal.volumeconnector.create.end``
* ``baremetal.volumeconnector.create.error``
* ``baremetal.volumeconnector.update.start``
* ``baremetal.volumeconnector.update.end``
* ``baremetal.volumeconnector.update.error``
* ``baremetal.volumeconnector.delete.start``
* ``baremetal.volumeconnector.delete.end``
* ``baremetal.volumeconnector.delete.error``
* ``baremetal.volumetarget.create.start``
* ``baremetal.volumetarget.create.end``
* ``baremetal.volumetarget.create.error``
* ``baremetal.volumetarget.update.start``
* ``baremetal.volumetarget.update.end``
* ``baremetal.volumetarget.update.error``
* ``baremetal.volumetarget.delete.start``
* ``baremetal.volumetarget.delete.end``
* ``baremetal.volumetarget.delete.error``