Add new attachment APIS
Replaces the original patch: https://review.openstack.org/#/c/387712/ Use the python-cinderclient WIP: https://review.openstack.org/#/c/387716/ Here's what you can do currently: `cinder attachment-create <volume-uuid>` Currently only tested/implemented the reserve piece Will create an attachment object, set volume to a status of 'reserved' `cinder attachment-list` Simple list output of attachments `cinder attachment-show <attachment-uuid>` Detailed list of specified attachment `cinder attachment-delete <attachment-uuid>` Removes an attachment Change-Id: Ie15233c99d91de67279b56d27a5508c5ea98d769
This commit is contained in:
parent
b5045489f8
commit
22e6998868
@ -77,6 +77,7 @@ REST_API_VERSION_HISTORY = """
|
||||
* 3.25 - Add ``volumes`` field to group list/detail and group show.
|
||||
* 3.26 - Add failover action and cluster listings accept new filters and
|
||||
return new data.
|
||||
* 3.27 - Add attachment API
|
||||
"""
|
||||
|
||||
# The minimum and maximum versions of the API supported
|
||||
@ -84,7 +85,7 @@ REST_API_VERSION_HISTORY = """
|
||||
# minimum version of the API supported.
|
||||
# Explicitly using /v1 or /v2 enpoints will still work
|
||||
_MIN_API_VERSION = "3.0"
|
||||
_MAX_API_VERSION = "3.26"
|
||||
_MAX_API_VERSION = "3.27"
|
||||
_LEGACY_API_VERSION1 = "1.0"
|
||||
_LEGACY_API_VERSION2 = "2.0"
|
||||
|
||||
|
@ -280,3 +280,7 @@ user documentation.
|
||||
- Cluster listing accepts ``replication_status``, ``frozen`` and
|
||||
``active_backend_id`` as filters, and returns additional fields for each
|
||||
cluster: ``replication_status``, ``frozen``, ``active_backend_id``.
|
||||
|
||||
3.27
|
||||
----
|
||||
Added new attachment API's
|
||||
|
253
cinder/api/v3/attachments.py
Normal file
253
cinder/api/v3/attachments.py
Normal file
@ -0,0 +1,253 @@
|
||||
# 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.
|
||||
|
||||
"""The volumes attachments api."""
|
||||
|
||||
from oslo_log import log as logging
|
||||
import webob
|
||||
|
||||
from cinder.api import common
|
||||
from cinder.api.openstack import wsgi
|
||||
from cinder.api.v3.views import attachments as attachment_views
|
||||
from cinder import exception
|
||||
from cinder.i18n import _
|
||||
from cinder import objects
|
||||
from cinder import utils
|
||||
from cinder.volume import api as volume_api
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
API_VERSION = '3.27'
|
||||
|
||||
|
||||
class AttachmentsController(wsgi.Controller):
|
||||
"""The Attachments API controller for the OpenStack API."""
|
||||
|
||||
_view_builder_class = attachment_views.ViewBuilder
|
||||
|
||||
allowed_filters = {'volume_id', 'status', 'instance_id', 'attach_status'}
|
||||
|
||||
def __init__(self, ext_mgr=None):
|
||||
"""Initialize controller class."""
|
||||
self.volume_api = volume_api.API()
|
||||
self.ext_mgr = ext_mgr
|
||||
super(AttachmentsController, self).__init__()
|
||||
|
||||
@wsgi.Controller.api_version(API_VERSION)
|
||||
def show(self, req, id):
|
||||
"""Return data about the given attachment."""
|
||||
context = req.environ['cinder.context']
|
||||
attachment = objects.VolumeAttachment.get_by_id(context, id)
|
||||
return attachment_views.ViewBuilder.detail(attachment)
|
||||
|
||||
@wsgi.Controller.api_version(API_VERSION)
|
||||
def index(self, req):
|
||||
"""Return a summary list of attachments."""
|
||||
attachments = self._items(req, detailed=False)
|
||||
return attachment_views.ViewBuilder.list(attachments)
|
||||
|
||||
@wsgi.Controller.api_version(API_VERSION)
|
||||
def detail(self, req):
|
||||
"""Return a detailed list of attachments."""
|
||||
attachments = self._items(req)
|
||||
return attachment_views.ViewBuilder.list(req, attachments)
|
||||
|
||||
def _items(self, req, detailed=True):
|
||||
"""Return a list of attachments, transformed through view builder."""
|
||||
context = req.environ['cinder.context']
|
||||
|
||||
# Pop out non search_opts and create local variables
|
||||
search_opts = req.GET.copy()
|
||||
sort_keys, sort_dirs = common.get_sort_params(search_opts)
|
||||
marker, limit, offset = common.get_pagination_params(search_opts)
|
||||
filters = dict(req.GET)
|
||||
allowed = self.allowed_filters
|
||||
if not allowed.issuperset(filters):
|
||||
invalid_keys = set(filters).difference(allowed)
|
||||
msg = _('Invalid filter keys: %s') % ', '.join(invalid_keys)
|
||||
raise exception.InvalidInput(reason=msg)
|
||||
|
||||
# Filter out invalid options
|
||||
allowed_search_options = ('status', 'volume_id',
|
||||
'instance_id')
|
||||
if search_opts.get('instance_id', None):
|
||||
search_opts['instance_uuid'] = search_opts.get('instance_id')
|
||||
utils.remove_invalid_filter_options(context, search_opts,
|
||||
allowed_search_options)
|
||||
return objects.VolumeAttachmentList.get_all(context)
|
||||
|
||||
@wsgi.Controller.api_version(API_VERSION)
|
||||
@wsgi.response(202)
|
||||
def create(self, req, body):
|
||||
"""Create an attachment.
|
||||
|
||||
This method can be used to create an empty attachment (reserve) or to
|
||||
create and initialize a volume attachment based on the provided input
|
||||
parameters.
|
||||
|
||||
If the caller does not yet have the connector information but needs to
|
||||
reserve an attachment for the volume (ie Nova BootFromVolume) the
|
||||
create can be called with just the volume-uuid and the server
|
||||
identifier. This will reserve an attachment, mark the volume as
|
||||
reserved and prevent any new attachment_create calls from being made
|
||||
until the attachment is updated (completed).
|
||||
|
||||
The alternative is that the connection can be reserved and initialized
|
||||
all at once with a single call if the caller has all of the required
|
||||
information (connector data) at the time of the call.
|
||||
|
||||
NOTE: In Nova terms server == instance, the server_id parameter
|
||||
referenced below is the uuid of the Instance, for non-nova consumers
|
||||
this can be a server uuid or some other arbitrary unique identifier.
|
||||
|
||||
Expected format of the input parameter 'body':
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"attachment":
|
||||
{
|
||||
"volume_uuid": "volume-uuid",
|
||||
"instance_uuid": "nova-server-uuid",
|
||||
"connector": None|<connector-object>,
|
||||
}
|
||||
}
|
||||
|
||||
Example connector:
|
||||
|
||||
.. code-block:: json
|
||||
|
||||
{
|
||||
"connector":
|
||||
{
|
||||
"initiator": "iqn.1993-08.org.debian:01:cad181614cec",
|
||||
"ip":"192.168.1.20",
|
||||
"platform": "x86_64",
|
||||
"host": "tempest-1",
|
||||
"os_type": "linux2",
|
||||
"multipath": False,
|
||||
"mountpoint": "/dev/vdb",
|
||||
"mode": None|"rw"|"ro",
|
||||
}
|
||||
}
|
||||
|
||||
NOTE all that's required for a reserve is volume_uuid
|
||||
and a instance_uuid.
|
||||
|
||||
returns: A summary view of the attachment object
|
||||
"""
|
||||
context = req.environ['cinder.context']
|
||||
instance_uuid = body['attachment'].get('instance_uuid', None)
|
||||
if not instance_uuid:
|
||||
raise webob.exc.HTTPBadRequest(
|
||||
explanation=_("Must specify 'instance_uuid' "
|
||||
"to create attachment."))
|
||||
|
||||
volume_ref = objects.Volume.get_by_id(
|
||||
context,
|
||||
body['attachment']['volume_uuid'])
|
||||
connector = body['attachment'].get('connector', None)
|
||||
err_msg = None
|
||||
try:
|
||||
attachment_ref = (
|
||||
self.volume_api.attachment_create(context,
|
||||
volume_ref,
|
||||
instance_uuid,
|
||||
connector=connector))
|
||||
except exception.CinderException as ex:
|
||||
err_msg = _(
|
||||
"Unable to create attachment for volume (%s).") % ex.msg
|
||||
LOG.exception(err_msg)
|
||||
except Exception as ex:
|
||||
err_msg = _("Unable to create attachment for volume.")
|
||||
LOG.exception(err_msg)
|
||||
finally:
|
||||
if err_msg:
|
||||
raise webob.exc.HTTPInternalServerError(explanation=err_msg)
|
||||
return attachment_views.ViewBuilder.detail(attachment_ref)
|
||||
|
||||
@wsgi.Controller.api_version(API_VERSION)
|
||||
def update(self, req, id, body):
|
||||
"""Update an attachment record.
|
||||
|
||||
Update a reserved attachment record with connector information and set
|
||||
up the appropriate connection_info from the driver.
|
||||
|
||||
Expected format of the input parameter 'body':
|
||||
|
||||
.. code-block:: json
|
||||
{
|
||||
"attachment":
|
||||
{
|
||||
"connector":
|
||||
{
|
||||
"initiator": "iqn.1993-08.org.debian:01:cad181614cec",
|
||||
"ip":"192.168.1.20",
|
||||
"platform": "x86_64",
|
||||
"host": "tempest-1",
|
||||
"os_type": "linux2",
|
||||
"multipath": False,
|
||||
"mountpoint": "/dev/vdb",
|
||||
"mode": None|"rw"|"ro",
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
context = req.environ['cinder.context']
|
||||
attachment_ref = (
|
||||
objects.VolumeAttachment.get_by_id(context, id))
|
||||
connector = body['attachment'].get('connector', None)
|
||||
if not connector:
|
||||
raise webob.exc.HTTPBadRequest(
|
||||
explanation=_("Must specify 'connector' "
|
||||
"to update attachment."))
|
||||
err_msg = None
|
||||
try:
|
||||
attachment_ref = (
|
||||
self.volume_api.attachment_update(context,
|
||||
attachment_ref,
|
||||
connector))
|
||||
|
||||
except exception.CinderException as ex:
|
||||
err_msg = (
|
||||
_("Unable to create attachment for volume (%s).") % ex.msg)
|
||||
LOG.exception(err_msg)
|
||||
except Exception as ex:
|
||||
err_msg = _("Unable to create attachment for volume.")
|
||||
LOG.exception(err_msg)
|
||||
finally:
|
||||
if err_msg:
|
||||
raise webob.exc.HTTPInternalServerError(explanation=err_msg)
|
||||
|
||||
# TODO(jdg): Test this out some more, do we want to return and object
|
||||
# or a dict?
|
||||
return attachment_views.ViewBuilder.detail(attachment_ref)
|
||||
|
||||
@wsgi.Controller.api_version(API_VERSION)
|
||||
def delete(self, req, id):
|
||||
"""Delete an attachment.
|
||||
|
||||
Disconnects/Deletes the specified attachment, returns a list of any
|
||||
known shared attachment-id's for the effected backend device.
|
||||
|
||||
returns: A summary list of any attachments sharing this connection
|
||||
|
||||
"""
|
||||
context = req.environ['cinder.context']
|
||||
attachment = objects.VolumeAttachment.get_by_id(context, id)
|
||||
attachments = self.volume_api.attachment_delete(context, attachment)
|
||||
return attachment_views.ViewBuilder.list(attachments)
|
||||
|
||||
|
||||
def create_resource(ext_mgr):
|
||||
"""Create the wsgi resource for this controller."""
|
||||
return wsgi.Resource(AttachmentsController(ext_mgr))
|
@ -24,6 +24,7 @@ import cinder.api.openstack
|
||||
from cinder.api.v2 import limits
|
||||
from cinder.api.v2 import snapshot_metadata
|
||||
from cinder.api.v2 import types
|
||||
from cinder.api.v3 import attachments
|
||||
from cinder.api.v3 import backups
|
||||
from cinder.api.v3 import clusters
|
||||
from cinder.api.v3 import consistencygroups
|
||||
@ -175,6 +176,12 @@ class APIRouter(cinder.api.openstack.APIRouter):
|
||||
controller=self.resources['backups'],
|
||||
collection={'detail': 'GET'})
|
||||
|
||||
self.resources['attachments'] = attachments.create_resource(ext_mgr)
|
||||
mapper.resource("attachment", "attachments",
|
||||
controller=self.resources['attachments'],
|
||||
collection={'detail': 'GET', 'summary': 'GET'},
|
||||
member={'action': 'POST'})
|
||||
|
||||
self.resources['workers'] = workers.create_resource()
|
||||
mapper.resource('worker', 'workers',
|
||||
controller=self.resources['workers'],
|
||||
|
57
cinder/api/v3/views/attachments.py
Normal file
57
cinder/api/v3/views/attachments.py
Normal file
@ -0,0 +1,57 @@
|
||||
# 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.
|
||||
|
||||
from oslo_utils import timeutils
|
||||
|
||||
|
||||
class ViewBuilder(object):
|
||||
"""Model an attachment API response as a python dictionary."""
|
||||
|
||||
_collection_name = "attachments"
|
||||
|
||||
@staticmethod
|
||||
def _normalize(date):
|
||||
if date:
|
||||
return timeutils.normalize_time(date)
|
||||
return ''
|
||||
|
||||
@classmethod
|
||||
def detail(cls, attachment, flat=False):
|
||||
"""Detailed view of an attachment."""
|
||||
result = cls.summary(attachment, flat=True)
|
||||
result.update(
|
||||
attached_at=cls._normalize(attachment.attach_time),
|
||||
detached_at=cls._normalize(attachment.detach_time),
|
||||
attach_mode=attachment.attach_mode,
|
||||
connection_info=getattr(attachment, 'connection_info', None),)
|
||||
if flat:
|
||||
return result
|
||||
return {'attachment': result}
|
||||
|
||||
@staticmethod
|
||||
def summary(attachment, flat=False):
|
||||
"""Non detailed view of an attachment."""
|
||||
result = {
|
||||
'id': attachment.id,
|
||||
'status': attachment.attach_status,
|
||||
'instance': attachment.instance_uuid,
|
||||
'volume_id': attachment.volume_id, }
|
||||
if flat:
|
||||
return result
|
||||
return {'attachment': result}
|
||||
|
||||
@classmethod
|
||||
def list(cls, attachments, detail=False):
|
||||
"""Build a view of a list of attachments."""
|
||||
func = cls.detail if detail else cls.summary
|
||||
return {'attachments': [func(attachment, flat=True) for attachment in
|
||||
attachments]}
|
@ -382,6 +382,11 @@ def volume_attachment_get_all_by_project(context, project_id, filters=None,
|
||||
sort_dirs)
|
||||
|
||||
|
||||
def attachment_destroy(context, attachment_id):
|
||||
"""Destroy the attachment or raise if it does not exist."""
|
||||
return IMPL.attachment_destroy(context, attachment_id)
|
||||
|
||||
|
||||
def volume_update_status_based_on_attachment(context, volume_id):
|
||||
"""Update volume status according to attached instance id"""
|
||||
return IMPL.volume_update_status_based_on_attachment(context, volume_id)
|
||||
@ -1734,6 +1739,33 @@ class Condition(object):
|
||||
raise ValueError(_('Condition has no field.'))
|
||||
return field
|
||||
|
||||
###################
|
||||
|
||||
|
||||
def attachment_specs_get(context, attachment_id):
|
||||
"""Get all specs for an attachment."""
|
||||
return IMPL.attachment_specs_get(context, attachment_id)
|
||||
|
||||
|
||||
def attachment_specs_delete(context, attachment_id, key):
|
||||
"""Delete the given attachment specs item."""
|
||||
return IMPL.attachment_specs_delete(context, attachment_id, key)
|
||||
|
||||
|
||||
def attachment_specs_update_or_create(context,
|
||||
attachment_id,
|
||||
specs):
|
||||
"""Create or update attachment specs.
|
||||
|
||||
This adds or modifies the key/value pairs specified in the attachment
|
||||
specs dict argument.
|
||||
"""
|
||||
return IMPL.attachment_specs_update_or_create(context,
|
||||
attachment_id,
|
||||
specs)
|
||||
|
||||
###################
|
||||
|
||||
|
||||
class Not(Condition):
|
||||
"""Class for negated condition values for conditional_update.
|
||||
|
@ -1771,7 +1771,8 @@ def _volume_get(context, volume_id, session=None, joined_load=True):
|
||||
|
||||
def _attachment_get_all(context, filters=None, marker=None, limit=None,
|
||||
offset=None, sort_keys=None, sort_dirs=None):
|
||||
project_id = filters.pop('project_id', None)
|
||||
|
||||
project_id = filters.pop('project_id', None) if filters else None
|
||||
if filters and not is_valid_model_filters(models.VolumeAttachment,
|
||||
filters):
|
||||
return []
|
||||
@ -1856,12 +1857,14 @@ def volume_attachment_get_all_by_host(context, host):
|
||||
|
||||
@require_context
|
||||
def volume_attachment_get(context, attachment_id):
|
||||
"""Fetch the specified attachment record."""
|
||||
return _attachment_get(context, attachment_id)
|
||||
|
||||
|
||||
@require_context
|
||||
def volume_attachment_get_all_by_instance_uuid(context,
|
||||
instance_uuid):
|
||||
"""Fetch all attachment records associated with the specified instance."""
|
||||
session = get_session()
|
||||
with session.begin():
|
||||
result = model_query(context, models.VolumeAttachment,
|
||||
@ -1892,6 +1895,102 @@ def volume_attachment_get_all_by_project(context, project_id, filters=None,
|
||||
sort_dirs)
|
||||
|
||||
|
||||
@require_admin_context
|
||||
@_retry_on_deadlock
|
||||
def attachment_destroy(context, attachment_id):
|
||||
"""Destroy the specified attachment record."""
|
||||
utcnow = timeutils.utcnow()
|
||||
session = get_session()
|
||||
with session.begin():
|
||||
updated_values = {'attach_status': 'deleted',
|
||||
'deleted': True,
|
||||
'deleted_at': utcnow,
|
||||
'updated_at': literal_column('updated_at')}
|
||||
model_query(context, models.VolumeAttachment, session=session).\
|
||||
filter_by(id=attachment_id).\
|
||||
update(updated_values)
|
||||
model_query(context, models.AttachmentSpecs, session=session).\
|
||||
filter_by(attachment_id=attachment_id).\
|
||||
update({'deleted': True,
|
||||
'deleted_at': utcnow,
|
||||
'updated_at': literal_column('updated_at')})
|
||||
del updated_values['updated_at']
|
||||
return updated_values
|
||||
|
||||
|
||||
def _attachment_specs_query(context, attachment_id, session=None):
|
||||
return model_query(context, models.AttachmentSpecs, session=session,
|
||||
read_deleted="no").\
|
||||
filter_by(attachment_id=attachment_id)
|
||||
|
||||
|
||||
@require_context
|
||||
def attachment_specs_get(context, attachment_id):
|
||||
"""Fetch the attachment_specs for the specified attachment record."""
|
||||
rows = _attachment_specs_query(context, attachment_id).\
|
||||
all()
|
||||
|
||||
result = {row['key']: row['value'] for row in rows}
|
||||
return result
|
||||
|
||||
|
||||
@require_context
|
||||
def attachment_specs_delete(context, attachment_id, key):
|
||||
"""Delete attachment_specs for the specified attachment record."""
|
||||
session = get_session()
|
||||
with session.begin():
|
||||
_attachment_specs_get_item(context,
|
||||
attachment_id,
|
||||
key,
|
||||
session)
|
||||
_attachment_specs_query(context, attachment_id, session).\
|
||||
filter_by(key=key).\
|
||||
update({'deleted': True,
|
||||
'deleted_at': timeutils.utcnow(),
|
||||
'updated_at': literal_column('updated_at')})
|
||||
|
||||
|
||||
@require_context
|
||||
def _attachment_specs_get_item(context,
|
||||
attachment_id,
|
||||
key,
|
||||
session=None):
|
||||
result = _attachment_specs_query(
|
||||
context, attachment_id, session=session).\
|
||||
filter_by(key=key).\
|
||||
first()
|
||||
|
||||
if not result:
|
||||
raise exception.AttachmentSpecsNotFound(
|
||||
specs_key=key,
|
||||
attachment_id=attachment_id)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@handle_db_data_error
|
||||
@require_context
|
||||
def attachment_specs_update_or_create(context,
|
||||
attachment_id,
|
||||
specs):
|
||||
"""Update attachment_specs for the specified attachment record."""
|
||||
session = get_session()
|
||||
with session.begin():
|
||||
spec_ref = None
|
||||
for key, value in specs.items():
|
||||
try:
|
||||
spec_ref = _attachment_specs_get_item(
|
||||
context, attachment_id, key, session)
|
||||
except exception.AttachmentSpecsNotFound:
|
||||
spec_ref = models.AttachmentSpecs()
|
||||
spec_ref.update({"key": key, "value": value,
|
||||
"attachment_id": attachment_id,
|
||||
"deleted": False})
|
||||
spec_ref.save(session=session)
|
||||
|
||||
return specs
|
||||
|
||||
|
||||
@require_context
|
||||
def volume_get(context, volume_id):
|
||||
return _volume_get(context, volume_id)
|
||||
|
@ -0,0 +1,40 @@
|
||||
# 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.
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer
|
||||
from sqlalchemy import MetaData, String, Table
|
||||
|
||||
|
||||
def upgrade(migrate_engine):
|
||||
"""Add attachment_specs table."""
|
||||
|
||||
meta = MetaData()
|
||||
meta.bind = migrate_engine
|
||||
Table('volume_attachment', meta, autoload=True)
|
||||
|
||||
attachment_specs = Table(
|
||||
'attachment_specs', meta,
|
||||
Column('created_at', DateTime(timezone=False)),
|
||||
Column('updated_at', DateTime(timezone=False)),
|
||||
Column('deleted_at', DateTime(timezone=False)),
|
||||
Column('deleted', Boolean(), default=False),
|
||||
Column('id', Integer, primary_key=True, nullable=False),
|
||||
Column('attachment_id', String(36),
|
||||
ForeignKey('volume_attachment.id'),
|
||||
nullable=False),
|
||||
Column('key', String(255)),
|
||||
Column('value', String(255)),
|
||||
mysql_engine='InnoDB',
|
||||
mysql_charset='utf8'
|
||||
)
|
||||
|
||||
attachment_specs.create()
|
@ -874,3 +874,24 @@ class Worker(BASE, CinderBase):
|
||||
backref="workers",
|
||||
foreign_keys=service_id,
|
||||
primaryjoin='Worker.service_id == Service.id')
|
||||
|
||||
|
||||
class AttachmentSpecs(BASE, CinderBase):
|
||||
"""Represents attachment specs as k/v pairs for a volume_attachment."""
|
||||
|
||||
__tablename__ = 'attachment_specs'
|
||||
id = Column(Integer, primary_key=True)
|
||||
key = Column(String(255))
|
||||
value = Column(String(255))
|
||||
attachment_id = (
|
||||
Column(String(36),
|
||||
ForeignKey('volume_attachment.id'),
|
||||
nullable=False))
|
||||
volume_attachment = relationship(
|
||||
VolumeAttachment,
|
||||
backref="attachment_specs",
|
||||
foreign_keys=attachment_id,
|
||||
primaryjoin='and_('
|
||||
'AttachmentSpecs.attachment_id == VolumeAttachment.id,'
|
||||
'AttachmentSpecs.deleted == False)'
|
||||
)
|
||||
|
@ -1347,3 +1347,12 @@ class RdxAPICommandException(VolumeDriverException):
|
||||
|
||||
class RdxAPIConnectionException(VolumeDriverException):
|
||||
message = _("Reduxio API Connection Exception")
|
||||
|
||||
|
||||
class AttachmentSpecsNotFound(NotFound):
|
||||
message = _("Attachment %(attachment_id)s has no "
|
||||
"key %(specs_key)s.")
|
||||
|
||||
|
||||
class InvalidAttachment(Invalid):
|
||||
message = _("Invalid attachment: %(reason)s")
|
||||
|
@ -148,11 +148,13 @@ class VolumeAttachStatus(BaseCinderEnum):
|
||||
ATTACHED = 'attached'
|
||||
ATTACHING = 'attaching'
|
||||
DETACHED = 'detached'
|
||||
RESERVED = 'reserved'
|
||||
ERROR_ATTACHING = 'error_attaching'
|
||||
ERROR_DETACHING = 'error_detaching'
|
||||
DELETED = 'deleted'
|
||||
|
||||
ALL = (ATTACHED, ATTACHING, DETACHED, ERROR_ATTACHING,
|
||||
ERROR_DETACHING)
|
||||
ERROR_DETACHING, RESERVED, DELETED)
|
||||
|
||||
|
||||
class VolumeAttachStatusField(BaseEnumField):
|
||||
|
@ -132,6 +132,11 @@ class VolumeAttachment(base.CinderPersistentObject, base.CinderObject,
|
||||
db_attachment = db.volume_attach(self._context, updates)
|
||||
self._from_db_object(self._context, self, db_attachment)
|
||||
|
||||
def destroy(self):
|
||||
updated_values = db.attachment_destroy(self._context, self.id)
|
||||
self.update(updated_values)
|
||||
self.obj_reset_changes(updated_values.keys())
|
||||
|
||||
|
||||
@base.CinderObjectRegistry.register
|
||||
class VolumeAttachmentList(base.ObjectListBase, base.CinderObject):
|
||||
|
0
cinder/tests/unit/attachments/__init__.py
Normal file
0
cinder/tests/unit/attachments/__init__.py
Normal file
139
cinder/tests/unit/attachments/test_attachments_api.py
Normal file
139
cinder/tests/unit/attachments/test_attachments_api.py
Normal file
@ -0,0 +1,139 @@
|
||||
# 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 mock
|
||||
from oslo_config import cfg
|
||||
|
||||
from cinder import context
|
||||
from cinder import db
|
||||
from cinder import objects
|
||||
from cinder import test
|
||||
from cinder.tests.unit import fake_constants as fake
|
||||
from cinder.tests.unit import utils as tests_utils
|
||||
from cinder.volume import api as volume_api
|
||||
from cinder.volume import configuration as conf
|
||||
|
||||
CONF = cfg.CONF
|
||||
|
||||
|
||||
class AttachmentManagerTestCase(test.TestCase):
|
||||
"""Attachment related test for volume/api.py."""
|
||||
|
||||
def setUp(self):
|
||||
"""Setup test class."""
|
||||
super(AttachmentManagerTestCase, self).setUp()
|
||||
self.configuration = mock.Mock(conf.Configuration)
|
||||
self.context = context.get_admin_context()
|
||||
self.context.user_id = fake.USER_ID
|
||||
self.project_id = fake.PROJECT3_ID
|
||||
self.context.project_id = self.project_id
|
||||
self.volume_api = volume_api.API()
|
||||
|
||||
@mock.patch('cinder.volume.api.check_policy')
|
||||
def test_attachment_create_no_connector(self, mock_policy):
|
||||
"""Test attachment_create no connector."""
|
||||
volume_params = {'status': 'available'}
|
||||
|
||||
vref = tests_utils.create_volume(self.context, **volume_params)
|
||||
aref = self.volume_api.attachment_create(self.context,
|
||||
vref,
|
||||
fake.UUID2)
|
||||
self.assertEqual(fake.UUID2, aref.instance_uuid)
|
||||
self.assertIsNone(aref.attach_time)
|
||||
self.assertEqual('reserved', aref.attach_status)
|
||||
self.assertIsNone(aref.attach_mode)
|
||||
self.assertEqual(vref.id, aref.volume_id)
|
||||
self.assertEqual({}, aref.connection_info)
|
||||
|
||||
@mock.patch('cinder.volume.api.check_policy')
|
||||
@mock.patch('cinder.volume.rpcapi.VolumeAPI.attachment_update')
|
||||
def test_attachment_create_with_connector(self,
|
||||
mock_rpc_attachment_update,
|
||||
mock_policy):
|
||||
"""Test attachment_create with connector."""
|
||||
volume_params = {'status': 'available'}
|
||||
|
||||
vref = tests_utils.create_volume(self.context, **volume_params)
|
||||
connector = {'fake': 'connector'}
|
||||
self.volume_api.attachment_create(self.context,
|
||||
vref,
|
||||
fake.UUID2,
|
||||
connector)
|
||||
mock_rpc_attachment_update.assert_called_once_with(self.context,
|
||||
mock.ANY,
|
||||
connector,
|
||||
mock.ANY)
|
||||
|
||||
@mock.patch('cinder.volume.api.check_policy')
|
||||
@mock.patch('cinder.volume.rpcapi.VolumeAPI.attachment_delete')
|
||||
def test_attachment_delete_reserved(self,
|
||||
mock_rpc_attachment_delete,
|
||||
mock_policy):
|
||||
"""Test attachment_delete with reserved."""
|
||||
volume_params = {'status': 'available'}
|
||||
|
||||
vref = tests_utils.create_volume(self.context, **volume_params)
|
||||
aref = self.volume_api.attachment_create(self.context,
|
||||
vref,
|
||||
fake.UUID2)
|
||||
aobj = objects.VolumeAttachment.get_by_id(self.context,
|
||||
aref.id)
|
||||
self.assertEqual('reserved', aref.attach_status)
|
||||
self.assertEqual(vref.id, aref.volume_id)
|
||||
self.volume_api.attachment_delete(self.context,
|
||||
aobj)
|
||||
|
||||
# Since it's just reserved and never finalized, we should never make an
|
||||
# rpc call
|
||||
mock_rpc_attachment_delete.assert_not_called()
|
||||
|
||||
@mock.patch('cinder.volume.api.check_policy')
|
||||
@mock.patch('cinder.volume.rpcapi.VolumeAPI.attachment_delete')
|
||||
@mock.patch('cinder.volume.rpcapi.VolumeAPI.attachment_update')
|
||||
def test_attachment_create_update_and_delete(
|
||||
self,
|
||||
mock_rpc_attachment_update,
|
||||
mock_rpc_attachment_delete,
|
||||
mock_policy):
|
||||
"""Test attachment_delete."""
|
||||
volume_params = {'status': 'available'}
|
||||
|
||||
vref = tests_utils.create_volume(self.context, **volume_params)
|
||||
aref = self.volume_api.attachment_create(self.context,
|
||||
vref,
|
||||
fake.UUID2)
|
||||
aref = objects.VolumeAttachment.get_by_id(self.context,
|
||||
aref.id)
|
||||
vref = objects.Volume.get_by_id(self.context,
|
||||
vref.id)
|
||||
|
||||
connector = {'fake': 'connector'}
|
||||
self.volume_api.attachment_update(self.context,
|
||||
aref,
|
||||
connector)
|
||||
# We mock the actual call that updates the status
|
||||
# so force it here
|
||||
values = {'volume_id': vref.id,
|
||||
'volume_host': vref.host,
|
||||
'attach_status': 'attached',
|
||||
'instance_uuid': fake.UUID2}
|
||||
aref = db.volume_attach(self.context, values)
|
||||
|
||||
aref = objects.VolumeAttachment.get_by_id(self.context,
|
||||
aref.id)
|
||||
self.assertEqual(vref.id, aref.volume_id)
|
||||
self.volume_api.attachment_delete(self.context,
|
||||
aref)
|
||||
|
||||
mock_rpc_attachment_delete.assert_called_once_with(self.context,
|
||||
aref.id,
|
||||
mock.ANY)
|
99
cinder/tests/unit/attachments/test_attachments_manager.py
Normal file
99
cinder/tests/unit/attachments/test_attachments_manager.py
Normal file
@ -0,0 +1,99 @@
|
||||
# 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 mock
|
||||
from oslo_config import cfg
|
||||
from oslo_utils import importutils
|
||||
|
||||
from cinder import context
|
||||
from cinder import db
|
||||
from cinder import exception
|
||||
from cinder import test
|
||||
from cinder.tests.unit import fake_constants as fake
|
||||
from cinder.tests.unit import utils as tests_utils
|
||||
from cinder.volume import configuration as conf
|
||||
|
||||
CONF = cfg.CONF
|
||||
|
||||
|
||||
class AttachmentManagerTestCase(test.TestCase):
|
||||
"""Attachment related test for volume.manager.py."""
|
||||
|
||||
def setUp(self):
|
||||
"""Setup test class."""
|
||||
super(AttachmentManagerTestCase, self).setUp()
|
||||
self.manager = importutils.import_object(CONF.volume_manager)
|
||||
self.configuration = mock.Mock(conf.Configuration)
|
||||
self.context = context.get_admin_context()
|
||||
self.context.user_id = fake.USER_ID
|
||||
self.project_id = fake.PROJECT3_ID
|
||||
self.context.project_id = self.project_id
|
||||
self.manager.driver.set_initialized()
|
||||
self.manager.stats = {'allocated_capacity_gb': 100,
|
||||
'pools': {}}
|
||||
|
||||
def test_attachment_update(self):
|
||||
"""Test attachment_update."""
|
||||
volume_params = {'status': 'available'}
|
||||
connector = {
|
||||
"initiator": "iqn.1993-08.org.debian:01:cad181614cec",
|
||||
"ip": "192.168.1.20",
|
||||
"platform": "x86_64",
|
||||
"host": "tempest-1",
|
||||
"os_type": "linux2",
|
||||
"multipath": False}
|
||||
|
||||
vref = tests_utils.create_volume(self.context, **volume_params)
|
||||
self.manager.create_volume(self.context, vref)
|
||||
values = {'volume_id': vref.id,
|
||||
'volume_host': vref.host,
|
||||
'attach_status': 'reserved',
|
||||
'instance_uuid': fake.UUID1}
|
||||
attachment_ref = db.volume_attach(self.context, values)
|
||||
with mock.patch.object(self.manager,
|
||||
'_notify_about_volume_usage',
|
||||
return_value=None):
|
||||
expected = {
|
||||
'encrypted': False,
|
||||
'qos_specs': None,
|
||||
'access_mode': 'rw',
|
||||
'driver_volume_type': 'iscsi',
|
||||
'attachment_id': attachment_ref.id}
|
||||
|
||||
self.assertEqual(expected,
|
||||
self.manager.attachment_update(
|
||||
self.context,
|
||||
vref,
|
||||
connector,
|
||||
attachment_ref.id))
|
||||
|
||||
def test_attachment_delete(self):
|
||||
"""Test attachment_delete."""
|
||||
volume_params = {'status': 'available'}
|
||||
|
||||
vref = tests_utils.create_volume(self.context, **volume_params)
|
||||
self.manager.create_volume(self.context, vref)
|
||||
values = {'volume_id': vref.id,
|
||||
'volume_host': vref.host,
|
||||
'attach_status': 'reserved',
|
||||
'instance_uuid': fake.UUID1}
|
||||
attachment_ref = db.volume_attach(self.context, values)
|
||||
attachment_ref = db.volume_attachment_get(
|
||||
self.context,
|
||||
attachment_ref['id'])
|
||||
self.manager.attachment_delete(self.context,
|
||||
attachment_ref['id'],
|
||||
vref)
|
||||
self.assertRaises(exception.VolumeAttachmentNotFound,
|
||||
db.volume_attachment_get,
|
||||
self.context,
|
||||
attachment_ref.id)
|
@ -80,3 +80,10 @@ GROUP_ID = '9a965cc6-ee3a-468d-a721-cebb193f696f'
|
||||
GROUP2_ID = '40a85639-abc3-4461-9230-b131abd8ee07'
|
||||
GROUP_SNAPSHOT_ID = '1e2ab152-44f0-11e6-819f-000c29d19d84'
|
||||
GROUP_SNAPSHOT2_ID = '33e2ff04-44f0-11e6-819f-000c29d19d84'
|
||||
|
||||
# I don't care what it's used for, I just want a damn UUID
|
||||
UUID1 = '84d0c5f7-2349-401c-8672-f76214d13cab'
|
||||
UUID2 = '25406d50-e645-4e62-a9ef-1f53f9cba13f'
|
||||
UUID3 = '29c80662-3a9f-4844-a585-55cd3cd180b5'
|
||||
UUID4 = '4cd72b2b-5a4f-4f24-93dc-7c0212002916'
|
||||
UUID5 = '0a574d83-cacf-42b9-8f9f-8f4faa6d4746'
|
||||
|
@ -41,9 +41,9 @@ object_data = {
|
||||
'ServiceList': '1.1-15ecf022a68ddbb8c2a6739cfc9f8f5e',
|
||||
'Snapshot': '1.3-69dfbe3244992478a0174cb512cd7f27',
|
||||
'SnapshotList': '1.0-15ecf022a68ddbb8c2a6739cfc9f8f5e',
|
||||
'Volume': '1.6-8a56256db74c0642dca1a30739d88074',
|
||||
'Volume': '1.6-7d3bc8577839d5725670d55e480fe95f',
|
||||
'VolumeList': '1.1-15ecf022a68ddbb8c2a6739cfc9f8f5e',
|
||||
'VolumeAttachment': '1.1-e98b04a372a303b01bedab1e47ee9f6d',
|
||||
'VolumeAttachment': '1.1-ed82a5fdd56655e14d9f86396c130aea',
|
||||
'VolumeAttachmentList': '1.1-15ecf022a68ddbb8c2a6739cfc9f8f5e',
|
||||
'VolumeProperties': '1.1-cadac86b2bdc11eb79d1dcea988ff9e8',
|
||||
'VolumeType': '1.3-a5d8c3473db9bc3bbcdbab9313acf4d1',
|
||||
|
@ -86,6 +86,7 @@ CONF.import_opt('glance_core_properties', 'cinder.image.glance')
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
QUOTAS = quota.QUOTAS
|
||||
AO_LIST = objects.VolumeAttachmentList
|
||||
|
||||
|
||||
def wrap_check_policy(func):
|
||||
@ -98,7 +99,6 @@ def wrap_check_policy(func):
|
||||
def wrapped(self, context, target_obj, *args, **kwargs):
|
||||
check_policy(context, func.__name__, target_obj)
|
||||
return func(self, context, target_obj, *args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
@ -1906,6 +1906,98 @@ class API(base.Base):
|
||||
else:
|
||||
return bool(val)
|
||||
|
||||
def _attachment_reserve(self, ctxt, vref, instance_uuid=None):
|
||||
# NOTE(jdg): Reserved is a special case, we're avoiding allowing
|
||||
# creation of other new reserves/attachments while in this state
|
||||
# so we avoid contention issues with shared connections
|
||||
|
||||
# FIXME(JDG): We want to be able to do things here like reserve a
|
||||
# volume for Nova to do BFV WHILE the volume may be in the process of
|
||||
# downloading image, we add downloading here; that's easy enough but
|
||||
# we've got a race inbetween with the attaching/detaching that we do
|
||||
# locally on the Cinder node. Just come up with an easy way to
|
||||
# determine if we're attaching to the Cinder host for some work or if
|
||||
# we're being used by the outside world.
|
||||
expected = {'multiattach': vref.multiattach,
|
||||
'status': (('available', 'in-use', 'downloading')
|
||||
if vref.multiattach
|
||||
else ('available', 'downloading'))}
|
||||
result = vref.conditional_update({'status': 'reserved'}, expected)
|
||||
if not result:
|
||||
msg = (_('Volume %(vol_id)s status must be %(statuses)s') %
|
||||
{'vol_id': vref.id,
|
||||
'statuses': utils.build_or_str(expected['status'])})
|
||||
raise exception.InvalidVolume(reason=msg)
|
||||
|
||||
values = {'volume_id': vref.id,
|
||||
'volume_host': vref.host,
|
||||
'attach_status': 'reserved',
|
||||
'instance_uuid': instance_uuid}
|
||||
db_ref = self.db.volume_attach(ctxt.elevated(), values)
|
||||
return objects.VolumeAttachment.get_by_id(ctxt, db_ref['id'])
|
||||
|
||||
@wrap_check_policy
|
||||
def attachment_create(self,
|
||||
ctxt,
|
||||
volume_ref,
|
||||
instance_uuid,
|
||||
connector=None):
|
||||
"""Create an attachment record for the specified volume."""
|
||||
connection_info = {}
|
||||
attachment_ref = self._attachment_reserve(ctxt,
|
||||
volume_ref,
|
||||
instance_uuid)
|
||||
if connector:
|
||||
connection_info = (
|
||||
self.volume_rpcapi.attachment_update(ctxt,
|
||||
volume_ref,
|
||||
connector,
|
||||
attachment_ref.id))
|
||||
attachment_ref.connection_info = connection_info
|
||||
attachment_ref.save()
|
||||
return attachment_ref
|
||||
|
||||
@wrap_check_policy
|
||||
def attachment_update(self, ctxt, attachment_ref, connector):
|
||||
"""Update an existing attachment record."""
|
||||
# Valid items to update (connector includes mode and mountpoint):
|
||||
# 1. connector (required)
|
||||
# a. mode (if None use value from attachment_ref)
|
||||
# b. mountpoint (if None use value from attachment_ref)
|
||||
# c. instance_uuid(if None use value from attachment_ref)
|
||||
|
||||
# We fetch the volume object and pass it to the rpc call because we
|
||||
# need to direct this to the correct host/backend
|
||||
|
||||
volume_ref = objects.Volume.get_by_id(ctxt, attachment_ref.volume_id)
|
||||
connection_info = (
|
||||
self.volume_rpcapi.attachment_update(ctxt,
|
||||
volume_ref,
|
||||
connector,
|
||||
attachment_ref.id))
|
||||
attachment_ref.connection_info = connection_info
|
||||
attachment_ref.save()
|
||||
return attachment_ref
|
||||
|
||||
@wrap_check_policy
|
||||
def attachment_delete(self, ctxt, attachment):
|
||||
volume = objects.Volume.get_by_id(ctxt, attachment.volume_id)
|
||||
if attachment.attach_status == 'reserved':
|
||||
attachment.destroy()
|
||||
else:
|
||||
self.volume_rpcapi.attachment_delete(ctxt,
|
||||
attachment.id,
|
||||
volume)
|
||||
remaining_attachments = AO_LIST.get_all_by_volume_id(ctxt, volume.id)
|
||||
|
||||
# TODO(jdg): Make this check attachments_by_volume_id when we
|
||||
# implement multi-attach for real
|
||||
if len(remaining_attachments) < 1:
|
||||
volume.status = 'available'
|
||||
volume.attach_status = 'detached'
|
||||
volume.save()
|
||||
return remaining_attachments
|
||||
|
||||
|
||||
class HostAPI(base.Base):
|
||||
"""Sub-set of the Volume Manager API for managing host operations."""
|
||||
|
@ -110,6 +110,7 @@ VALID_CREATE_CG_SRC_SNAP_STATUS = (fields.SnapshotStatus.AVAILABLE,)
|
||||
VALID_CREATE_GROUP_SRC_SNAP_STATUS = (fields.SnapshotStatus.AVAILABLE,)
|
||||
VALID_CREATE_CG_SRC_CG_STATUS = ('available',)
|
||||
VALID_CREATE_GROUP_SRC_GROUP_STATUS = ('available',)
|
||||
VA_LIST = objects.VolumeAttachmentList
|
||||
|
||||
volume_manager_opts = [
|
||||
cfg.StrOpt('volume_driver',
|
||||
@ -1002,11 +1003,11 @@ class VolumeManager(manager.CleanableManager,
|
||||
host_name) if host_name else None
|
||||
if instance_uuid:
|
||||
attachments = (
|
||||
objects.VolumeAttachmentList.get_all_by_instance_uuid(
|
||||
VA_LIST.get_all_by_instance_uuid(
|
||||
context, instance_uuid))
|
||||
else:
|
||||
attachments = (
|
||||
objects.VolumeAttachmentList.get_all_by_host(
|
||||
VA_LIST.get_all_by_host(
|
||||
context, host_name_sanitized))
|
||||
if attachments:
|
||||
# check if volume<->instance mapping is already tracked in DB
|
||||
@ -1378,85 +1379,7 @@ class VolumeManager(manager.CleanableManager,
|
||||
exc_info=True, resource={'type': 'image',
|
||||
'id': image_id})
|
||||
|
||||
def initialize_connection(self, context, volume, connector):
|
||||
"""Prepare volume for connection from host represented by connector.
|
||||
|
||||
This method calls the driver initialize_connection and returns
|
||||
it to the caller. The connector parameter is a dictionary with
|
||||
information about the host that will connect to the volume in the
|
||||
following format::
|
||||
|
||||
{
|
||||
'ip': ip,
|
||||
'initiator': initiator,
|
||||
}
|
||||
|
||||
ip: the ip address of the connecting machine
|
||||
|
||||
initiator: the iscsi initiator name of the connecting machine.
|
||||
This can be None if the connecting machine does not support iscsi
|
||||
connections.
|
||||
|
||||
driver is responsible for doing any necessary security setup and
|
||||
returning a connection_info dictionary in the following format::
|
||||
|
||||
{
|
||||
'driver_volume_type': driver_volume_type,
|
||||
'data': data,
|
||||
}
|
||||
|
||||
driver_volume_type: a string to identify the type of volume. This
|
||||
can be used by the calling code to determine the
|
||||
strategy for connecting to the volume. This could
|
||||
be 'iscsi', 'rbd', 'sheepdog', etc.
|
||||
|
||||
data: this is the data that the calling code will use to connect
|
||||
to the volume. Keep in mind that this will be serialized to
|
||||
json in various places, so it should not contain any non-json
|
||||
data types.
|
||||
"""
|
||||
|
||||
# NOTE(flaper87): Verify the driver is enabled
|
||||
# before going forward. The exception will be caught
|
||||
# and the volume status updated.
|
||||
utils.require_driver_initialized(self.driver)
|
||||
try:
|
||||
self.driver.validate_connector(connector)
|
||||
except exception.InvalidConnectorException as err:
|
||||
raise exception.InvalidInput(reason=six.text_type(err))
|
||||
except Exception as err:
|
||||
err_msg = (_("Validate volume connection failed "
|
||||
"(error: %(err)s).") % {'err': six.text_type(err)})
|
||||
LOG.exception(err_msg, resource=volume)
|
||||
raise exception.VolumeBackendAPIException(data=err_msg)
|
||||
|
||||
try:
|
||||
model_update = self.driver.create_export(context.elevated(),
|
||||
volume, connector)
|
||||
except exception.CinderException:
|
||||
err_msg = (_("Create export for volume failed."))
|
||||
LOG.exception(err_msg, resource=volume)
|
||||
raise exception.VolumeBackendAPIException(data=err_msg)
|
||||
|
||||
try:
|
||||
if model_update:
|
||||
volume.update(model_update)
|
||||
volume.save()
|
||||
except exception.CinderException as ex:
|
||||
LOG.exception(_LE("Model update failed."), resource=volume)
|
||||
raise exception.ExportFailure(reason=six.text_type(ex))
|
||||
|
||||
try:
|
||||
conn_info = self.driver.initialize_connection(volume, connector)
|
||||
except Exception as err:
|
||||
err_msg = (_("Driver initialize connection failed "
|
||||
"(error: %(err)s).") % {'err': six.text_type(err)})
|
||||
LOG.exception(err_msg, resource=volume)
|
||||
|
||||
self.driver.remove_export(context.elevated(), volume)
|
||||
|
||||
raise exception.VolumeBackendAPIException(data=err_msg)
|
||||
|
||||
def _parse_connection_options(self, context, volume, conn_info):
|
||||
# Add qos_specs to connection info
|
||||
typeid = volume.volume_type_id
|
||||
specs = None
|
||||
@ -1498,6 +1421,91 @@ class VolumeManager(manager.CleanableManager,
|
||||
resource=volume)
|
||||
return conn_info
|
||||
|
||||
def initialize_connection(self, context, volume, connector):
|
||||
"""Prepare volume for connection from host represented by connector.
|
||||
|
||||
This method calls the driver initialize_connection and returns
|
||||
it to the caller. The connector parameter is a dictionary with
|
||||
information about the host that will connect to the volume in the
|
||||
following format::
|
||||
|
||||
{
|
||||
'ip': ip,
|
||||
'initiator': initiator,
|
||||
}
|
||||
|
||||
ip: the ip address of the connecting machine
|
||||
|
||||
initiator: the iscsi initiator name of the connecting machine.
|
||||
This can be None if the connecting machine does not support iscsi
|
||||
connections.
|
||||
|
||||
driver is responsible for doing any necessary security setup and
|
||||
returning a connection_info dictionary in the following format::
|
||||
|
||||
{
|
||||
'driver_volume_type': driver_volume_type,
|
||||
'data': data,
|
||||
}
|
||||
|
||||
driver_volume_type: a string to identify the type of volume. This
|
||||
can be used by the calling code to determine the
|
||||
strategy for connecting to the volume. This could
|
||||
be 'iscsi', 'rbd', 'sheepdog', etc.
|
||||
|
||||
data: this is the data that the calling code will use to connect
|
||||
to the volume. Keep in mind that this will be serialized to
|
||||
json in various places, so it should not contain any non-json
|
||||
data types.
|
||||
"""
|
||||
# NOTE(flaper87): Verify the driver is enabled
|
||||
# before going forward. The exception will be caught
|
||||
# and the volume status updated.
|
||||
|
||||
# TODO(jdg): Add deprecation warning
|
||||
utils.require_driver_initialized(self.driver)
|
||||
try:
|
||||
self.driver.validate_connector(connector)
|
||||
except exception.InvalidConnectorException as err:
|
||||
raise exception.InvalidInput(reason=six.text_type(err))
|
||||
except Exception as err:
|
||||
err_msg = (_("Validate volume connection failed "
|
||||
"(error: %(err)s).") % {'err': six.text_type(err)})
|
||||
LOG.exception(err_msg, resource=volume)
|
||||
raise exception.VolumeBackendAPIException(data=err_msg)
|
||||
|
||||
try:
|
||||
model_update = self.driver.create_export(context.elevated(),
|
||||
volume, connector)
|
||||
except exception.CinderException as ex:
|
||||
msg = _("Create export of volume failed (%s)") % ex.msg
|
||||
LOG.exception(msg, resource=volume)
|
||||
raise exception.VolumeBackendAPIException(data=msg)
|
||||
|
||||
try:
|
||||
if model_update:
|
||||
volume.update(model_update)
|
||||
volume.save()
|
||||
except exception.CinderException as ex:
|
||||
LOG.exception(_LE("Model update failed."), resource=volume)
|
||||
raise exception.ExportFailure(reason=six.text_type(ex))
|
||||
|
||||
try:
|
||||
conn_info = self.driver.initialize_connection(volume, connector)
|
||||
except Exception as err:
|
||||
err_msg = (_("Driver initialize connection failed "
|
||||
"(error: %(err)s).") % {'err': six.text_type(err)})
|
||||
LOG.exception(err_msg, resource=volume)
|
||||
|
||||
self.driver.remove_export(context.elevated(), volume)
|
||||
|
||||
raise exception.VolumeBackendAPIException(data=err_msg)
|
||||
|
||||
conn_info = self._parse_connection_options(context, volume, conn_info)
|
||||
LOG.info(_LI("Initialize volume connection completed successfully."),
|
||||
resource=volume)
|
||||
return conn_info
|
||||
|
||||
def terminate_connection(self, context, volume_id, connector, force=False):
|
||||
"""Cleanup connection from host represented by connector.
|
||||
|
||||
@ -1522,7 +1530,6 @@ class VolumeManager(manager.CleanableManager,
|
||||
|
||||
def remove_export(self, context, volume_id):
|
||||
"""Removes an export for a volume."""
|
||||
|
||||
utils.require_driver_initialized(self.driver)
|
||||
volume_ref = self.db.volume_get(context, volume_id)
|
||||
try:
|
||||
@ -4435,3 +4442,211 @@ class VolumeManager(manager.CleanableManager,
|
||||
def secure_file_operations_enabled(self, ctxt, volume):
|
||||
secure_enabled = self.driver.secure_file_operations_enabled()
|
||||
return secure_enabled
|
||||
|
||||
def _connection_create(self, ctxt, volume, attachment, connector):
|
||||
try:
|
||||
self.driver.validate_connector(connector)
|
||||
except exception.InvalidConnectorException as err:
|
||||
raise exception.InvalidInput(reason=six.text_type(err))
|
||||
except Exception as err:
|
||||
err_msg = (_("Validate volume connection failed "
|
||||
"(error: %(err)s).") % {'err': six.text_type(err)})
|
||||
LOG.error(err_msg, resource=volume)
|
||||
raise exception.VolumeBackendAPIException(data=err_msg)
|
||||
|
||||
try:
|
||||
model_update = self.driver.create_export(ctxt.elevated(),
|
||||
volume, connector)
|
||||
except exception.CinderException as ex:
|
||||
err_msg = (_("Create export for volume failed (%s).") % ex.msg)
|
||||
LOG.exception(err_msg, resource=volume)
|
||||
raise exception.VolumeBackendAPIException(data=err_msg)
|
||||
|
||||
try:
|
||||
if model_update:
|
||||
volume.update(model_update)
|
||||
volume.save()
|
||||
except exception.CinderException as ex:
|
||||
LOG.exception(_LE("Model update failed."), resource=volume)
|
||||
raise exception.ExportFailure(reason=six.text_type(ex))
|
||||
|
||||
try:
|
||||
conn_info = self.driver.initialize_connection(volume, connector)
|
||||
except Exception as err:
|
||||
err_msg = (_("Driver initialize connection failed "
|
||||
"(error: %(err)s).") % {'err': six.text_type(err)})
|
||||
LOG.exception(err_msg, resource=volume)
|
||||
self.driver.remove_export(ctxt.elevated(), volume)
|
||||
raise exception.VolumeBackendAPIException(data=err_msg)
|
||||
conn_info = self._parse_connection_options(ctxt, volume, conn_info)
|
||||
|
||||
# NOTE(jdg): Get rid of the nested dict (data key)
|
||||
conn_data = conn_info.pop('data', {})
|
||||
connection_info = conn_data.copy()
|
||||
connection_info.update(conn_info)
|
||||
values = {'volume_id': volume.id,
|
||||
'attach_status': 'attaching', }
|
||||
|
||||
self.db.volume_attachment_update(ctxt, attachment.id, values)
|
||||
self.db.attachment_specs_update_or_create(
|
||||
ctxt,
|
||||
attachment.id,
|
||||
connector)
|
||||
|
||||
connection_info['attachment_id'] = attachment.id
|
||||
return connection_info
|
||||
|
||||
def attachment_update(self,
|
||||
context,
|
||||
vref,
|
||||
connector,
|
||||
attachment_id):
|
||||
"""Update/Finalize an attachment.
|
||||
|
||||
This call updates a valid attachment record to associate with a volume
|
||||
and provide the caller with the proper connection info. Note that
|
||||
this call requires an `attachment_ref`. It's expected that prior to
|
||||
this call that the volume and an attachment UUID has been reserved.
|
||||
|
||||
param: vref: Volume object to create attachment for
|
||||
param: connector: Connector object to use for attachment creation
|
||||
param: attachment_ref: ID of the attachment record to update
|
||||
"""
|
||||
|
||||
mode = connector.get('mode', 'rw')
|
||||
self._notify_about_volume_usage(context, vref, 'attach.start')
|
||||
attachment_ref = objects.VolumeAttachment.get_by_id(context,
|
||||
attachment_id)
|
||||
connection_info = self._connection_create(context,
|
||||
vref,
|
||||
attachment_ref,
|
||||
connector)
|
||||
# FIXME(jdg): get rid of this admin_meta option here, the only thing
|
||||
# it does is enforce that a volume is R/O, that should be done via a
|
||||
# type and not *more* metadata
|
||||
volume_metadata = self.db.volume_admin_metadata_update(
|
||||
context.elevated(),
|
||||
attachment_ref.volume_id,
|
||||
{'attached_mode': mode}, False)
|
||||
|
||||
if volume_metadata.get('readonly') == 'True' and mode != 'ro':
|
||||
self.db.volume_update(context, vref.id,
|
||||
{'status': 'error_attaching'})
|
||||
self.message_api.create(
|
||||
context, defined_messages.ATTACH_READONLY_VOLUME,
|
||||
context.project_id, resource_type=resource_types.VOLUME,
|
||||
resource_uuid=vref.id)
|
||||
raise exception.InvalidVolumeAttachMode(mode=mode,
|
||||
volume_id=vref.id)
|
||||
try:
|
||||
utils.require_driver_initialized(self.driver)
|
||||
self.driver.attach_volume(context,
|
||||
vref,
|
||||
attachment_ref.instance_uuid,
|
||||
connector.get('hostname', ''),
|
||||
connector.get('mountpoint', 'na'))
|
||||
except Exception:
|
||||
with excutils.save_and_reraise_exception():
|
||||
self.db.volume_attachment_update(
|
||||
context, attachment_ref.id,
|
||||
{'attach_status': 'error_attaching'})
|
||||
|
||||
self.db.volume_attached(context.elevated(),
|
||||
attachment_ref.id,
|
||||
attachment_ref.instance_uuid,
|
||||
connector.get('hostname', ''),
|
||||
connector.get('mountpoint', 'na'),
|
||||
mode)
|
||||
vref.refresh()
|
||||
self._notify_about_volume_usage(context, vref, "attach.end")
|
||||
LOG.info(_LI("Attach volume completed successfully."),
|
||||
resource=vref)
|
||||
attachment_ref = objects.VolumeAttachment.get_by_id(context,
|
||||
attachment_id)
|
||||
return connection_info
|
||||
|
||||
def _connection_terminate(self, context, volume,
|
||||
attachment, force=False):
|
||||
"""Remove a volume connection, but leave attachment."""
|
||||
utils.require_driver_initialized(self.driver)
|
||||
|
||||
# TODO(jdg): Add an object method to cover this
|
||||
connector = self.db.attachment_specs_get(
|
||||
context,
|
||||
attachment.id)
|
||||
|
||||
try:
|
||||
shared_connections = self.driver.terminate_connection(volume,
|
||||
connector,
|
||||
force=force)
|
||||
if not isinstance(shared_connections, bool):
|
||||
shared_connections = False
|
||||
|
||||
except Exception as err:
|
||||
err_msg = (_('Terminate volume connection failed: %(err)s')
|
||||
% {'err': six.text_type(err)})
|
||||
LOG.exception(err_msg, resource=volume)
|
||||
raise exception.VolumeBackendAPIException(data=err_msg)
|
||||
LOG.info(_LI("Terminate volume connection completed successfully."),
|
||||
resource=volume)
|
||||
# NOTE(jdg): Return True/False if there are other outstanding
|
||||
# attachments that share this connection. If True should signify
|
||||
# caller to preserve the actual host connection (work should be
|
||||
# done in the brick connector as it has the knowledge of what's
|
||||
# going on here.
|
||||
return shared_connections
|
||||
|
||||
def attachment_delete(self, context, attachment_id, vref):
|
||||
"""Delete/Detach the specified attachment.
|
||||
|
||||
Notifies the backend device that we're detaching the specified
|
||||
attachment instance.
|
||||
|
||||
param: vref: Volume object associated with the attachment
|
||||
param: attachment: Attachment reference object to remove
|
||||
|
||||
NOTE if the attachment reference is None, we remove all existing
|
||||
attachments for the specified volume object.
|
||||
"""
|
||||
has_shared_connection = False
|
||||
attachment_ref = objects.VolumeAttachment.get_by_id(context,
|
||||
attachment_id)
|
||||
if not attachment_ref:
|
||||
for attachment in VA_LIST.get_all_by_volume_id(context, vref.id):
|
||||
if self._do_attachment_delete(context, vref, attachment):
|
||||
has_shared_connection = True
|
||||
else:
|
||||
has_shared_connection = (
|
||||
self._do_attachment_delete(context, vref, attachment_ref))
|
||||
return has_shared_connection
|
||||
|
||||
def _do_attachment_delete(self, context, vref, attachment):
|
||||
utils.require_driver_initialized(self.driver)
|
||||
self._notify_about_volume_usage(context, vref, "detach.start")
|
||||
has_shared_connection = self._connection_terminate(context,
|
||||
vref,
|
||||
attachment)
|
||||
self.driver.detach_volume(context, vref, attachment)
|
||||
try:
|
||||
LOG.debug('Deleting attachment %(attachment_id)s.',
|
||||
{'attachment_id': attachment.id},
|
||||
resource=vref)
|
||||
self.driver.detach_volume(context, vref, attachment)
|
||||
self.driver.remove_export(context.elevated(), vref)
|
||||
except Exception:
|
||||
# FIXME(jdg): Obviously our volume object is going to need some
|
||||
# changes to deal with multi-attach and figuring out how to
|
||||
# represent a single failed attach out of multiple attachments
|
||||
|
||||
# TODO(jdg): object method here
|
||||
self.db.volume_attachment_update(
|
||||
context, attachment.get('id'),
|
||||
{'attach_status': 'error_detaching'})
|
||||
else:
|
||||
self.db.volume_detached(context.elevated(), vref.id,
|
||||
attachment.get('id'))
|
||||
self.db.volume_admin_metadata_delete(context.elevated(),
|
||||
vref.id,
|
||||
'attached_mode')
|
||||
self._notify_about_volume_usage(context, vref, "detach.end")
|
||||
return has_shared_connection
|
||||
|
@ -123,9 +123,10 @@ class VolumeAPI(rpc.RPCAPI):
|
||||
3.7 - Adds do_cleanup method to do volume cleanups from other nodes
|
||||
that we were doing in init_host.
|
||||
3.8 - Make failover_host cluster aware and add failover_completed.
|
||||
3.9 - Adds new attach/detach methods
|
||||
"""
|
||||
|
||||
RPC_API_VERSION = '3.8'
|
||||
RPC_API_VERSION = '3.9'
|
||||
RPC_DEFAULT_VERSION = '3.0'
|
||||
TOPIC = constants.VOLUME_TOPIC
|
||||
BINARY = 'cinder-volume'
|
||||
@ -406,6 +407,35 @@ class VolumeAPI(rpc.RPCAPI):
|
||||
cctxt.cast(ctxt, 'delete_group_snapshot',
|
||||
group_snapshot=group_snapshot)
|
||||
|
||||
def attachment_update(self, ctxt, vref, connector, attachment_id):
|
||||
if not self.client.can_send_version('3.9'):
|
||||
msg = _('One of cinder-volume services is too old to accept '
|
||||
'such request. Are you running mixed Newton-Ocata'
|
||||
'cinder-schedulers?')
|
||||
raise exception.ServiceTooOld(msg)
|
||||
|
||||
version = self._compat_ver('3.9')
|
||||
cctxt = self._get_cctxt(vref.host, version=version)
|
||||
return cctxt.call(ctxt,
|
||||
'attachment_update',
|
||||
vref=vref,
|
||||
connector=connector,
|
||||
attachment_id=attachment_id)
|
||||
|
||||
def attachment_delete(self, ctxt, attachment_id, vref):
|
||||
if not self.client.can_send_version('3.9'):
|
||||
msg = _('One of cinder-volume services is too old to accept '
|
||||
'such request. Are you running mixed Newton-Ocata'
|
||||
'cinder-schedulers?')
|
||||
raise exception.ServiceTooOld(msg)
|
||||
|
||||
version = self._compat_ver('3.9')
|
||||
cctxt = self._get_cctxt(vref.host, version=version)
|
||||
return cctxt.call(ctxt,
|
||||
'attachment_delete',
|
||||
attachment_id=attachment_id,
|
||||
vref=vref)
|
||||
|
||||
def do_cleanup(self, ctxt, cleanup_request):
|
||||
"""Perform this service/cluster resource cleanup as requested."""
|
||||
if not self.client.can_send_version('3.7'):
|
||||
|
135
doc/source/devref/attach_detach_conventions_v2.rst
Normal file
135
doc/source/devref/attach_detach_conventions_v2.rst
Normal file
@ -0,0 +1,135 @@
|
||||
..
|
||||
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.
|
||||
|
||||
=============================
|
||||
Volume Attach/Detach workflow
|
||||
=============================
|
||||
|
||||
Previously there were six API calls associated with attach/detach of volumes in
|
||||
Cinder (3 calls for each operation). As the projects grew and the
|
||||
functionality of *simple* things like attach/detach evolved things have become
|
||||
a bit vague and we have a number of race issues during the calls that
|
||||
continually cause us some problems.
|
||||
|
||||
Additionally, the existing code path makes things like multi-attach extremely
|
||||
difficult to implement due to no real good tracking mechansim of attachment
|
||||
info.
|
||||
|
||||
To try and improve this we've proposed a new Attachments Object and API. Now
|
||||
we keep an Attachment record for each attachment that we want to perform as
|
||||
opposed to trying to infer the information from the Volume Object.
|
||||
|
||||
Attachment Object
|
||||
=================
|
||||
|
||||
We actually already had a VolumeAttachment Table in the db, however we
|
||||
weren't really using it, or at least using it efficiently. For V2 of attach
|
||||
implementation (V3 API) flow we'll use the Attachment Table (object) as
|
||||
the primary handle for managing attachment(s) for a volume.
|
||||
|
||||
In addition, we also introduce the AttachmentSpecs Table which will store the
|
||||
connector information for an Attachment so we no longer have the problem of
|
||||
lost connector info, or trying to reassemble it.
|
||||
|
||||
New API and Flow
|
||||
=================
|
||||
|
||||
```
|
||||
attachment_create <instance_uuid> <volume_id>
|
||||
```
|
||||
|
||||
The attachment_create call simply creates an empty Attachment record for the
|
||||
specified Volume with an optional Instance UUID field set. This is
|
||||
particularly useful for cases like Nova Boot from Volume where Nova hasn't sent
|
||||
the job to the actual Compute host yet, but needs to make initial preparations
|
||||
to reserve the volume for use, so here we can reserve the volume and indicate
|
||||
that we will be attaching it to <Instance-UUID> in the future.
|
||||
|
||||
Alternatively, the caller may provide a connector in which case the Cinder API
|
||||
will create the attachment and perform the update on the attachment to set the
|
||||
connector info and return the connection data needed to make a connection.
|
||||
|
||||
The attachment_create call can be used in one of two ways:
|
||||
1. Create an empty Attachment object (reserve)
|
||||
attachment_create call. In this case the attachment_create call requires
|
||||
an instance_uuid and a volume_uuid, and just creates an empty attachment
|
||||
object and returns the UUID of said attachment to the caller.
|
||||
|
||||
2. Create and Complete the Attachment process in one call. The Reserve process
|
||||
is only needed in certain cases, in many cases Nova actually has enough
|
||||
information to do everything in a single call. Also, non-nova consumers
|
||||
typically don't require the granularity of a separate reserve at all.
|
||||
|
||||
To perform the complete operation, include the connector data in the
|
||||
attachment_create call and the Cinder API will perform the reserve and
|
||||
initialize the connection in the single request.
|
||||
|
||||
|
||||
|
||||
param instance-uuid: The ID of the Instance we'll be attaching to
|
||||
param volume-id: The ID of the volume to reserve an Attachment record for
|
||||
rtyp: string:`VolumeAttachmentID`
|
||||
|
||||
```
|
||||
cinder --os-volume-api-version 3.27 attachment-create --instance <instance-uuid> <volume-id>
|
||||
```
|
||||
|
||||
param volume_id: The ID of the volume to create attachment for.
|
||||
parm attachment_id: The ID of a previously reserved attachment.
|
||||
|
||||
param connector: Dictionary of connection info
|
||||
param mode: `rw` or `ro` (defaults to `rw` if omitted).
|
||||
param mountpoint: Mountpoint of remote attachment.
|
||||
rtype: :class:`VolumeAttachment`
|
||||
|
||||
Example connector:
|
||||
{'initiator': 'iqn.1993-08.org.debian:01:cad181614cec',
|
||||
'ip':'192.168.1.20',
|
||||
'platform': 'x86_64',
|
||||
'host': 'tempest-1',
|
||||
'os_type': 'linux2',
|
||||
'multipath': False}
|
||||
|
||||
```
|
||||
cinder --os-volume-api-version 3.27 attachment-create --initiator iqn.1993-08.org.debian:01:29353d53fa41 --ip 1.1.1.1 --host blah --instance <instance-id> <volume-id>
|
||||
```
|
||||
|
||||
Returns a dictionary including the connector and attachment_id:
|
||||
|
||||
```
|
||||
+-------------------+-----------------------------------------------------------------------+
|
||||
| Property | Value |
|
||||
+-------------------+-----------------------------------------------------------------------+
|
||||
| access_mode | rw |
|
||||
| attachment_id | 6ab061ad-5c45-48f3-ad9c-bbd3b6275bf2 |
|
||||
| auth_method | CHAP |
|
||||
| auth_password | kystSioDKHSV2j9y |
|
||||
| auth_username | hxGUgiWvsS4GqAQcfA78 |
|
||||
| encrypted | False |
|
||||
| qos_specs | None |
|
||||
| target_discovered | False |
|
||||
| target_iqn | iqn.2010-10.org.openstack:volume-23212c97-5ed7-42d7-b433-dbf8fc38ec35 |
|
||||
| target_lun | 0 |
|
||||
| target_portal | 192.168.0.9:3260 |
|
||||
| volume_id | 23212c97-5ed7-42d7-b433-dbf8fc38ec35 |
|
||||
+-------------------+-----------------------------------------------------------------------+
|
||||
```
|
||||
|
||||
|
||||
attachment-delete
|
||||
=================
|
||||
|
||||
```
|
||||
cinder --os-volume-api-version 3.27 attachment-delete 6ab061ad-5c45-48f3-ad9c-bbd3b6275bf2
|
||||
```
|
||||
|
Loading…
Reference in New Issue
Block a user