diff options
author | Chris Yeoh <cyeoh@au1.ibm.com> | 2013-05-06 22:39:13 +0930 |
---|---|---|
committer | Chris Yeoh <cyeoh@au1.ibm.com> | 2013-05-28 01:21:36 +0930 |
commit | d7da449eef09d6d86d10ffc354e614b58b08d1ce (patch) | |
tree | 0632b672fe04042e954f980e24c1086ed3875766 | |
parent | e06ab5877462c83f6574b0304331e3ff906ddb14 (diff) | |
download | nova-d7da449eef09d6d86d10ffc354e614b58b08d1ce.tar.gz nova-d7da449eef09d6d86d10ffc354e614b58b08d1ce.tar.xz nova-d7da449eef09d6d86d10ffc354e614b58b08d1ce.zip |
API Extensions framework for v3 API Part 2
This is the second patch for the new extension framework
which is only to be used by the Nova v3 API.
- Adds tracking of extensions loaded and allows
extensions access to this information
- Adds core API functionality as extensions
- 'server'
- Adds an entry point that other extensions can
use to modify the server create arguments without
having to modify the server extension itself
- TODO: Will have to add more entry points as other
extensions are ported. Delaying adding entry points
now so they can be tested as they are added.
- Adds port of os-keypairs extension
- This is an example of a controller extension in the new
framework
- This is an example of using the server extension entry
point to add functionality without modify the core API code
- Ports tests for the os-keypairs extensions
- Adds v3 API fake specific code for tests
This completes the bulk of the new extension framework. Porting
of the server tests will be done in future changesets as more
of the core API is ported across as the tests are dependent
on multiple core APIs.
Partially implements blueprint v3-api-extension-framework
Change-Id: Ibadb5bbe808c27d2f4afebe65c06a92576397085
-rw-r--r-- | etc/nova/policy.json | 1 | ||||
-rw-r--r-- | nova/api/openstack/__init__.py | 29 | ||||
-rw-r--r-- | nova/api/openstack/compute/__init__.py | 14 | ||||
-rw-r--r-- | nova/api/openstack/compute/plugins/__init__.py | 59 | ||||
-rw-r--r-- | nova/api/openstack/compute/plugins/v3/extension_info.py | 105 | ||||
-rw-r--r-- | nova/api/openstack/compute/plugins/v3/keypairs.py | 215 | ||||
-rw-r--r-- | nova/api/openstack/compute/plugins/v3/servers.py | 1533 | ||||
-rw-r--r-- | nova/api/openstack/extensions.py | 6 | ||||
-rw-r--r-- | nova/tests/api/openstack/compute/plugins/v3/test_keypairs.py | 378 | ||||
-rw-r--r-- | nova/tests/api/openstack/fakes.py | 24 | ||||
-rw-r--r-- | nova/tests/fake_policy.py | 1 | ||||
-rw-r--r-- | setup.cfg | 6 |
12 files changed, 2360 insertions, 11 deletions
diff --git a/etc/nova/policy.json b/etc/nova/policy.json index 26a227ae2..cd6892596 100644 --- a/etc/nova/policy.json +++ b/etc/nova/policy.json @@ -74,6 +74,7 @@ "compute_extension:instance_actions:events": "rule:admin_api", "compute_extension:instance_usage_audit_log": "rule:admin_api", "compute_extension:keypairs": "", + "compute_extension:v3:os-keypairs": "", "compute_extension:multinic": "", "compute_extension:networks": "rule:admin_api", "compute_extension:networks:view": "", diff --git a/nova/api/openstack/__init__.py b/nova/api/openstack/__init__.py index 18c38a2fd..113354d04 100644 --- a/nova/api/openstack/__init__.py +++ b/nova/api/openstack/__init__.py @@ -222,17 +222,24 @@ class APIRouterV3(base_wsgi.Router): """Simple paste factory, :class:`nova.wsgi.Router` doesn't have one.""" return cls() - def __init__(self): + def __init__(self, init_only=None): # TODO(cyeoh): bp v3-api-extension-framework. Currently load # all extensions but eventually should be able to exclude # based on a config file def _check_load_extension(ext): - return isinstance(ext.obj, extensions.V3APIExtensionBase) + if (self.init_only is None or ext.obj.alias in + self.init_only) and isinstance(ext.obj, + extensions.V3APIExtensionBase): + return self._register_extension(ext) + else: + return False + self.init_only = init_only self.api_extension_manager = stevedore.enabled.EnabledExtensionManager( namespace=self.API_EXTENSION_NAMESPACE, check_func=_check_load_extension, - invoke_on_load=True) + invoke_on_load=True, + invoke_kwds={"extension_info": self.loaded_extension_info}) mapper = PlainMapper() self.resources = {} @@ -242,14 +249,17 @@ class APIRouterV3(base_wsgi.Router): if list(self.api_extension_manager): # NOTE(cyeoh): Stevedore raises an exception if there are # no plugins detected. I wonder if this is a bug. - self.api_extension_manager.map(self._register_extensions) self.api_extension_manager.map(self._register_resources, mapper=mapper) self.api_extension_manager.map(self._register_controllers) super(APIRouterV3, self).__init__(mapper) - def _register_extensions(self, ext): + @property + def loaded_extension_info(self): + raise NotImplementedError + + def _register_extension(self, ext): raise NotImplementedError() def _register_resources(self, ext, mapper): @@ -281,7 +291,14 @@ class APIRouterV3(base_wsgi.Router): if resource.parent: kargs['parent_resource'] = resource.parent - mapper.resource(resource.collection, resource.collection, + # non core-API plugins use the collection name as the + # member name, but the core-API plugins use the + # singular/plural convention for member/collection names + if resource.member_name: + member_name = resource.member_name + else: + member_name = resource.collection + mapper.resource(member_name, resource.collection, **kargs) if resource.custom_routes_fn: diff --git a/nova/api/openstack/compute/__init__.py b/nova/api/openstack/compute/__init__.py index 80247705f..e754bc011 100644 --- a/nova/api/openstack/compute/__init__.py +++ b/nova/api/openstack/compute/__init__.py @@ -30,6 +30,7 @@ from nova.api.openstack.compute import image_metadata from nova.api.openstack.compute import images from nova.api.openstack.compute import ips from nova.api.openstack.compute import limits +from nova.api.openstack.compute import plugins from nova.api.openstack.compute import server_metadata from nova.api.openstack.compute import servers from nova.api.openstack.compute import versions @@ -135,8 +136,13 @@ class APIRouterV3(nova.api.openstack.APIRouterV3): Routes requests on the OpenStack API to the appropriate controller and method. """ + def __init__(self, init_only=None): + self._loaded_extension_info = plugins.LoadedExtensionInfo() + super(APIRouterV3, self).__init__(init_only) - def _register_extensions(self, ext): - pass - # TODO(cyeoh): bp v3-api-extension-framework - Register extension - # information + def _register_extension(self, ext): + return self.loaded_extension_info.register_extension(ext.obj) + + @property + def loaded_extension_info(self): + return self._loaded_extension_info diff --git a/nova/api/openstack/compute/plugins/__init__.py b/nova/api/openstack/compute/plugins/__init__.py index e69de29bb..f93f56513 100644 --- a/nova/api/openstack/compute/plugins/__init__.py +++ b/nova/api/openstack/compute/plugins/__init__.py @@ -0,0 +1,59 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 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. + + +from nova import exception +from nova.openstack.common import log as logging + +LOG = logging.getLogger(__name__) + + +class LoadedExtensionInfo(object): + """Keep track of all loaded API extensions.""" + + def __init__(self): + self.extensions = {} + + def register_extension(self, ext): + if not self._check_extension(ext): + return False + + alias = ext.alias + LOG.audit(_("Loaded extension %s"), alias) + + if alias in self.extensions: + raise exception.NovaException("Found duplicate extension: %s" + % alias) + self.extensions[alias] = ext + return True + + def _check_extension(self, extension): + """Checks for required methods in extension objects.""" + try: + LOG.debug(_('Ext name: %s'), extension.name) + LOG.debug(_('Ext alias: %s'), extension.alias) + LOG.debug(_('Ext description: %s'), + ' '.join(extension.__doc__.strip().split())) + LOG.debug(_('Ext namespace: %s'), extension.namespace) + LOG.debug(_('Ext version: %i'), extension.version) + except AttributeError as ex: + LOG.exception(_("Exception loading extension: %s"), unicode(ex)) + return False + + return True + + def get_extensions(self): + return self.extensions diff --git a/nova/api/openstack/compute/plugins/v3/extension_info.py b/nova/api/openstack/compute/plugins/v3/extension_info.py new file mode 100644 index 000000000..43b0551c7 --- /dev/null +++ b/nova/api/openstack/compute/plugins/v3/extension_info.py @@ -0,0 +1,105 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 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 webob.exc + +from nova.api.openstack import extensions +from nova.api.openstack import wsgi +from nova.api.openstack import xmlutil + + +def make_ext(elem): + elem.set('name') + elem.set('namespace') + elem.set('alias') + elem.set('version') + + desc = xmlutil.SubTemplateElement(elem, 'description') + desc.text = 'description' + + +ext_nsmap = {None: xmlutil.XMLNS_COMMON_V10, 'atom': xmlutil.XMLNS_ATOM} + + +class ExtensionTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('extension', selector='extension') + make_ext(root) + return xmlutil.MasterTemplate(root, 1, nsmap=ext_nsmap) + + +class ExtensionsTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('extensions') + elem = xmlutil.SubTemplateElement(root, 'extension', + selector='extensions') + make_ext(elem) + return xmlutil.MasterTemplate(root, 1, nsmap=ext_nsmap) + + +class ExtensionInfoController(object): + + def __init__(self, extension_info): + self.extension_info = extension_info + + def _translate(self, ext): + ext_data = {} + ext_data['name'] = ext.name + ext_data['alias'] = ext.alias + ext_data['description'] = ext.__doc__ + ext_data['namespace'] = ext.namespace + ext_data['version'] = ext.version + return ext_data + + @wsgi.serializers(xml=ExtensionsTemplate) + def index(self, req): + + sorted_ext_list = sorted( + self.extension_info.get_extensions().iteritems()) + + extensions = [] + for _alias, ext in sorted_ext_list: + extensions.append(self._translate(ext)) + return dict(extensions=extensions) + + @wsgi.serializers(xml=ExtensionTemplate) + def show(self, req, id): + try: + # NOTE(dprince): the extensions alias is used as the 'id' for show + ext = self.extension_info.get_extensions()[id] + except KeyError: + raise webob.exc.HTTPNotFound() + + return dict(extension=self._translate(ext)) + + +class ExtensionInfo(extensions.V3APIExtensionBase): + """Extension information.""" + + name = "extensions" + alias = "extensions" + namespace = "http://docs.openstack.org/compute/core/extension_info/api/v3" + version = 1 + + def get_resources(self): + resources = [ + extensions.ResourceExtension( + 'extensions', ExtensionInfoController(self.extension_info), + member_name='extension')] + return resources + + def get_controller_extensions(self): + return [] diff --git a/nova/api/openstack/compute/plugins/v3/keypairs.py b/nova/api/openstack/compute/plugins/v3/keypairs.py new file mode 100644 index 000000000..4051a3497 --- /dev/null +++ b/nova/api/openstack/compute/plugins/v3/keypairs.py @@ -0,0 +1,215 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack Foundation +# 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. + +"""Keypair management extension.""" + +import webob +import webob.exc + +from nova.api.openstack.compute import servers +from nova.api.openstack import extensions +from nova.api.openstack import wsgi +from nova.api.openstack import xmlutil +from nova.compute import api as compute_api +from nova import exception + + +ALIAS = 'os-keypairs' +authorize = extensions.extension_authorizer('compute', 'v3:' + ALIAS) +soft_authorize = extensions.soft_extension_authorizer('compute', 'v3:' + ALIAS) + + +class KeypairTemplate(xmlutil.TemplateBuilder): + def construct(self): + return xmlutil.MasterTemplate(xmlutil.make_flat_dict('keypair'), 1) + + +class KeypairsTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('keypairs') + elem = xmlutil.make_flat_dict('keypair', selector='keypairs', + subselector='keypair') + root.append(elem) + + return xmlutil.MasterTemplate(root, 1) + + +class KeypairController(object): + + """Keypair API controller for the OpenStack API.""" + def __init__(self): + self.api = compute_api.KeypairAPI() + + @wsgi.serializers(xml=KeypairTemplate) + def create(self, req, body): + """ + Create or import keypair. + + Sending name will generate a key and return private_key + and fingerprint. + + You can send a public_key to add an existing ssh key + + params: keypair object with: + name (required) - string + public_key (optional) - string + """ + + context = req.environ['nova.context'] + authorize(context) + + try: + params = body['keypair'] + name = params['name'] + except KeyError: + msg = _("Invalid request body") + raise webob.exc.HTTPBadRequest(explanation=msg) + + try: + if 'public_key' in params: + keypair = self.api.import_key_pair(context, + context.user_id, name, + params['public_key']) + else: + keypair = self.api.create_key_pair(context, context.user_id, + name) + + return {'keypair': keypair} + + except exception.KeypairLimitExceeded: + msg = _("Quota exceeded, too many key pairs.") + raise webob.exc.HTTPRequestEntityTooLarge( + explanation=msg, + headers={'Retry-After': 0}) + except exception.InvalidKeypair: + msg = _("Keypair data is invalid") + raise webob.exc.HTTPBadRequest(explanation=msg) + except exception.KeyPairExists: + msg = _("Key pair '%s' already exists.") % name + raise webob.exc.HTTPConflict(explanation=msg) + + def delete(self, req, id): + """ + Delete a keypair with a given name + """ + context = req.environ['nova.context'] + authorize(context) + try: + self.api.delete_key_pair(context, context.user_id, id) + except exception.KeypairNotFound: + raise webob.exc.HTTPNotFound() + return webob.Response(status_int=202) + + @wsgi.serializers(xml=KeypairTemplate) + def show(self, req, id): + """Return data for the given key name.""" + context = req.environ['nova.context'] + authorize(context) + + try: + keypair = self.api.get_key_pair(context, context.user_id, id) + except exception.KeypairNotFound: + raise webob.exc.HTTPNotFound() + return {'keypair': keypair} + + @wsgi.serializers(xml=KeypairsTemplate) + def index(self, req): + """ + List of keypairs for a user + """ + context = req.environ['nova.context'] + authorize(context) + key_pairs = self.api.get_key_pairs(context, context.user_id) + rval = [] + for key_pair in key_pairs: + rval.append({'keypair': { + 'name': key_pair['name'], + 'public_key': key_pair['public_key'], + 'fingerprint': key_pair['fingerprint'], + }}) + + return {'keypairs': rval} + + +class ServerKeyNameTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('server') + root.set('key_name', 'key_name') + return xmlutil.SlaveTemplate(root, 1) + + +class ServersKeyNameTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('servers') + elem = xmlutil.SubTemplateElement(root, 'server', selector='servers') + elem.set('key_name', 'key_name') + return xmlutil.SlaveTemplate(root, 1) + + +class Controller(servers.Controller): + + def _add_key_name(self, req, servers): + for server in servers: + db_server = req.get_db_instance(server['id']) + # server['id'] is guaranteed to be in the cache due to + # the core API adding it in its 'show'/'detail' methods. + server['key_name'] = db_server['key_name'] + + def _show(self, req, resp_obj): + if 'server' in resp_obj.obj: + resp_obj.attach(xml=ServerKeyNameTemplate()) + server = resp_obj.obj['server'] + self._add_key_name(req, [server]) + + @wsgi.extends + def show(self, req, resp_obj, id): + context = req.environ['nova.context'] + if soft_authorize(context): + self._show(req, resp_obj) + + @wsgi.extends + def detail(self, req, resp_obj): + context = req.environ['nova.context'] + if 'servers' in resp_obj.obj and soft_authorize(context): + resp_obj.attach(xml=ServersKeyNameTemplate()) + servers = resp_obj.obj['servers'] + self._add_key_name(req, servers) + + +class Keypairs(extensions.V3APIExtensionBase): + """Keypair Support.""" + + name = "Keypairs" + alias = ALIAS + namespace = "http://docs.openstack.org/compute/ext/keypairs/api/v3" + version = 1 + + def get_resources(self): + resources = [ + extensions.ResourceExtension('os-keypairs', + KeypairController())] + return resources + + def get_controller_extensions(self): + controller = Controller() + extension = extensions.ControllerExtension(self, 'servers', controller) + return [extension] + + # use nova.api.extensions.server.extensions entry point to modify + # server create kwargs + def server_create(self, server_dict, create_kwargs): + create_kwargs['key_name'] = server_dict.get('key_name') diff --git a/nova/api/openstack/compute/plugins/v3/servers.py b/nova/api/openstack/compute/plugins/v3/servers.py new file mode 100644 index 000000000..2475af0e4 --- /dev/null +++ b/nova/api/openstack/compute/plugins/v3/servers.py @@ -0,0 +1,1533 @@ +# Copyright 2010 OpenStack Foundation +# Copyright 2011 Piston Cloud Computing, Inc +# 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 base64 +import os +import re +import stevedore + +from oslo.config import cfg +import webob +from webob import exc + +from nova.api.openstack import common +from nova.api.openstack.compute import ips +from nova.api.openstack.compute.views import servers as views_servers +from nova.api.openstack import extensions +from nova.api.openstack import wsgi +from nova.api.openstack import xmlutil +from nova import compute +from nova.compute import flavors +from nova import exception +from nova.openstack.common import importutils +from nova.openstack.common import log as logging +from nova.openstack.common.rpc import common as rpc_common +from nova.openstack.common import strutils +from nova.openstack.common import timeutils +from nova.openstack.common import uuidutils +from nova import utils + + +server_opts = [ + cfg.BoolOpt('enable_instance_password', + default=True, + help='Allows use of instance password during ' + 'server creation'), +] +CONF = cfg.CONF +CONF.register_opts(server_opts) +CONF.import_opt('network_api_class', 'nova.network') +CONF.import_opt('reclaim_instance_interval', 'nova.compute.manager') + +LOG = logging.getLogger(__name__) + + +def make_fault(elem): + fault = xmlutil.SubTemplateElement(elem, 'fault', selector='fault') + fault.set('code') + fault.set('created') + msg = xmlutil.SubTemplateElement(fault, 'message') + msg.text = 'message' + det = xmlutil.SubTemplateElement(fault, 'details') + det.text = 'details' + + +def make_server(elem, detailed=False): + elem.set('name') + elem.set('id') + + if detailed: + elem.set('userId', 'user_id') + elem.set('tenantId', 'tenant_id') + elem.set('updated') + elem.set('created') + elem.set('hostId') + elem.set('accessIPv4') + elem.set('accessIPv6') + elem.set('status') + elem.set('progress') + elem.set('reservation_id') + + # Attach image node + image = xmlutil.SubTemplateElement(elem, 'image', selector='image') + image.set('id') + xmlutil.make_links(image, 'links') + + # Attach flavor node + flavor = xmlutil.SubTemplateElement(elem, 'flavor', selector='flavor') + flavor.set('id') + xmlutil.make_links(flavor, 'links') + + # Attach fault node + make_fault(elem) + + # Attach metadata node + elem.append(common.MetadataTemplate()) + + # Attach addresses node + elem.append(ips.AddressesTemplate()) + + xmlutil.make_links(elem, 'links') + + +server_nsmap = {None: xmlutil.XMLNS_V11, 'atom': xmlutil.XMLNS_ATOM} + + +class ServerTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('server', selector='server') + make_server(root, detailed=True) + return xmlutil.MasterTemplate(root, 1, nsmap=server_nsmap) + + +class MinimalServersTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('servers') + elem = xmlutil.SubTemplateElement(root, 'server', selector='servers') + make_server(elem) + xmlutil.make_links(root, 'servers_links') + return xmlutil.MasterTemplate(root, 1, nsmap=server_nsmap) + + +class ServersTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('servers') + elem = xmlutil.SubTemplateElement(root, 'server', selector='servers') + make_server(elem, detailed=True) + return xmlutil.MasterTemplate(root, 1, nsmap=server_nsmap) + + +class ServerAdminPassTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('server') + root.set('adminPass') + return xmlutil.SlaveTemplate(root, 1, nsmap=server_nsmap) + + +class ServerMultipleCreateTemplate(xmlutil.TemplateBuilder): + def construct(self): + root = xmlutil.TemplateElement('server') + root.set('reservation_id') + return xmlutil.MasterTemplate(root, 1, nsmap=server_nsmap) + + +def FullServerTemplate(): + master = ServerTemplate() + master.attach(ServerAdminPassTemplate()) + return master + + +class CommonDeserializer(wsgi.MetadataXMLDeserializer): + """Common deserializer to handle xml-formatted server create requests. + + Handles standard server attributes as well as optional metadata + and personality attributes + """ + + metadata_deserializer = common.MetadataXMLDeserializer() + + def _extract_personality(self, server_node): + """Marshal the personality attribute of a parsed request.""" + node = self.find_first_child_named(server_node, "personality") + if node is not None: + personality = [] + for file_node in self.find_children_named(node, "file"): + item = {} + if file_node.hasAttribute("path"): + item["path"] = file_node.getAttribute("path") + item["contents"] = self.extract_text(file_node) + personality.append(item) + return personality + else: + return None + + def _extract_server(self, node): + """Marshal the server attribute of a parsed request.""" + server = {} + server_node = self.find_first_child_named(node, 'server') + + attributes = ["name", "imageRef", "flavorRef", "adminPass", + "accessIPv4", "accessIPv6", "key_name", + "availability_zone", "min_count", "max_count"] + for attr in attributes: + if server_node.getAttribute(attr): + server[attr] = server_node.getAttribute(attr) + + res_id = server_node.getAttribute('return_reservation_id') + if res_id: + server['return_reservation_id'] = strutils.bool_from_string(res_id) + + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an entry point when the OS-SCH-HNT extension is + # ported + #scheduler_hints = self._extract_scheduler_hints(server_node) + #if scheduler_hints: + # server['OS-SCH-HNT:scheduler_hints'] = scheduler_hints + + metadata_node = self.find_first_child_named(server_node, "metadata") + if metadata_node is not None: + server["metadata"] = self.extract_metadata(metadata_node) + + user_data_node = self.find_first_child_named(server_node, "user_data") + if user_data_node is not None: + server["user_data"] = self.extract_text(user_data_node) + + personality = self._extract_personality(server_node) + if personality is not None: + server["personality"] = personality + + networks = self._extract_networks(server_node) + if networks is not None: + server["networks"] = networks + + security_groups = self._extract_security_groups(server_node) + if security_groups is not None: + server["security_groups"] = security_groups + + # NOTE(vish): this is not namespaced in json, so leave it without a + # namespace for now + block_device_mapping = self._extract_block_device_mapping(server_node) + if block_device_mapping is not None: + server["block_device_mapping"] = block_device_mapping + + # NOTE(vish): Support this incorrect version because it was in the code + # base for a while and we don't want to accidentally break + # anyone that might be using it. + + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the disk_config + # extension is ported + # auto_disk_config = server_node.getAttribute('auto_disk_config') + #if auto_disk_config: + # server['OS-DCF:diskConfig'] = auto_disk_config + + # auto_disk_config = server_node.getAttribute('OS-DCF:diskConfig') + #if auto_disk_config: + # server['OS-DCF:diskConfig'] = auto_disk_config + + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the disk_config + # extension is ported + #config_drive = server_node.getAttribute('config_drive') + #if config_drive: + # server['config_drive'] = config_drive + + return server + + def _extract_block_device_mapping(self, server_node): + """Marshal the block_device_mapping node of a parsed request.""" + node = self.find_first_child_named(server_node, "block_device_mapping") + if node: + block_device_mapping = [] + for child in self.extract_elements(node): + if child.nodeName != "mapping": + continue + mapping = {} + attributes = ["volume_id", "snapshot_id", "device_name", + "virtual_name", "volume_size"] + for attr in attributes: + value = child.getAttribute(attr) + if value: + mapping[attr] = value + attributes = ["delete_on_termination", "no_device"] + for attr in attributes: + value = child.getAttribute(attr) + if value: + mapping[attr] = strutils.bool_from_string(value) + block_device_mapping.append(mapping) + return block_device_mapping + else: + return None + + def _extract_scheduler_hints(self, server_node): + """Marshal the scheduler hints attribute of a parsed request.""" + node = self.find_first_child_named_in_namespace(server_node, + "http://docs.openstack.org/compute/ext/scheduler-hints/api/v2", + "scheduler_hints") + if node: + scheduler_hints = {} + for child in self.extract_elements(node): + scheduler_hints.setdefault(child.nodeName, []) + value = self.extract_text(child).strip() + scheduler_hints[child.nodeName].append(value) + return scheduler_hints + else: + return None + + def _extract_networks(self, server_node): + """Marshal the networks attribute of a parsed request.""" + node = self.find_first_child_named(server_node, "networks") + if node is not None: + networks = [] + for network_node in self.find_children_named(node, + "network"): + item = {} + if network_node.hasAttribute("uuid"): + item["uuid"] = network_node.getAttribute("uuid") + if network_node.hasAttribute("fixed_ip"): + item["fixed_ip"] = network_node.getAttribute("fixed_ip") + if network_node.hasAttribute("port"): + item["port"] = network_node.getAttribute("port") + networks.append(item) + return networks + else: + return None + + def _extract_security_groups(self, server_node): + """Marshal the security_groups attribute of a parsed request.""" + node = self.find_first_child_named(server_node, "security_groups") + if node is not None: + security_groups = [] + for sg_node in self.find_children_named(node, "security_group"): + item = {} + name = self.find_attribute_or_element(sg_node, 'name') + if name: + item["name"] = name + security_groups.append(item) + return security_groups + else: + return None + + +class ActionDeserializer(CommonDeserializer): + """Deserializer to handle xml-formatted server action requests. + + Handles standard server attributes as well as optional metadata + and personality attributes + """ + + def default(self, string): + dom = xmlutil.safe_minidom_parse_string(string) + action_node = dom.childNodes[0] + action_name = action_node.tagName + + action_deserializer = { + 'createImage': self._action_create_image, + 'changePassword': self._action_change_password, + 'reboot': self._action_reboot, + 'rebuild': self._action_rebuild, + 'resize': self._action_resize, + 'confirmResize': self._action_confirm_resize, + 'revertResize': self._action_revert_resize, + }.get(action_name, super(ActionDeserializer, self).default) + + action_data = action_deserializer(action_node) + + return {'body': {action_name: action_data}} + + def _action_create_image(self, node): + return self._deserialize_image_action(node, ('name',)) + + def _action_change_password(self, node): + if not node.hasAttribute("adminPass"): + raise AttributeError("No adminPass was specified in request") + return {"adminPass": node.getAttribute("adminPass")} + + def _action_reboot(self, node): + if not node.hasAttribute("type"): + raise AttributeError("No reboot type was specified in request") + return {"type": node.getAttribute("type")} + + def _action_rebuild(self, node): + rebuild = {} + if node.hasAttribute("name"): + name = node.getAttribute("name") + if not name: + raise AttributeError("Name cannot be blank") + rebuild['name'] = name + + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the disk_config + # extension is ported + # if node.hasAttribute("auto_disk_config"): + # rebuild['OS-DCF:diskConfig'] = node.getAttribute( + # "auto_disk_config") + + # if node.hasAttribute("OS-DCF:diskConfig"): + # rebuild['OS-DCF:diskConfig'] = node.getAttribute( + # "OS-DCF:diskConfig") + + metadata_node = self.find_first_child_named(node, "metadata") + if metadata_node is not None: + rebuild["metadata"] = self.extract_metadata(metadata_node) + + personality = self._extract_personality(node) + if personality is not None: + rebuild["personality"] = personality + + if not node.hasAttribute("imageRef"): + raise AttributeError("No imageRef was specified in request") + rebuild["imageRef"] = node.getAttribute("imageRef") + + if node.hasAttribute("adminPass"): + rebuild["adminPass"] = node.getAttribute("adminPass") + + if node.hasAttribute("accessIPv4"): + rebuild["accessIPv4"] = node.getAttribute("accessIPv4") + + if node.hasAttribute("accessIPv6"): + rebuild["accessIPv6"] = node.getAttribute("accessIPv6") + + return rebuild + + def _action_resize(self, node): + resize = {} + + if node.hasAttribute("flavorRef"): + resize["flavorRef"] = node.getAttribute("flavorRef") + else: + raise AttributeError("No flavorRef was specified in request") + + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the disk_config + # extension is ported + # if node.hasAttribute("auto_disk_config"): + # resize['OS-DCF:diskConfig'] = node.getAttribute( + # "auto_disk_config") + + # if node.hasAttribute("OS-DCF:diskConfig"): + # resize['OS-DCF:diskConfig'] = node.getAttribute( + # "OS-DCF:diskConfig") + + return resize + + def _action_confirm_resize(self, node): + return None + + def _action_revert_resize(self, node): + return None + + def _deserialize_image_action(self, node, allowed_attributes): + data = {} + for attribute in allowed_attributes: + value = node.getAttribute(attribute) + if value: + data[attribute] = value + metadata_node = self.find_first_child_named(node, 'metadata') + if metadata_node is not None: + metadata = self.metadata_deserializer.extract_metadata( + metadata_node) + data['metadata'] = metadata + return data + + +class CreateDeserializer(CommonDeserializer): + """Deserializer to handle xml-formatted server create requests. + + Handles standard server attributes as well as optional metadata + and personality attributes + """ + + def default(self, string): + """Deserialize an xml-formatted server create request.""" + dom = xmlutil.safe_minidom_parse_string(string) + server = self._extract_server(dom) + return {'body': {'server': server}} + + +class ServersController(wsgi.Controller): + """The Server API base controller class for the OpenStack API.""" + + EXTENSION_CREATE_NAMESPACE = 'nova.api.v3.extensions.server.create' + _view_builder_class = views_servers.ViewBuilder + + @staticmethod + def _add_location(robj): + # Just in case... + if 'server' not in robj.obj: + return robj + + link = filter(lambda l: l['rel'] == 'self', + robj.obj['server']['links']) + if link: + robj['Location'] = link[0]['href'].encode('utf-8') + + # Convenience return + return robj + + def __init__(self, **kwargs): + def _check_load_extension(ext): + if isinstance(ext.obj, extensions.V3APIExtensionBase): + # Filter out for the existence of server_create here + # rather than on every request. We don't have a new + # abstract base class to reduce duplication in the + # extensions as they may want to implement multiple + # server (and other) entry points + if hasattr(ext.obj, 'server_create'): + LOG.debug(_('server create extension %s detected'), + ext.obj.alias) + return True + else: + LOG.debug( + _('extension %s is missing server_create'), ext.obj) + return False + else: + return False + + self.extension_info = kwargs.pop('extension_info') + super(ServersController, self).__init__(**kwargs) + self.compute_api = compute.API() + self.quantum_attempted = False + + # Look for implmentation of extension point of server creation + self.create_extension_manager = \ + stevedore.enabled.EnabledExtensionManager( + namespace=self.EXTENSION_CREATE_NAMESPACE, + check_func=_check_load_extension, + invoke_on_load=True, + invoke_kwds={"extension_info": self.extension_info}) + if not list(self.create_extension_manager): + LOG.debug(_("Did not find any server create extensions")) + + @wsgi.serializers(xml=MinimalServersTemplate) + def index(self, req): + """Returns a list of server names and ids for a given user.""" + try: + servers = self._get_servers(req, is_detail=False) + except exception.Invalid as err: + raise exc.HTTPBadRequest(explanation=str(err)) + return servers + + @wsgi.serializers(xml=ServersTemplate) + def detail(self, req): + """Returns a list of server details for a given user.""" + try: + servers = self._get_servers(req, is_detail=True) + except exception.Invalid as err: + raise exc.HTTPBadRequest(explanation=str(err)) + return servers + + def _add_instance_faults(self, ctxt, instances): + faults = self.compute_api.get_instance_faults(ctxt, instances) + if faults is not None: + for instance in instances: + faults_list = faults.get(instance['uuid'], []) + try: + instance['fault'] = faults_list[0] + except IndexError: + pass + + return instances + + def _get_servers(self, req, is_detail): + """Returns a list of servers, based on any search options specified.""" + + search_opts = {} + search_opts.update(req.GET) + + context = req.environ['nova.context'] + remove_invalid_options(context, search_opts, + self._get_server_search_options()) + + # Verify search by 'status' contains a valid status. + # Convert it to filter by vm_state for compute_api. + status = search_opts.pop('status', None) + if status is not None: + state = common.vm_state_from_status(status) + if state is None: + return {'servers': []} + search_opts['vm_state'] = state + + if 'changes-since' in search_opts: + try: + parsed = timeutils.parse_isotime(search_opts['changes-since']) + except ValueError: + msg = _('Invalid changes-since value') + raise exc.HTTPBadRequest(explanation=msg) + search_opts['changes-since'] = parsed + + # By default, compute's get_all() will return deleted instances. + # If an admin hasn't specified a 'deleted' search option, we need + # to filter out deleted instances by setting the filter ourselves. + # ... Unless 'changes-since' is specified, because 'changes-since' + # should return recently deleted images according to the API spec. + + if 'deleted' not in search_opts: + if 'changes-since' not in search_opts: + # No 'changes-since', so we only want non-deleted servers + search_opts['deleted'] = False + + if search_opts.get("vm_state") == "deleted": + if context.is_admin: + search_opts['deleted'] = True + else: + msg = _("Only administrators may list deleted instances") + raise exc.HTTPBadRequest(explanation=msg) + + if 'all_tenants' not in search_opts: + if context.project_id: + search_opts['project_id'] = context.project_id + else: + search_opts['user_id'] = context.user_id + + limit, marker = common.get_limit_and_marker(req) + try: + instance_list = self.compute_api.get_all(context, + search_opts=search_opts, + limit=limit, + marker=marker) + except exception.MarkerNotFound as e: + msg = _('marker [%s] not found') % marker + raise exc.HTTPBadRequest(explanation=msg) + except exception.FlavorNotFound as e: + log_msg = _("Flavor '%s' could not be found ") + LOG.debug(log_msg, search_opts['flavor']) + instance_list = [] + + if is_detail: + self._add_instance_faults(context, instance_list) + response = self._view_builder.detail(req, instance_list) + else: + response = self._view_builder.index(req, instance_list) + req.cache_db_instances(instance_list) + return response + + def _get_server(self, context, req, instance_uuid): + """Utility function for looking up an instance by uuid.""" + try: + instance = self.compute_api.get(context, instance_uuid) + except exception.NotFound: + msg = _("Instance could not be found") + raise exc.HTTPNotFound(explanation=msg) + req.cache_db_instance(instance) + return instance + + def _check_string_length(self, value, name, max_length=None): + try: + utils.check_string_length(value, name, min_length=1, + max_length=max_length) + except exception.InvalidInput as e: + raise exc.HTTPBadRequest(explanation=str(e)) + + def _validate_server_name(self, value): + self._check_string_length(value, 'Server name', max_length=255) + + def _validate_device_name(self, value): + self._check_string_length(value, 'Device name', max_length=255) + + if ' ' in value: + msg = _("Device name cannot include spaces.") + raise exc.HTTPBadRequest(explanation=msg) + + def _get_injected_files(self, personality): + """Create a list of injected files from the personality attribute. + + At this time, injected_files must be formatted as a list of + (file_path, file_content) pairs for compatibility with the + underlying compute service. + """ + injected_files = [] + + for item in personality: + try: + path = item['path'] + contents = item['contents'] + except KeyError as key: + expl = _('Bad personality format: missing %s') % key + raise exc.HTTPBadRequest(explanation=expl) + except TypeError: + expl = _('Bad personality format') + raise exc.HTTPBadRequest(explanation=expl) + if self._decode_base64(contents) is None: + expl = _('Personality content for %s cannot be decoded') % path + raise exc.HTTPBadRequest(explanation=expl) + injected_files.append((path, contents)) + return injected_files + + def _is_quantum_v2(self): + # NOTE(dprince): quantumclient is not a requirement + if self.quantum_attempted: + return self.have_quantum + + try: + self.quantum_attempted = True + from nova.network.quantumv2 import api as quantum_api + self.have_quantum = issubclass( + importutils.import_class(CONF.network_api_class), + quantum_api.API) + except ImportError: + self.have_quantum = False + + return self.have_quantum + + def _get_requested_networks(self, requested_networks): + """Create a list of requested networks from the networks attribute.""" + networks = [] + for network in requested_networks: + try: + port_id = network.get('port', None) + if port_id: + network_uuid = None + if not self._is_quantum_v2(): + # port parameter is only for quantum v2.0 + msg = _("Unknown argment : port") + raise exc.HTTPBadRequest(explanation=msg) + if not uuidutils.is_uuid_like(port_id): + msg = _("Bad port format: port uuid is " + "not in proper format " + "(%s)") % port_id + raise exc.HTTPBadRequest(explanation=msg) + else: + network_uuid = network['uuid'] + + if not port_id and not uuidutils.is_uuid_like(network_uuid): + br_uuid = network_uuid.split('-', 1)[-1] + if not uuidutils.is_uuid_like(br_uuid): + msg = _("Bad networks format: network uuid is " + "not in proper format " + "(%s)") % network_uuid + raise exc.HTTPBadRequest(explanation=msg) + + #fixed IP address is optional + #if the fixed IP address is not provided then + #it will use one of the available IP address from the network + address = network.get('fixed_ip', None) + if address is not None and not utils.is_valid_ipv4(address): + msg = _("Invalid fixed IP address (%s)") % address + raise exc.HTTPBadRequest(explanation=msg) + + # For quantumv2, requestd_networks + # should be tuple of (network_uuid, fixed_ip, port_id) + if self._is_quantum_v2(): + networks.append((network_uuid, address, port_id)) + else: + # check if the network id is already present in the list, + # we don't want duplicate networks to be passed + # at the boot time + for id, ip in networks: + if id == network_uuid: + expl = (_("Duplicate networks" + " (%s) are not allowed") % + network_uuid) + raise exc.HTTPBadRequest(explanation=expl) + networks.append((network_uuid, address)) + except KeyError as key: + expl = _('Bad network format: missing %s') % key + raise exc.HTTPBadRequest(explanation=expl) + except TypeError: + expl = _('Bad networks format') + raise exc.HTTPBadRequest(explanation=expl) + + return networks + + # NOTE(vish): Without this regex, b64decode will happily + # ignore illegal bytes in the base64 encoded + # data. + B64_REGEX = re.compile('^(?:[A-Za-z0-9+\/]{4})*' + '(?:[A-Za-z0-9+\/]{2}==' + '|[A-Za-z0-9+\/]{3}=)?$') + + def _decode_base64(self, data): + data = re.sub(r'\s', '', data) + if not self.B64_REGEX.match(data): + return None + try: + return base64.b64decode(data) + except TypeError: + return None + + def _validate_user_data(self, user_data): + """Check if the user_data is encoded properly.""" + if not user_data: + return + if self._decode_base64(user_data) is None: + expl = _('Userdata content cannot be decoded') + raise exc.HTTPBadRequest(explanation=expl) + + def _validate_access_ipv4(self, address): + if not utils.is_valid_ipv4(address): + expl = _('accessIPv4 is not proper IPv4 format') + raise exc.HTTPBadRequest(explanation=expl) + + def _validate_access_ipv6(self, address): + if not utils.is_valid_ipv6(address): + expl = _('accessIPv6 is not proper IPv6 format') + raise exc.HTTPBadRequest(explanation=expl) + + @wsgi.serializers(xml=ServerTemplate) + def show(self, req, id): + """Returns server details by server id.""" + try: + context = req.environ['nova.context'] + instance = self.compute_api.get(context, id) + req.cache_db_instance(instance) + self._add_instance_faults(context, [instance]) + return self._view_builder.show(req, instance) + except exception.NotFound: + msg = _("Instance could not be found") + raise exc.HTTPNotFound(explanation=msg) + + @wsgi.response(202) + @wsgi.serializers(xml=FullServerTemplate) + @wsgi.deserializers(xml=CreateDeserializer) + def create(self, req, body): + """Creates a new server for a given user.""" + if not self.is_valid_body(body, 'server'): + raise exc.HTTPUnprocessableEntity() + + context = req.environ['nova.context'] + server_dict = body['server'] + password = self._get_server_admin_password(server_dict) + + if 'name' not in server_dict: + msg = _("Server name is not defined") + raise exc.HTTPBadRequest(explanation=msg) + + name = server_dict['name'] + self._validate_server_name(name) + name = name.strip() + + image_uuid = self._image_from_req_data(body) + + # Arguments to be passed to instance create function + create_kwargs = {} + + # Query extensions which want to manipulate the keyword + # arguments. + # NOTE(cyeoh): This is the hook that extensions use + # to replace the extension specific code below. + # When the extensions are ported this will also result + # in some convenience function from this class being + # moved to the extension + if list(self.create_extension_manager): + self.create_extension_manager.map(self._create_extension_point, + server_dict, create_kwargs) + + personality = server_dict.get('personality') + config_drive = None + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the config_drive + # extension is ported + # if self.ext_mgr.is_loaded('os-config-drive'): + # config_drive = server_dict.get('config_drive') + + injected_files = [] + if personality: + injected_files = self._get_injected_files(personality) + + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the security groups + # extension is ported + sg_names = [] + #if self.ext_mgr.is_loaded('os-security-groups'): + # security_groups = server_dict.get('security_groups') + # if security_groups is not None: + # sg_names = [sg['name'] for sg in security_groups + # if sg.get('name')] + if not sg_names: + sg_names.append('default') + + sg_names = list(set(sg_names)) + + requested_networks = None + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the os-networks + # extension is ported. Currently reworked + # to take into account _is_quantum_v2 + #if (self.ext_mgr.is_loaded('os-networks') + # or self._is_quantum_v2()): + # requested_networks = server_dict.get('networks') + + if self._is_quantum_v2(): + requested_networks = server_dict.get('networks') + if requested_networks is not None: + requested_networks = self._get_requested_networks( + requested_networks) + + (access_ip_v4, ) = server_dict.get('accessIPv4'), + if access_ip_v4 is not None: + self._validate_access_ipv4(access_ip_v4) + + (access_ip_v6, ) = server_dict.get('accessIPv6'), + if access_ip_v6 is not None: + self._validate_access_ipv6(access_ip_v6) + + try: + flavor_id = self._flavor_id_from_req_data(body) + except ValueError as error: + msg = _("Invalid flavorRef provided.") + raise exc.HTTPBadRequest(explanation=msg) + + user_data = None + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the os-user-data + # extension is ported + #if self.ext_mgr.is_loaded('os-user-data'): + # user_data = server_dict.get('user_data') + #self._validate_user_data(user_data) + + availability_zone = None + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the + # os-availability-zone extension is ported + #if self.ext_mgr.is_loaded('os-availability-zone'): + # availability_zone = server_dict.get('availability_zone') + + block_device_mapping = None + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the os-volumes + # extension is ported + #if self.ext_mgr.is_loaded('os-volumes'): + # block_device_mapping = server_dict.get('block_device_mapping', []) + # for bdm in block_device_mapping: + # self._validate_device_name(bdm["device_name"]) + # if 'delete_on_termination' in bdm: + # bdm['delete_on_termination'] = strutils.bool_from_string( + # bdm['delete_on_termination']) + + ret_resv_id = False + # min_count and max_count are optional. If they exist, they may come + # in as strings. Verify that they are valid integers and > 0. + # Also, we want to default 'min_count' to 1, and default + # 'max_count' to be 'min_count'. + min_count = 1 + max_count = 1 + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the os-multiple-create + # extension is ported + #if self.ext_mgr.is_loaded('os-multiple-create'): + # ret_resv_id = server_dict.get('return_reservation_id', False) + # min_count = server_dict.get('min_count', 1) + # max_count = server_dict.get('max_count', min_count) + + try: + min_count = int(str(min_count)) + except ValueError: + msg = _('min_count must be an integer value') + raise exc.HTTPBadRequest(explanation=msg) + if min_count < 1: + msg = _('min_count must be > 0') + raise exc.HTTPBadRequest(explanation=msg) + + try: + max_count = int(str(max_count)) + except ValueError: + msg = _('max_count must be an integer value') + raise exc.HTTPBadRequest(explanation=msg) + if max_count < 1: + msg = _('max_count must be > 0') + raise exc.HTTPBadRequest(explanation=msg) + + if min_count > max_count: + msg = _('min_count must be <= max_count') + raise exc.HTTPBadRequest(explanation=msg) + + auto_disk_config = False + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the OS-DCF extension is + # ported + #if self.ext_mgr.is_loaded('OS-DCF'): + # auto_disk_config = server_dict.get('auto_disk_config') + + scheduler_hints = {} + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with an extension point when the scheduler hints + # extension is ported + #if self.ext_mgr.is_loaded('OS-SCH-HNT'): + # scheduler_hints = server_dict.get('scheduler_hints', {}) + + try: + _get_inst_type = flavors.get_instance_type_by_flavor_id + inst_type = _get_inst_type(flavor_id, read_deleted="no") + + (instances, resv_id) = self.compute_api.create(context, + inst_type, + image_uuid, + display_name=name, + display_description=name, + metadata=server_dict.get('metadata', {}), + access_ip_v4=access_ip_v4, + access_ip_v6=access_ip_v6, + injected_files=injected_files, + admin_password=password, + min_count=min_count, + max_count=max_count, + requested_networks=requested_networks, + security_group=sg_names, + user_data=user_data, + availability_zone=availability_zone, + config_drive=config_drive, + block_device_mapping=block_device_mapping, + auto_disk_config=auto_disk_config, + scheduler_hints=scheduler_hints, + **create_kwargs) + except exception.QuotaError as error: + raise exc.HTTPRequestEntityTooLarge( + explanation=error.format_message(), + headers={'Retry-After': 0}) + except exception.InvalidMetadataSize as error: + raise exc.HTTPRequestEntityTooLarge( + explanation=error.format_message()) + except exception.ImageNotFound as error: + msg = _("Can not find requested image") + raise exc.HTTPBadRequest(explanation=msg) + except exception.FlavorNotFound as error: + msg = _("Invalid flavorRef provided.") + raise exc.HTTPBadRequest(explanation=msg) + except exception.KeypairNotFound as error: + msg = _("Invalid key_name provided.") + raise exc.HTTPBadRequest(explanation=msg) + except rpc_common.RemoteError as err: + msg = "%(err_type)s: %(err_msg)s" % {'err_type': err.exc_type, + 'err_msg': err.value} + raise exc.HTTPBadRequest(explanation=msg) + except UnicodeDecodeError as error: + msg = "UnicodeError: %s" % unicode(error) + raise exc.HTTPBadRequest(explanation=msg) + except (exception.ImageNotActive, + exception.InstanceTypeDiskTooSmall, + exception.InstanceTypeMemoryTooSmall, + exception.InstanceTypeNotFound, + exception.InvalidMetadata, + exception.InvalidRequest, + exception.SecurityGroupNotFound) as error: + raise exc.HTTPBadRequest(explanation=error.format_message()) + + # If the caller wanted a reservation_id, return it + if ret_resv_id: + return wsgi.ResponseObject({'reservation_id': resv_id}, + xml=ServerMultipleCreateTemplate) + + req.cache_db_instances(instances) + server = self._view_builder.create(req, instances[0]) + + if '_is_precooked' in server['server'].keys(): + del server['server']['_is_precooked'] + else: + if CONF.enable_instance_password: + server['server']['adminPass'] = password + + robj = wsgi.ResponseObject(server) + + return self._add_location(robj) + + def _create_extension_point(self, ext, server_dict, create_kwargs): + handler = ext.obj + LOG.debug(_("Running _create_extension_point for %s"), ext.obj) + + handler.server_create(server_dict, create_kwargs) + + def _delete(self, context, req, instance_uuid): + instance = self._get_server(context, req, instance_uuid) + if CONF.reclaim_instance_interval: + self.compute_api.soft_delete(context, instance) + else: + self.compute_api.delete(context, instance) + + @wsgi.serializers(xml=ServerTemplate) + def update(self, req, id, body): + """Update server then pass on to version-specific controller.""" + if not self.is_valid_body(body, 'server'): + raise exc.HTTPUnprocessableEntity() + + ctxt = req.environ['nova.context'] + update_dict = {} + + if 'name' in body['server']: + name = body['server']['name'] + self._validate_server_name(name) + update_dict['display_name'] = name.strip() + + if 'accessIPv4' in body['server']: + access_ipv4 = body['server']['accessIPv4'] + if access_ipv4 is None: + access_ipv4 = '' + if access_ipv4: + self._validate_access_ipv4(access_ipv4) + update_dict['access_ip_v4'] = access_ipv4.strip() + + if 'accessIPv6' in body['server']: + access_ipv6 = body['server']['accessIPv6'] + if access_ipv6 is None: + access_ipv6 = '' + if access_ipv6: + self._validate_access_ipv6(access_ipv6) + update_dict['access_ip_v6'] = access_ipv6.strip() + + if 'auto_disk_config' in body['server']: + auto_disk_config = strutils.bool_from_string( + body['server']['auto_disk_config']) + update_dict['auto_disk_config'] = auto_disk_config + + if 'hostId' in body['server']: + msg = _("HostId cannot be updated.") + raise exc.HTTPBadRequest(explanation=msg) + + if 'personality' in body['server']: + msg = _("Personality cannot be updated.") + raise exc.HTTPBadRequest(explanation=msg) + + try: + instance = self.compute_api.get(ctxt, id) + req.cache_db_instance(instance) + self.compute_api.update(ctxt, instance, **update_dict) + except exception.NotFound: + msg = _("Instance could not be found") + raise exc.HTTPNotFound(explanation=msg) + + instance.update(update_dict) + + self._add_instance_faults(ctxt, [instance]) + return self._view_builder.show(req, instance) + + @wsgi.response(202) + @wsgi.serializers(xml=FullServerTemplate) + @wsgi.deserializers(xml=ActionDeserializer) + @wsgi.action('confirmResize') + def _action_confirm_resize(self, req, id, body): + context = req.environ['nova.context'] + instance = self._get_server(context, req, id) + try: + self.compute_api.confirm_resize(context, instance) + except exception.MigrationNotFound: + msg = _("Instance has not been resized.") + raise exc.HTTPBadRequest(explanation=msg) + except exception.InstanceInvalidState as state_error: + common.raise_http_conflict_for_instance_invalid_state(state_error, + 'confirmResize') + return exc.HTTPNoContent() + + @wsgi.response(202) + @wsgi.serializers(xml=FullServerTemplate) + @wsgi.deserializers(xml=ActionDeserializer) + @wsgi.action('revertResize') + def _action_revert_resize(self, req, id, body): + context = req.environ['nova.context'] + instance = self._get_server(context, req, id) + try: + self.compute_api.revert_resize(context, instance) + except exception.MigrationNotFound: + msg = _("Instance has not been resized.") + raise exc.HTTPBadRequest(explanation=msg) + except exception.InstanceTypeNotFound: + msg = _("Flavor used by the instance could not be found.") + raise exc.HTTPBadRequest(explanation=msg) + except exception.InstanceInvalidState as state_error: + common.raise_http_conflict_for_instance_invalid_state(state_error, + 'revertResize') + return webob.Response(status_int=202) + + @wsgi.response(202) + @wsgi.serializers(xml=FullServerTemplate) + @wsgi.deserializers(xml=ActionDeserializer) + @wsgi.action('reboot') + def _action_reboot(self, req, id, body): + if 'reboot' in body and 'type' in body['reboot']: + valid_reboot_types = ['HARD', 'SOFT'] + reboot_type = body['reboot']['type'].upper() + if not valid_reboot_types.count(reboot_type): + msg = _("Argument 'type' for reboot is not HARD or SOFT") + LOG.error(msg) + raise exc.HTTPBadRequest(explanation=msg) + else: + msg = _("Missing argument 'type' for reboot") + LOG.error(msg) + raise exc.HTTPBadRequest(explanation=msg) + + context = req.environ['nova.context'] + instance = self._get_server(context, req, id) + + try: + self.compute_api.reboot(context, instance, reboot_type) + except exception.InstanceInvalidState as state_error: + common.raise_http_conflict_for_instance_invalid_state(state_error, + 'reboot') + return webob.Response(status_int=202) + + def _resize(self, req, instance_id, flavor_id, **kwargs): + """Begin the resize process with given instance/flavor.""" + context = req.environ["nova.context"] + instance = self._get_server(context, req, instance_id) + + try: + self.compute_api.resize(context, instance, flavor_id, **kwargs) + except exception.FlavorNotFound: + msg = _("Unable to locate requested flavor.") + raise exc.HTTPBadRequest(explanation=msg) + except exception.CannotResizeToSameFlavor: + msg = _("Resize requires a flavor change.") + raise exc.HTTPBadRequest(explanation=msg) + except exception.InstanceInvalidState as state_error: + common.raise_http_conflict_for_instance_invalid_state(state_error, + 'resize') + except exception.ImageNotAuthorized as image_error: + msg = _("You are not authorized to access the image " + "the instance was started with.") + raise exc.HTTPUnauthorized(explanation=msg) + except exception.ImageNotFound as image_error: + msg = _("Image that the instance was started " + "with could not be found.") + raise exc.HTTPBadRequest(explanation=msg) + except exception.Invalid: + msg = _("Invalid instance image.") + raise exc.HTTPBadRequest(explanation=msg) + + return webob.Response(status_int=202) + + @wsgi.response(204) + def delete(self, req, id): + """Destroys a server.""" + try: + self._delete(req.environ['nova.context'], req, id) + except exception.NotFound: + msg = _("Instance could not be found") + raise exc.HTTPNotFound(explanation=msg) + except exception.InstanceInvalidState as state_error: + common.raise_http_conflict_for_instance_invalid_state(state_error, + 'delete') + + def _image_ref_from_req_data(self, data): + try: + return unicode(data['server']['imageRef']) + except (TypeError, KeyError): + msg = _("Missing imageRef attribute") + raise exc.HTTPBadRequest(explanation=msg) + + def _image_uuid_from_href(self, image_href): + # If the image href was generated by nova api, strip image_href + # down to an id and use the default glance connection params + image_uuid = image_href.split('/').pop() + + if not uuidutils.is_uuid_like(image_uuid): + msg = _("Invalid imageRef provided.") + raise exc.HTTPBadRequest(explanation=msg) + + return image_uuid + + def _image_from_req_data(self, data): + """ + Get image data from the request or raise appropriate + exceptions + + If no image is supplied - checks to see if there is + block devices set and proper extesions loaded. + """ + image_ref = data['server'].get('imageRef') + bdm = data['server'].get('block_device_mapping') + + # TODO(cyeoh): bp v3-api-core-as-extensions + # Replace with extension point. For the moment + # with no ep, assume os-volumes is not present + #if not image_ref and bdm and self.ext_mgr.is_loaded('os-volumes'): + # return '' + #else: + # image_href = self._image_ref_from_req_data(data) + # image_uuid = self._image_uuid_from_href(image_href) + # return image_uuid + image_href = self._image_ref_from_req_data(data) + image_uuid = self._image_uuid_from_href(image_href) + return image_uuid + + def _flavor_id_from_req_data(self, data): + try: + flavor_ref = data['server']['flavorRef'] + except (TypeError, KeyError): + msg = _("Missing flavorRef attribute") + raise exc.HTTPBadRequest(explanation=msg) + + return common.get_id_from_href(flavor_ref) + + @wsgi.response(202) + @wsgi.serializers(xml=FullServerTemplate) + @wsgi.deserializers(xml=ActionDeserializer) + @wsgi.action('changePassword') + def _action_change_password(self, req, id, body): + context = req.environ['nova.context'] + if (not 'changePassword' in body + or 'adminPass' not in body['changePassword']): + msg = _("No adminPass was specified") + raise exc.HTTPBadRequest(explanation=msg) + password = body['changePassword']['adminPass'] + if not isinstance(password, basestring): + msg = _("Invalid adminPass") + raise exc.HTTPBadRequest(explanation=msg) + server = self._get_server(context, req, id) + try: + self.compute_api.set_admin_password(context, server, password) + except NotImplementedError: + msg = _("Unable to set password on instance") + raise exc.HTTPNotImplemented(explanation=msg) + return webob.Response(status_int=202) + + def _validate_metadata(self, metadata): + """Ensure that we can work with the metadata given.""" + try: + metadata.iteritems() + except AttributeError: + msg = _("Unable to parse metadata key/value pairs.") + LOG.debug(msg) + raise exc.HTTPBadRequest(explanation=msg) + + @wsgi.response(202) + @wsgi.serializers(xml=FullServerTemplate) + @wsgi.deserializers(xml=ActionDeserializer) + @wsgi.action('resize') + def _action_resize(self, req, id, body): + """Resizes a given instance to the flavor size requested.""" + try: + flavor_ref = str(body["resize"]["flavorRef"]) + if not flavor_ref: + msg = _("Resize request has invalid 'flavorRef' attribute.") + raise exc.HTTPBadRequest(explanation=msg) + except (KeyError, TypeError): + msg = _("Resize requests require 'flavorRef' attribute.") + raise exc.HTTPBadRequest(explanation=msg) + + kwargs = {} + if 'auto_disk_config' in body['resize']: + kwargs['auto_disk_config'] = body['resize']['auto_disk_config'] + + return self._resize(req, id, flavor_ref, **kwargs) + + @wsgi.response(202) + @wsgi.serializers(xml=FullServerTemplate) + @wsgi.deserializers(xml=ActionDeserializer) + @wsgi.action('rebuild') + def _action_rebuild(self, req, id, body): + """Rebuild an instance with the given attributes.""" + try: + body = body['rebuild'] + except (KeyError, TypeError): + msg = _('Invalid request body') + raise exc.HTTPBadRequest(explanation=msg) + + try: + image_href = body["imageRef"] + except (KeyError, TypeError): + msg = _("Could not parse imageRef from request.") + raise exc.HTTPBadRequest(explanation=msg) + + image_href = self._image_uuid_from_href(image_href) + + try: + password = body['adminPass'] + except (KeyError, TypeError): + password = utils.generate_password() + + context = req.environ['nova.context'] + instance = self._get_server(context, req, id) + + attr_map = { + 'personality': 'files_to_inject', + 'name': 'display_name', + 'accessIPv4': 'access_ip_v4', + 'accessIPv6': 'access_ip_v6', + 'metadata': 'metadata', + 'auto_disk_config': 'auto_disk_config', + } + + if 'accessIPv4' in body: + self._validate_access_ipv4(body['accessIPv4']) + + if 'accessIPv6' in body: + self._validate_access_ipv6(body['accessIPv6']) + + if 'name' in body: + self._validate_server_name(body['name']) + + kwargs = {} + + for request_attribute, instance_attribute in attr_map.items(): + try: + kwargs[instance_attribute] = body[request_attribute] + except (KeyError, TypeError): + pass + + self._validate_metadata(kwargs.get('metadata', {})) + + if 'files_to_inject' in kwargs: + personality = kwargs['files_to_inject'] + kwargs['files_to_inject'] = self._get_injected_files(personality) + + try: + self.compute_api.rebuild(context, + instance, + image_href, + password, + **kwargs) + except exception.InstanceInvalidState as state_error: + common.raise_http_conflict_for_instance_invalid_state(state_error, + 'rebuild') + except exception.InstanceNotFound: + msg = _("Instance could not be found") + raise exc.HTTPNotFound(explanation=msg) + except exception.InvalidMetadataSize as error: + raise exc.HTTPRequestEntityTooLarge( + explanation=error.format_message()) + except exception.ImageNotFound: + msg = _("Cannot find image for rebuild") + raise exc.HTTPBadRequest(explanation=msg) + except (exception.ImageNotActive, + exception.InstanceTypeDiskTooSmall, + exception.InstanceTypeMemoryTooSmall, + exception.InvalidMetadata) as error: + raise exc.HTTPBadRequest(explanation=error.format_message()) + + instance = self._get_server(context, req, id) + + self._add_instance_faults(context, [instance]) + view = self._view_builder.show(req, instance) + + # Add on the adminPass attribute since the view doesn't do it + # unless instance passwords are disabled + if CONF.enable_instance_password: + view['server']['adminPass'] = password + + robj = wsgi.ResponseObject(view) + return self._add_location(robj) + + @wsgi.response(202) + @wsgi.serializers(xml=FullServerTemplate) + @wsgi.deserializers(xml=ActionDeserializer) + @wsgi.action('createImage') + @common.check_snapshots_enabled + def _action_create_image(self, req, id, body): + """Snapshot a server instance.""" + context = req.environ['nova.context'] + entity = body.get("createImage", {}) + + image_name = entity.get("name") + + if not image_name: + msg = _("createImage entity requires name attribute") + raise exc.HTTPBadRequest(explanation=msg) + + props = {} + metadata = entity.get('metadata', {}) + common.check_img_metadata_properties_quota(context, metadata) + try: + props.update(metadata) + except ValueError: + msg = _("Invalid metadata") + raise exc.HTTPBadRequest(explanation=msg) + + instance = self._get_server(context, req, id) + + bdms = self.compute_api.get_instance_bdms(context, instance) + + try: + if self.compute_api.is_volume_backed_instance(context, instance, + bdms): + img = instance['image_ref'] + src_image = self.compute_api.image_service.show(context, img) + image_meta = dict(src_image) + + image = self.compute_api.snapshot_volume_backed( + context, + instance, + image_meta, + image_name, + extra_properties=props) + else: + image = self.compute_api.snapshot(context, + instance, + image_name, + extra_properties=props) + except exception.InstanceInvalidState as state_error: + common.raise_http_conflict_for_instance_invalid_state(state_error, + 'createImage') + except exception.Invalid as err: + raise exc.HTTPBadRequest(explanation=str(err)) + + # build location of newly-created image entity + image_id = str(image['id']) + image_ref = os.path.join(req.application_url, + context.project_id, + 'images', + image_id) + + resp = webob.Response(status_int=202) + resp.headers['Location'] = image_ref + return resp + + def _get_server_admin_password(self, server): + """Determine the admin password for a server on creation.""" + try: + password = server['adminPass'] + self._validate_admin_password(password) + except KeyError: + password = utils.generate_password() + except ValueError: + raise exc.HTTPBadRequest(explanation=_("Invalid adminPass")) + + return password + + def _validate_admin_password(self, password): + if not isinstance(password, basestring): + raise ValueError() + + def _get_server_search_options(self): + """Return server search options allowed by non-admin.""" + return ('reservation_id', 'name', 'status', 'image', 'flavor', + 'changes-since', 'all_tenants') + + +def remove_invalid_options(context, search_options, allowed_search_options): + """Remove search options that are not valid for non-admin API/context.""" + if context.is_admin: + # Allow all options + return + # Otherwise, strip out all unknown options + unknown_options = [opt for opt in search_options + if opt not in allowed_search_options] + LOG.debug(_("Removing options '%s' from query"), + ", ".join(unknown_options)) + for opt in unknown_options: + search_options.pop(opt, None) + + +class Servers(extensions.V3APIExtensionBase): + """Servers.""" + + name = "Servers" + alias = "servers" + namespace = "http://docs.openstack.org/compute/core/servers/v3" + version = 1 + + def get_resources(self): + member_actions = {'action': 'POST'} + collection_actions = {'detail': 'GET'} + resources = [ + extensions.ResourceExtension( + 'servers', + ServersController(extension_info=self.extension_info), + member_name='server', collection_actions=collection_actions, + member_actions=member_actions)] + + return resources + + def get_controller_extensions(self): + return [] diff --git a/nova/api/openstack/extensions.py b/nova/api/openstack/extensions.py index 83e4b7ba5..6cbc5bb78 100644 --- a/nova/api/openstack/extensions.py +++ b/nova/api/openstack/extensions.py @@ -297,7 +297,7 @@ class ResourceExtension(object): def __init__(self, collection, controller=None, parent=None, collection_actions=None, member_actions=None, - custom_routes_fn=None, inherits=None): + custom_routes_fn=None, inherits=None, member_name=None): if not collection_actions: collection_actions = {} if not member_actions: @@ -309,6 +309,7 @@ class ResourceExtension(object): self.member_actions = member_actions self.custom_routes_fn = custom_routes_fn self.inherits = inherits + self.member_name = member_name def load_standard_extensions(ext_mgr, logger, path, package, ext_list=None): @@ -410,6 +411,9 @@ class V3APIExtensionBase(object): """ __metaclass__ = abc.ABCMeta + def __init__(self, extension_info): + self.extension_info = extension_info + @abc.abstractmethod def get_resources(self): """Return a list of resources extensions. diff --git a/nova/tests/api/openstack/compute/plugins/v3/test_keypairs.py b/nova/tests/api/openstack/compute/plugins/v3/test_keypairs.py new file mode 100644 index 000000000..529c5eb71 --- /dev/null +++ b/nova/tests/api/openstack/compute/plugins/v3/test_keypairs.py @@ -0,0 +1,378 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 Eldar Nugaev +# 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. + +from lxml import etree +import webob + +from nova.api.openstack.compute.plugins.v3 import keypairs +from nova.api.openstack import wsgi +from nova import db +from nova import exception +from nova.openstack.common import jsonutils +from nova import quota +from nova import test +from nova.tests.api.openstack import fakes + + +QUOTAS = quota.QUOTAS + + +def fake_keypair(name): + return {'public_key': 'FAKE_KEY', + 'fingerprint': 'FAKE_FINGERPRINT', + 'name': name} + + +def db_key_pair_get_all_by_user(self, user_id): + return [fake_keypair('FAKE')] + + +def db_key_pair_create(self, keypair): + return keypair + + +def db_key_pair_destroy(context, user_id, name): + if not (user_id and name): + raise Exception() + + +def db_key_pair_create_duplicate(context, keypair): + raise exception.KeyPairExists(key_name=keypair.get('name', '')) + + +class KeypairsTest(test.TestCase): + + def setUp(self): + super(KeypairsTest, self).setUp() + self.Controller = keypairs.Controller() + fakes.stub_out_networking(self.stubs) + fakes.stub_out_rate_limiting(self.stubs) + + self.stubs.Set(db, "key_pair_get_all_by_user", + db_key_pair_get_all_by_user) + self.stubs.Set(db, "key_pair_create", + db_key_pair_create) + self.stubs.Set(db, "key_pair_destroy", + db_key_pair_destroy) + self.flags( + osapi_compute_extension=[ + 'nova.api.openstack.compute.contrib.select_extensions'], + osapi_compute_ext_list=['Keypairs']) + self.app = fakes.wsgi_app_v3(init_only=('os-keypairs', 'servers')) + + def test_keypair_list(self): + req = webob.Request.blank('/v3/os-keypairs') + res = req.get_response(self.app) + self.assertEqual(res.status_int, 200) + res_dict = jsonutils.loads(res.body) + response = {'keypairs': [{'keypair': fake_keypair('FAKE')}]} + self.assertEqual(res_dict, response) + + def test_keypair_create(self): + body = {'keypair': {'name': 'create_test'}} + req = webob.Request.blank('/v3/os-keypairs') + req.method = 'POST' + req.body = jsonutils.dumps(body) + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + self.assertEqual(res.status_int, 200) + res_dict = jsonutils.loads(res.body) + self.assertTrue(len(res_dict['keypair']['fingerprint']) > 0) + self.assertTrue(len(res_dict['keypair']['private_key']) > 0) + + def test_keypair_create_with_empty_name(self): + body = {'keypair': {'name': ''}} + req = webob.Request.blank('/v3/os-keypairs') + req.method = 'POST' + req.body = jsonutils.dumps(body) + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + self.assertEqual(res.status_int, 400) + + def test_keypair_create_with_invalid_name(self): + body = { + 'keypair': { + 'name': 'a' * 256 + } + } + req = webob.Request.blank('/v3/os-keypairs') + req.method = 'POST' + req.body = jsonutils.dumps(body) + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + self.assertEqual(res.status_int, 400) + + def test_keypair_create_with_non_alphanumeric_name(self): + body = { + 'keypair': { + 'name': 'test/keypair' + } + } + req = webob.Request.blank('/v3/os-keypairs') + req.method = 'POST' + req.body = jsonutils.dumps(body) + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + res_dict = jsonutils.loads(res.body) + self.assertEqual(res.status_int, 400) + + def test_keypair_import(self): + body = { + 'keypair': { + 'name': 'create_test', + 'public_key': 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDBYIznA' + 'x9D7118Q1VKGpXy2HDiKyUTM8XcUuhQpo0srqb9rboUp4' + 'a9NmCwpWpeElDLuva707GOUnfaBAvHBwsRXyxHJjRaI6Y' + 'Qj2oLJwqvaSaWUbyT1vtryRqy6J3TecN0WINY71f4uymi' + 'MZP0wby4bKBcYnac8KiCIlvkEl0ETjkOGUq8OyWRmn7lj' + 'j5SESEUdBP0JnuTFKddWTU/wD6wydeJaUhBTqOlHn0kX1' + 'GyqoNTE1UEhcM5ZRWgfUZfTjVyDF2kGj3vJLCJtJ8LoGc' + 'j7YaN4uPg1rBle+izwE/tLonRrds+cev8p6krSSrxWOwB' + 'bHkXa6OciiJDvkRzJXzf', + }, + } + + req = webob.Request.blank('/v3/os-keypairs') + req.method = 'POST' + req.body = jsonutils.dumps(body) + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + self.assertEqual(res.status_int, 200) + # FIXME(ja): sholud we check that public_key was sent to create? + res_dict = jsonutils.loads(res.body) + self.assertTrue(len(res_dict['keypair']['fingerprint']) > 0) + self.assertFalse('private_key' in res_dict['keypair']) + + def test_keypair_import_quota_limit(self): + + def fake_quotas_count(self, context, resource, *args, **kwargs): + return 100 + + self.stubs.Set(QUOTAS, "count", fake_quotas_count) + + body = { + 'keypair': { + 'name': 'create_test', + 'public_key': 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDBYIznA' + 'x9D7118Q1VKGpXy2HDiKyUTM8XcUuhQpo0srqb9rboUp4' + 'a9NmCwpWpeElDLuva707GOUnfaBAvHBwsRXyxHJjRaI6Y' + 'Qj2oLJwqvaSaWUbyT1vtryRqy6J3TecN0WINY71f4uymi' + 'MZP0wby4bKBcYnac8KiCIlvkEl0ETjkOGUq8OyWRmn7lj' + 'j5SESEUdBP0JnuTFKddWTU/wD6wydeJaUhBTqOlHn0kX1' + 'GyqoNTE1UEhcM5ZRWgfUZfTjVyDF2kGj3vJLCJtJ8LoGc' + 'j7YaN4uPg1rBle+izwE/tLonRrds+cev8p6krSSrxWOwB' + 'bHkXa6OciiJDvkRzJXzf', + }, + } + + req = webob.Request.blank('/v3/os-keypairs') + req.method = 'POST' + req.body = jsonutils.dumps(body) + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + self.assertEqual(res.status_int, 413) + + def test_keypair_create_quota_limit(self): + + def fake_quotas_count(self, context, resource, *args, **kwargs): + return 100 + + self.stubs.Set(QUOTAS, "count", fake_quotas_count) + + body = { + 'keypair': { + 'name': 'create_test', + }, + } + + req = webob.Request.blank('/v3/os-keypairs') + req.method = 'POST' + req.body = jsonutils.dumps(body) + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + self.assertEqual(res.status_int, 413) + + def test_keypair_create_duplicate(self): + self.stubs.Set(db, "key_pair_create", db_key_pair_create_duplicate) + body = {'keypair': {'name': 'create_duplicate'}} + req = webob.Request.blank('/v3/os-keypairs') + req.method = 'POST' + req.body = jsonutils.dumps(body) + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + self.assertEqual(res.status_int, 409) + + def test_keypair_import_bad_key(self): + body = { + 'keypair': { + 'name': 'create_test', + 'public_key': 'ssh-what negative', + }, + } + + req = webob.Request.blank('/v3/os-keypairs') + req.method = 'POST' + req.body = jsonutils.dumps(body) + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + self.assertEqual(res.status_int, 400) + + def test_keypair_delete(self): + req = webob.Request.blank('/v3/os-keypairs/FAKE') + req.method = 'DELETE' + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + self.assertEqual(res.status_int, 202) + + def test_keypair_get_keypair_not_found(self): + req = webob.Request.blank('/v3/os-keypairs/DOESNOTEXIST') + res = req.get_response(self.app) + self.assertEqual(res.status_int, 404) + + def test_keypair_delete_not_found(self): + + def db_key_pair_get_not_found(context, user_id, name): + raise exception.KeypairNotFound(user_id=user_id, name=name) + + self.stubs.Set(db, "key_pair_get", + db_key_pair_get_not_found) + req = webob.Request.blank('/v3/os-keypairs/WHAT') + res = req.get_response(self.app) + self.assertEqual(res.status_int, 404) + + def test_keypair_show(self): + + def _db_key_pair_get(context, user_id, name): + return {'name': 'foo', 'public_key': 'XXX', 'fingerprint': 'YYY'} + + self.stubs.Set(db, "key_pair_get", _db_key_pair_get) + + req = webob.Request.blank('/v3/os-keypairs/FAKE') + req.method = 'GET' + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + res_dict = jsonutils.loads(res.body) + self.assertEqual(res.status_int, 200) + self.assertEqual('foo', res_dict['keypair']['name']) + self.assertEqual('XXX', res_dict['keypair']['public_key']) + self.assertEqual('YYY', res_dict['keypair']['fingerprint']) + + def test_keypair_show_not_found(self): + + def _db_key_pair_get(context, user_id, name): + raise exception.KeypairNotFound(user_id=user_id, name=name) + + self.stubs.Set(db, "key_pair_get", _db_key_pair_get) + + req = webob.Request.blank('/v3/os-keypairs/FAKE') + req.method = 'GET' + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + self.assertEqual(res.status_int, 404) + + def test_show_server(self): + self.stubs.Set(db, 'instance_get', + fakes.fake_instance_get()) + req = webob.Request.blank('/v3/servers/1') + req.headers['Content-Type'] = 'application/json' + response = req.get_response(self.app) + self.assertEquals(response.status_int, 200) + res_dict = jsonutils.loads(response.body) + self.assertTrue('key_name' in res_dict['server']) + self.assertEquals(res_dict['server']['key_name'], '') + + def test_detail_servers(self): + self.stubs.Set(db, 'instance_get_all_by_filters', + fakes.fake_instance_get_all_by_filters()) + req = fakes.HTTPRequest.blank('/v3/servers/detail') + res = req.get_response(self.app) + server_dicts = jsonutils.loads(res.body)['servers'] + self.assertEquals(len(server_dicts), 5) + + for server_dict in server_dicts: + self.assertTrue('key_name' in server_dict) + self.assertEquals(server_dict['key_name'], '') + + def test_keypair_create_with_invalid_keypairBody(self): + body = {'alpha': {'name': 'create_test'}} + req = webob.Request.blank('/v3/os-keypairs') + req.method = 'POST' + req.body = jsonutils.dumps(body) + req.headers['Content-Type'] = 'application/json' + res = req.get_response(self.app) + res_dict = jsonutils.loads(res.body) + self.assertEqual(res.status_int, 400) + self.assertEqual(res_dict['badRequest']['message'], + "Invalid request body") + + +class KeypairsXMLSerializerTest(test.TestCase): + def setUp(self): + super(KeypairsXMLSerializerTest, self).setUp() + self.deserializer = wsgi.XMLDeserializer() + + def test_default_serializer(self): + exemplar = dict(keypair=dict( + public_key='fake_public_key', + private_key='fake_private_key', + fingerprint='fake_fingerprint', + user_id='fake_user_id', + name='fake_key_name')) + serializer = keypairs.KeypairTemplate() + text = serializer.serialize(exemplar) + + tree = etree.fromstring(text) + + self.assertEqual('keypair', tree.tag) + for child in tree: + self.assertTrue(child.tag in exemplar['keypair']) + self.assertEqual(child.text, exemplar['keypair'][child.tag]) + + def test_index_serializer(self): + exemplar = dict(keypairs=[ + dict(keypair=dict( + name='key1_name', + public_key='key1_key', + fingerprint='key1_fingerprint')), + dict(keypair=dict( + name='key2_name', + public_key='key2_key', + fingerprint='key2_fingerprint'))]) + serializer = keypairs.KeypairsTemplate() + text = serializer.serialize(exemplar) + + tree = etree.fromstring(text) + + self.assertEqual('keypairs', tree.tag) + self.assertEqual(len(exemplar['keypairs']), len(tree)) + for idx, keypair in enumerate(tree): + self.assertEqual('keypair', keypair.tag) + kp_data = exemplar['keypairs'][idx]['keypair'] + for child in keypair: + self.assertTrue(child.tag in kp_data) + self.assertEqual(child.text, kp_data[child.tag]) + + def test_deserializer(self): + exemplar = dict(keypair=dict( + name='key_name', + public_key='public_key')) + intext = ("<?xml version='1.0' encoding='UTF-8'?>\n" + '<keypair><name>key_name</name>' + '<public_key>public_key</public_key></keypair>') + + result = self.deserializer.deserialize(intext)['body'] + self.assertEqual(result, exemplar) diff --git a/nova/tests/api/openstack/fakes.py b/nova/tests/api/openstack/fakes.py index 2316093c2..86fa3f750 100644 --- a/nova/tests/api/openstack/fakes.py +++ b/nova/tests/api/openstack/fakes.py @@ -100,6 +100,30 @@ def wsgi_app(inner_app_v2=None, fake_auth_context=None, return mapper +def wsgi_app_v3(inner_app_v3=None, fake_auth_context=None, + use_no_auth=False, ext_mgr=None, init_only=None): + if not inner_app_v3: + inner_app_v3 = compute.APIRouterV3(init_only) + + if use_no_auth: + api_v3 = openstack_api.FaultWrapper(auth.NoAuthMiddleware( + limits.RateLimitingMiddleware(inner_app_v3))) + else: + if fake_auth_context is not None: + ctxt = fake_auth_context + else: + ctxt = context.RequestContext('fake', 'fake', auth_token=True) + api_v3 = openstack_api.FaultWrapper(api_auth.InjectContext(ctxt, + limits.RateLimitingMiddleware(inner_app_v3))) + + mapper = urlmap.URLMap() + mapper['/v3'] = api_v3 + # TODO(cyeoh): bp nova-api-core-as-extensions + # Still need to implement versions for v3 API + # mapper['/'] = openstack_api.FaultWrapper(versions.Versions()) + return mapper + + def stub_out_key_pair_funcs(stubs, have_key_pair=True): def key_pair(context, user_id): return [dict(name='key', public_key='public_key')] diff --git a/nova/tests/fake_policy.py b/nova/tests/fake_policy.py index 1290ef80b..08a85c4de 100644 --- a/nova/tests/fake_policy.py +++ b/nova/tests/fake_policy.py @@ -152,6 +152,7 @@ policy_data = """ "compute_extension:instance_actions:events": "is_admin:True", "compute_extension:instance_usage_audit_log": "", "compute_extension:keypairs": "", + "compute_extension:v3:os-keypairs": "", "compute_extension:multinic": "", "compute_extension:networks": "", "compute_extension:networks:view": "", @@ -55,6 +55,12 @@ console_scripts = nova.api.v3.extensions = fixed_ips = nova.api.openstack.compute.plugins.v3.fixed_ips:FixedIPs + extension_info = nova.api.openstack.compute.plugins.v3.extension_info:ExtensionInfo + servers = nova.api.openstack.compute.plugins.v3.servers:Servers + keypairs = nova.api.openstack.compute.plugins.v3.keypairs:Keypairs + +nova.api.v3.extensions.server.create = + keypairs_create = nova.api.openstack.compute.plugins.v3.keypairs:Keypairs [build_sphinx] all_files = 1 |