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"
}
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
------------------------------

View File

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

View File

@ -133,3 +133,15 @@
# Access IPA ramdisk functions
#"baremetal:driver:ipa_lookup": "rule:public_api"
# Retrieve Volume connector and target records
#"baremetal:volume:get": "rule:is_admin or rule:is_observer"
# Create Volume connector and target records
#"baremetal:volume:create": "rule:is_admin"
# Delete Volume connetor and target records
#"baremetal:volume:delete": "rule:is_admin"
# Update Volume connector and target records
#"baremetal:volume:update": "rule:is_admin"

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

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

View File

@ -27,6 +27,8 @@ from ironic.objects import node as node_objects
from ironic.objects import notification
from ironic.objects import port as port_objects
from ironic.objects import portgroup as portgroup_objects
from ironic.objects import volume_connector as volume_connector_objects
from ironic.objects import volume_target as volume_target_objects
LOG = log.getLogger(__name__)
CONF = cfg.CONF
@ -40,7 +42,13 @@ CRUD_NOTIFY_OBJ = {
'port': (port_objects.PortCRUDNotification,
port_objects.PortCRUDPayload),
'portgroup': (portgroup_objects.PortgroupCRUDNotification,
portgroup_objects.PortgroupCRUDPayload)
portgroup_objects.PortgroupCRUDPayload),
'volumeconnector':
(volume_connector_objects.VolumeConnectorCRUDNotification,
volume_connector_objects.VolumeConnectorCRUDPayload),
'volumetarget':
(volume_target_objects.VolumeTargetCRUDNotification,
volume_target_objects.VolumeTargetCRUDPayload),
}

View File

