diff options
-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 |