diff --git a/devstack/plugin.sh b/devstack/plugin.sh index 20597bae..043ee618 100644 --- a/devstack/plugin.sh +++ b/devstack/plugin.sh @@ -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" \ diff --git a/tricircle/common/client.py b/tricircle/common/client.py index 1f771ec4..abcca5eb 100644 --- a/tricircle/common/client.py +++ b/tricircle/common/client.py @@ -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 diff --git a/tricircle/common/resource_handle.py b/tricircle/common/resource_handle.py index cb3bf4f5..78a7496e 100644 --- a/tricircle/common/resource_handle.py +++ b/tricircle/common/resource_handle.py @@ -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', diff --git a/tricircle/nova_apigw/controllers/root.py b/tricircle/nova_apigw/controllers/root.py index e062f6a5..93a3f4d8 100755 --- a/tricircle/nova_apigw/controllers/root.py +++ b/tricircle/nova_apigw/controllers/root.py @@ -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() diff --git a/tricircle/nova_apigw/controllers/volume.py b/tricircle/nova_apigw/controllers/volume.py new file mode 100644 index 00000000..1e68aecd --- /dev/null +++ b/tricircle/nova_apigw/controllers/volume.py @@ -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()} diff --git a/tricircle/tests/unit/network/test_plugin.py b/tricircle/tests/unit/network/test_plugin.py index 6a6aae5f..98939cfa 100644 --- a/tricircle/tests/unit/network/test_plugin.py +++ b/tricircle/tests/unit/network/test_plugin.py @@ -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 diff --git a/tricircle/tests/unit/nova_apigw/controllers/test_volume.py b/tricircle/tests/unit/nova_apigw/controllers/test_volume.py new file mode 100644 index 00000000..efac02a5 --- /dev/null +++ b/tricircle/tests/unit/nova_apigw/controllers/test_volume.py @@ -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)