From 6007a019cb3f23dfa9f18b977da5a00192e23fcd Mon Sep 17 00:00:00 2001 From: Eli Qiao Date: Thu, 9 Oct 2014 14:04:11 +0800 Subject: [PATCH] Port cloudpipe extension to v2.1 This patch ports couldpipe extension from v2 to v2.1, and have v2 unit test cases shared between v2.1 and v2. Partially implements blueprint v2-on-v3-api Change-Id: Id40c637c0fc207e97b1d094805eb0cee6e9cd9c9 --- .../os-cloudpipe/cloud-pipe-create-req.json | 5 + .../os-cloudpipe/cloud-pipe-create-resp.json | 3 + .../os-cloudpipe/cloud-pipe-get-resp.json | 13 ++ etc/nova/policy.json | 2 + .../openstack/compute/plugins/v3/cloudpipe.py | 171 ++++++++++++++++++ .../compute/contrib/test_cloudpipe.py | 31 ++-- nova/tests/fake_policy.py | 1 + .../cloud-pipe-create-req.json.tpl | 5 + .../cloud-pipe-create-resp.json.tpl | 3 + .../os-cloudpipe/cloud-pipe-get-resp.json.tpl | 13 ++ nova/tests/integrated/v3/test_cloudpipe.py | 71 ++++++++ setup.cfg | 1 + 12 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 doc/v3/api_samples/os-cloudpipe/cloud-pipe-create-req.json create mode 100644 doc/v3/api_samples/os-cloudpipe/cloud-pipe-create-resp.json create mode 100644 doc/v3/api_samples/os-cloudpipe/cloud-pipe-get-resp.json create mode 100644 nova/api/openstack/compute/plugins/v3/cloudpipe.py create mode 100644 nova/tests/integrated/v3/api_samples/os-cloudpipe/cloud-pipe-create-req.json.tpl create mode 100644 nova/tests/integrated/v3/api_samples/os-cloudpipe/cloud-pipe-create-resp.json.tpl create mode 100644 nova/tests/integrated/v3/api_samples/os-cloudpipe/cloud-pipe-get-resp.json.tpl create mode 100644 nova/tests/integrated/v3/test_cloudpipe.py diff --git a/doc/v3/api_samples/os-cloudpipe/cloud-pipe-create-req.json b/doc/v3/api_samples/os-cloudpipe/cloud-pipe-create-req.json new file mode 100644 index 000000000000..dd1cf348c69d --- /dev/null +++ b/doc/v3/api_samples/os-cloudpipe/cloud-pipe-create-req.json @@ -0,0 +1,5 @@ +{ + "cloudpipe": { + "project_id": "cloudpipe-059f21e3-c20e-4efc-9e7a-eba2ab3c6f9a" + } +} \ No newline at end of file diff --git a/doc/v3/api_samples/os-cloudpipe/cloud-pipe-create-resp.json b/doc/v3/api_samples/os-cloudpipe/cloud-pipe-create-resp.json new file mode 100644 index 000000000000..a66b456c132c --- /dev/null +++ b/doc/v3/api_samples/os-cloudpipe/cloud-pipe-create-resp.json @@ -0,0 +1,3 @@ +{ + "instance_id": "1e9b8425-34af-488e-b969-4d46f4a6382e" +} \ No newline at end of file diff --git a/doc/v3/api_samples/os-cloudpipe/cloud-pipe-get-resp.json b/doc/v3/api_samples/os-cloudpipe/cloud-pipe-get-resp.json new file mode 100644 index 000000000000..d6773dfa5ae5 --- /dev/null +++ b/doc/v3/api_samples/os-cloudpipe/cloud-pipe-get-resp.json @@ -0,0 +1,13 @@ +{ + "cloudpipes": [ + { + "created_at": "2012-11-27T17:18:01Z", + "instance_id": "27deecdb-baa3-4a26-9c82-32994b815b01", + "internal_ip": "192.168.0.3", + "project_id": "cloudpipe-fa1765bd-a352-49c7-a6b7-8ee108a3cb0c", + "public_ip": "127.0.0.1", + "public_port": 22, + "state": "down" + } + ] +} \ No newline at end of file diff --git a/etc/nova/policy.json b/etc/nova/policy.json index c4b8911b0196..8880ee70e5f8 100644 --- a/etc/nova/policy.json +++ b/etc/nova/policy.json @@ -82,6 +82,8 @@ "compute_extension:v3:os-certificates:show": "", "compute_extension:v3:os-certificates:discoverable": "", "compute_extension:cloudpipe": "rule:admin_api", + "compute_extension:v3:os-cloudpipe": "rule:admin_api", + "compute_extension:v3:os-cloudpipe:discoverable": "", "compute_extension:cloudpipe_update": "rule:admin_api", "compute_extension:console_output": "", "compute_extension:v3:consoles:discoverable": "", diff --git a/nova/api/openstack/compute/plugins/v3/cloudpipe.py b/nova/api/openstack/compute/plugins/v3/cloudpipe.py new file mode 100644 index 000000000000..15ab90dfcd8d --- /dev/null +++ b/nova/api/openstack/compute/plugins/v3/cloudpipe.py @@ -0,0 +1,171 @@ +# Copyright 2011 OpenStack Foundation +# +# 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. + +"""Connect your vlan to the world.""" + +from oslo.config import cfg +from webob import exc + +from nova.api.openstack import extensions +from nova.api.openstack import wsgi +from nova.cloudpipe import pipelib +from nova import compute +from nova.compute import utils as compute_utils +from nova.compute import vm_states +from nova import exception +from nova.i18n import _ +from nova import network +from nova.openstack.common import fileutils +from nova.openstack.common import timeutils +from nova import utils + +CONF = cfg.CONF +CONF.import_opt('keys_path', 'nova.crypto') + +ALIAS = 'os-cloudpipe' +authorize = extensions.extension_authorizer('compute', 'v3:' + ALIAS) + + +class CloudpipeController(wsgi.Controller): + """Handle creating and listing cloudpipe instances.""" + + def __init__(self): + self.compute_api = compute.API() + self.network_api = network.API() + self.cloudpipe = pipelib.CloudPipe() + self.setup() + + def setup(self): + """Ensure the keychains and folders exist.""" + # NOTE(vish): One of the drawbacks of doing this in the api is + # the keys will only be on the api node that launched + # the cloudpipe. + fileutils.ensure_tree(CONF.keys_path) + + def _get_all_cloudpipes(self, context): + """Get all cloudpipes.""" + instances = self.compute_api.get_all(context, + search_opts={'deleted': False}) + return [instance for instance in instances + if pipelib.is_vpn_image(instance['image_ref']) + and instance['vm_state'] != vm_states.DELETED] + + def _get_cloudpipe_for_project(self, context, project_id): + """Get the cloudpipe instance for a project ID.""" + cloudpipes = self._get_all_cloudpipes(context) or [None] + return cloudpipes[0] + + def _get_ip_and_port(self, instance): + pass + + def _vpn_dict(self, context, project_id, instance): + elevated = context.elevated() + rv = {'project_id': project_id} + if not instance: + rv['state'] = 'pending' + return rv + rv['instance_id'] = instance['uuid'] + rv['created_at'] = timeutils.isotime(instance['created_at']) + nw_info = compute_utils.get_nw_info_for_instance(instance) + if not nw_info: + return rv + vif = nw_info[0] + ips = [ip for ip in vif.fixed_ips() if ip['version'] == 4] + if ips: + rv['internal_ip'] = ips[0]['address'] + # NOTE(vish): Currently network_api.get does an owner check on + # project_id. This is probably no longer necessary + # but rather than risk changes in the db layer, + # we are working around it here by changing the + # project_id in the context. This can be removed + # if we remove the project_id check in the db. + elevated.project_id = project_id + network = self.network_api.get(elevated, vif['network']['id']) + if network: + vpn_ip = network['vpn_public_address'] + vpn_port = network['vpn_public_port'] + rv['public_ip'] = vpn_ip + rv['public_port'] = vpn_port + if vpn_ip and vpn_port: + if utils.vpn_ping(vpn_ip, vpn_port): + rv['state'] = 'running' + else: + rv['state'] = 'down' + else: + rv['state'] = 'invalid' + return rv + + @extensions.expected_errors((400, 403)) + def create(self, req, body): + """Create a new cloudpipe instance, if none exists. + + Parameters: {cloudpipe: {'project_id': ''}} + """ + + context = req.environ['nova.context'] + authorize(context) + params = body.get('cloudpipe', {}) + project_id = params.get('project_id', context.project_id) + # NOTE(vish): downgrade to project context. Note that we keep + # the same token so we can still talk to glance + context.project_id = project_id + context.user_id = 'project-vpn' + context.is_admin = False + context.roles = [] + instance = self._get_cloudpipe_for_project(context, project_id) + if not instance: + try: + result = self.cloudpipe.launch_vpn_instance(context) + instance = result[0][0] + except exception.NoMoreNetworks: + msg = _("Unable to claim IP for VPN instances, ensure it " + "isn't running, and try again in a few minutes") + raise exc.HTTPBadRequest(explanation=msg) + return {'instance_id': instance['uuid']} + + @extensions.expected_errors((400, 403, 404)) + def index(self, req): + """List running cloudpipe instances.""" + context = req.environ['nova.context'] + authorize(context) + vpns = [self._vpn_dict(context, x['project_id'], x) + for x in self._get_all_cloudpipes(context)] + return {'cloudpipes': vpns} + + +class Cloudpipe(extensions.V3APIExtensionBase): + """Adds actions to create cloudpipe instances. + + When running with the Vlan network mode, you need a mechanism to route + from the public Internet to your vlans. This mechanism is known as a + cloudpipe. + + At the time of creating this class, only OpenVPN is supported. Support for + a SSH Bastion host is forthcoming. + """ + + name = "Cloudpipe" + alias = ALIAS + version = 1 + + def get_resources(self): + resource = [extensions.ResourceExtension(ALIAS, + CloudpipeController())] + return resource + + def get_controller_extensions(self): + """It's an abstract function V3APIExtensionBase and the extension + will not be loaded without it. + """ + return [] diff --git a/nova/tests/api/openstack/compute/contrib/test_cloudpipe.py b/nova/tests/api/openstack/compute/contrib/test_cloudpipe.py index 0e89445298eb..129453ba37ae 100644 --- a/nova/tests/api/openstack/compute/contrib/test_cloudpipe.py +++ b/nova/tests/api/openstack/compute/contrib/test_cloudpipe.py @@ -18,7 +18,8 @@ from oslo.config import cfg from oslo.utils import timeutils from webob import exc -from nova.api.openstack.compute.contrib import cloudpipe +from nova.api.openstack.compute.contrib import cloudpipe as cloudpipe_v2 +from nova.api.openstack.compute.contrib import cloudpipe as cloudpipe_v21 from nova.api.openstack import wsgi from nova.compute import utils as compute_utils from nova import exception @@ -52,11 +53,13 @@ def utils_vpn_ping(addr, port, timoeout=0.05, session_id=None): return True -class CloudpipeTest(test.NoDBTestCase): +class CloudpipeTestV21(test.NoDBTestCase): + cloudpipe = cloudpipe_v21 + url = '/v2/fake/os-cloudpipe' def setUp(self): - super(CloudpipeTest, self).setUp() - self.controller = cloudpipe.CloudpipeController() + super(CloudpipeTestV21, self).setUp() + self.controller = self.cloudpipe.CloudpipeController() self.stubs.Set(self.controller.compute_api, "get_all", compute_api_get_all_empty) self.stubs.Set(utils, 'vpn_ping', utils_vpn_ping) @@ -70,7 +73,7 @@ class CloudpipeTest(test.NoDBTestCase): fake_get_nw_info_for_instance) self.stubs.Set(self.controller.compute_api, "get_all", compute_api_get_all) - req = fakes.HTTPRequest.blank('/v2/fake/os-cloudpipe') + req = fakes.HTTPRequest.blank(self.url) res_dict = self.controller.index(req) response = {'cloudpipes': [{'project_id': 'other', 'instance_id': 7777, @@ -93,7 +96,7 @@ class CloudpipeTest(test.NoDBTestCase): network_api_get) self.stubs.Set(self.controller.compute_api, "get_all", compute_api_get_all) - req = fakes.HTTPRequest.blank('/v2/fake/os-cloudpipe') + req = fakes.HTTPRequest.blank(self.url) res_dict = self.controller.index(req) response = {'cloudpipes': [{'project_id': 'other', 'internal_ip': '192.168.1.100', @@ -111,7 +114,7 @@ class CloudpipeTest(test.NoDBTestCase): self.stubs.Set(self.controller.cloudpipe, 'launch_vpn_instance', launch_vpn_instance) body = {'cloudpipe': {'project_id': 1}} - req = fakes.HTTPRequest.blank('/v2/fake/os-cloudpipe') + req = fakes.HTTPRequest.blank(self.url) res_dict = self.controller.create(req, body) response = {'instance_id': 7777} @@ -124,7 +127,7 @@ class CloudpipeTest(test.NoDBTestCase): self.stubs.Set(self.controller.cloudpipe, 'launch_vpn_instance', launch_vpn_instance) body = {'cloudpipe': {'project_id': 1}} - req = fakes.HTTPRequest.blank('/v2/fake/os-cloudpipe') + req = fakes.HTTPRequest.blank(self.url) self.assertRaises(exc.HTTPBadRequest, self.controller.create, req, body) @@ -137,15 +140,19 @@ class CloudpipeTest(test.NoDBTestCase): self.stubs.Set(self.controller.compute_api, "get_all", compute_api_get_all) body = {'cloudpipe': {'project_id': 1}} - req = fakes.HTTPRequest.blank('/v2/fake/os-cloudpipe') + req = fakes.HTTPRequest.blank(self.url) res_dict = self.controller.create(req, body) response = {'instance_id': 7777} self.assertEqual(res_dict, response) -class CloudpipesXMLSerializerTest(test.NoDBTestCase): +class CloudpipeTestV2(CloudpipeTestV21): + cloudpipe = cloudpipe_v2 + + +class CloudpipesXMLSerializerTestV2(test.NoDBTestCase): def test_default_serializer(self): - serializer = cloudpipe.CloudpipeTemplate() + serializer = cloudpipe_v2.CloudpipeTemplate() exemplar = dict(cloudpipe=dict(instance_id='1234-1234-1234-1234')) text = serializer.serialize(exemplar) tree = etree.fromstring(text) @@ -155,7 +162,7 @@ class CloudpipesXMLSerializerTest(test.NoDBTestCase): self.assertEqual(child.text, exemplar['cloudpipe'][child.tag]) def test_index_serializer(self): - serializer = cloudpipe.CloudpipesTemplate() + serializer = cloudpipe_v2.CloudpipesTemplate() exemplar = dict(cloudpipes=[ dict( project_id='1234', diff --git a/nova/tests/fake_policy.py b/nova/tests/fake_policy.py index c9be5a3e2368..b59affb0086d 100644 --- a/nova/tests/fake_policy.py +++ b/nova/tests/fake_policy.py @@ -152,6 +152,7 @@ policy_data = """ "compute_extension:v3:os-certificates:create": "", "compute_extension:v3:os-certificates:show": "", "compute_extension:cloudpipe": "", + "compute_extension:v3:os-cloudpipe": "", "compute_extension:cloudpipe_update": "", "compute_extension:config_drive": "", "compute_extension:v3:os-config-drive": "", diff --git a/nova/tests/integrated/v3/api_samples/os-cloudpipe/cloud-pipe-create-req.json.tpl b/nova/tests/integrated/v3/api_samples/os-cloudpipe/cloud-pipe-create-req.json.tpl new file mode 100644 index 000000000000..c8fc75995a95 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-cloudpipe/cloud-pipe-create-req.json.tpl @@ -0,0 +1,5 @@ +{ + "cloudpipe": { + "project_id": "%(project_id)s" + } +} diff --git a/nova/tests/integrated/v3/api_samples/os-cloudpipe/cloud-pipe-create-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-cloudpipe/cloud-pipe-create-resp.json.tpl new file mode 100644 index 000000000000..6aa2ff60e2b2 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-cloudpipe/cloud-pipe-create-resp.json.tpl @@ -0,0 +1,3 @@ +{ + "instance_id": "%(id)s" +} diff --git a/nova/tests/integrated/v3/api_samples/os-cloudpipe/cloud-pipe-get-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-cloudpipe/cloud-pipe-get-resp.json.tpl new file mode 100644 index 000000000000..698008802e03 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-cloudpipe/cloud-pipe-get-resp.json.tpl @@ -0,0 +1,13 @@ +{ + "cloudpipes": [ + { + "created_at": "%(isotime)s", + "instance_id": "%(uuid)s", + "internal_ip": "%(ip)s", + "project_id": "%(project_id)s", + "public_ip": "%(ip)s", + "public_port": 22, + "state": "down" + } + ] +} diff --git a/nova/tests/integrated/v3/test_cloudpipe.py b/nova/tests/integrated/v3/test_cloudpipe.py new file mode 100644 index 000000000000..435f87579522 --- /dev/null +++ b/nova/tests/integrated/v3/test_cloudpipe.py @@ -0,0 +1,71 @@ +# Copyright 2014 IBM Corp. +# +# 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 uuid as uuid_lib + +from oslo.config import cfg + +from nova.cloudpipe import pipelib +from nova.network import api as network_api +from nova.tests.image import fake +from nova.tests.integrated.v3 import api_sample_base + + +CONF = cfg.CONF +CONF.import_opt('vpn_image_id', 'nova.cloudpipe.pipelib') + + +class CloudPipeSampleTest(api_sample_base.ApiSampleTestBaseV3): + extension_name = "os-cloudpipe" + + def setUp(self): + super(CloudPipeSampleTest, self).setUp() + + def get_user_data(self, project_id): + """Stub method to generate user data for cloudpipe tests.""" + return "VVNFUiBEQVRB\n" + + def network_api_get(self, context, network_uuid): + """Stub to get a valid network and its information.""" + return {'vpn_public_address': '127.0.0.1', + 'vpn_public_port': 22} + + self.stubs.Set(pipelib.CloudPipe, 'get_encoded_zip', get_user_data) + self.stubs.Set(network_api.API, "get", + network_api_get) + + def generalize_subs(self, subs, vanilla_regexes): + subs['project_id'] = 'cloudpipe-[0-9a-f-]+' + return subs + + def test_cloud_pipe_create(self): + # Get api samples of cloud pipe extension creation. + self.flags(vpn_image_id=fake.get_valid_image_id()) + project = {'project_id': 'cloudpipe-' + str(uuid_lib.uuid4())} + response = self._do_post('os-cloudpipe', 'cloud-pipe-create-req', + project) + subs = self._get_regexes() + subs.update(project) + subs['image_id'] = CONF.vpn_image_id + self._verify_response('cloud-pipe-create-resp', subs, response, 200) + return project + + def test_cloud_pipe_list(self): + # Get api samples of cloud pipe extension get request. + project = self.test_cloud_pipe_create() + response = self._do_get('os-cloudpipe') + subs = self._get_regexes() + subs.update(project) + subs['image_id'] = CONF.vpn_image_id + self._verify_response('cloud-pipe-get-resp', subs, response, 200) diff --git a/setup.cfg b/setup.cfg index ca3f69170319..138857b3e5bf 100644 --- a/setup.cfg +++ b/setup.cfg @@ -66,6 +66,7 @@ nova.api.v3.extensions = block_device_mapping = nova.api.openstack.compute.plugins.v3.block_device_mapping:BlockDeviceMapping cells = nova.api.openstack.compute.plugins.v3.cells:Cells certificates = nova.api.openstack.compute.plugins.v3.certificates:Certificates + cloudpipe = nova.api.openstack.compute.plugins.v3.cloudpipe:Cloudpipe config_drive = nova.api.openstack.compute.plugins.v3.config_drive:ConfigDrive console_auth_tokens = nova.api.openstack.compute.plugins.v3.console_auth_tokens:ConsoleAuthTokens console_output = nova.api.openstack.compute.plugins.v3.console_output:ConsoleOutput