Implement volume attaching functionality
Enable users to attach a volume to a server via Nova API gateway. Change-Id: Ia348a9adaad144bdd31c5d42553011af755b566f
This commit is contained in:
parent
2c996e7c61
commit
44f1f311ae
@ -301,6 +301,7 @@ if [[ "$Q_ENABLE_TRICIRCLE" == "True" ]]; then
|
||||
# move them to bottom region
|
||||
iniset $NOVA_CONF neutron region_name $POD_REGION_NAME
|
||||
iniset $NOVA_CONF neutron url "$Q_PROTOCOL://$SERVICE_HOST:$TRICIRCLE_NEUTRON_PORT"
|
||||
iniset $NOVA_CONF cinder os_region_name $POD_REGION_NAME
|
||||
|
||||
get_or_create_endpoint "compute" \
|
||||
"$POD_REGION_NAME" \
|
||||
|
@ -455,6 +455,9 @@ class Client(object):
|
||||
volume -> set_bootable -> volume, flag -> none
|
||||
router -> add_interface -> router, body -> none
|
||||
router -> add_gateway -> router, body -> none
|
||||
server_volume -> create_server_volume
|
||||
-> server_id, volume_id, device=None
|
||||
-> none
|
||||
--------------------------
|
||||
:return: None
|
||||
:raises: EndpointNotAvailable
|
||||
|
@ -206,7 +206,8 @@ class NovaResourceHandle(ResourceHandle):
|
||||
service_type = cons.ST_NOVA
|
||||
support_resource = {'flavor': LIST,
|
||||
'server': LIST | CREATE | GET,
|
||||
'aggregate': LIST | CREATE | DELETE | ACTION}
|
||||
'aggregate': LIST | CREATE | DELETE | ACTION,
|
||||
'server_volume': ACTION}
|
||||
|
||||
def _get_client(self, cxt):
|
||||
cli = n_client.Client('2',
|
||||
@ -217,8 +218,15 @@ class NovaResourceHandle(ResourceHandle):
|
||||
self.endpoint_url.replace('$(tenant_id)s', cxt.tenant))
|
||||
return cli
|
||||
|
||||
def _adapt_resource(self, resource):
|
||||
if resource == 'server_volume':
|
||||
return 'volume'
|
||||
else:
|
||||
return resource
|
||||
|
||||
def handle_list(self, cxt, resource, filters):
|
||||
try:
|
||||
resource = self._adapt_resource(resource)
|
||||
client = self._get_client(cxt)
|
||||
collection = '%ss' % resource
|
||||
# only server list supports filter
|
||||
@ -236,6 +244,7 @@ class NovaResourceHandle(ResourceHandle):
|
||||
|
||||
def handle_create(self, cxt, resource, *args, **kwargs):
|
||||
try:
|
||||
resource = self._adapt_resource(resource)
|
||||
client = self._get_client(cxt)
|
||||
collection = '%ss' % resource
|
||||
return getattr(client, collection).create(
|
||||
@ -247,6 +256,7 @@ class NovaResourceHandle(ResourceHandle):
|
||||
|
||||
def handle_get(self, cxt, resource, resource_id):
|
||||
try:
|
||||
resource = self._adapt_resource(resource)
|
||||
client = self._get_client(cxt)
|
||||
collection = '%ss' % resource
|
||||
return getattr(client, collection).get(resource_id).to_dict()
|
||||
@ -260,6 +270,7 @@ class NovaResourceHandle(ResourceHandle):
|
||||
|
||||
def handle_delete(self, cxt, resource, resource_id):
|
||||
try:
|
||||
resource = self._adapt_resource(resource)
|
||||
client = self._get_client(cxt)
|
||||
collection = '%ss' % resource
|
||||
return getattr(client, collection).delete(resource_id)
|
||||
@ -273,10 +284,11 @@ class NovaResourceHandle(ResourceHandle):
|
||||
|
||||
def handle_action(self, cxt, resource, action, *args, **kwargs):
|
||||
try:
|
||||
resource = self._adapt_resource(resource)
|
||||
client = self._get_client(cxt)
|
||||
collection = '%ss' % resource
|
||||
resource_manager = getattr(client, collection)
|
||||
getattr(resource_manager, action)(*args, **kwargs)
|
||||
return getattr(resource_manager, action)(*args, **kwargs)
|
||||
except r_exceptions.ConnectTimeout:
|
||||
self.endpoint_url = None
|
||||
raise exceptions.EndpointNotAvailable('nova',
|
||||
|
@ -30,6 +30,7 @@ from tricircle.nova_apigw.controllers import flavor
|
||||
from tricircle.nova_apigw.controllers import image
|
||||
from tricircle.nova_apigw.controllers import quota_sets
|
||||
from tricircle.nova_apigw.controllers import server
|
||||
from tricircle.nova_apigw.controllers import volume
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
@ -93,6 +94,9 @@ class V21Controller(object):
|
||||
'os-quota-sets': quota_sets.QuotaSetsController,
|
||||
'limits': quota_sets.LimitsController,
|
||||
}
|
||||
self.server_sub_controller = {
|
||||
'os-volume_attachments': volume.VolumeController
|
||||
}
|
||||
|
||||
def _get_resource_controller(self, project_id, remainder):
|
||||
if not remainder:
|
||||
@ -102,6 +106,14 @@ class V21Controller(object):
|
||||
if resource not in self.resource_controller:
|
||||
pecan.abort(404)
|
||||
return
|
||||
if resource == 'servers' and len(remainder) >= 3:
|
||||
server_id = remainder[1]
|
||||
sub_resource = remainder[2]
|
||||
if sub_resource not in self.server_sub_controller:
|
||||
pecan.abort(404)
|
||||
return
|
||||
return self.server_sub_controller[sub_resource](
|
||||
project_id, server_id), remainder[3:]
|
||||
return self.resource_controller[resource](project_id), remainder[1:]
|
||||
|
||||
@pecan.expose()
|
||||
|
94
tricircle/nova_apigw/controllers/volume.py
Normal file
94
tricircle/nova_apigw/controllers/volume.py
Normal file
@ -0,0 +1,94 @@
|
||||
# Copyright (c) 2015 Huawei Tech. Co., Ltd.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# 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 expose
|
||||
from pecan import rest
|
||||
import re
|
||||
|
||||
from oslo_log import log as logging
|
||||
|
||||
import tricircle.common.client as t_client
|
||||
from tricircle.common import constants
|
||||
import tricircle.common.context as t_context
|
||||
from tricircle.common.i18n import _LE
|
||||
import tricircle.db.api as db_api
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VolumeController(rest.RestController):
|
||||
|
||||
def __init__(self, project_id, server_id):
|
||||
self.project_id = project_id
|
||||
self.server_id = server_id
|
||||
self.clients = {'top': t_client.Client()}
|
||||
|
||||
def _get_client(self, pod_name='top'):
|
||||
if pod_name not in self.clients:
|
||||
self.clients[pod_name] = t_client.Client(pod_name)
|
||||
return self.clients[pod_name]
|
||||
|
||||
@expose(generic=True, template='json')
|
||||
def post(self, **kw):
|
||||
context = t_context.extract_context_from_environ()
|
||||
|
||||
if 'volumeAttachment' not in kw:
|
||||
pecan.abort(400, 'Request body not found')
|
||||
return
|
||||
body = kw['volumeAttachment']
|
||||
if 'volumeId' not in body:
|
||||
pecan.abort(400, 'Volume not set')
|
||||
return
|
||||
|
||||
server_mappings = db_api.get_bottom_mappings_by_top_id(
|
||||
context, self.server_id, constants.RT_SERVER)
|
||||
if not server_mappings:
|
||||
pecan.abort(404, 'Server not found')
|
||||
return
|
||||
volume_mappings = db_api.get_bottom_mappings_by_top_id(
|
||||
context, body['volumeId'], constants.RT_VOLUME)
|
||||
if not volume_mappings:
|
||||
pecan.abort(404, 'Volume not found')
|
||||
return
|
||||
|
||||
server_pod_name = server_mappings[0][0]['pod_name']
|
||||
volume_pod_name = volume_mappings[0][0]['pod_name']
|
||||
if server_pod_name != volume_pod_name:
|
||||
LOG.error(_LE('Server %(server)s is in pod %(server_pod)s and '
|
||||
'volume %(volume)s is in pod %(volume_pod)s, which '
|
||||
'are not the same.'),
|
||||
{'server': self.server_id,
|
||||
'server_pod': server_pod_name,
|
||||
'volume': body['volumeId'],
|
||||
'volume_pod': volume_pod_name})
|
||||
pecan.abort(400, 'Server and volume not in the same pod')
|
||||
return
|
||||
|
||||
device = None
|
||||
if 'device' in body:
|
||||
device = body['device']
|
||||
# this regular expression is copied from nova/block_device.py
|
||||
match = re.match('(^/dev/x{0,1}[a-z]{0,1}d{0,1})([a-z]+)[0-9]*$',
|
||||
device)
|
||||
if not match:
|
||||
pecan.abort(400, 'Invalid device path')
|
||||
return
|
||||
|
||||
client = self._get_client(server_pod_name)
|
||||
volume = client.action_server_volumes(
|
||||
context, 'create_server_volume',
|
||||
server_mappings[0][1], volume_mappings[0][1], device)
|
||||
return {'volumeAttachment': volume.to_dict()}
|
@ -476,6 +476,9 @@ class FakeSession(object):
|
||||
def __exit__(self, type, value, traceback):
|
||||
pass
|
||||
|
||||
def __init__(self):
|
||||
self.info = {}
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return True
|
||||
|
147
tricircle/tests/unit/nova_apigw/controllers/test_volume.py
Normal file
147
tricircle/tests/unit/nova_apigw/controllers/test_volume.py
Normal file
@ -0,0 +1,147 @@
|
||||
# Copyright (c) 2015 Huawei Tech. Co., Ltd.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# 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 mock import patch
|
||||
import pecan
|
||||
import unittest
|
||||
|
||||
from oslo_utils import uuidutils
|
||||
|
||||
from tricircle.common import client
|
||||
from tricircle.common import constants
|
||||
from tricircle.common import context
|
||||
from tricircle.db import api
|
||||
from tricircle.db import core
|
||||
from tricircle.db import models
|
||||
from tricircle.nova_apigw.controllers import volume
|
||||
|
||||
|
||||
class FakeVolume(object):
|
||||
def to_dict(self):
|
||||
pass
|
||||
|
||||
|
||||
class VolumeTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
core.initialize()
|
||||
core.ModelBase.metadata.create_all(core.get_engine())
|
||||
self.context = context.get_admin_context()
|
||||
self.project_id = 'test_project'
|
||||
self.controller = volume.VolumeController(self.project_id, '')
|
||||
|
||||
def _prepare_pod(self, bottom_pod_num=1):
|
||||
t_pod = {'pod_id': 't_pod_uuid', 'pod_name': 't_region',
|
||||
'az_name': ''}
|
||||
api.create_pod(self.context, t_pod)
|
||||
if bottom_pod_num == 1:
|
||||
b_pod = {'pod_id': 'b_pod_uuid', 'pod_name': 'b_region',
|
||||
'az_name': 'b_az'}
|
||||
api.create_pod(self.context, b_pod)
|
||||
return t_pod, b_pod
|
||||
b_pods = []
|
||||
for i in xrange(1, bottom_pod_num + 1):
|
||||
b_pod = {'pod_id': 'b_pod_%d_uuid' % i,
|
||||
'pod_name': 'b_region_%d' % i,
|
||||
'az_name': 'b_az_%d' % i}
|
||||
api.create_pod(self.context, b_pod)
|
||||
b_pods.append(b_pod)
|
||||
return t_pod, b_pods
|
||||
|
||||
@patch.object(pecan, 'abort')
|
||||
@patch.object(client.Client, 'action_resources')
|
||||
@patch.object(context, 'extract_context_from_environ')
|
||||
def test_attach_volume(self, mock_context, mock_action, mock_abort):
|
||||
mock_context.return_value = self.context
|
||||
mock_action.return_value = FakeVolume()
|
||||
|
||||
t_pod, b_pods = self._prepare_pod(bottom_pod_num=2)
|
||||
b_pod1 = b_pods[0]
|
||||
b_pod2 = b_pods[1]
|
||||
t_server_id = uuidutils.generate_uuid()
|
||||
b_server_id = t_server_id
|
||||
with self.context.session.begin():
|
||||
core.create_resource(
|
||||
self.context, models.ResourceRouting,
|
||||
{'top_id': t_server_id, 'bottom_id': b_server_id,
|
||||
'pod_id': b_pod1['pod_id'], 'project_id': self.project_id,
|
||||
'resource_type': constants.RT_SERVER})
|
||||
|
||||
t_volume1_id = uuidutils.generate_uuid()
|
||||
b_volume1_id = t_volume1_id
|
||||
t_volume2_id = uuidutils.generate_uuid()
|
||||
b_volume2_id = t_volume1_id
|
||||
with self.context.session.begin():
|
||||
core.create_resource(
|
||||
self.context, models.ResourceRouting,
|
||||
{'top_id': t_volume1_id, 'bottom_id': b_volume1_id,
|
||||
'pod_id': b_pod1['pod_id'], 'project_id': self.project_id,
|
||||
'resource_type': constants.RT_VOLUME})
|
||||
core.create_resource(
|
||||
self.context, models.ResourceRouting,
|
||||
{'top_id': t_volume2_id, 'bottom_id': b_volume2_id,
|
||||
'pod_id': b_pod2['pod_id'], 'project_id': self.project_id,
|
||||
'resource_type': constants.RT_VOLUME})
|
||||
|
||||
# success case
|
||||
self.controller.server_id = t_server_id
|
||||
body = {'volumeAttachment': {'volumeId': t_volume1_id}}
|
||||
self.controller.post(**body)
|
||||
body = {'volumeAttachment': {'volumeId': t_volume1_id,
|
||||
'device': '/dev/vdb'}}
|
||||
self.controller.post(**body)
|
||||
calls = [mock.call('server_volume', self.context,
|
||||
'create_server_volume',
|
||||
b_server_id, b_volume1_id, None),
|
||||
mock.call('server_volume', self.context,
|
||||
'create_server_volume',
|
||||
b_server_id, b_volume1_id, '/dev/vdb')]
|
||||
mock_action.assert_has_calls(calls)
|
||||
|
||||
# failure case, bad request
|
||||
body = {'volumeAttachment': {'volumeId': t_volume2_id}}
|
||||
self.controller.post(**body)
|
||||
body = {'fakePara': ''}
|
||||
self.controller.post(**body)
|
||||
body = {'volumeAttachment': {}}
|
||||
self.controller.post(**body)
|
||||
# each part of path should not start with digit
|
||||
body = {'volumeAttachment': {'volumeId': t_volume1_id,
|
||||
'device': '/dev/001disk'}}
|
||||
self.controller.post(**body)
|
||||
# the first part should be "dev", and only two parts are allowed
|
||||
body = {'volumeAttachment': {'volumeId': t_volume1_id,
|
||||
'device': '/dev/vdb/disk'}}
|
||||
self.controller.post(**body)
|
||||
body = {'volumeAttachment': {'volumeId': t_volume1_id,
|
||||
'device': '/disk/vdb'}}
|
||||
self.controller.post(**body)
|
||||
calls = [mock.call(400, 'Server and volume not in the same pod'),
|
||||
mock.call(400, 'Request body not found'),
|
||||
mock.call(400, 'Volume not set'),
|
||||
mock.call(400, 'Invalid device path'),
|
||||
mock.call(400, 'Invalid device path'),
|
||||
mock.call(400, 'Invalid device path')]
|
||||
mock_abort.assert_has_calls(calls)
|
||||
|
||||
# failure case, resource not found
|
||||
body = {'volumeAttachment': {'volumeId': 'fake_volume_id'}}
|
||||
self.controller.post(**body)
|
||||
self.controller.server_id = 'fake_server_id'
|
||||
body = {'volumeAttachment': {'volumeId': t_volume1_id}}
|
||||
self.controller.post(**body)
|
||||
calls = [mock.call(404, 'Volume not found'),
|
||||
mock.call(404, 'Server not found')]
|
||||
mock_abort.assert_has_calls(calls)
|
Loading…
Reference in New Issue
Block a user