@ -549,6 +549,14 @@ def allow_dynamic_interfaces():
versions.MINOR_31_DYNAMIC_INTERFACES)
def allow_volume():
"""Check if volume connectors and targets are allowed.
Version 1.32 of the API added support for volume connectors and targets
"""
return pecan.request.version.minor >= versions.MINOR_32_VOLUME
def get_controller_reserved_names(cls):
"""Get reserved names for a given controller.

View File

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

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'),
]
volume_policies = [
policy.RuleDefault('baremetal:volume:get',
'rule:is_admin or rule:is_observer',
description='Retrieve Volume connector and target '
'records'),
policy.RuleDefault('baremetal:volume:create',
'rule:is_admin',
description='Create Volume connector and target '
'records'),
policy.RuleDefault('baremetal:volume:delete',
'rule:is_admin',
description='Delete Volume connetor and target '
'records'),
policy.RuleDefault('baremetal:volume:update',
'rule:is_admin',
description='Update Volume connector and target '
'records'),
]
def list_policies():
policies = (default_policies
@ -212,7 +231,8 @@ def list_policies():
+ portgroup_policies
+ chassis_policies
+ driver_policies
+ extra_policies)
+ extra_policies
+ volume_policies)
return policies

View File

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

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 port as port_controller
from ironic.api.controllers.v1 import portgroup as portgroup_controller
from ironic.api.controllers.v1 import volume_connector as vc_controller
from ironic.api.controllers.v1 import volume_target as vt_controller
from ironic.drivers import base as drivers_base
from ironic.tests.unit.db import utils as db_utils
@ -124,6 +126,24 @@ def port_post_data(**kw):
return remove_internal(port, internal)
def volume_connector_post_data(**kw):
connector = db_utils.get_test_volume_connector(**kw)
# These values are not part of the API object
connector.pop('node_id')
connector.pop('version')
internal = vc_controller.VolumeConnectorPatchType.internal_attrs()
return remove_internal(connector, internal)
def volume_target_post_data(**kw):
target = db_utils.get_test_volume_target(**kw)
# These values are not part of the API object
target.pop('node_id')
target.pop('version')
internal = vt_controller.VolumeTargetPatchType.internal_attrs()
return remove_internal(target, internal)
def chassis_post_data(**kw):
chassis = db_utils.get_test_chassis(**kw)
# version is not part of the API object

View File

@ -402,6 +402,17 @@ class TestListNodes(test_api_base.BaseApiTest):
self.assertEqual(getattr(node, field),
new_data['nodes'][0][field])
def test_hide_fields_in_newer_versions_volume(self):
node = obj_utils.create_test_node(self.context)
data = self.get_json(
'/nodes/%s' % node.uuid,
headers={api_base.Version.string: '1.31'})
self.assertNotIn('volume', data)
data = self.get_json('/nodes/%s' % node.uuid,
headers={api_base.Version.string: "1.32"})
self.assertIn('volume', data)
def test_many(self):
nodes = []
for id in range(5):
@ -630,6 +641,113 @@ class TestListNodes(test_api_base.BaseApiTest):
headers={api_base.Version.string: '1.24'})
self.assertEqual(http_client.FORBIDDEN, response.status_int)
def test_volume_subresource_link(self):
node = obj_utils.create_test_node(self.context)
data = self.get_json(
'/nodes/%s' % node.uuid,
headers={api_base.Version.string: '1.32'})
self.assertIn('volume', data)
def test_volume_subresource(self):
node = obj_utils.create_test_node(self.context)
data = self.get_json('/nodes/%s/volume' % node.uuid,
headers={api_base.Version.string: '1.32'})
self.assertIn('connectors', data)
self.assertIn('targets', data)
self.assertIn('/volume/connectors',
data['connectors'][0]['href'])
self.assertIn('/volume/connectors',
data['connectors'][1]['href'])
self.assertIn('/volume/targets',
data['targets'][0]['href'])
self.assertIn('/volume/targets',
data['targets'][1]['href'])
def test_volume_subresource_invalid_api_version(self):
node = obj_utils.create_test_node(self.context)
response = self.get_json('/nodes/%s/volume' % node.uuid,
headers={api_base.Version.string: '1.31'},
expect_errors=True)
self.assertEqual(http_client.NOT_FOUND, response.status_int)
def test_volume_connectors_subresource(self):
node = obj_utils.create_test_node(self.context)
for id_ in range(2):
obj_utils.create_test_volume_connector(
self.context, node_id=node.id, uuid=uuidutils.generate_uuid(),
connector_id='test-connector_id-%s' % id_)
data = self.get_json(
'/nodes/%s/volume/connectors' % node.uuid,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(2, len(data['connectors']))
self.assertNotIn('next', data)
# Test collection pagination
data = self.get_json(
'/nodes/%s/volume/connectors?limit=1' % node.uuid,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(1, len(data['connectors']))
self.assertIn('next', data)
def test_volume_connectors_subresource_noid(self):
node = obj_utils.create_test_node(self.context)
obj_utils.create_test_volume_connector(self.context, node_id=node.id)
# No node_id specified.
response = self.get_json(
'/nodes/volume/connectors',
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.NOT_FOUND, response.status_int)
def test_volume_connectors_subresource_node_not_found(self):
non_existent_uuid = 'eeeeeeee-cccc-aaaa-bbbb-cccccccccccc'
response = self.get_json(
'/nodes/%s/volume/connectors' % non_existent_uuid,
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.NOT_FOUND, response.status_int)
def test_volume_targets_subresource(self):
node = obj_utils.create_test_node(self.context)
for id_ in range(2):
obj_utils.create_test_volume_target(
self.context, node_id=node.id, uuid=uuidutils.generate_uuid(),
boot_index=id_)
data = self.get_json(
'/nodes/%s/volume/targets' % node.uuid,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(2, len(data['targets']))
self.assertNotIn('next', data)
# Test collection pagination
data = self.get_json(
'/nodes/%s/volume/targets?limit=1' % node.uuid,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(1, len(data['targets']))
self.assertIn('next', data)
def test_volume_targets_subresource_noid(self):
node = obj_utils.create_test_node(self.context)
obj_utils.create_test_volume_target(self.context, node_id=node.id)
# No node_id specified.
response = self.get_json(
'/nodes/volume/targets',
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.NOT_FOUND, response.status_int)
def test_volume_targets_subresource_node_not_found(self):
non_existent_uuid = 'eeeeeeee-cccc-aaaa-bbbb-cccccccccccc'
response = self.get_json(
'/nodes/%s/volume/targets' % non_existent_uuid,
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.NOT_FOUND, response.status_int)
@mock.patch.object(timeutils, 'utcnow')
def _test_node_states(self, mock_utcnow, api_version=None):
fake_state = 'fake-state'
@ -1382,6 +1500,37 @@ class TestPatch(test_api_base.BaseApiTest):
headers={'X-OpenStack-Ironic-API-Version': '1.24'})
self.assertEqual(http_client.FORBIDDEN, response.status_int)
def test_patch_volume_connectors_subresource_no_connector_id(self):
response = self.patch_json(
'/nodes/%s/volume/connectors' % self.node.uuid,
[{'path': '/extra/foo', 'value': 'bar', 'op': 'add'}],
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_patch_volume_connectors_subresource(self):
connector = (
obj_utils.create_test_volume_connector(self.context,
node_id=self.node.id))
response = self.patch_json(
'/nodes/%s/volume/connectors/%s' % (self.node.uuid,
connector.uuid),
[{'path': '/extra/foo', 'value': 'bar', 'op': 'add'}],
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.FORBIDDEN, response.status_int)
def test_patch_volume_targets_subresource(self):
target = obj_utils.create_test_volume_target(self.context,
node_id=self.node.id)
response = self.patch_json(
'/nodes/%s/volume/targets/%s' % (self.node.uuid,
target.uuid),
[{'path': '/extra/foo', 'value': 'bar', 'op': 'add'}],
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.FORBIDDEN, response.status_int)
def test_remove_uuid(self):
response = self.patch_json('/nodes/%s' % self.node.uuid,
[{'path': '/uuid', 'op': 'remove'}],
@ -2194,6 +2343,36 @@ class TestPost(test_api_base.BaseApiTest):
headers={'X-OpenStack-Ironic-API-Version': '1.24'})
self.assertEqual(http_client.FORBIDDEN, response.status_int)
def test_post_volume_connectors_subresource_no_node_id(self):
node = obj_utils.create_test_node(self.context)
pdict = test_api_utils.volume_connector_post_data(node_id=None)
pdict['node_uuid'] = node.uuid
response = self.post_json(
'/nodes/volume/connectors', pdict,
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.NOT_FOUND, response.status_int)
def test_post_volume_connectors_subresource(self):
node = obj_utils.create_test_node(self.context)
pdict = test_api_utils.volume_connector_post_data(node_id=None)
pdict['node_uuid'] = node.uuid
response = self.post_json(
'/nodes/%s/volume/connectors' % node.uuid, pdict,
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.FORBIDDEN, response.status_int)
def test_post_volume_targets_subresource(self):
node = obj_utils.create_test_node(self.context)
pdict = test_api_utils.volume_target_post_data(node_id=None)
pdict['node_uuid'] = node.uuid
response = self.post_json(
'/nodes/%s/volume/targets' % node.uuid, pdict,
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.FORBIDDEN, response.status_int)
def test_create_node_no_mandatory_field_driver(self):
ndict = test_api_utils.post_get_test_node()
del ndict['driver']
@ -2427,6 +2606,34 @@ class TestDelete(test_api_base.BaseApiTest):
headers={'X-OpenStack-Ironic-API-Version': '1.24'})
self.assertEqual(http_client.FORBIDDEN, response.status_int)
def test_delete_volume_connectors_subresource_no_connector_id(self):
node = obj_utils.create_test_node(self.context)
response = self.delete(
'/nodes/%s/volume/connectors' % node.uuid,
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.BAD_REQUEST, response.status_int)
def test_delete_volume_connectors_subresource(self):
node = obj_utils.create_test_node(self.context)
connector = obj_utils.create_test_volume_connector(self.context,
node_id=node.id)
response = self.delete(
'/nodes/%s/volume/connectors/%s' % (node.uuid, connector.uuid),
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.FORBIDDEN, response.status_int)
def test_delete_volume_targets_subresource(self):
node = obj_utils.create_test_node(self.context)
target = obj_utils.create_test_volume_target(self.context,
node_id=node.id)
response = self.delete(
'/nodes/%s/volume/targets/%s' % (node.uuid, target.uuid),
expect_errors=True,
headers={api_base.Version.string: str(api_v1.MAX_VER)})
self.assertEqual(http_client.FORBIDDEN, response.status_int)
@mock.patch.object(notification_utils, '_emit_api_notification')
@mock.patch.object(rpcapi.ConductorAPI, 'destroy_node')
def test_delete_associated(self, mock_dn, mock_notify):

View File

@ -412,6 +412,13 @@ class TestApiUtils(base.TestCase):
mock_request.version.minor = 29
self.assertFalse(utils.allow_dynamic_drivers())
@mock.patch.object(pecan, 'request', spec_set=['version'])
def test_allow_volume(self, mock_request):
mock_request.version.minor = 32
self.assertTrue(utils.allow_volume())
mock_request.version.minor = 31
self.assertFalse(utils.allow_volume())
class TestNodeIdent(base.TestCase):

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