diff options
| author | Vishvananda Ishaya <vishvananda@yahoo.com> | 2010-09-23 11:22:44 -0700 |
|---|---|---|
| committer | Vishvananda Ishaya <vishvananda@yahoo.com> | 2010-09-23 11:22:44 -0700 |
| commit | 1afc0995bc77fbbb7f724e5086762f64ddc53c31 (patch) | |
| tree | a6c0847d01945cfba761b0991480212d07c2a06f | |
| parent | e74b8070f73d8bada01cfe2d26223e5180ab67fb (diff) | |
| parent | e6c1c9dcef9f57930783a64ecb0595ba8e714c4c (diff) | |
merged trunk
59 files changed, 3068 insertions, 1198 deletions
diff --git a/bin/nova-api b/bin/nova-api index ede09d38c..a5027700b 100755 --- a/bin/nova-api +++ b/bin/nova-api @@ -1,31 +1,28 @@ #!/usr/bin/env python +# pylint: disable-msg=C0103 # vim: tabstop=4 shiftwidth=4 softtabstop=4 # Copyright 2010 United States Government as represented by the # Administrator of the National Aeronautics and Space Administration. # 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 +# 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 +# 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. - +# 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. """ -Tornado daemon for the main API endpoint. +Nova API daemon. """ -import logging import os import sys -from tornado import httpserver -from tornado import ioloop # If ../nova/__init__.py exists, add ../ to Python search path, so that # it will override what happens to be installed in /usr/(local/)lib/python... @@ -36,28 +33,16 @@ if os.path.exists(os.path.join(possible_topdir, 'nova', '__init__.py')): sys.path.insert(0, possible_topdir) from nova import flags -from nova import server from nova import utils -from nova.endpoint import admin -from nova.endpoint import api -from nova.endpoint import cloud +from nova import server FLAGS = flags.FLAGS +flags.DEFINE_integer('api_port', 8773, 'API port') - -def main(_argv): - """Load the controllers and start the tornado I/O loop.""" - controllers = { - 'Cloud': cloud.CloudController(), - 'Admin': admin.AdminController()} - _app = api.APIServerApplication(controllers) - - io_inst = ioloop.IOLoop.instance() - http_server = httpserver.HTTPServer(_app) - http_server.listen(FLAGS.cc_port) - logging.debug('Started HTTP server on %s', FLAGS.cc_port) - io_inst.start() - +def main(_args): + from nova import api + from nova import wsgi + wsgi.run_server(api.API(), FLAGS.api_port) if __name__ == '__main__': utils.default_flagfile() diff --git a/bin/nova-manage b/bin/nova-manage index 325245ac4..baa1cb4db 100755 --- a/bin/nova-manage +++ b/bin/nova-manage @@ -50,7 +50,6 @@ """ CLI interface for nova management. - Connects to the running ADMIN api in the api daemon. """ import os @@ -68,11 +67,12 @@ if os.path.exists(os.path.join(possible_topdir, 'nova', '__init__.py')): sys.path.insert(0, possible_topdir) from nova import db +from nova import exception from nova import flags +from nova import quota from nova import utils from nova.auth import manager from nova.cloudpipe import pipelib -from nova.endpoint import cloud FLAGS = flags.FLAGS @@ -83,7 +83,7 @@ class VpnCommands(object): def __init__(self): self.manager = manager.AuthManager() - self.pipe = pipelib.CloudPipe(cloud.CloudController()) + self.pipe = pipelib.CloudPipe() def list(self): """Print a listing of the VPNs for all projects.""" @@ -186,6 +186,13 @@ class RoleCommands(object): class UserCommands(object): """Class for managing users.""" + @staticmethod + def _print_export(user): + """Print export variables to use with API.""" + print 'export EC2_ACCESS_KEY=%s' % user.access + print 'export EC2_SECRET_KEY=%s' % user.secret + + def __init__(self): self.manager = manager.AuthManager() @@ -193,13 +200,13 @@ class UserCommands(object): """creates a new admin and prints exports arguments: name [access] [secret]""" user = self.manager.create_user(name, access, secret, True) - print_export(user) + self._print_export(user) def create(self, name, access=None, secret=None): """creates a new user and prints exports arguments: name [access] [secret]""" user = self.manager.create_user(name, access, secret, False) - print_export(user) + self._print_export(user) def delete(self, name): """deletes an existing user @@ -211,7 +218,7 @@ class UserCommands(object): arguments: name""" user = self.manager.get_user(name) if user: - print_export(user) + self._print_export(user) else: print "User %s doesn't exist" % name @@ -222,12 +229,6 @@ class UserCommands(object): print user.name -def print_export(user): - """Print export variables to use with API.""" - print 'export EC2_ACCESS_KEY=%s' % user.access - print 'export EC2_SECRET_KEY=%s' % user.secret - - class ProjectCommands(object): """Class for managing projects.""" @@ -262,6 +263,19 @@ class ProjectCommands(object): for project in self.manager.get_projects(): print project.name + def quota(self, project_id, key=None, value=None): + """Set or display quotas for project + arguments: project_id [key] [value]""" + if key: + quo = {'project_id': project_id, key: value} + try: + db.quota_update(None, project_id, quo) + except exception.NotFound: + db.quota_create(None, quo) + project_quota = quota.get_quota(None, project_id) + for key, value in project_quota.iteritems(): + print '%s: %s' % (key, value) + def remove(self, project, user): """Removes user from project arguments: project user""" @@ -274,6 +288,7 @@ class ProjectCommands(object): with open(filename, 'w') as f: f.write(zip_file) + class FloatingIpCommands(object): """Class for managing floating ip.""" @@ -306,6 +321,7 @@ class FloatingIpCommands(object): floating_ip['address'], instance) + CATEGORIES = [ ('user', UserCommands), ('project', ProjectCommands), diff --git a/bin/nova-api-new b/bin/nova-scheduler index 8625c487f..38a8f213f 100755 --- a/bin/nova-api-new +++ b/bin/nova-scheduler @@ -1,24 +1,24 @@ #!/usr/bin/env python -# pylint: disable-msg=C0103 # vim: tabstop=4 shiftwidth=4 softtabstop=4 # Copyright 2010 United States Government as represented by the # Administrator of the National Aeronautics and Space Administration. # 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 +# 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 +# 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. +# 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. + """ -Nova API daemon. + Twistd daemon for the nova scheduler nodes. """ import os @@ -32,14 +32,12 @@ possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]), if os.path.exists(os.path.join(possible_topdir, 'nova', '__init__.py')): sys.path.insert(0, possible_topdir) -from nova import api -from nova import flags -from nova import utils -from nova import wsgi +from nova import service +from nova import twistd -FLAGS = flags.FLAGS -flags.DEFINE_integer('api_port', 8773, 'API port') if __name__ == '__main__': - utils.default_flagfile() - wsgi.run_server(api.API(), FLAGS.api_port) + twistd.serve(__file__) + +if __name__ == '__builtin__': + application = service.Service.create() diff --git a/doc/source/auth.rst b/doc/source/auth.rst index 70aca704a..3fcb309cd 100644 --- a/doc/source/auth.rst +++ b/doc/source/auth.rst @@ -172,14 +172,6 @@ Further Challenges -The :mod:`rbac` Module --------------------------- - -.. automodule:: nova.auth.rbac - :members: - :undoc-members: - :show-inheritance: - The :mod:`signer` Module ------------------------ diff --git a/nova/api/__init__.py b/nova/api/__init__.py index b9b9e3988..744abd621 100644 --- a/nova/api/__init__.py +++ b/nova/api/__init__.py @@ -21,17 +21,91 @@ Root WSGI middleware for all API controllers. """ import routes +import webob.dec +from nova import flags from nova import wsgi +from nova.api import cloudpipe from nova.api import ec2 from nova.api import rackspace +from nova.api.ec2 import metadatarequesthandler + + +flags.DEFINE_string('rsapi_subdomain', 'rs', + 'subdomain running the RS API') +flags.DEFINE_string('ec2api_subdomain', 'ec2', + 'subdomain running the EC2 API') +flags.DEFINE_string('FAKE_subdomain', None, + 'set to rs or ec2 to fake the subdomain of the host for testing') +FLAGS = flags.FLAGS class API(wsgi.Router): """Routes top-level requests to the appropriate controller.""" def __init__(self): + rsdomain = {'sub_domain': [FLAGS.rsapi_subdomain]} + ec2domain = {'sub_domain': [FLAGS.ec2api_subdomain]} + # If someone wants to pretend they're hitting the RS subdomain + # on their local box, they can set FAKE_subdomain to 'rs', which + # removes subdomain restrictions from the RS routes below. + if FLAGS.FAKE_subdomain == 'rs': + rsdomain = {} + elif FLAGS.FAKE_subdomain == 'ec2': + ec2domain = {} mapper = routes.Mapper() - mapper.connect("/v1.0/{path_info:.*}", controller=rackspace.API()) - mapper.connect("/ec2/{path_info:.*}", controller=ec2.API()) + mapper.sub_domains = True + mapper.connect("/", controller=self.rsapi_versions, + conditions=rsdomain) + mapper.connect("/v1.0/{path_info:.*}", controller=rackspace.API(), + conditions=rsdomain) + + mapper.connect("/", controller=self.ec2api_versions, + conditions=ec2domain) + mapper.connect("/services/{path_info:.*}", controller=ec2.API(), + conditions=ec2domain) + mapper.connect("/cloudpipe/{path_info:.*}", controller=cloudpipe.API()) + mrh = metadatarequesthandler.MetadataRequestHandler() + for s in ['/latest', + '/2009-04-04', + '/2008-09-01', + '/2008-02-01', + '/2007-12-15', + '/2007-10-10', + '/2007-08-29', + '/2007-03-01', + '/2007-01-19', + '/1.0']: + mapper.connect('%s/{path_info:.*}' % s, controller=mrh, + conditions=ec2domain) super(API, self).__init__(mapper) + + @webob.dec.wsgify + def rsapi_versions(self, req): + """Respond to a request for all OpenStack API versions.""" + response = { + "versions": [ + dict(status="CURRENT", id="v1.0")]} + metadata = { + "application/xml": { + "attributes": dict(version=["status", "id"])}} + return wsgi.Serializer(req.environ, metadata).to_content_type(response) + + @webob.dec.wsgify + def ec2api_versions(self, req): + """Respond to a request for all EC2 versions.""" + # available api versions + versions = [ + '1.0', + '2007-01-19', + '2007-03-01', + '2007-08-29', + '2007-10-10', + '2007-12-15', + '2008-02-01', + '2008-09-01', + '2009-04-04', + ] + return ''.join('%s\n' % v for v in versions) + + diff --git a/nova/api/cloudpipe/__init__.py b/nova/api/cloudpipe/__init__.py new file mode 100644 index 000000000..6d40990a8 --- /dev/null +++ b/nova/api/cloudpipe/__init__.py @@ -0,0 +1,69 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. + +""" +REST API Request Handlers for CloudPipe +""" + +import logging +import urllib +import webob +import webob.dec +import webob.exc + +from nova import crypto +from nova import wsgi +from nova.auth import manager +from nova.api.ec2 import cloud + + +_log = logging.getLogger("api") +_log.setLevel(logging.DEBUG) + + +class API(wsgi.Application): + + def __init__(self): + self.controller = cloud.CloudController() + + @webob.dec.wsgify + def __call__(self, req): + if req.method == 'POST': + return self.sign_csr(req) + _log.debug("Cloudpipe path is %s" % req.path_info) + if req.path_info.endswith("/getca/"): + return self.send_root_ca(req) + return webob.exc.HTTPNotFound() + + def get_project_id_from_ip(self, ip): + # TODO(eday): This was removed with the ORM branch, fix! + instance = self.controller.get_instance_by_ip(ip) + return instance['project_id'] + + def send_root_ca(self, req): + _log.debug("Getting root ca") + project_id = self.get_project_id_from_ip(req.remote_addr) + res = webob.Response() + res.headers["Content-Type"] = "text/plain" + res.body = crypto.fetch_ca(project_id) + return res + + def sign_csr(self, req): + project_id = self.get_project_id_from_ip(req.remote_addr) + cert = self.str_params['cert'] + return crypto.sign_csr(urllib.unquote(cert), project_id) diff --git a/nova/api/ec2/__init__.py b/nova/api/ec2/__init__.py index 6eec0abf7..f0aa57ee4 100644 --- a/nova/api/ec2/__init__.py +++ b/nova/api/ec2/__init__.py @@ -1,6 +1,7 @@ # vim: tabstop=4 shiftwidth=4 softtabstop=4 -# Copyright 2010 OpenStack LLC. +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. # All Rights Reserved. # # Licensed under the Apache License, Version 2.0 (the "License"); you may @@ -15,28 +16,223 @@ # License for the specific language governing permissions and limitations # under the License. -""" -WSGI middleware for EC2 API controllers. -""" +"""Starting point for routing EC2 requests""" +import logging import routes +import webob import webob.dec +import webob.exc +from nova import exception +from nova import flags from nova import wsgi +from nova.api.ec2 import apirequest +from nova.api.ec2 import context +from nova.api.ec2 import admin +from nova.api.ec2 import cloud +from nova.auth import manager -class API(wsgi.Router): - """Routes EC2 requests to the appropriate controller.""" +FLAGS = flags.FLAGS +_log = logging.getLogger("api") +_log.setLevel(logging.DEBUG) + + +class API(wsgi.Middleware): + + """Routing for all EC2 API requests.""" def __init__(self): - mapper = routes.Mapper() - mapper.connect(None, "{all:.*}", controller=self.dummy) - super(API, self).__init__(mapper) + self.application = Authenticate(Router(Authorizer(Executor()))) + + +class Authenticate(wsgi.Middleware): + + """Authenticate an EC2 request and add 'ec2.context' to WSGI environ.""" + + @webob.dec.wsgify + def __call__(self, req): + # Read request signature and access id. + try: + signature = req.params['Signature'] + access = req.params['AWSAccessKeyId'] + except: + raise webob.exc.HTTPBadRequest() + + # Make a copy of args for authentication and signature verification. + auth_params = dict(req.params) + auth_params.pop('Signature') # not part of authentication args + + # Authenticate the request. + try: + (user, project) = manager.AuthManager().authenticate( + access, + signature, + auth_params, + req.method, + req.host, + req.path) + except exception.Error, ex: + logging.debug("Authentication Failure: %s" % ex) + raise webob.exc.HTTPForbidden() + + # Authenticated! + req.environ['ec2.context'] = context.APIRequestContext(user, project) + return self.application + + +class Router(wsgi.Middleware): + + """Add ec2.'controller', .'action', and .'action_args' to WSGI environ.""" + + def __init__(self, application): + super(Router, self).__init__(application) + self.map = routes.Mapper() + self.map.connect("/{controller_name}/") + self.controllers = dict(Cloud=cloud.CloudController(), + Admin=admin.AdminController()) + + @webob.dec.wsgify + def __call__(self, req): + # Obtain the appropriate controller and action for this request. + try: + match = self.map.match(req.path_info) + controller_name = match['controller_name'] + controller = self.controllers[controller_name] + except: + raise webob.exc.HTTPNotFound() + non_args = ['Action', 'Signature', 'AWSAccessKeyId', 'SignatureMethod', + 'SignatureVersion', 'Version', 'Timestamp'] + args = dict(req.params) + try: + action = req.params['Action'] # raise KeyError if omitted + for non_arg in non_args: + args.pop(non_arg) # remove, but raise KeyError if omitted + except: + raise webob.exc.HTTPBadRequest() + + _log.debug('action: %s' % action) + for key, value in args.items(): + _log.debug('arg: %s\t\tval: %s' % (key, value)) + + # Success! + req.environ['ec2.controller'] = controller + req.environ['ec2.action'] = action + req.environ['ec2.action_args'] = args + return self.application + + +class Authorizer(wsgi.Middleware): + + """Authorize an EC2 API request. + + Return a 401 if ec2.controller and ec2.action in WSGI environ may not be + executed in ec2.context. + """ + + def __init__(self, application): + super(Authorizer, self).__init__(application) + self.action_roles = { + 'CloudController': { + 'DescribeAvailabilityzones': ['all'], + 'DescribeRegions': ['all'], + 'DescribeSnapshots': ['all'], + 'DescribeKeyPairs': ['all'], + 'CreateKeyPair': ['all'], + 'DeleteKeyPair': ['all'], + 'DescribeSecurityGroups': ['all'], + 'CreateSecurityGroup': ['netadmin'], + 'DeleteSecurityGroup': ['netadmin'], + 'GetConsoleOutput': ['projectmanager', 'sysadmin'], + 'DescribeVolumes': ['projectmanager', 'sysadmin'], + 'CreateVolume': ['projectmanager', 'sysadmin'], + 'AttachVolume': ['projectmanager', 'sysadmin'], + 'DetachVolume': ['projectmanager', 'sysadmin'], + 'DescribeInstances': ['all'], + 'DescribeAddresses': ['all'], + 'AllocateAddress': ['netadmin'], + 'ReleaseAddress': ['netadmin'], + 'AssociateAddress': ['netadmin'], + 'DisassociateAddress': ['netadmin'], + 'RunInstances': ['projectmanager', 'sysadmin'], + 'TerminateInstances': ['projectmanager', 'sysadmin'], + 'RebootInstances': ['projectmanager', 'sysadmin'], + 'DeleteVolume': ['projectmanager', 'sysadmin'], + 'DescribeImages': ['all'], + 'DeregisterImage': ['projectmanager', 'sysadmin'], + 'RegisterImage': ['projectmanager', 'sysadmin'], + 'DescribeImageAttribute': ['all'], + 'ModifyImageAttribute': ['projectmanager', 'sysadmin'], + }, + 'AdminController': { + # All actions have the same permission: ['none'] (the default) + # superusers will be allowed to run them + # all others will get HTTPUnauthorized. + }, + } - @staticmethod @webob.dec.wsgify - def dummy(req): - """Temporary dummy controller.""" - msg = "dummy response -- please hook up __init__() to cloud.py instead" - return repr({'dummy': msg, - 'kwargs': repr(req.environ['wsgiorg.routing_args'][1])}) + def __call__(self, req): + context = req.environ['ec2.context'] + controller_name = req.environ['ec2.controller'].__class__.__name__ + action = req.environ['ec2.action'] + allowed_roles = self.action_roles[controller_name].get(action, ['none']) + if self._matches_any_role(context, allowed_roles): + return self.application + else: + raise webob.exc.HTTPUnauthorized() + + def _matches_any_role(self, context, roles): + """Return True if any role in roles is allowed in context.""" + if context.user.is_superuser(): + return True + if 'all' in roles: + return True + if 'none' in roles: + return False + return any(context.project.has_role(context.user.id, role) + for role in roles) + + +class Executor(wsgi.Application): + + """Execute an EC2 API request. + + Executes 'ec2.action' upon 'ec2.controller', passing 'ec2.context' and + 'ec2.action_args' (all variables in WSGI environ.) Returns an XML + response, or a 400 upon failure. + """ + + @webob.dec.wsgify + def __call__(self, req): + context = req.environ['ec2.context'] + controller = req.environ['ec2.controller'] + action = req.environ['ec2.action'] + args = req.environ['ec2.action_args'] + + api_request = apirequest.APIRequest(controller, action) + try: + result = api_request.send(context, **args) + req.headers['Content-Type'] = 'text/xml' + return result + except exception.ApiError as ex: + + if ex.code: + return self._error(req, ex.code, ex.message) + else: + return self._error(req, type(ex).__name__, ex.message) + # TODO(vish): do something more useful with unknown exceptions + except Exception as ex: + return self._error(req, type(ex).__name__, str(ex)) + + def _error(self, req, code, message): + resp = webob.Response() + resp.status = 400 + resp.headers['Content-Type'] = 'text/xml' + resp.body = ('<?xml version="1.0"?>\n' + '<Response><Errors><Error><Code>%s</Code>' + '<Message>%s</Message></Error></Errors>' + '<RequestID>?</RequestID></Response>') % (code, message) + return resp + diff --git a/nova/endpoint/admin.py b/nova/api/ec2/admin.py index c6dcb5320..36feae451 100644 --- a/nova/endpoint/admin.py +++ b/nova/api/ec2/admin.py @@ -58,46 +58,27 @@ def host_dict(host): return {} -def admin_only(target): - """Decorator for admin-only API calls""" - def wrapper(*args, **kwargs): - """Internal wrapper method for admin-only API calls""" - context = args[1] - if context.user.is_admin(): - return target(*args, **kwargs) - else: - return {} - - return wrapper - - class AdminController(object): """ API Controller for users, hosts, nodes, and workers. - Trivial admin_only wrapper will be replaced with RBAC, - allowing project managers to administer project users. """ def __str__(self): return 'AdminController' - @admin_only def describe_user(self, _context, name, **_kwargs): """Returns user data, including access and secret keys.""" return user_dict(manager.AuthManager().get_user(name)) - @admin_only def describe_users(self, _context, **_kwargs): """Returns all users - should be changed to deal with a list.""" return {'userSet': [user_dict(u) for u in manager.AuthManager().get_users()] } - @admin_only def register_user(self, _context, name, **_kwargs): """Creates a new user, and returns generated credentials.""" return user_dict(manager.AuthManager().create_user(name)) - @admin_only def deregister_user(self, _context, name, **_kwargs): """Deletes a single user (NOT undoable.) Should throw an exception if the user has instances, @@ -107,13 +88,11 @@ class AdminController(object): return True - @admin_only def describe_roles(self, context, project_roles=True, **kwargs): """Returns a list of allowed roles.""" roles = manager.AuthManager().get_roles(project_roles) return { 'roles': [{'role': r} for r in roles]} - @admin_only def describe_user_roles(self, context, user, project=None, **kwargs): """Returns a list of roles for the given user. Omitting project will return any global roles that the user has. @@ -122,7 +101,6 @@ class AdminController(object): roles = manager.AuthManager().get_user_roles(user, project=project) return { 'roles': [{'role': r} for r in roles]} - @admin_only def modify_user_role(self, context, user, role, project=None, operation='add', **kwargs): """Add or remove a role for a user and project.""" @@ -135,7 +113,6 @@ class AdminController(object): return True - @admin_only def generate_x509_for_user(self, _context, name, project=None, **kwargs): """Generates and returns an x509 certificate for a single user. Is usually called from a client that will wrap this with @@ -147,19 +124,16 @@ class AdminController(object): user = manager.AuthManager().get_user(name) return user_dict(user, base64.b64encode(project.get_credentials(user))) - @admin_only def describe_project(self, context, name, **kwargs): """Returns project data, including member ids.""" return project_dict(manager.AuthManager().get_project(name)) - @admin_only def describe_projects(self, context, user=None, **kwargs): """Returns all projects - should be changed to deal with a list.""" return {'projectSet': [project_dict(u) for u in manager.AuthManager().get_projects(user=user)]} - @admin_only def register_project(self, context, name, manager_user, description=None, member_users=None, **kwargs): """Creates a new project""" @@ -170,20 +144,17 @@ class AdminController(object): description=None, member_users=None)) - @admin_only def deregister_project(self, context, name): """Permanently deletes a project.""" manager.AuthManager().delete_project(name) return True - @admin_only def describe_project_members(self, context, name, **kwargs): project = manager.AuthManager().get_project(name) result = { 'members': [{'member': m} for m in project.member_ids]} return result - @admin_only def modify_project_member(self, context, user, project, operation, **kwargs): """Add or remove a user from a project.""" if operation =='add': @@ -196,7 +167,6 @@ class AdminController(object): # FIXME(vish): these host commands don't work yet, perhaps some of the # required data can be retrieved from service objects? - @admin_only def describe_hosts(self, _context, **_kwargs): """Returns status info for all nodes. Includes: * Disk Space @@ -208,7 +178,6 @@ class AdminController(object): """ return {'hostSet': [host_dict(h) for h in db.host_get_all()]} - @admin_only def describe_host(self, _context, name, **_kwargs): """Returns status info for single node.""" return host_dict(db.host_get(name)) diff --git a/nova/api/ec2/apirequest.py b/nova/api/ec2/apirequest.py new file mode 100644 index 000000000..a87c21fb3 --- /dev/null +++ b/nova/api/ec2/apirequest.py @@ -0,0 +1,131 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. + +""" +APIRequest class +""" + +import logging +import re +# TODO(termie): replace minidom with etree +from xml.dom import minidom + +_log = logging.getLogger("api") +_log.setLevel(logging.DEBUG) + + +_c2u = re.compile('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))') + + +def _camelcase_to_underscore(str): + return _c2u.sub(r'_\1', str).lower().strip('_') + + +def _underscore_to_camelcase(str): + return ''.join([x[:1].upper() + x[1:] for x in str.split('_')]) + + +def _underscore_to_xmlcase(str): + res = _underscore_to_camelcase(str) + return res[:1].lower() + res[1:] + + +class APIRequest(object): + def __init__(self, controller, action): + self.controller = controller + self.action = action + + def send(self, context, **kwargs): + try: + method = getattr(self.controller, + _camelcase_to_underscore(self.action)) + except AttributeError: + _error = ('Unsupported API request: controller = %s,' + 'action = %s') % (self.controller, self.action) + _log.warning(_error) + # TODO: Raise custom exception, trap in apiserver, + # and reraise as 400 error. + raise Exception(_error) + + args = {} + for key, value in kwargs.items(): + parts = key.split(".") + key = _camelcase_to_underscore(parts[0]) + if len(parts) > 1: + d = args.get(key, {}) + d[parts[1]] = value + value = d + args[key] = value + + for key in args.keys(): + if isinstance(args[key], dict): + if args[key] != {} and args[key].keys()[0].isdigit(): + s = args[key].items() + s.sort() + args[key] = [v for k, v in s] + + result = method(context, **args) + return self._render_response(result, context.request_id) + + def _render_response(self, response_data, request_id): + xml = minidom.Document() + + response_el = xml.createElement(self.action + 'Response') + response_el.setAttribute('xmlns', + 'http://ec2.amazonaws.com/doc/2009-11-30/') + request_id_el = xml.createElement('requestId') + request_id_el.appendChild(xml.createTextNode(request_id)) + response_el.appendChild(request_id_el) + if(response_data == True): + self._render_dict(xml, response_el, {'return': 'true'}) + else: + self._render_dict(xml, response_el, response_data) + + xml.appendChild(response_el) + + response = xml.toxml() + xml.unlink() + _log.debug(response) + return response + + def _render_dict(self, xml, el, data): + try: + for key in data.keys(): + val = data[key] + el.appendChild(self._render_data(xml, key, val)) + except: + _log.debug(data) + raise + + def _render_data(self, xml, el_name, data): + el_name = _underscore_to_xmlcase(el_name) + data_el = xml.createElement(el_name) + + if isinstance(data, list): + for item in data: + data_el.appendChild(self._render_data(xml, 'item', item)) + elif isinstance(data, dict): + self._render_dict(xml, data_el, data) + elif hasattr(data, '__dict__'): + self._render_dict(xml, data_el, data.__dict__) + elif isinstance(data, bool): + data_el.appendChild(xml.createTextNode(str(data).lower())) + elif data != None: + data_el.appendChild(xml.createTextNode(str(data))) + + return data_el diff --git a/nova/endpoint/cloud.py b/nova/api/ec2/cloud.py index 622b4e2a4..367511e3b 100644 --- a/nova/endpoint/cloud.py +++ b/nova/api/ec2/cloud.py @@ -23,34 +23,51 @@ datastore. """ import base64 +import datetime import logging import os import time -from twisted.internet import defer - +from nova import crypto from nova import db from nova import exception from nova import flags +from nova import quota from nova import rpc from nova import utils -from nova.auth import rbac -from nova.auth import manager from nova.compute.instance_types import INSTANCE_TYPES -from nova.endpoint import images +from nova.api.ec2 import images FLAGS = flags.FLAGS flags.DECLARE('storage_availability_zone', 'nova.volume.manager') -def _gen_key(user_id, key_name): - """ Tuck this into AuthManager """ +class QuotaError(exception.ApiError): + """Quota Exceeeded""" + pass + + +def _gen_key(context, user_id, key_name): + """Generate a key + + This is a module level method because it is slow and we need to defer + it into a process pool.""" + # NOTE(vish): generating key pair is slow so check for legal + # creation before creating key_pair try: - mgr = manager.AuthManager() - private_key, fingerprint = mgr.generate_key_pair(user_id, key_name) - except Exception as ex: - return {'exception': ex} + db.key_pair_get(context, user_id, key_name) + raise exception.Duplicate("The key_pair %s already exists" + % key_name) + except exception.NotFound: + pass + private_key, public_key, fingerprint = crypto.generate_key_pair() + key = {} + key['user_id'] = user_id + key['name'] = key_name + key['public_key'] = public_key + key['fingerprint'] = fingerprint + db.key_pair_create(context, key) return {'private_key': private_key, 'fingerprint': fingerprint} @@ -88,10 +105,11 @@ class CloudController(object): if instance['fixed_ip']: line = '%s slots=%d' % (instance['fixed_ip']['str_id'], INSTANCE_TYPES[instance['instance_type']]['vcpus']) - if instance['key_name'] in result: - result[instance['key_name']].append(line) + key = str(instance['key_name']) + if key in result: + result[key].append(line) else: - result[instance['key_name']] = [line] + result[key] = [line] return result def get_metadata(self, address): @@ -148,18 +166,24 @@ class CloudController(object): data['product-codes'] = [] return data - @rbac.allow('all') def describe_availability_zones(self, context, **kwargs): return {'availabilityZoneInfo': [{'zoneName': 'nova', 'zoneState': 'available'}]} - @rbac.allow('all') def describe_regions(self, context, region_name=None, **kwargs): - # TODO(vish): region_name is an array. Support filtering - return {'regionInfo': [{'regionName': 'nova', - 'regionUrl': FLAGS.ec2_url}]} + if FLAGS.region_list: + regions = [] + for region in FLAGS.region_list: + name, _sep, url = region.partition('=') + regions.append({'regionName': name, + 'regionEndpoint': url}) + else: + regions = [{'regionName': 'nova', + 'regionEndpoint': FLAGS.ec2_url}] + if region_name: + regions = [r for r in regions if r['regionName'] in region_name] + return {'regionInfo': regions } - @rbac.allow('all') def describe_snapshots(self, context, snapshot_id=None, @@ -175,61 +199,50 @@ class CloudController(object): 'volumeSize': 0, 'description': 'fixme'}]} - @rbac.allow('all') def describe_key_pairs(self, context, key_name=None, **kwargs): - key_pairs = context.user.get_key_pairs() + key_pairs = db.key_pair_get_all_by_user(context, context.user.id) if not key_name is None: - key_pairs = [x for x in key_pairs if x.name in key_name] + key_pairs = [x for x in key_pairs if x['name'] in key_name] result = [] for key_pair in key_pairs: # filter out the vpn keys suffix = FLAGS.vpn_key_suffix - if context.user.is_admin() or not key_pair.name.endswith(suffix): + if context.user.is_admin() or not key_pair['name'].endswith(suffix): result.append({ - 'keyName': key_pair.name, - 'keyFingerprint': key_pair.fingerprint, + 'keyName': key_pair['name'], + 'keyFingerprint': key_pair['fingerprint'], }) return {'keypairsSet': result} - @rbac.allow('all') def create_key_pair(self, context, key_name, **kwargs): - dcall = defer.Deferred() - pool = context.handler.application.settings.get('pool') - def _complete(kwargs): - if 'exception' in kwargs: - dcall.errback(kwargs['exception']) - return - dcall.callback({'keyName': key_name, - 'keyFingerprint': kwargs['fingerprint'], - 'keyMaterial': kwargs['private_key']}) - pool.apply_async(_gen_key, [context.user.id, key_name], - callback=_complete) - return dcall - - @rbac.allow('all') + data = _gen_key(None, context.user.id, key_name) + return {'keyName': key_name, + 'keyFingerprint': data['fingerprint'], + 'keyMaterial': data['private_key']} + # TODO(vish): when context is no longer an object, pass it here + def delete_key_pair(self, context, key_name, **kwargs): - context.user.delete_key_pair(key_name) - # aws returns true even if the key doens't exist + try: + db.key_pair_destroy(context, context.user.id, key_name) + except exception.NotFound: + # aws returns true even if the key doesn't exist + pass return True - @rbac.allow('all') def describe_security_groups(self, context, group_names, **kwargs): groups = {'securityGroupSet': []} # Stubbed for now to unblock other things. return groups - @rbac.allow('netadmin') def create_security_group(self, context, group_name, **kwargs): return True - @rbac.allow('netadmin') def delete_security_group(self, context, group_name, **kwargs): return True - @rbac.allow('projectmanager', 'sysadmin') def get_console_output(self, context, instance_id, **kwargs): # instance_id is passed in as a list of instances instance_ref = db.instance_get_by_str(context, instance_id[0]) @@ -239,7 +252,6 @@ class CloudController(object): "args": {"context": None, "instance_id": instance_ref['id']}}) - @rbac.allow('projectmanager', 'sysadmin') def describe_volumes(self, context, **kwargs): if context.user.is_admin(): volumes = db.volume_get_all(context) @@ -275,8 +287,14 @@ class CloudController(object): v['attachmentSet'] = [{}] return v - @rbac.allow('projectmanager', 'sysadmin') def create_volume(self, context, size, **kwargs): + # check quota + if quota.allowed_volumes(context, 1, size) < 1: + logging.warn("Quota exceeeded for %s, tried to create %sG volume", + context.project.id, size) + raise QuotaError("Volume quota exceeded. You cannot " + "create a volume of size %s" % + size) vol = {} vol['size'] = size vol['user_id'] = context.user.id @@ -286,17 +304,20 @@ class CloudController(object): vol['attach_status'] = "detached" volume_ref = db.volume_create(context, vol) - rpc.cast(FLAGS.volume_topic, {"method": "create_volume", - "args": {"context": None, - "volume_id": volume_ref['id']}}) + rpc.cast(FLAGS.scheduler_topic, + {"method": "create_volume", + "args": {"context": None, + "topic": FLAGS.volume_topic, + "volume_id": volume_ref['id']}}) return {'volumeSet': [self._format_volume(context, volume_ref)]} - @rbac.allow('projectmanager', 'sysadmin') def attach_volume(self, context, volume_id, instance_id, device, **kwargs): volume_ref = db.volume_get_by_str(context, volume_id) # TODO(vish): abstract status checking? + if volume_ref['status'] != "available": + raise exception.ApiError("Volume status must be available") if volume_ref['attach_status'] == "attached": raise exception.ApiError("Volume is already attached") instance_ref = db.instance_get_by_str(context, instance_id) @@ -307,22 +328,21 @@ class CloudController(object): "volume_id": volume_ref['id'], "instance_id": instance_ref['id'], "mountpoint": device}}) - return defer.succeed({'attachTime': volume_ref['attach_time'], - 'device': volume_ref['mountpoint'], - 'instanceId': instance_ref['id'], - 'requestId': context.request_id, - 'status': volume_ref['attach_status'], - 'volumeId': volume_ref['id']}) - - @rbac.allow('projectmanager', 'sysadmin') + return {'attachTime': volume_ref['attach_time'], + 'device': volume_ref['mountpoint'], + 'instanceId': instance_ref['id'], + 'requestId': context.request_id, + 'status': volume_ref['attach_status'], + 'volumeId': volume_ref['id']} + def detach_volume(self, context, volume_id, **kwargs): volume_ref = db.volume_get_by_str(context, volume_id) instance_ref = db.volume_get_instance(context, volume_ref['id']) if not instance_ref: - raise exception.Error("Volume isn't attached to anything!") + raise exception.ApiError("Volume isn't attached to anything!") # TODO(vish): abstract status checking? if volume_ref['status'] == "available": - raise exception.Error("Volume is already detached") + raise exception.ApiError("Volume is already detached") try: host = instance_ref['host'] rpc.cast(db.queue_get_for(context, FLAGS.compute_topic, host), @@ -334,12 +354,12 @@ class CloudController(object): # If the instance doesn't exist anymore, # then we need to call detach blind db.volume_detached(context) - return defer.succeed({'attachTime': volume_ref['attach_time'], - 'device': volume_ref['mountpoint'], - 'instanceId': instance_ref['str_id'], - 'requestId': context.request_id, - 'status': volume_ref['attach_status'], - 'volumeId': volume_ref['id']}) + return {'attachTime': volume_ref['attach_time'], + 'device': volume_ref['mountpoint'], + 'instanceId': instance_ref['str_id'], + 'requestId': context.request_id, + 'status': volume_ref['attach_status'], + 'volumeId': volume_ref['id']} def _convert_to_set(self, lst, label): if lst == None or lst == []: @@ -348,9 +368,8 @@ class CloudController(object): lst = [lst] return [{label: x} for x in lst] - @rbac.allow('all') def describe_instances(self, context, **kwargs): - return defer.succeed(self._format_describe_instances(context)) + return self._format_describe_instances(context) def _format_describe_instances(self, context): return { 'reservationSet': self._format_instances(context) } @@ -366,7 +385,7 @@ class CloudController(object): instances = db.instance_get_by_reservation(context, reservation_id) else: - if not context.user.is_admin(): + if context.user.is_admin(): instances = db.instance_get_all(context) else: instances = db.instance_get_by_project(context, @@ -412,7 +431,6 @@ class CloudController(object): return list(reservations.values()) - @rbac.allow('all') def describe_addresses(self, context, **kwargs): return self.format_addresses(context) @@ -438,68 +456,79 @@ class CloudController(object): addresses.append(address_rv) return {'addressesSet': addresses} - @rbac.allow('netadmin') - @defer.inlineCallbacks def allocate_address(self, context, **kwargs): - network_topic = yield self._get_network_topic(context) - public_ip = yield rpc.call(network_topic, + # check quota + if quota.allowed_floating_ips(context, 1) < 1: + logging.warn("Quota exceeeded for %s, tried to allocate address", + context.project.id) + raise QuotaError("Address quota exceeded. You cannot " + "allocate any more addresses") + network_topic = self._get_network_topic(context) + public_ip = rpc.call(network_topic, {"method": "allocate_floating_ip", "args": {"context": None, "project_id": context.project.id}}) - defer.returnValue({'addressSet': [{'publicIp': public_ip}]}) + return {'addressSet': [{'publicIp': public_ip}]} - @rbac.allow('netadmin') - @defer.inlineCallbacks def release_address(self, context, public_ip, **kwargs): # NOTE(vish): Should we make sure this works? floating_ip_ref = db.floating_ip_get_by_address(context, public_ip) - network_topic = yield self._get_network_topic(context) + network_topic = self._get_network_topic(context) rpc.cast(network_topic, {"method": "deallocate_floating_ip", "args": {"context": None, "floating_address": floating_ip_ref['str_id']}}) - defer.returnValue({'releaseResponse': ["Address released."]}) + return {'releaseResponse': ["Address released."]} - @rbac.allow('netadmin') - @defer.inlineCallbacks def associate_address(self, context, instance_id, public_ip, **kwargs): instance_ref = db.instance_get_by_str(context, instance_id) fixed_ip_ref = db.fixed_ip_get_by_instance(context, instance_ref['id']) floating_ip_ref = db.floating_ip_get_by_address(context, public_ip) - network_topic = yield self._get_network_topic(context) + network_topic = self._get_network_topic(context) rpc.cast(network_topic, {"method": "associate_floating_ip", "args": {"context": None, "floating_address": floating_ip_ref['str_id'], "fixed_address": fixed_ip_ref['str_id']}}) - defer.returnValue({'associateResponse': ["Address associated."]}) + return {'associateResponse': ["Address associated."]} - @rbac.allow('netadmin') - @defer.inlineCallbacks def disassociate_address(self, context, public_ip, **kwargs): floating_ip_ref = db.floating_ip_get_by_address(context, public_ip) - network_topic = yield self._get_network_topic(context) + network_topic = self._get_network_topic(context) rpc.cast(network_topic, {"method": "disassociate_floating_ip", "args": {"context": None, "floating_address": floating_ip_ref['str_id']}}) - defer.returnValue({'disassociateResponse': ["Address disassociated."]}) + return {'disassociateResponse': ["Address disassociated."]} - @defer.inlineCallbacks def _get_network_topic(self, context): """Retrieves the network host for a project""" network_ref = db.project_get_network(context, context.project.id) host = network_ref['host'] if not host: - host = yield rpc.call(FLAGS.network_topic, + host = rpc.call(FLAGS.network_topic, {"method": "set_network_host", "args": {"context": None, "project_id": context.project.id}}) - defer.returnValue(db.queue_get_for(context, FLAGS.network_topic, host)) + return db.queue_get_for(context, FLAGS.network_topic, host) - @rbac.allow('projectmanager', 'sysadmin') - @defer.inlineCallbacks def run_instances(self, context, **kwargs): + instance_type = kwargs.get('instance_type', 'm1.small') + if instance_type not in INSTANCE_TYPES: + raise exception.ApiError("Unknown instance type: %s", + instance_type) + # check quota + max_instances = int(kwargs.get('max_count', 1)) + min_instances = int(kwargs.get('min_count', max_instances)) + num_instances = quota.allowed_instances(context, + max_instances, + instance_type) + if num_instances < min_instances: + logging.warn("Quota exceeeded for %s, tried to run %s instances", + context.project.id, min_instances) + raise QuotaError("Instance quota exceeded. You can only " + "run %s more instances of this type." % + num_instances, "InstanceLimitExceeded") # make sure user can access the image # vpn image is private so it doesn't show up on lists vpn = kwargs['image_id'] == FLAGS.vpn_image_id @@ -521,21 +550,21 @@ class CloudController(object): images.get(context, kernel_id) images.get(context, ramdisk_id) - logging.debug("Going to run instances...") + logging.debug("Going to run %s instances...", num_instances) launch_time = time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()) key_data = None if kwargs.has_key('key_name'): - key_pair = context.user.get_key_pair(kwargs['key_name']) - if not key_pair: - raise exception.ApiError('Key Pair %s not found' % - kwargs['key_name']) - key_data = key_pair.public_key + key_pair_ref = db.key_pair_get(context, + context.user.id, + kwargs['key_name']) + key_data = key_pair_ref['public_key'] # TODO: Get the real security group of launch in here security_group = "default" reservation_id = utils.generate_uid('r') base_options = {} + base_options['state_description'] = 'scheduling' base_options['image_id'] = image_id base_options['kernel_id'] = kernel_id base_options['ramdisk_id'] = ramdisk_id @@ -545,10 +574,15 @@ class CloudController(object): base_options['user_id'] = context.user.id base_options['project_id'] = context.project.id base_options['user_data'] = kwargs.get('user_data', '') - base_options['instance_type'] = kwargs.get('instance_type', 'm1.small') base_options['security_group'] = security_group + base_options['instance_type'] = instance_type + + type_data = INSTANCE_TYPES[instance_type] + base_options['memory_mb'] = type_data['memory_mb'] + base_options['vcpus'] = type_data['vcpus'] + base_options['local_gb'] = type_data['local_gb'] - for num in range(int(kwargs['max_count'])): + for num in range(num_instances): instance_ref = db.instance_create(context, base_options) inst_id = instance_ref['id'] @@ -563,24 +597,22 @@ class CloudController(object): # TODO(vish): This probably should be done in the scheduler # network is setup when host is assigned - network_topic = yield self._get_network_topic(context) + network_topic = self._get_network_topic(context) rpc.call(network_topic, {"method": "setup_fixed_ip", "args": {"context": None, "address": address}}) - rpc.cast(FLAGS.compute_topic, - {"method": "run_instance", - "args": {"context": None, - "instance_id": inst_id}}) - logging.debug("Casting to node for %s/%s's instance %s" % + rpc.cast(FLAGS.scheduler_topic, + {"method": "run_instance", + "args": {"context": None, + "topic": FLAGS.compute_topic, + "instance_id": inst_id}}) + logging.debug("Casting to scheduler for %s/%s's instance %s" % (context.project.name, context.user.name, inst_id)) - defer.returnValue(self._format_run_instances(context, - reservation_id)) + return self._format_run_instances(context, reservation_id) - @rbac.allow('projectmanager', 'sysadmin') - @defer.inlineCallbacks def terminate_instances(self, context, instance_id, **kwargs): logging.debug("Going to start terminating instances") for id_str in instance_id: @@ -592,6 +624,10 @@ class CloudController(object): % id_str) continue + now = datetime.datetime.utcnow() + db.instance_update(context, + instance_ref['id'], + {'terminated_at': now}) # FIXME(ja): where should network deallocate occur? address = db.instance_get_floating_address(context, instance_ref['id']) @@ -600,7 +636,7 @@ class CloudController(object): # NOTE(vish): Right now we don't really care if the ip is # disassociated. We may need to worry about # checking this later. Perhaps in the scheduler? - network_topic = yield self._get_network_topic(context) + network_topic = self._get_network_topic(context) rpc.cast(network_topic, {"method": "disassociate_floating_ip", "args": {"context": None, @@ -613,7 +649,7 @@ class CloudController(object): # NOTE(vish): Currently, nothing needs to be done on the # network node until release. If this changes, # we will need to cast here. - self.network.deallocate_fixed_ip(context, address) + self.network_manager.deallocate_fixed_ip(context, address) host = instance_ref['host'] if host: @@ -623,9 +659,8 @@ class CloudController(object): "instance_id": instance_ref['id']}}) else: db.instance_destroy(context, instance_ref['id']) - defer.returnValue(True) + return True - @rbac.allow('projectmanager', 'sysadmin') def reboot_instances(self, context, instance_id, **kwargs): """instance_id is a list of instance ids""" for id_str in instance_id: @@ -635,42 +670,40 @@ class CloudController(object): {"method": "reboot_instance", "args": {"context": None, "instance_id": instance_ref['id']}}) - return defer.succeed(True) + return True - @rbac.allow('projectmanager', 'sysadmin') def delete_volume(self, context, volume_id, **kwargs): # TODO: return error if not authorized volume_ref = db.volume_get_by_str(context, volume_id) + if volume_ref['status'] != "available": + raise exception.ApiError("Volume status must be available") + now = datetime.datetime.utcnow() + db.volume_update(context, volume_ref['id'], {'terminated_at': now}) host = volume_ref['host'] rpc.cast(db.queue_get_for(context, FLAGS.volume_topic, host), {"method": "delete_volume", "args": {"context": None, "volume_id": volume_ref['id']}}) - return defer.succeed(True) + return True - @rbac.allow('all') def describe_images(self, context, image_id=None, **kwargs): # The objectstore does its own authorization for describe imageSet = images.list(context, image_id) - return defer.succeed({'imagesSet': imageSet}) + return {'imagesSet': imageSet} - @rbac.allow('projectmanager', 'sysadmin') def deregister_image(self, context, image_id, **kwargs): # FIXME: should the objectstore be doing these authorization checks? images.deregister(context, image_id) - return defer.succeed({'imageId': image_id}) + return {'imageId': image_id} - @rbac.allow('projectmanager', 'sysadmin') def register_image(self, context, image_location=None, **kwargs): # FIXME: should the objectstore be doing these authorization checks? if image_location is None and kwargs.has_key('name'): image_location = kwargs['name'] image_id = images.register(context, image_location) logging.debug("Registered %s as %s" % (image_location, image_id)) + return {'imageId': image_id} - return defer.succeed({'imageId': image_id}) - - @rbac.allow('all') def describe_image_attribute(self, context, image_id, attribute, **kwargs): if attribute != 'launchPermission': raise exception.ApiError('attribute not supported: %s' % attribute) @@ -681,9 +714,8 @@ class CloudController(object): result = {'image_id': image_id, 'launchPermission': []} if image['isPublic']: result['launchPermission'].append({'group': 'all'}) - return defer.succeed(result) + return result - @rbac.allow('projectmanager', 'sysadmin') def modify_image_attribute(self, context, image_id, attribute, operation_type, **kwargs): # TODO(devcamcar): Support users and groups other than 'all'. if attribute != 'launchPermission': @@ -694,5 +726,4 @@ class CloudController(object): raise exception.ApiError('only group "all" is supported') if not operation_type in ['add', 'remove']: raise exception.ApiError('operation_type must be add or remove') - result = images.modify(context, image_id, operation_type) - return defer.succeed(result) + return images.modify(context, image_id, operation_type) diff --git a/nova/api/ec2/context.py b/nova/api/ec2/context.py new file mode 100644 index 000000000..c53ba98d9 --- /dev/null +++ b/nova/api/ec2/context.py @@ -0,0 +1,33 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. + +""" +APIRequestContext +""" + +import random + + +class APIRequestContext(object): + def __init__(self, user, project): + self.user = user + self.project = project + self.request_id = ''.join( + [random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-') + for x in xrange(20)] + ) diff --git a/nova/endpoint/images.py b/nova/api/ec2/images.py index 4579cd81a..4579cd81a 100644 --- a/nova/endpoint/images.py +++ b/nova/api/ec2/images.py diff --git a/nova/api/ec2/metadatarequesthandler.py b/nova/api/ec2/metadatarequesthandler.py new file mode 100644 index 000000000..08a8040ca --- /dev/null +++ b/nova/api/ec2/metadatarequesthandler.py @@ -0,0 +1,73 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. + +"""Metadata request handler.""" + +import logging + +import webob.dec +import webob.exc + +from nova.api.ec2 import cloud + + +class MetadataRequestHandler(object): + + """Serve metadata from the EC2 API.""" + + def print_data(self, data): + if isinstance(data, dict): + output = '' + for key in data: + if key == '_name': + continue + output += key + if isinstance(data[key], dict): + if '_name' in data[key]: + output += '=' + str(data[key]['_name']) + else: + output += '/' + output += '\n' + return output[:-1] # cut off last \n + elif isinstance(data, list): + return '\n'.join(data) + else: + return str(data) + + def lookup(self, path, data): + items = path.split('/') + for item in items: + if item: + if not isinstance(data, dict): + return data + if not item in data: + return None + data = data[item] + return data + + @webob.dec.wsgify + def __call__(self, req): + cc = cloud.CloudController() + meta_data = cc.get_metadata(req.remote_addr) + if meta_data is None: + logging.error('Failed to get metadata for ip: %s' % req.remote_addr) + raise webob.exc.HTTPNotFound() + data = self.lookup(req.path_info, meta_data) + if data is None: + raise webob.exc.HTTPNotFound() + return self.print_data(data) diff --git a/nova/api/rackspace/__init__.py b/nova/api/rackspace/__init__.py index b4d666d63..c24d08585 100644 --- a/nova/api/rackspace/__init__.py +++ b/nova/api/rackspace/__init__.py @@ -26,44 +26,116 @@ import time import routes import webob.dec import webob.exc +import webob from nova import flags +from nova import utils from nova import wsgi from nova.api.rackspace import flavors from nova.api.rackspace import images +from nova.api.rackspace import ratelimiting from nova.api.rackspace import servers from nova.api.rackspace import sharedipgroups from nova.auth import manager +FLAGS = flags.FLAGS +flags.DEFINE_string('nova_api_auth', + 'nova.api.rackspace.auth.BasicApiAuthManager', + 'The auth mechanism to use for the Rackspace API implemenation') + class API(wsgi.Middleware): """WSGI entry point for all Rackspace API requests.""" def __init__(self): - app = AuthMiddleware(APIRouter()) + app = AuthMiddleware(RateLimitingMiddleware(APIRouter())) super(API, self).__init__(app) - class AuthMiddleware(wsgi.Middleware): """Authorize the rackspace API request or return an HTTP Forbidden.""" - #TODO(gundlach): isn't this the old Nova API's auth? Should it be replaced - #with correct RS API auth? + def __init__(self, application): + self.auth_driver = utils.import_class(FLAGS.nova_api_auth)() + super(AuthMiddleware, self).__init__(application) @webob.dec.wsgify def __call__(self, req): - context = {} - if "HTTP_X_AUTH_TOKEN" in req.environ: - context['user'] = manager.AuthManager().get_user_from_access_key( - req.environ['HTTP_X_AUTH_TOKEN']) - if context['user']: - context['project'] = manager.AuthManager().get_project( - context['user'].name) - if "user" not in context: - return webob.exc.HTTPForbidden() + if not req.headers.has_key("X-Auth-Token"): + return self.auth_driver.authenticate(req) + + user = self.auth_driver.authorize_token(req.headers["X-Auth-Token"]) + + if not user: + return webob.exc.HTTPUnauthorized() + context = {'user': user} req.environ['nova.context'] = context return self.application +class RateLimitingMiddleware(wsgi.Middleware): + """Rate limit incoming requests according to the OpenStack rate limits.""" + + def __init__(self, application, service_host=None): + """Create a rate limiting middleware that wraps the given application. + + By default, rate counters are stored in memory. If service_host is + specified, the middleware instead relies on the ratelimiting.WSGIApp + at the given host+port to keep rate counters. + """ + super(RateLimitingMiddleware, self).__init__(application) + if not service_host: + #TODO(gundlach): These limits were based on limitations of Cloud + #Servers. We should revisit them in Nova. + self.limiter = ratelimiting.Limiter(limits={ + 'DELETE': (100, ratelimiting.PER_MINUTE), + 'PUT': (10, ratelimiting.PER_MINUTE), + 'POST': (10, ratelimiting.PER_MINUTE), + 'POST servers': (50, ratelimiting.PER_DAY), + 'GET changes-since': (3, ratelimiting.PER_MINUTE), + }) + else: + self.limiter = ratelimiting.WSGIAppProxy(service_host) + + @webob.dec.wsgify + def __call__(self, req): + """Rate limit the request. + + If the request should be rate limited, return a 413 status with a + Retry-After header giving the time when the request would succeed. + """ + username = req.headers['X-Auth-User'] + action_name = self.get_action_name(req) + if not action_name: # not rate limited + return self.application + delay = self.get_delay(action_name, username) + if delay: + # TODO(gundlach): Get the retry-after format correct. + raise webob.exc.HTTPRequestEntityTooLarge(headers={ + 'Retry-After': time.time() + delay}) + return self.application + + def get_delay(self, action_name, username): + """Return the delay for the given action and username, or None if + the action would not be rate limited. + """ + if action_name == 'POST servers': + # "POST servers" is a POST, so it counts against "POST" too. + # Attempt the "POST" first, lest we are rate limited by "POST" but + # use up a precious "POST servers" call. + delay = self.limiter.perform("POST", username=username) + if delay: + return delay + return self.limiter.perform(action_name, username=username) + + def get_action_name(self, req): + """Return the action name for this request.""" + if req.method == 'GET' and 'changes-since' in req.GET: + return 'GET changes-since' + if req.method == 'POST' and req.path_info.startswith('/servers'): + return 'POST servers' + if req.method in ['PUT', 'POST', 'DELETE']: + return req.method + return None + class APIRouter(wsgi.Router): """ diff --git a/nova/api/rackspace/auth.py b/nova/api/rackspace/auth.py new file mode 100644 index 000000000..ce5a967eb --- /dev/null +++ b/nova/api/rackspace/auth.py @@ -0,0 +1,98 @@ +import datetime +import json +import time +import webob.exc +import webob.dec +import hashlib +from nova import flags +from nova import auth +from nova import manager +from nova import db +from nova import utils + +FLAGS = flags.FLAGS + +class Context(object): + pass + +class BasicApiAuthManager(object): + """ Implements a somewhat rudimentary version of Rackspace Auth""" + + def __init__(self, host=None, db_driver=None): + if not host: + host = FLAGS.host + self.host = host + if not db_driver: + db_driver = FLAGS.db_driver + self.db = utils.import_object(db_driver) + self.auth = auth.manager.AuthManager() + self.context = Context() + super(BasicApiAuthManager, self).__init__() + + def authenticate(self, req): + # Unless the request is explicitly made against /<version>/ don't + # honor it + path_info = req.path_info + if len(path_info) > 1: + return webob.exc.HTTPUnauthorized() + + try: + username, key = req.headers['X-Auth-User'], \ + req.headers['X-Auth-Key'] + except KeyError: + return webob.exc.HTTPUnauthorized() + + username, key = req.headers['X-Auth-User'], req.headers['X-Auth-Key'] + token, user = self._authorize_user(username, key) + if user and token: + res = webob.Response() + res.headers['X-Auth-Token'] = token['token_hash'] + res.headers['X-Server-Management-Url'] = \ + token['server_management_url'] + res.headers['X-Storage-Url'] = token['storage_url'] + res.headers['X-CDN-Management-Url'] = token['cdn_management_url'] + res.content_type = 'text/plain' + res.status = '204' + return res + else: + return webob.exc.HTTPUnauthorized() + + def authorize_token(self, token_hash): + """ retrieves user information from the datastore given a token + + If the token has expired, returns None + If the token is not found, returns None + Otherwise returns the token + + This method will also remove the token if the timestamp is older than + 2 days ago. + """ + token = self.db.auth_get_token(self.context, token_hash) + if token: + delta = datetime.datetime.now() - token['created_at'] + if delta.days >= 2: + self.db.auth_destroy_token(self.context, token) + else: + user = self.auth.get_user(token['user_id']) + return { 'id':user['uid'] } + return None + + def _authorize_user(self, username, key): + """ Generates a new token and assigns it to a user """ + user = self.auth.get_user_from_access_key(key) + if user and user['name'] == username: + token_hash = hashlib.sha1('%s%s%f' % (username, key, + time.time())).hexdigest() + token = {} + token['token_hash'] = token_hash + token['cdn_management_url'] = '' + token['server_management_url'] = self._get_server_mgmt_url() + token['storage_url'] = '' + token['user_id'] = user['uid'] + self.db.auth_create_token(self.context, token) + return token, user + return None, None + + def _get_server_mgmt_url(self): + return 'https://%s/v1.0/' % self.host + diff --git a/nova/api/rackspace/ratelimiting/__init__.py b/nova/api/rackspace/ratelimiting/__init__.py new file mode 100644 index 000000000..f843bac0f --- /dev/null +++ b/nova/api/rackspace/ratelimiting/__init__.py @@ -0,0 +1,122 @@ +"""Rate limiting of arbitrary actions.""" + +import httplib +import time +import urllib +import webob.dec +import webob.exc + + +# Convenience constants for the limits dictionary passed to Limiter(). +PER_SECOND = 1 +PER_MINUTE = 60 +PER_HOUR = 60 * 60 +PER_DAY = 60 * 60 * 24 + +class Limiter(object): + + """Class providing rate limiting of arbitrary actions.""" + + def __init__(self, limits): + """Create a rate limiter. + + limits: a dict mapping from action name to a tuple. The tuple contains + the number of times the action may be performed, and the time period + (in seconds) during which the number must not be exceeded for this + action. Example: dict(reboot=(10, ratelimiting.PER_MINUTE)) would + allow 10 'reboot' actions per minute. + """ + self.limits = limits + self._levels = {} + + def perform(self, action_name, username='nobody'): + """Attempt to perform an action by the given username. + + action_name: the string name of the action to perform. This must + be a key in the limits dict passed to the ctor. + + username: an optional string name of the user performing the action. + Each user has her own set of rate limiting counters. Defaults to + 'nobody' (so that if you never specify a username when calling + perform(), a single set of counters will be used.) + + Return None if the action may proceed. If the action may not proceed + because it has been rate limited, return the float number of seconds + until the action would succeed. + """ + # Think of rate limiting as a bucket leaking water at 1cc/second. The + # bucket can hold as many ccs as there are seconds in the rate + # limiting period (e.g. 3600 for per-hour ratelimits), and if you can + # perform N actions in that time, each action fills the bucket by + # 1/Nth of its volume. You may only perform an action if the bucket + # would not overflow. + now = time.time() + key = '%s:%s' % (username, action_name) + last_time_performed, water_level = self._levels.get(key, (now, 0)) + # The bucket leaks 1cc/second. + water_level -= (now - last_time_performed) + if water_level < 0: + water_level = 0 + num_allowed_per_period, period_in_secs = self.limits[action_name] + # Fill the bucket by 1/Nth its capacity, and hope it doesn't overflow. + capacity = period_in_secs + new_level = water_level + (capacity * 1.0 / num_allowed_per_period) + if new_level > capacity: + # Delay this many seconds. + return new_level - capacity + self._levels[key] = (now, new_level) + return None + + +# If one instance of this WSGIApps is unable to handle your load, put a +# sharding app in front that shards by username to one of many backends. + +class WSGIApp(object): + + """Application that tracks rate limits in memory. Send requests to it of + this form: + + POST /limiter/<username>/<urlencoded action> + + and receive a 200 OK, or a 403 Forbidden with an X-Wait-Seconds header + containing the number of seconds to wait before the action would succeed. + """ + + def __init__(self, limiter): + """Create the WSGI application using the given Limiter instance.""" + self.limiter = limiter + + @webob.dec.wsgify + def __call__(self, req): + parts = req.path_info.split('/') + # format: /limiter/<username>/<urlencoded action> + if req.method != 'POST': + raise webob.exc.HTTPMethodNotAllowed() + if len(parts) != 4 or parts[1] != 'limiter': + raise webob.exc.HTTPNotFound() + username = parts[2] + action_name = urllib.unquote(parts[3]) + delay = self.limiter.perform(action_name, username) + if delay: + return webob.exc.HTTPForbidden( + headers={'X-Wait-Seconds': "%.2f" % delay}) + else: + return '' # 200 OK + + +class WSGIAppProxy(object): + + """Limiter lookalike that proxies to a ratelimiting.WSGIApp.""" + + def __init__(self, service_host): + """Creates a proxy pointing to a ratelimiting.WSGIApp at the given + host.""" + self.service_host = service_host + + def perform(self, action, username='nobody'): + conn = httplib.HTTPConnection(self.service_host) + conn.request('POST', '/limiter/%s/%s' % (username, action)) + resp = conn.getresponse() + if resp.status == 200: + return None # no delay + return float(resp.getheader('X-Wait-Seconds')) diff --git a/nova/api/rackspace/ratelimiting/tests.py b/nova/api/rackspace/ratelimiting/tests.py new file mode 100644 index 000000000..4c9510917 --- /dev/null +++ b/nova/api/rackspace/ratelimiting/tests.py @@ -0,0 +1,237 @@ +import httplib +import StringIO +import time +import unittest +import webob + +import nova.api.rackspace.ratelimiting as ratelimiting + +class LimiterTest(unittest.TestCase): + + def setUp(self): + self.limits = { + 'a': (5, ratelimiting.PER_SECOND), + 'b': (5, ratelimiting.PER_MINUTE), + 'c': (5, ratelimiting.PER_HOUR), + 'd': (1, ratelimiting.PER_SECOND), + 'e': (100, ratelimiting.PER_SECOND)} + self.rl = ratelimiting.Limiter(self.limits) + + def exhaust(self, action, times_until_exhausted, **kwargs): + for i in range(times_until_exhausted): + when = self.rl.perform(action, **kwargs) + self.assertEqual(when, None) + num, period = self.limits[action] + delay = period * 1.0 / num + # Verify that we are now thoroughly delayed + for i in range(10): + when = self.rl.perform(action, **kwargs) + self.assertAlmostEqual(when, delay, 2) + + def test_second(self): + self.exhaust('a', 5) + time.sleep(0.2) + self.exhaust('a', 1) + time.sleep(1) + self.exhaust('a', 5) + + def test_minute(self): + self.exhaust('b', 5) + + def test_one_per_period(self): + def allow_once_and_deny_once(): + when = self.rl.perform('d') + self.assertEqual(when, None) + when = self.rl.perform('d') + self.assertAlmostEqual(when, 1, 2) + return when + time.sleep(allow_once_and_deny_once()) + time.sleep(allow_once_and_deny_once()) + allow_once_and_deny_once() + + def test_we_can_go_indefinitely_if_we_spread_out_requests(self): + for i in range(200): + when = self.rl.perform('e') + self.assertEqual(when, None) + time.sleep(0.01) + + def test_users_get_separate_buckets(self): + self.exhaust('c', 5, username='alice') + self.exhaust('c', 5, username='bob') + self.exhaust('c', 5, username='chuck') + self.exhaust('c', 0, username='chuck') + self.exhaust('c', 0, username='bob') + self.exhaust('c', 0, username='alice') + + +class FakeLimiter(object): + """Fake Limiter class that you can tell how to behave.""" + def __init__(self, test): + self._action = self._username = self._delay = None + self.test = test + def mock(self, action, username, delay): + self._action = action + self._username = username + self._delay = delay + def perform(self, action, username): + self.test.assertEqual(action, self._action) + self.test.assertEqual(username, self._username) + return self._delay + + +class WSGIAppTest(unittest.TestCase): + + def setUp(self): + self.limiter = FakeLimiter(self) + self.app = ratelimiting.WSGIApp(self.limiter) + + def test_invalid_methods(self): + requests = [] + for method in ['GET', 'PUT', 'DELETE']: + req = webob.Request.blank('/limits/michael/breakdance', + dict(REQUEST_METHOD=method)) + requests.append(req) + for req in requests: + self.assertEqual(req.get_response(self.app).status_int, 405) + + def test_invalid_urls(self): + requests = [] + for prefix in ['limit', '', 'limiter2', 'limiter/limits', 'limiter/1']: + req = webob.Request.blank('/%s/michael/breakdance' % prefix, + dict(REQUEST_METHOD='POST')) + requests.append(req) + for req in requests: + self.assertEqual(req.get_response(self.app).status_int, 404) + + def verify(self, url, username, action, delay=None): + """Make sure that POSTing to the given url causes the given username + to perform the given action. Make the internal rate limiter return + delay and make sure that the WSGI app returns the correct response. + """ + req = webob.Request.blank(url, dict(REQUEST_METHOD='POST')) + self.limiter.mock(action, username, delay) + resp = req.get_response(self.app) + if not delay: + self.assertEqual(resp.status_int, 200) + else: + self.assertEqual(resp.status_int, 403) + self.assertEqual(resp.headers['X-Wait-Seconds'], "%.2f" % delay) + + def test_good_urls(self): + self.verify('/limiter/michael/hoot', 'michael', 'hoot') + + def test_escaping(self): + self.verify('/limiter/michael/jump%20up', 'michael', 'jump up') + + def test_response_to_delays(self): + self.verify('/limiter/michael/hoot', 'michael', 'hoot', 1) + self.verify('/limiter/michael/hoot', 'michael', 'hoot', 1.56) + self.verify('/limiter/michael/hoot', 'michael', 'hoot', 1000) + + +class FakeHttplibSocket(object): + """a fake socket implementation for httplib.HTTPResponse, trivial""" + + def __init__(self, response_string): + self._buffer = StringIO.StringIO(response_string) + + def makefile(self, _mode, _other): + """Returns the socket's internal buffer""" + return self._buffer + + +class FakeHttplibConnection(object): + """A fake httplib.HTTPConnection + + Requests made via this connection actually get translated and routed into + our WSGI app, we then wait for the response and turn it back into + an httplib.HTTPResponse. + """ + def __init__(self, app, host, is_secure=False): + self.app = app + self.host = host + + def request(self, method, path, data='', headers={}): + req = webob.Request.blank(path) + req.method = method + req.body = data + req.headers = headers + req.host = self.host + # Call the WSGI app, get the HTTP response + resp = str(req.get_response(self.app)) + # For some reason, the response doesn't have "HTTP/1.0 " prepended; I + # guess that's a function the web server usually provides. + resp = "HTTP/1.0 %s" % resp + sock = FakeHttplibSocket(resp) + self.http_response = httplib.HTTPResponse(sock) + self.http_response.begin() + + def getresponse(self): + return self.http_response + + +def wire_HTTPConnection_to_WSGI(host, app): + """Monkeypatches HTTPConnection so that if you try to connect to host, you + are instead routed straight to the given WSGI app. + + After calling this method, when any code calls + + httplib.HTTPConnection(host) + + the connection object will be a fake. Its requests will be sent directly + to the given WSGI app rather than through a socket. + + Code connecting to hosts other than host will not be affected. + + This method may be called multiple times to map different hosts to + different apps. + """ + class HTTPConnectionDecorator(object): + """Wraps the real HTTPConnection class so that when you instantiate + the class you might instead get a fake instance.""" + def __init__(self, wrapped): + self.wrapped = wrapped + def __call__(self, connection_host, *args, **kwargs): + if connection_host == host: + return FakeHttplibConnection(app, host) + else: + return self.wrapped(connection_host, *args, **kwargs) + httplib.HTTPConnection = HTTPConnectionDecorator(httplib.HTTPConnection) + + +class WSGIAppProxyTest(unittest.TestCase): + + def setUp(self): + """Our WSGIAppProxy is going to call across an HTTPConnection to a + WSGIApp running a limiter. The proxy will send input, and the proxy + should receive that same input, pass it to the limiter who gives a + result, and send the expected result back. + + The HTTPConnection isn't real -- it's monkeypatched to point straight + at the WSGIApp. And the limiter isn't real -- it's a fake that + behaves the way we tell it to. + """ + self.limiter = FakeLimiter(self) + app = ratelimiting.WSGIApp(self.limiter) + wire_HTTPConnection_to_WSGI('100.100.100.100:80', app) + self.proxy = ratelimiting.WSGIAppProxy('100.100.100.100:80') + + def test_200(self): + self.limiter.mock('conquer', 'caesar', None) + when = self.proxy.perform('conquer', 'caesar') + self.assertEqual(when, None) + + def test_403(self): + self.limiter.mock('grumble', 'proletariat', 1.5) + when = self.proxy.perform('grumble', 'proletariat') + self.assertEqual(when, 1.5) + + def test_failure(self): + def shouldRaise(): + self.limiter.mock('murder', 'brutus', None) + self.proxy.perform('stab', 'brutus') + self.assertRaises(AssertionError, shouldRaise) + + +if __name__ == '__main__': + unittest.main() diff --git a/nova/auth/fakeldap.py b/nova/auth/fakeldap.py index bfc3433c5..2791dfde6 100644 --- a/nova/auth/fakeldap.py +++ b/nova/auth/fakeldap.py @@ -33,6 +33,7 @@ SCOPE_ONELEVEL = 1 # not implemented SCOPE_SUBTREE = 2 MOD_ADD = 0 MOD_DELETE = 1 +MOD_REPLACE = 2 class NO_SUCH_OBJECT(Exception): # pylint: disable-msg=C0103 @@ -175,7 +176,7 @@ class FakeLDAP(object): Args: dn -- a dn attrs -- a list of tuples in the following form: - ([MOD_ADD | MOD_DELETE], attribute, value) + ([MOD_ADD | MOD_DELETE | MOD_REPACE], attribute, value) """ redis = datastore.Redis.instance() @@ -185,6 +186,8 @@ class FakeLDAP(object): values = _from_json(redis.hget(key, k)) if cmd == MOD_ADD: values.append(v) + elif cmd == MOD_REPLACE: + values = [v] else: values.remove(v) values = redis.hset(key, k, _to_json(values)) diff --git a/nova/auth/ldapdriver.py b/nova/auth/ldapdriver.py index 74ba011b5..021851ebf 100644 --- a/nova/auth/ldapdriver.py +++ b/nova/auth/ldapdriver.py @@ -99,13 +99,6 @@ class LdapDriver(object): dn = FLAGS.ldap_user_subtree return self.__to_user(self.__find_object(dn, query)) - def get_key_pair(self, uid, key_name): - """Retrieve key pair by uid and key name""" - dn = 'cn=%s,%s' % (key_name, - self.__uid_to_dn(uid)) - attr = self.__find_object(dn, '(objectclass=novaKeyPair)') - return self.__to_key_pair(uid, attr) - def get_project(self, pid): """Retrieve project by id""" dn = 'cn=%s,%s' % (pid, @@ -119,12 +112,6 @@ class LdapDriver(object): '(objectclass=novaUser)') return [self.__to_user(attr) for attr in attrs] - def get_key_pairs(self, uid): - """Retrieve list of key pairs""" - attrs = self.__find_objects(self.__uid_to_dn(uid), - '(objectclass=novaKeyPair)') - return [self.__to_key_pair(uid, attr) for attr in attrs] - def get_projects(self, uid=None): """Retrieve list of projects""" pattern = '(objectclass=novaProject)' @@ -154,21 +141,6 @@ class LdapDriver(object): self.conn.add_s(self.__uid_to_dn(name), attr) return self.__to_user(dict(attr)) - def create_key_pair(self, uid, key_name, public_key, fingerprint): - """Create a key pair""" - # TODO(vish): possibly refactor this to store keys in their own ou - # and put dn reference in the user object - attr = [ - ('objectclass', ['novaKeyPair']), - ('cn', [key_name]), - ('sshPublicKey', [public_key]), - ('keyFingerprint', [fingerprint]), - ] - self.conn.add_s('cn=%s,%s' % (key_name, - self.__uid_to_dn(uid)), - attr) - return self.__to_key_pair(uid, dict(attr)) - def create_project(self, name, manager_uid, description=None, member_uids=None): """Create a project""" @@ -202,6 +174,24 @@ class LdapDriver(object): self.conn.add_s('cn=%s,%s' % (name, FLAGS.ldap_project_subtree), attr) return self.__to_project(dict(attr)) + def modify_project(self, project_id, manager_uid=None, description=None): + """Modify an existing project""" + if not manager_uid and not description: + return + attr = [] + if manager_uid: + if not self.__user_exists(manager_uid): + raise exception.NotFound("Project can't be modified because " + "manager %s doesn't exist" % + manager_uid) + manager_dn = self.__uid_to_dn(manager_uid) + attr.append((self.ldap.MOD_REPLACE, 'projectManager', manager_dn)) + if description: + attr.append((self.ldap.MOD_REPLACE, 'description', description)) + self.conn.modify_s('cn=%s,%s' % (project_id, + FLAGS.ldap_project_subtree), + attr) + def add_to_project(self, uid, project_id): """Add user to project""" dn = 'cn=%s,%s' % (project_id, FLAGS.ldap_project_subtree) @@ -265,19 +255,10 @@ class LdapDriver(object): """Delete a user""" if not self.__user_exists(uid): raise exception.NotFound("User %s doesn't exist" % uid) - self.__delete_key_pairs(uid) self.__remove_from_all(uid) self.conn.delete_s('uid=%s,%s' % (uid, FLAGS.ldap_user_subtree)) - def delete_key_pair(self, uid, key_name): - """Delete a key pair""" - if not self.__key_pair_exists(uid, key_name): - raise exception.NotFound("Key Pair %s doesn't exist for user %s" % - (key_name, uid)) - self.conn.delete_s('cn=%s,uid=%s,%s' % (key_name, uid, - FLAGS.ldap_user_subtree)) - def delete_project(self, project_id): """Delete a project""" project_dn = 'cn=%s,%s' % (project_id, FLAGS.ldap_project_subtree) @@ -288,10 +269,6 @@ class LdapDriver(object): """Check if user exists""" return self.get_user(uid) != None - def __key_pair_exists(self, uid, key_name): - """Check if key pair exists""" - return self.get_key_pair(uid, key_name) != None - def __project_exists(self, project_id): """Check if project exists""" return self.get_project(project_id) != None @@ -341,13 +318,6 @@ class LdapDriver(object): """Check if group exists""" return self.__find_object(dn, '(objectclass=groupOfNames)') != None - def __delete_key_pairs(self, uid): - """Delete all key pairs for user""" - keys = self.get_key_pairs(uid) - if keys != None: - for key in keys: - self.delete_key_pair(uid, key['name']) - @staticmethod def __role_to_dn(role, project_id=None): """Convert role to corresponding dn""" @@ -472,18 +442,6 @@ class LdapDriver(object): 'secret': attr['secretKey'][0], 'admin': (attr['isAdmin'][0] == 'TRUE')} - @staticmethod - def __to_key_pair(owner, attr): - """Convert ldap attributes to KeyPair object""" - if attr == None: - return None - return { - 'id': attr['cn'][0], - 'name': attr['cn'][0], - 'owner_id': owner, - 'public_key': attr['sshPublicKey'][0], - 'fingerprint': attr['keyFingerprint'][0]} - def __to_project(self, attr): """Convert ldap attributes to Project object""" if attr == None: diff --git a/nova/auth/manager.py b/nova/auth/manager.py index d5fbec7c5..55fbf42aa 100644 --- a/nova/auth/manager.py +++ b/nova/auth/manager.py @@ -44,7 +44,7 @@ flags.DEFINE_list('allowed_roles', # NOTE(vish): a user with one of these roles will be a superuser and # have access to all api commands flags.DEFINE_list('superuser_roles', ['cloudadmin'], - 'Roles that ignore rbac checking completely') + 'Roles that ignore authorization checking completely') # NOTE(vish): a user with one of these roles will have it for every # project, even if he or she is not a member of the project @@ -128,24 +128,6 @@ class User(AuthBase): def is_project_manager(self, project): return AuthManager().is_project_manager(self, project) - def generate_key_pair(self, name): - return AuthManager().generate_key_pair(self.id, name) - - def create_key_pair(self, name, public_key, fingerprint): - return AuthManager().create_key_pair(self.id, - name, - public_key, - fingerprint) - - def get_key_pair(self, name): - return AuthManager().get_key_pair(self.id, name) - - def delete_key_pair(self, name): - return AuthManager().delete_key_pair(self.id, name) - - def get_key_pairs(self): - return AuthManager().get_key_pairs(self.id) - def __repr__(self): return "User('%s', '%s', '%s', '%s', %s)" % (self.id, self.name, @@ -154,29 +136,6 @@ class User(AuthBase): self.admin) -class KeyPair(AuthBase): - """Represents an ssh key returned from the datastore - - Even though this object is named KeyPair, only the public key and - fingerprint is stored. The user's private key is not saved. - """ - - def __init__(self, id, name, owner_id, public_key, fingerprint): - AuthBase.__init__(self) - self.id = id - self.name = name - self.owner_id = owner_id - self.public_key = public_key - self.fingerprint = fingerprint - - def __repr__(self): - return "KeyPair('%s', '%s', '%s', '%s', '%s')" % (self.id, - self.name, - self.owner_id, - self.public_key, - self.fingerprint) - - class Project(AuthBase): """Represents a Project returned from the datastore""" @@ -307,7 +266,7 @@ class AuthManager(object): # NOTE(vish): if we stop using project name as id we need better # logic to find a default project for user - if project_id is '': + if project_id == '': project_id = user.name project = self.get_project(project_id) @@ -345,7 +304,7 @@ class AuthManager(object): return "%s:%s" % (user.access, Project.safe_id(project)) def is_superuser(self, user): - """Checks for superuser status, allowing user to bypass rbac + """Checks for superuser status, allowing user to bypass authorization @type user: User or uid @param user: User to check. @@ -533,6 +492,26 @@ class AuthManager(object): raise return project + def modify_project(self, project, manager_user=None, description=None): + """Modify a project + + @type name: Project or project_id + @param project: The project to modify. + + @type manager_user: User or uid + @param manager_user: This user will be the new project manager. + + @type description: str + @param project: This will be the new description of the project. + + """ + if manager_user: + manager_user = User.safe_id(manager_user) + with self.driver() as drv: + drv.modify_project(Project.safe_id(project), + manager_user, + description) + def add_to_project(self, user, project): """Add user to project""" with self.driver() as drv: @@ -643,67 +622,13 @@ class AuthManager(object): return User(**user_dict) def delete_user(self, user): - """Deletes a user""" - with self.driver() as drv: - drv.delete_user(User.safe_id(user)) - - def generate_key_pair(self, user, key_name): - """Generates a key pair for a user - - Generates a public and private key, stores the public key using the - key_name, and returns the private key and fingerprint. - - @type user: User or uid - @param user: User for which to create key pair. + """Deletes a user - @type key_name: str - @param key_name: Name to use for the generated KeyPair. - - @rtype: tuple (private_key, fingerprint) - @return: A tuple containing the private_key and fingerprint. - """ - # NOTE(vish): generating key pair is slow so check for legal - # creation before creating keypair + Additionally deletes all users key_pairs""" uid = User.safe_id(user) + db.key_pair_destroy_all_by_user(None, uid) with self.driver() as drv: - if not drv.get_user(uid): - raise exception.NotFound("User %s doesn't exist" % user) - if drv.get_key_pair(uid, key_name): - raise exception.Duplicate("The keypair %s already exists" - % key_name) - private_key, public_key, fingerprint = crypto.generate_key_pair() - self.create_key_pair(uid, key_name, public_key, fingerprint) - return private_key, fingerprint - - def create_key_pair(self, user, key_name, public_key, fingerprint): - """Creates a key pair for user""" - with self.driver() as drv: - kp_dict = drv.create_key_pair(User.safe_id(user), - key_name, - public_key, - fingerprint) - if kp_dict: - return KeyPair(**kp_dict) - - def get_key_pair(self, user, key_name): - """Retrieves a key pair for user""" - with self.driver() as drv: - kp_dict = drv.get_key_pair(User.safe_id(user), key_name) - if kp_dict: - return KeyPair(**kp_dict) - - def get_key_pairs(self, user): - """Retrieves all key pairs for user""" - with self.driver() as drv: - kp_list = drv.get_key_pairs(User.safe_id(user)) - if not kp_list: - return [] - return [KeyPair(**kp_dict) for kp_dict in kp_list] - - def delete_key_pair(self, user, key_name): - """Deletes a key pair for user""" - with self.driver() as drv: - drv.delete_key_pair(User.safe_id(user), key_name) + drv.delete_user(uid) def get_credentials(self, user, project=None): """Get credential zip for user in project""" diff --git a/nova/auth/rbac.py b/nova/auth/rbac.py deleted file mode 100644 index d157f44b3..000000000 --- a/nova/auth/rbac.py +++ /dev/null @@ -1,69 +0,0 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 - -# Copyright 2010 United States Government as represented by the -# Administrator of the National Aeronautics and Space Administration. -# 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. - -"""Role-based access control decorators to use fpr wrapping other -methods with.""" - -from nova import exception - - -def allow(*roles): - """Allow the given roles access the wrapped function.""" - - def wrap(func): # pylint: disable-msg=C0111 - - def wrapped_func(self, context, *args, - **kwargs): # pylint: disable-msg=C0111 - if context.user.is_superuser(): - return func(self, context, *args, **kwargs) - for role in roles: - if __matches_role(context, role): - return func(self, context, *args, **kwargs) - raise exception.NotAuthorized() - - return wrapped_func - - return wrap - - -def deny(*roles): - """Deny the given roles access the wrapped function.""" - - def wrap(func): # pylint: disable-msg=C0111 - - def wrapped_func(self, context, *args, - **kwargs): # pylint: disable-msg=C0111 - if context.user.is_superuser(): - return func(self, context, *args, **kwargs) - for role in roles: - if __matches_role(context, role): - raise exception.NotAuthorized() - return func(self, context, *args, **kwargs) - - return wrapped_func - - return wrap - - -def __matches_role(context, role): - """Check if a role is allowed.""" - if role == 'all': - return True - if role == 'none': - return False - return context.project.has_role(context.user.id, role) diff --git a/nova/cloudpipe/api.py b/nova/cloudpipe/api.py deleted file mode 100644 index 56aa89834..000000000 --- a/nova/cloudpipe/api.py +++ /dev/null @@ -1,59 +0,0 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 - -# Copyright 2010 United States Government as represented by the -# Administrator of the National Aeronautics and Space Administration. -# 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. - -""" -Tornado REST API Request Handlers for CloudPipe -""" - -import logging -import urllib - -import tornado.web - -from nova import crypto -from nova.auth import manager - - -_log = logging.getLogger("api") -_log.setLevel(logging.DEBUG) - - -class CloudPipeRequestHandler(tornado.web.RequestHandler): - def get(self, path): - path = self.request.path - _log.debug( "Cloudpipe path is %s" % path) - if path.endswith("/getca/"): - self.send_root_ca() - self.finish() - - def get_project_id_from_ip(self, ip): - cc = self.application.controllers['Cloud'] - instance = cc.get_instance_by_ip(ip) - instance['project_id'] - - def send_root_ca(self): - _log.debug( "Getting root ca") - project_id = self.get_project_id_from_ip(self.request.remote_ip) - self.set_header("Content-Type", "text/plain") - self.write(crypto.fetch_ca(project_id)) - - def post(self, *args, **kwargs): - project_id = self.get_project_id_from_ip(self.request.remote_ip) - cert = self.get_argument('cert', '') - self.write(crypto.sign_csr(urllib.unquote(cert), project_id)) - self.finish() diff --git a/nova/cloudpipe/pipelib.py b/nova/cloudpipe/pipelib.py index 2867bcb21..706a175d9 100644 --- a/nova/cloudpipe/pipelib.py +++ b/nova/cloudpipe/pipelib.py @@ -32,7 +32,9 @@ from nova import exception from nova import flags from nova import utils from nova.auth import manager -from nova.endpoint import api +# TODO(eday): Eventually changes these to something not ec2-specific +from nova.api.ec2 import cloud +from nova.api.ec2 import context FLAGS = flags.FLAGS @@ -42,8 +44,8 @@ flags.DEFINE_string('boot_script_template', class CloudPipe(object): - def __init__(self, cloud_controller): - self.controller = cloud_controller + def __init__(self): + self.controller = cloud.CloudController() self.manager = manager.AuthManager() def launch_vpn_instance(self, project_id): @@ -58,9 +60,9 @@ class CloudPipe(object): z.write(FLAGS.boot_script_template,'autorun.sh') z.close() - key_name = self.setup_keypair(project.project_manager_id, project_id) + key_name = self.setup_key_pair(project.project_manager_id, project_id) zippy = open(zippath, "r") - context = api.APIRequestContext(handler=None, user=project.project_manager, project=project) + context = context.APIRequestContext(user=project.project_manager, project=project) reservation = self.controller.run_instances(context, # run instances expects encoded userdata, it is decoded in the get_metadata_call @@ -74,7 +76,7 @@ class CloudPipe(object): security_groups=["vpn-secgroup"]) zippy.close() - def setup_keypair(self, user_id, project_id): + def setup_key_pair(self, user_id, project_id): key_name = '%s%s' % (project_id, FLAGS.vpn_key_suffix) try: private_key, fingerprint = self.manager.generate_key_pair(user_id, key_name) diff --git a/nova/compute/manager.py b/nova/compute/manager.py index ae7099812..24538e4f1 100644 --- a/nova/compute/manager.py +++ b/nova/compute/manager.py @@ -85,7 +85,9 @@ class ComputeManager(manager.Manager): try: yield self.driver.spawn(instance_ref) now = datetime.datetime.utcnow() - self.db.instance_update(None, instance_id, {'launched_at': now}) + self.db.instance_update(context, + instance_id, + {'launched_at': now}) except Exception: # pylint: disable-msg=W0702 logging.exception("instance %s: Failed to spawn", instance_ref['name']) @@ -100,8 +102,8 @@ class ComputeManager(manager.Manager): def terminate_instance(self, context, instance_id): """Terminate an instance on this machine.""" logging.debug("instance %s: terminating", instance_id) - instance_ref = self.db.instance_get(context, instance_id) + instance_ref = self.db.instance_get(context, instance_id) if instance_ref['state'] == power_state.SHUTOFF: self.db.instance_destroy(context, instance_id) raise exception.Error('trying to destroy already destroyed' @@ -112,8 +114,6 @@ class ComputeManager(manager.Manager): power_state.NOSTATE, 'shutting_down') yield self.driver.destroy(instance_ref) - now = datetime.datetime.utcnow() - self.db.instance_update(None, instance_id, {'terminated_at': now}) # TODO(ja): should we keep it in a terminated state for a bit? self.db.instance_destroy(context, instance_id) @@ -189,7 +189,7 @@ class ComputeManager(manager.Manager): volume_id) instance_ref = self.db.instance_get(context, instance_id) volume_ref = self.db.volume_get(context, volume_id) - self.driver.detach_volume(instance_ref['str_id'], - volume_ref['mountpoint']) + yield self.driver.detach_volume(instance_ref['str_id'], + volume_ref['mountpoint']) self.db.volume_detached(context, volume_id) defer.returnValue(True) diff --git a/nova/crypto.py b/nova/crypto.py index b05548ea1..1c6fe57ad 100644 --- a/nova/crypto.py +++ b/nova/crypto.py @@ -18,7 +18,7 @@ """ Wrappers around standard crypto, including root and intermediate CAs, -SSH keypairs and x509 certificates. +SSH key_pairs and x509 certificates. """ import base64 diff --git a/nova/db/api.py b/nova/db/api.py index d749ae50a..c1cb1953a 100644 --- a/nova/db/api.py +++ b/nova/db/api.py @@ -51,11 +51,45 @@ class NoMoreNetworks(exception.Error): ################### +def service_destroy(context, instance_id): + """Destroy the service or raise if it does not exist.""" + return IMPL.service_destroy(context, instance_id) + + def service_get(context, service_id): """Get an service or raise if it does not exist.""" return IMPL.service_get(context, service_id) +def service_get_all_by_topic(context, topic): + """Get all compute services for a given topic """ + return IMPL.service_get_all_by_topic(context, topic) + + +def service_get_all_compute_sorted(context): + """Get all compute services sorted by instance count + + Returns a list of (Service, instance_count) tuples + """ + return IMPL.service_get_all_compute_sorted(context) + + +def service_get_all_network_sorted(context): + """Get all network services sorted by network count + + Returns a list of (Service, network_count) tuples + """ + return IMPL.service_get_all_network_sorted(context) + + +def service_get_all_volume_sorted(context): + """Get all volume services sorted by volume count + + Returns a list of (Service, volume_count) tuples + """ + return IMPL.service_get_all_volume_sorted(context) + + def service_get_by_args(context, host, binary): """Get the state of an service by node name and binary.""" return IMPL.service_get_by_args(context, host, binary) @@ -91,6 +125,11 @@ def floating_ip_create(context, values): return IMPL.floating_ip_create(context, values) +def floating_ip_count_by_project(context, project_id): + """Count floating ips used by project.""" + return IMPL.floating_ip_count_by_project(context, project_id) + + def floating_ip_deallocate(context, address): """Deallocate an floating ip by address""" return IMPL.floating_ip_deallocate(context, address) @@ -193,6 +232,11 @@ def instance_create(context, values): return IMPL.instance_create(context, values) +def instance_data_get_for_project(context, project_id): + """Get (instance_count, core_count) for project.""" + return IMPL.instance_data_get_for_project(context, project_id) + + def instance_destroy(context, instance_id): """Destroy the instance or raise if it does not exist.""" return IMPL.instance_destroy(context, instance_id) @@ -252,6 +296,34 @@ def instance_update(context, instance_id, values): return IMPL.instance_update(context, instance_id, values) +################### + + +def key_pair_create(context, values): + """Create a key_pair from the values dictionary.""" + return IMPL.key_pair_create(context, values) + + +def key_pair_destroy(context, user_id, name): + """Destroy the key_pair or raise if it does not exist.""" + return IMPL.key_pair_destroy(context, user_id, name) + + +def key_pair_destroy_all_by_user(context, user_id): + """Destroy all key_pairs by user.""" + return IMPL.key_pair_destroy_all_by_user(context, user_id) + + +def key_pair_get(context, user_id, name): + """Get a key_pair or raise if it does not exist.""" + return IMPL.key_pair_get(context, user_id, name) + + +def key_pair_get_all_by_user(context, user_id): + """Get all key_pairs by user.""" + return IMPL.key_pair_get_all_by_user(context, user_id) + + #################### @@ -376,6 +448,44 @@ def export_device_create(context, values): ################### +def auth_destroy_token(context, token): + """Destroy an auth token""" + return IMPL.auth_destroy_token(context, token) + +def auth_get_token(context, token_hash): + """Retrieves a token given the hash representing it""" + return IMPL.auth_get_token(context, token_hash) + +def auth_create_token(context, token): + """Creates a new token""" + return IMPL.auth_create_token(context, token_hash, token) + + +################### + + +def quota_create(context, values): + """Create a quota from the values dictionary.""" + return IMPL.quota_create(context, values) + + +def quota_get(context, project_id): + """Retrieve a quota or raise if it does not exist.""" + return IMPL.quota_get(context, project_id) + + +def quota_update(context, project_id, values): + """Update a quota from the values dictionary.""" + return IMPL.quota_update(context, project_id, values) + + +def quota_destroy(context, project_id): + """Destroy the quota or raise if it does not exist.""" + return IMPL.quota_destroy(context, project_id) + + +################### + def volume_allocate_shelf_and_blade(context, volume_id): """Atomically allocate a free shelf and blade from the pool.""" @@ -392,6 +502,11 @@ def volume_create(context, values): return IMPL.volume_create(context, values) +def volume_data_get_for_project(context, project_id): + """Get (volume_count, gigabytes) for project.""" + return IMPL.volume_data_get_for_project(context, project_id) + + def volume_destroy(context, volume_id): """Destroy the volume or raise if it does not exist.""" return IMPL.volume_destroy(context, volume_id) diff --git a/nova/db/sqlalchemy/api.py b/nova/db/sqlalchemy/api.py index 485dca2b0..2b0dd6ea6 100644 --- a/nova/db/sqlalchemy/api.py +++ b/nova/db/sqlalchemy/api.py @@ -26,6 +26,7 @@ from nova.db.sqlalchemy import models from nova.db.sqlalchemy.session import get_session from sqlalchemy import or_ from sqlalchemy.orm import joinedload_all +from sqlalchemy.sql import func FLAGS = flags.FLAGS @@ -46,10 +47,92 @@ def _deleted(context): ################### +def service_destroy(context, service_id): + session = get_session() + with session.begin(): + service_ref = models.Service.find(service_id, session=session) + service_ref.delete(session=session) + def service_get(_context, service_id): return models.Service.find(service_id) +def service_get_all_by_topic(context, topic): + session = get_session() + return session.query(models.Service + ).filter_by(deleted=False + ).filter_by(topic=topic + ).all() + + +def _service_get_all_topic_subquery(_context, session, topic, subq, label): + sort_value = getattr(subq.c, label) + return session.query(models.Service, func.coalesce(sort_value, 0) + ).filter_by(topic=topic + ).filter_by(deleted=False + ).outerjoin((subq, models.Service.host == subq.c.host) + ).order_by(sort_value + ).all() + + +def service_get_all_compute_sorted(context): + session = get_session() + with session.begin(): + # NOTE(vish): The intended query is below + # SELECT services.*, COALESCE(inst_cores.instance_cores, + # 0) + # FROM services LEFT OUTER JOIN + # (SELECT host, SUM(instances.vcpus) AS instance_cores + # FROM instances GROUP BY host) AS inst_cores + # ON services.host = inst_cores.host + topic = 'compute' + label = 'instance_cores' + subq = session.query(models.Instance.host, + func.sum(models.Instance.vcpus).label(label) + ).filter_by(deleted=False + ).group_by(models.Instance.host + ).subquery() + return _service_get_all_topic_subquery(context, + session, + topic, + subq, + label) + + +def service_get_all_network_sorted(context): + session = get_session() + with session.begin(): + topic = 'network' + label = 'network_count' + subq = session.query(models.Network.host, + func.count(models.Network.id).label(label) + ).filter_by(deleted=False + ).group_by(models.Network.host + ).subquery() + return _service_get_all_topic_subquery(context, + session, + topic, + subq, + label) + + +def service_get_all_volume_sorted(context): + session = get_session() + with session.begin(): + topic = 'volume' + label = 'volume_gigabytes' + subq = session.query(models.Volume.host, + func.sum(models.Volume.size).label(label) + ).filter_by(deleted=False + ).group_by(models.Volume.host + ).subquery() + return _service_get_all_topic_subquery(context, + session, + topic, + subq, + label) + + def service_get_by_args(_context, host, binary): return models.Service.find_by_args(host, binary) @@ -100,6 +183,14 @@ def floating_ip_create(_context, values): return floating_ip_ref['address'] +def floating_ip_count_by_project(_context, project_id): + session = get_session() + return session.query(models.FloatingIp + ).filter_by(project_id=project_id + ).filter_by(deleted=False + ).count() + + def floating_ip_fixed_ip_associate(_context, floating_address, fixed_address): session = get_session() with session.begin(): @@ -269,6 +360,17 @@ def instance_create(_context, values): return instance_ref +def instance_data_get_for_project(_context, project_id): + session = get_session() + result = session.query(func.count(models.Instance.id), + func.sum(models.Instance.vcpus) + ).filter_by(project_id=project_id + ).filter_by(deleted=False + ).first() + # NOTE(vish): convert None to 0 + return (result[0] or 0, result[1] or 0) + + def instance_destroy(_context, instance_id): session = get_session() with session.begin(): @@ -360,6 +462,46 @@ def instance_update(_context, instance_id, values): ################### +def key_pair_create(_context, values): + key_pair_ref = models.KeyPair() + for (key, value) in values.iteritems(): + key_pair_ref[key] = value + key_pair_ref.save() + return key_pair_ref + + +def key_pair_destroy(_context, user_id, name): + session = get_session() + with session.begin(): + key_pair_ref = models.KeyPair.find_by_args(user_id, + name, + session=session) + key_pair_ref.delete(session=session) + + +def key_pair_destroy_all_by_user(_context, user_id): + session = get_session() + with session.begin(): + # TODO(vish): do we have to use sql here? + session.execute('update key_pairs set deleted=1 where user_id=:id', + {'id': user_id}) + + +def key_pair_get(_context, user_id, name): + return models.KeyPair.find_by_args(user_id, name) + + +def key_pair_get_all_by_user(_context, user_id): + session = get_session() + return session.query(models.KeyPair + ).filter_by(user_id=user_id + ).filter_by(deleted=False + ).all() + + +################### + + def network_count(_context): return models.Network.count() @@ -539,6 +681,60 @@ def export_device_create(_context, values): ################### +def auth_destroy_token(_context, token): + session = get_session() + session.delete(token) + +def auth_get_token(_context, token_hash): + session = get_session() + tk = session.query(models.AuthToken + ).filter_by(token_hash=token_hash) + if not tk: + raise exception.NotFound('Token %s does not exist' % token_hash) + return tk + +def auth_create_token(_context, token): + tk = models.AuthToken() + for k,v in token.iteritems(): + tk[k] = v + tk.save() + return tk + + +################### + + +def quota_create(_context, values): + quota_ref = models.Quota() + for (key, value) in values.iteritems(): + quota_ref[key] = value + quota_ref.save() + return quota_ref + + +def quota_get(_context, project_id): + return models.Quota.find_by_str(project_id) + + +def quota_update(_context, project_id, values): + session = get_session() + with session.begin(): + quota_ref = models.Quota.find_by_str(project_id, session=session) + for (key, value) in values.iteritems(): + quota_ref[key] = value + quota_ref.save(session=session) + + +def quota_destroy(_context, project_id): + session = get_session() + with session.begin(): + quota_ref = models.Quota.find_by_str(project_id, session=session) + quota_ref.delete(session=session) + + +################### + + def volume_allocate_shelf_and_blade(_context, volume_id): session = get_session() with session.begin(): @@ -576,6 +772,17 @@ def volume_create(_context, values): return volume_ref +def volume_data_get_for_project(_context, project_id): + session = get_session() + result = session.query(func.count(models.Volume.id), + func.sum(models.Volume.size) + ).filter_by(project_id=project_id + ).filter_by(deleted=False + ).first() + # NOTE(vish): convert None to 0 + return (result[0] or 0, result[1] or 0) + + def volume_destroy(_context, volume_id): session = get_session() with session.begin(): diff --git a/nova/db/sqlalchemy/models.py b/nova/db/sqlalchemy/models.py index 6818f838c..f6ba7953f 100644 --- a/nova/db/sqlalchemy/models.py +++ b/nova/db/sqlalchemy/models.py @@ -103,7 +103,7 @@ class NovaBase(object): def delete(self, session=None): """Delete this object""" self.deleted = True - self.deleted_at = datetime.datetime.now() + self.deleted_at = datetime.datetime.utcnow() self.save(session=session) def __setitem__(self, key, value): @@ -214,6 +214,7 @@ class Instance(BASE, NovaBase): image_id = Column(String(255)) kernel_id = Column(String(255)) ramdisk_id = Column(String(255)) + # image_id = Column(Integer, ForeignKey('images.id'), nullable=True) # kernel_id = Column(Integer, ForeignKey('images.id'), nullable=True) # ramdisk_id = Column(Integer, ForeignKey('images.id'), nullable=True) @@ -229,6 +230,11 @@ class Instance(BASE, NovaBase): state = Column(Integer) state_description = Column(String(255)) + memory_mb = Column(Integer) + vcpus = Column(Integer) + local_gb = Column(Integer) + + hostname = Column(String(255)) host = Column(String(255)) # , ForeignKey('hosts.id')) @@ -239,6 +245,7 @@ class Instance(BASE, NovaBase): reservation_id = Column(String(255)) mac_address = Column(String(255)) + scheduled_at = Column(DateTime) launched_at = Column(DateTime) terminated_at = Column(DateTime) # TODO(vish): see Ewan's email about state improvements, probably @@ -272,6 +279,39 @@ class Volume(BASE, NovaBase): status = Column(String(255)) # TODO(vish): enum? attach_status = Column(String(255)) # TODO(vish): enum + scheduled_at = Column(DateTime) + launched_at = Column(DateTime) + terminated_at = Column(DateTime) + +class Quota(BASE, NovaBase): + """Represents quota overrides for a project""" + __tablename__ = 'quotas' + id = Column(Integer, primary_key=True) + + project_id = Column(String(255)) + + instances = Column(Integer) + cores = Column(Integer) + volumes = Column(Integer) + gigabytes = Column(Integer) + floating_ips = Column(Integer) + + @property + def str_id(self): + return self.project_id + + @classmethod + def find_by_str(cls, str_id, session=None, deleted=False): + if not session: + session = get_session() + try: + return session.query(cls + ).filter_by(project_id=str_id + ).filter_by(deleted=deleted + ).one() + except exc.NoResultFound: + new_exc = exception.NotFound("No model for project_id %s" % str_id) + raise new_exc.__class__, new_exc, sys.exc_info()[2] class ExportDevice(BASE, NovaBase): """Represates a shelf and blade that a volume can be exported on""" @@ -284,6 +324,42 @@ class ExportDevice(BASE, NovaBase): uselist=False)) +class KeyPair(BASE, NovaBase): + """Represents a public key pair for ssh""" + __tablename__ = 'key_pairs' + id = Column(Integer, primary_key=True) + name = Column(String(255)) + + user_id = Column(String(255)) + + fingerprint = Column(String(255)) + public_key = Column(Text) + + @property + def str_id(self): + return '%s.%s' % (self.user_id, self.name) + + @classmethod + def find_by_str(cls, str_id, session=None, deleted=False): + user_id, _sep, name = str_id.partition('.') + return cls.find_by_str(user_id, name, session, deleted) + + @classmethod + def find_by_args(cls, user_id, name, session=None, deleted=False): + if not session: + session = get_session() + try: + return session.query(cls + ).filter_by(user_id=user_id + ).filter_by(name=name + ).filter_by(deleted=deleted + ).one() + except exc.NoResultFound: + new_exc = exception.NotFound("No model for user %s, name %s" % + (user_id, name)) + raise new_exc.__class__, new_exc, sys.exc_info()[2] + + class Network(BASE, NovaBase): """Represents a network""" __tablename__ = 'networks' @@ -321,6 +397,18 @@ class NetworkIndex(BASE, NovaBase): network = relationship(Network, backref=backref('network_index', uselist=False)) +class AuthToken(BASE, NovaBase): + """Represents an authorization token for all API transactions. Fields + are a string representing the actual token and a user id for mapping + to the actual user""" + __tablename__ = 'auth_tokens' + token_hash = Column(String(255), primary_key=True) + user_id = Column(Integer) + server_manageent_url = Column(String(255)) + storage_url = Column(String(255)) + cdn_management_url = Column(String(255)) + + # TODO(vish): can these both come from the same baseclass? class FixedIp(BASE, NovaBase): @@ -388,7 +476,8 @@ def register_models(): """Register Models and create metadata""" from sqlalchemy import create_engine models = (Service, Instance, Volume, ExportDevice, - FixedIp, FloatingIp, Network, NetworkIndex) # , Image, Host) + FixedIp, FloatingIp, Network, NetworkIndex, + AuthToken) # , Image, Host) engine = create_engine(FLAGS.sql_connection, echo=False) for model in models: model.metadata.create_all(engine) diff --git a/nova/endpoint/__init__.py b/nova/endpoint/__init__.py deleted file mode 100644 index e69de29bb..000000000 --- a/nova/endpoint/__init__.py +++ /dev/null diff --git a/nova/endpoint/api.py b/nova/endpoint/api.py deleted file mode 100755 index 56481b2c0..000000000 --- a/nova/endpoint/api.py +++ /dev/null @@ -1,342 +0,0 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 - -# Copyright 2010 United States Government as represented by the -# Administrator of the National Aeronautics and Space Administration. -# 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. - -""" -Tornado REST API Request Handlers for Nova functions -Most calls are proxied into the responsible controller. -""" - -import logging -import multiprocessing -import random -import re -import urllib -# TODO(termie): replace minidom with etree -from xml.dom import minidom - -import tornado.web -from twisted.internet import defer - -from nova import crypto -from nova import exception -from nova import flags -from nova import utils -from nova.auth import manager -import nova.cloudpipe.api -from nova.endpoint import cloud - - -FLAGS = flags.FLAGS - -_log = logging.getLogger("api") -_log.setLevel(logging.DEBUG) - - -_c2u = re.compile('(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))') - - -def _camelcase_to_underscore(str): - return _c2u.sub(r'_\1', str).lower().strip('_') - - -def _underscore_to_camelcase(str): - return ''.join([x[:1].upper() + x[1:] for x in str.split('_')]) - - -def _underscore_to_xmlcase(str): - res = _underscore_to_camelcase(str) - return res[:1].lower() + res[1:] - - -class APIRequestContext(object): - def __init__(self, handler, user, project): - self.handler = handler - self.user = user - self.project = project - self.request_id = ''.join( - [random.choice('ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890-') - for x in xrange(20)] - ) - - -class APIRequest(object): - def __init__(self, controller, action): - self.controller = controller - self.action = action - - def send(self, context, **kwargs): - - try: - method = getattr(self.controller, - _camelcase_to_underscore(self.action)) - except AttributeError: - _error = ('Unsupported API request: controller = %s,' - 'action = %s') % (self.controller, self.action) - _log.warning(_error) - # TODO: Raise custom exception, trap in apiserver, - # and reraise as 400 error. - raise Exception(_error) - - args = {} - for key, value in kwargs.items(): - parts = key.split(".") - key = _camelcase_to_underscore(parts[0]) - if len(parts) > 1: - d = args.get(key, {}) - d[parts[1]] = value[0] - value = d - else: - value = value[0] - args[key] = value - - for key in args.keys(): - if isinstance(args[key], dict): - if args[key] != {} and args[key].keys()[0].isdigit(): - s = args[key].items() - s.sort() - args[key] = [v for k, v in s] - - d = defer.maybeDeferred(method, context, **args) - d.addCallback(self._render_response, context.request_id) - return d - - def _render_response(self, response_data, request_id): - xml = minidom.Document() - - response_el = xml.createElement(self.action + 'Response') - response_el.setAttribute('xmlns', - 'http://ec2.amazonaws.com/doc/2009-11-30/') - request_id_el = xml.createElement('requestId') - request_id_el.appendChild(xml.createTextNode(request_id)) - response_el.appendChild(request_id_el) - if(response_data == True): - self._render_dict(xml, response_el, {'return': 'true'}) - else: - self._render_dict(xml, response_el, response_data) - - xml.appendChild(response_el) - - response = xml.toxml() - xml.unlink() - _log.debug(response) - return response - - def _render_dict(self, xml, el, data): - try: - for key in data.keys(): - val = data[key] - el.appendChild(self._render_data(xml, key, val)) - except: - _log.debug(data) - raise - - def _render_data(self, xml, el_name, data): - el_name = _underscore_to_xmlcase(el_name) - data_el = xml.createElement(el_name) - - if isinstance(data, list): - for item in data: - data_el.appendChild(self._render_data(xml, 'item', item)) - elif isinstance(data, dict): - self._render_dict(xml, data_el, data) - elif hasattr(data, '__dict__'): - self._render_dict(xml, data_el, data.__dict__) - elif isinstance(data, bool): - data_el.appendChild(xml.createTextNode(str(data).lower())) - elif data != None: - data_el.appendChild(xml.createTextNode(str(data))) - - return data_el - - -class RootRequestHandler(tornado.web.RequestHandler): - def get(self): - # available api versions - versions = [ - '1.0', - '2007-01-19', - '2007-03-01', - '2007-08-29', - '2007-10-10', - '2007-12-15', - '2008-02-01', - '2008-09-01', - '2009-04-04', - ] - for version in versions: - self.write('%s\n' % version) - self.finish() - - -class MetadataRequestHandler(tornado.web.RequestHandler): - def print_data(self, data): - if isinstance(data, dict): - output = '' - for key in data: - if key == '_name': - continue - output += key - if isinstance(data[key], dict): - if '_name' in data[key]: - output += '=' + str(data[key]['_name']) - else: - output += '/' - output += '\n' - self.write(output[:-1]) # cut off last \n - elif isinstance(data, list): - self.write('\n'.join(data)) - else: - self.write(str(data)) - - def lookup(self, path, data): - items = path.split('/') - for item in items: - if item: - if not isinstance(data, dict): - return data - if not item in data: - return None - data = data[item] - return data - - def get(self, path): - cc = self.application.controllers['Cloud'] - meta_data = cc.get_metadata(self.request.remote_ip) - if meta_data is None: - _log.error('Failed to get metadata for ip: %s' % - self.request.remote_ip) - raise tornado.web.HTTPError(404) - data = self.lookup(path, meta_data) - if data is None: - raise tornado.web.HTTPError(404) - self.print_data(data) - self.finish() - - -class APIRequestHandler(tornado.web.RequestHandler): - def get(self, controller_name): - self.execute(controller_name) - - @tornado.web.asynchronous - def execute(self, controller_name): - # Obtain the appropriate controller for this request. - try: - controller = self.application.controllers[controller_name] - except KeyError: - self._error('unhandled', 'no controller named %s' % controller_name) - return - - args = self.request.arguments - - # Read request signature. - try: - signature = args.pop('Signature')[0] - except: - raise tornado.web.HTTPError(400) - - # Make a copy of args for authentication and signature verification. - auth_params = {} - for key, value in args.items(): - auth_params[key] = value[0] - - # Get requested action and remove authentication args for final request. - try: - action = args.pop('Action')[0] - access = args.pop('AWSAccessKeyId')[0] - args.pop('SignatureMethod') - args.pop('SignatureVersion') - args.pop('Version') - args.pop('Timestamp') - except: - raise tornado.web.HTTPError(400) - - # Authenticate the request. - try: - (user, project) = manager.AuthManager().authenticate( - access, - signature, - auth_params, - self.request.method, - self.request.host, - self.request.path - ) - - except exception.Error, ex: - logging.debug("Authentication Failure: %s" % ex) - raise tornado.web.HTTPError(403) - - _log.debug('action: %s' % action) - - for key, value in args.items(): - _log.debug('arg: %s\t\tval: %s' % (key, value)) - - request = APIRequest(controller, action) - context = APIRequestContext(self, user, project) - d = request.send(context, **args) - # d.addCallback(utils.debug) - - # TODO: Wrap response in AWS XML format - d.addCallbacks(self._write_callback, self._error_callback) - - def _write_callback(self, data): - self.set_header('Content-Type', 'text/xml') - self.write(data) - self.finish() - - def _error_callback(self, failure): - try: - failure.raiseException() - except exception.ApiError as ex: - self._error(type(ex).__name__ + "." + ex.code, ex.message) - # TODO(vish): do something more useful with unknown exceptions - except Exception as ex: - self._error(type(ex).__name__, str(ex)) - raise - - def post(self, controller_name): - self.execute(controller_name) - - def _error(self, code, message): - self._status_code = 400 - self.set_header('Content-Type', 'text/xml') - self.write('<?xml version="1.0"?>\n') - self.write('<Response><Errors><Error><Code>%s</Code>' - '<Message>%s</Message></Error></Errors>' - '<RequestID>?</RequestID></Response>' % (code, message)) - self.finish() - - -class APIServerApplication(tornado.web.Application): - def __init__(self, controllers): - tornado.web.Application.__init__(self, [ - (r'/', RootRequestHandler), - (r'/cloudpipe/(.*)', nova.cloudpipe.api.CloudPipeRequestHandler), - (r'/cloudpipe', nova.cloudpipe.api.CloudPipeRequestHandler), - (r'/services/([A-Za-z0-9]+)/', APIRequestHandler), - (r'/latest/([-A-Za-z0-9/]*)', MetadataRequestHandler), - (r'/2009-04-04/([-A-Za-z0-9/]*)', MetadataRequestHandler), - (r'/2008-09-01/([-A-Za-z0-9/]*)', MetadataRequestHandler), - (r'/2008-02-01/([-A-Za-z0-9/]*)', MetadataRequestHandler), - (r'/2007-12-15/([-A-Za-z0-9/]*)', MetadataRequestHandler), - (r'/2007-10-10/([-A-Za-z0-9/]*)', MetadataRequestHandler), - (r'/2007-08-29/([-A-Za-z0-9/]*)', MetadataRequestHandler), - (r'/2007-03-01/([-A-Za-z0-9/]*)', MetadataRequestHandler), - (r'/2007-01-19/([-A-Za-z0-9/]*)', MetadataRequestHandler), - (r'/1.0/([-A-Za-z0-9/]*)', MetadataRequestHandler), - ], pool=multiprocessing.Pool(4)) - self.controllers = controllers diff --git a/nova/exception.py b/nova/exception.py index 29bcb17f8..b8894758f 100644 --- a/nova/exception.py +++ b/nova/exception.py @@ -26,6 +26,18 @@ import sys import traceback +class ProcessExecutionError(IOError): + def __init__(self, stdout=None, stderr=None, exit_code=None, cmd=None, + description=None): + if description is None: + description = "Unexpected error while running command." + if exit_code is None: + exit_code = '-' + message = "%s\nCommand: %s\nExit code: %s\nStdout: %r\nStderr: %r" % ( + description, cmd, exit_code, stdout, stderr) + IOError.__init__(self, message) + + class Error(Exception): def __init__(self, message=None): super(Error, self).__init__(message) diff --git a/nova/flags.py b/nova/flags.py index 9d27d336d..5ed0f92ee 100644 --- a/nova/flags.py +++ b/nova/flags.py @@ -167,10 +167,14 @@ def DECLARE(name, module_string, flag_values=FLAGS): # Define any app-specific flags in their own files, docs at: # http://code.google.com/p/python-gflags/source/browse/trunk/gflags.py#39 +DEFINE_list('region_list', + [], + 'list of region=url pairs separated by commas') DEFINE_string('connection_type', 'libvirt', 'libvirt, xenapi or fake') DEFINE_integer('s3_port', 3333, 's3 port') DEFINE_string('s3_host', '127.0.0.1', 's3 host') DEFINE_string('compute_topic', 'compute', 'the topic compute nodes listen on') +DEFINE_string('scheduler_topic', 'scheduler', 'the topic scheduler nodes listen on') DEFINE_string('volume_topic', 'volume', 'the topic volume nodes listen on') DEFINE_string('network_topic', 'network', 'the topic network nodes listen on') @@ -215,6 +219,8 @@ DEFINE_string('network_manager', 'nova.network.manager.VlanManager', 'Manager for network') DEFINE_string('volume_manager', 'nova.volume.manager.AOEManager', 'Manager for volume') +DEFINE_string('scheduler_manager', 'nova.scheduler.manager.SchedulerManager', + 'Manager for scheduler') DEFINE_string('host', socket.gethostname(), 'name of this node') diff --git a/nova/network/manager.py b/nova/network/manager.py index c7bcfa175..dcca21127 100644 --- a/nova/network/manager.py +++ b/nova/network/manager.py @@ -90,7 +90,7 @@ class NetworkManager(manager.Manager): network_id = network_ref['id'] host = self.db.network_set_host(context, network_id, - FLAGS.host) + self.host) self._on_set_network_host(context, network_id) return host @@ -118,7 +118,7 @@ class NetworkManager(manager.Manager): """Gets an floating ip from the pool""" # TODO(vish): add floating ips through manage command return self.db.floating_ip_allocate_address(context, - FLAGS.host, + self.host, project_id) def associate_floating_ip(self, context, floating_address, fixed_address): diff --git a/nova/objectstore/handler.py b/nova/objectstore/handler.py index 5c3dc286b..aabf6831f 100644 --- a/nova/objectstore/handler.py +++ b/nova/objectstore/handler.py @@ -55,7 +55,7 @@ from twisted.web import static from nova import exception from nova import flags from nova.auth import manager -from nova.endpoint import api +from nova.api.ec2 import context from nova.objectstore import bucket from nova.objectstore import image @@ -131,7 +131,7 @@ def get_context(request): request.uri, headers=request.getAllHeaders(), check_type='s3') - return api.APIRequestContext(None, user, project) + return context.APIRequestContext(user, project) except exception.Error as ex: logging.debug("Authentication Failure: %s", ex) raise exception.NotAuthorized diff --git a/nova/process.py b/nova/process.py index 74725c157..b3cad894b 100644 --- a/nova/process.py +++ b/nova/process.py @@ -30,7 +30,7 @@ from twisted.internet import protocol from twisted.internet import reactor from nova import flags -from nova.utils import ProcessExecutionError +from nova.exception import ProcessExecutionError FLAGS = flags.FLAGS flags.DEFINE_integer('process_pool_size', 4, @@ -127,7 +127,7 @@ def get_process_output(executable, args=None, env=None, path=None, deferred = defer.Deferred() cmd = executable if args: - cmd = cmd + " " + ' '.join(args) + cmd = " ".join([cmd] + args) logging.debug("Running cmd: %s", cmd) process_handler = BackRelayWithInput( deferred, @@ -141,8 +141,8 @@ def get_process_output(executable, args=None, env=None, path=None, executable = str(executable) if not args is None: args = [str(x) for x in args] - process_reactor.spawnProcess( process_handler, executable, - (executable,)+tuple(args), env, path) + process_reactor.spawnProcess(process_handler, executable, + (executable,)+tuple(args), env, path) return deferred diff --git a/nova/quota.py b/nova/quota.py new file mode 100644 index 000000000..edbb83111 --- /dev/null +++ b/nova/quota.py @@ -0,0 +1,92 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. +""" +Quotas for instances, volumes, and floating ips +""" + +from nova import db +from nova import exception +from nova import flags +from nova.compute import instance_types + +FLAGS = flags.FLAGS + +flags.DEFINE_integer('quota_instances', 10, + 'number of instances allowed per project') +flags.DEFINE_integer('quota_cores', 20, + 'number of instance cores allowed per project') +flags.DEFINE_integer('quota_volumes', 10, + 'number of volumes allowed per project') +flags.DEFINE_integer('quota_gigabytes', 1000, + 'number of volume gigabytes allowed per project') +flags.DEFINE_integer('quota_floating_ips', 10, + 'number of floating ips allowed per project') + +def get_quota(context, project_id): + rval = {'instances': FLAGS.quota_instances, + 'cores': FLAGS.quota_cores, + 'volumes': FLAGS.quota_volumes, + 'gigabytes': FLAGS.quota_gigabytes, + 'floating_ips': FLAGS.quota_floating_ips} + try: + quota = db.quota_get(context, project_id) + for key in rval.keys(): + if quota[key] is not None: + rval[key] = quota[key] + except exception.NotFound: + pass + return rval + +def allowed_instances(context, num_instances, instance_type): + """Check quota and return min(num_instances, allowed_instances)""" + project_id = context.project.id + used_instances, used_cores = db.instance_data_get_for_project(context, + project_id) + quota = get_quota(context, project_id) + allowed_instances = quota['instances'] - used_instances + allowed_cores = quota['cores'] - used_cores + type_cores = instance_types.INSTANCE_TYPES[instance_type]['vcpus'] + num_cores = num_instances * type_cores + allowed_instances = min(allowed_instances, + int(allowed_cores // type_cores)) + return min(num_instances, allowed_instances) + + +def allowed_volumes(context, num_volumes, size): + """Check quota and return min(num_volumes, allowed_volumes)""" + project_id = context.project.id + used_volumes, used_gigabytes = db.volume_data_get_for_project(context, + project_id) + quota = get_quota(context, project_id) + allowed_volumes = quota['volumes'] - used_volumes + allowed_gigabytes = quota['gigabytes'] - used_gigabytes + size = int(size) + num_gigabytes = num_volumes * size + allowed_volumes = min(allowed_volumes, + int(allowed_gigabytes // size)) + return min(num_volumes, allowed_volumes) + + +def allowed_floating_ips(context, num_floating_ips): + """Check quota and return min(num_floating_ips, allowed_floating_ips)""" + project_id = context.project.id + used_floating_ips = db.floating_ip_count_by_project(context, project_id) + quota = get_quota(context, project_id) + allowed_floating_ips = quota['floating_ips'] - used_floating_ips + return min(num_floating_ips, allowed_floating_ips) + diff --git a/nova/rpc.py b/nova/rpc.py index 84a9b5590..6363335ea 100644 --- a/nova/rpc.py +++ b/nova/rpc.py @@ -46,9 +46,9 @@ LOG.setLevel(logging.DEBUG) class Connection(carrot_connection.BrokerConnection): """Connection instance object""" @classmethod - def instance(cls): + def instance(cls, new=False): """Returns the instance""" - if not hasattr(cls, '_instance'): + if new or not hasattr(cls, '_instance'): params = dict(hostname=FLAGS.rabbit_host, port=FLAGS.rabbit_port, userid=FLAGS.rabbit_userid, @@ -60,7 +60,10 @@ class Connection(carrot_connection.BrokerConnection): # NOTE(vish): magic is fun! # pylint: disable-msg=W0142 - cls._instance = cls(**params) + if new: + return cls(**params) + else: + cls._instance = cls(**params) return cls._instance @classmethod @@ -94,8 +97,6 @@ class Consumer(messaging.Consumer): injected.start() return injected - attachToTornado = attach_to_tornado - def fetch(self, no_ack=None, auto_ack=None, enable_callbacks=False): """Wraps the parent fetch with some logic for failed connections""" # TODO(vish): the logic for failed connections and logging should be @@ -265,28 +266,32 @@ def call(topic, msg): msg.update({'_msg_id': msg_id}) LOG.debug("MSG_ID is %s" % (msg_id)) - conn = Connection.instance() - d = defer.Deferred() - consumer = DirectConsumer(connection=conn, msg_id=msg_id) - - def deferred_receive(data, message): - """Acks message and callbacks or errbacks""" - message.ack() - if data['failure']: - return d.errback(RemoteError(*data['failure'])) - else: - return d.callback(data['result']) + class WaitMessage(object): - consumer.register_callback(deferred_receive) - injected = consumer.attach_to_tornado() + def __call__(self, data, message): + """Acks message and sets result.""" + message.ack() + if data['failure']: + self.result = RemoteError(*data['failure']) + else: + self.result = data['result'] - # clean up after the injected listened and return x - d.addCallback(lambda x: injected.stop() and x or x) + wait_msg = WaitMessage() + conn = Connection.instance(True) + consumer = DirectConsumer(connection=conn, msg_id=msg_id) + consumer.register_callback(wait_msg) + conn = Connection.instance() publisher = TopicPublisher(connection=conn, topic=topic) publisher.send(msg) publisher.close() - return d + + try: + consumer.wait(limit=1) + except StopIteration: + pass + consumer.close() + return wait_msg.result def cast(topic, msg): diff --git a/nova/scheduler/__init__.py b/nova/scheduler/__init__.py new file mode 100644 index 000000000..8359a7aeb --- /dev/null +++ b/nova/scheduler/__init__.py @@ -0,0 +1,25 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2010 Openstack, LLC. +# +# 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. + +""" +:mod:`nova.scheduler` -- Scheduler Nodes +===================================================== + +.. automodule:: nova.scheduler + :platform: Unix + :synopsis: Module that picks a compute node to run a VM instance. +.. moduleauthor:: Chris Behrens <cbehrens@codestud.com> +""" diff --git a/nova/scheduler/chance.py b/nova/scheduler/chance.py new file mode 100644 index 000000000..7fd09b053 --- /dev/null +++ b/nova/scheduler/chance.py @@ -0,0 +1,38 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2010 Openstack, LLC. +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. + +""" +Chance (Random) Scheduler implementation +""" + +import random + +from nova.scheduler import driver + + +class ChanceScheduler(driver.Scheduler): + """Implements Scheduler as a random node selector.""" + + def schedule(self, context, topic, *_args, **_kwargs): + """Picks a host that is up at random.""" + + hosts = self.hosts_up(context, topic) + if not hosts: + raise driver.NoValidHost("No hosts found") + return hosts[int(random.random() * len(hosts))] diff --git a/nova/scheduler/driver.py b/nova/scheduler/driver.py new file mode 100644 index 000000000..c89d25a47 --- /dev/null +++ b/nova/scheduler/driver.py @@ -0,0 +1,59 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2010 Openstack, LLC. +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. + +""" +Scheduler base class that all Schedulers should inherit from +""" + +import datetime + +from nova import db +from nova import exception +from nova import flags + +FLAGS = flags.FLAGS +flags.DEFINE_integer('service_down_time', 60, + 'maximum time since last checkin for up service') + +class NoValidHost(exception.Error): + """There is no valid host for the command.""" + pass + +class Scheduler(object): + """The base class that all Scheduler clases should inherit from.""" + + @staticmethod + def service_is_up(service): + """Check whether a service is up based on last heartbeat.""" + last_heartbeat = service['updated_at'] or service['created_at'] + # Timestamps in DB are UTC. + elapsed = datetime.datetime.utcnow() - last_heartbeat + return elapsed < datetime.timedelta(seconds=FLAGS.service_down_time) + + def hosts_up(self, context, topic): + """Return the list of hosts that have a running service for topic.""" + + services = db.service_get_all_by_topic(context, topic) + return [service.host + for service in services + if self.service_is_up(service)] + + def schedule(self, context, topic, *_args, **_kwargs): + """Must override at least this method for scheduler to work.""" + raise NotImplementedError("Must implement a fallback schedule") diff --git a/nova/scheduler/manager.py b/nova/scheduler/manager.py new file mode 100644 index 000000000..0ad7ca86b --- /dev/null +++ b/nova/scheduler/manager.py @@ -0,0 +1,66 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2010 Openstack, LLC. +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. + +""" +Scheduler Service +""" + +import logging +import functools + +from nova import db +from nova import flags +from nova import manager +from nova import rpc +from nova import utils + +FLAGS = flags.FLAGS +flags.DEFINE_string('scheduler_driver', + 'nova.scheduler.chance.ChanceScheduler', + 'Driver to use for the scheduler') + + +class SchedulerManager(manager.Manager): + """Chooses a host to run instances on.""" + def __init__(self, scheduler_driver=None, *args, **kwargs): + if not scheduler_driver: + scheduler_driver = FLAGS.scheduler_driver + self.driver = utils.import_object(scheduler_driver) + super(SchedulerManager, self).__init__(*args, **kwargs) + + def __getattr__(self, key): + """Converts all method calls to use the schedule method""" + return functools.partial(self._schedule, key) + + def _schedule(self, method, context, topic, *args, **kwargs): + """Tries to call schedule_* method on the driver to retrieve host. + + Falls back to schedule(context, topic) if method doesn't exist. + """ + driver_method = 'schedule_%s' % method + try: + host = getattr(self.driver, driver_method)(context, *args, **kwargs) + except AttributeError: + host = self.driver.schedule(context, topic, *args, **kwargs) + + kwargs.update({"context": None}) + rpc.cast(db.queue_get_for(context, topic, host), + {"method": method, + "args": kwargs}) + logging.debug("Casting to %s %s for %s", topic, host, method) diff --git a/nova/scheduler/simple.py b/nova/scheduler/simple.py new file mode 100644 index 000000000..fdaff74d8 --- /dev/null +++ b/nova/scheduler/simple.py @@ -0,0 +1,90 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2010 Openstack, LLC. +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. + +""" +Simple Scheduler +""" + +import datetime + +from nova import db +from nova import flags +from nova.scheduler import driver +from nova.scheduler import chance + +FLAGS = flags.FLAGS +flags.DEFINE_integer("max_cores", 16, + "maximum number of instance cores to allow per host") +flags.DEFINE_integer("max_gigabytes", 10000, + "maximum number of volume gigabytes to allow per host") +flags.DEFINE_integer("max_networks", 1000, + "maximum number of networks to allow per host") + +class SimpleScheduler(chance.ChanceScheduler): + """Implements Naive Scheduler that tries to find least loaded host.""" + + def schedule_run_instance(self, context, instance_id, *_args, **_kwargs): + """Picks a host that is up and has the fewest running instances.""" + instance_ref = db.instance_get(context, instance_id) + results = db.service_get_all_compute_sorted(context) + for result in results: + (service, instance_cores) = result + if instance_cores + instance_ref['vcpus'] > FLAGS.max_cores: + raise driver.NoValidHost("All hosts have too many cores") + if self.service_is_up(service): + # NOTE(vish): this probably belongs in the manager, if we + # can generalize this somehow + now = datetime.datetime.utcnow() + db.instance_update(context, + instance_id, + {'host': service['host'], + 'scheduled_at': now}) + return service['host'] + raise driver.NoValidHost("No hosts found") + + def schedule_create_volume(self, context, volume_id, *_args, **_kwargs): + """Picks a host that is up and has the fewest volumes.""" + volume_ref = db.volume_get(context, volume_id) + results = db.service_get_all_volume_sorted(context) + for result in results: + (service, volume_gigabytes) = result + if volume_gigabytes + volume_ref['size'] > FLAGS.max_gigabytes: + raise driver.NoValidHost("All hosts have too many gigabytes") + if self.service_is_up(service): + # NOTE(vish): this probably belongs in the manager, if we + # can generalize this somehow + now = datetime.datetime.utcnow() + db.volume_update(context, + volume_id, + {'host': service['host'], + 'scheduled_at': now}) + return service['host'] + raise driver.NoValidHost("No hosts found") + + def schedule_set_network_host(self, context, *_args, **_kwargs): + """Picks a host that is up and has the fewest networks.""" + + results = db.service_get_all_network_sorted(context) + for result in results: + (service, instance_count) = result + if instance_count >= FLAGS.max_networks: + raise driver.NoValidHost("All hosts have too many networks") + if self.service_is_up(service): + return service['host'] + raise driver.NoValidHost("No hosts found") diff --git a/nova/tests/access_unittest.py b/nova/tests/access_unittest.py index 59e1683db..c8a49d2ca 100644 --- a/nova/tests/access_unittest.py +++ b/nova/tests/access_unittest.py @@ -18,12 +18,13 @@ import unittest import logging +import webob from nova import exception from nova import flags from nova import test +from nova.api import ec2 from nova.auth import manager -from nova.auth import rbac FLAGS = flags.FLAGS @@ -72,9 +73,17 @@ class AccessTestCase(test.BaseTestCase): try: self.project.add_role(self.testsys, 'sysadmin') except: pass - self.context = Context() - self.context.project = self.project #user is set in each test + def noopWSGIApp(environ, start_response): + start_response('200 OK', []) + return [''] + self.mw = ec2.Authorizer(noopWSGIApp) + self.mw.action_roles = {'str': { + '_allow_all': ['all'], + '_allow_none': [], + '_allow_project_manager': ['projectmanager'], + '_allow_sys_and_net': ['sysadmin', 'netadmin'], + '_allow_sysadmin': ['sysadmin']}} def tearDown(self): um = manager.AuthManager() @@ -87,76 +96,46 @@ class AccessTestCase(test.BaseTestCase): um.delete_user('testsys') super(AccessTestCase, self).tearDown() + def response_status(self, user, methodName): + context = Context() + context.project = self.project + context.user = user + environ = {'ec2.context' : context, + 'ec2.controller': 'some string', + 'ec2.action': methodName} + req = webob.Request.blank('/', environ) + resp = req.get_response(self.mw) + return resp.status_int + + def shouldAllow(self, user, methodName): + self.assertEqual(200, self.response_status(user, methodName)) + + def shouldDeny(self, user, methodName): + self.assertEqual(401, self.response_status(user, methodName)) + def test_001_allow_all(self): - self.context.user = self.testadmin - self.assertTrue(self._allow_all(self.context)) - self.context.user = self.testpmsys - self.assertTrue(self._allow_all(self.context)) - self.context.user = self.testnet - self.assertTrue(self._allow_all(self.context)) - self.context.user = self.testsys - self.assertTrue(self._allow_all(self.context)) + users = [self.testadmin, self.testpmsys, self.testnet, self.testsys] + for user in users: + self.shouldAllow(user, '_allow_all') def test_002_allow_none(self): - self.context.user = self.testadmin - self.assertTrue(self._allow_none(self.context)) - self.context.user = self.testpmsys - self.assertRaises(exception.NotAuthorized, self._allow_none, self.context) - self.context.user = self.testnet - self.assertRaises(exception.NotAuthorized, self._allow_none, self.context) - self.context.user = self.testsys - self.assertRaises(exception.NotAuthorized, self._allow_none, self.context) + self.shouldAllow(self.testadmin, '_allow_none') + users = [self.testpmsys, self.testnet, self.testsys] + for user in users: + self.shouldDeny(user, '_allow_none') def test_003_allow_project_manager(self): - self.context.user = self.testadmin - self.assertTrue(self._allow_project_manager(self.context)) - self.context.user = self.testpmsys - self.assertTrue(self._allow_project_manager(self.context)) - self.context.user = self.testnet - self.assertRaises(exception.NotAuthorized, self._allow_project_manager, self.context) - self.context.user = self.testsys - self.assertRaises(exception.NotAuthorized, self._allow_project_manager, self.context) + for user in [self.testadmin, self.testpmsys]: + self.shouldAllow(user, '_allow_project_manager') + for user in [self.testnet, self.testsys]: + self.shouldDeny(user, '_allow_project_manager') def test_004_allow_sys_and_net(self): - self.context.user = self.testadmin - self.assertTrue(self._allow_sys_and_net(self.context)) - self.context.user = self.testpmsys # doesn't have the per project sysadmin - self.assertRaises(exception.NotAuthorized, self._allow_sys_and_net, self.context) - self.context.user = self.testnet - self.assertTrue(self._allow_sys_and_net(self.context)) - self.context.user = self.testsys - self.assertTrue(self._allow_sys_and_net(self.context)) - - def test_005_allow_sys_no_pm(self): - self.context.user = self.testadmin - self.assertTrue(self._allow_sys_no_pm(self.context)) - self.context.user = self.testpmsys - self.assertRaises(exception.NotAuthorized, self._allow_sys_no_pm, self.context) - self.context.user = self.testnet - self.assertRaises(exception.NotAuthorized, self._allow_sys_no_pm, self.context) - self.context.user = self.testsys - self.assertTrue(self._allow_sys_no_pm(self.context)) - - @rbac.allow('all') - def _allow_all(self, context): - return True - - @rbac.allow('none') - def _allow_none(self, context): - return True - - @rbac.allow('projectmanager') - def _allow_project_manager(self, context): - return True - - @rbac.allow('sysadmin', 'netadmin') - def _allow_sys_and_net(self, context): - return True - - @rbac.allow('sysadmin') - @rbac.deny('projectmanager') - def _allow_sys_no_pm(self, context): - return True + for user in [self.testadmin, self.testnet, self.testsys]: + self.shouldAllow(user, '_allow_sys_and_net') + # denied because it doesn't have the per project sysadmin + for user in [self.testpmsys]: + self.shouldDeny(user, '_allow_sys_and_net') if __name__ == "__main__": # TODO: Implement use_fake as an option diff --git a/nova/tests/api/__init__.py b/nova/tests/api/__init__.py index 59c4adc3d..fc1ab9ae2 100644 --- a/nova/tests/api/__init__.py +++ b/nova/tests/api/__init__.py @@ -25,6 +25,7 @@ import stubout import webob import webob.dec +import nova.exception from nova import api from nova.tests.api.test_helper import * @@ -36,24 +37,46 @@ class Test(unittest.TestCase): def tearDown(self): # pylint: disable-msg=C0103 self.stubs.UnsetAll() + def _request(self, url, subdomain, **kwargs): + environ_keys = {'HTTP_HOST': '%s.example.com' % subdomain} + environ_keys.update(kwargs) + req = webob.Request.blank(url, environ_keys) + return req.get_response(api.API()) + def test_rackspace(self): self.stubs.Set(api.rackspace, 'API', APIStub) - result = webob.Request.blank('/v1.0/cloud').get_response(api.API()) + result = self._request('/v1.0/cloud', 'rs') self.assertEqual(result.body, "/cloud") def test_ec2(self): self.stubs.Set(api.ec2, 'API', APIStub) - result = webob.Request.blank('/ec2/cloud').get_response(api.API()) + result = self._request('/services/cloud', 'ec2') self.assertEqual(result.body, "/cloud") def test_not_found(self): self.stubs.Set(api.ec2, 'API', APIStub) self.stubs.Set(api.rackspace, 'API', APIStub) - result = webob.Request.blank('/test/cloud').get_response(api.API()) + result = self._request('/test/cloud', 'ec2') self.assertNotEqual(result.body, "/cloud") - def test_query_api_version(self): - pass + def test_query_api_versions(self): + result = self._request('/', 'rs') + self.assertTrue('CURRENT' in result.body) + + def test_metadata(self): + def go(url): + result = self._request(url, 'ec2', + REMOTE_ADDR='128.192.151.2') + # Each should get to the ORM layer and fail to find the IP + self.assertRaises(nova.exception.NotFound, go, '/latest/') + self.assertRaises(nova.exception.NotFound, go, '/2009-04-04/') + self.assertRaises(nova.exception.NotFound, go, '/1.0/') + + def test_ec2_root(self): + result = self._request('/', 'ec2') + self.assertTrue('2007-12-15\n' in result.body) + + if __name__ == '__main__': unittest.main() diff --git a/nova/tests/api/rackspace/__init__.py b/nova/tests/api/rackspace/__init__.py index e69de29bb..622cb4335 100644 --- a/nova/tests/api/rackspace/__init__.py +++ b/nova/tests/api/rackspace/__init__.py @@ -0,0 +1,79 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 OpenStack LLC. +# 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 unittest + +from nova.api.rackspace import RateLimitingMiddleware +from nova.tests.api.test_helper import * +from webob import Request + + +class RateLimitingMiddlewareTest(unittest.TestCase): + + def test_get_action_name(self): + middleware = RateLimitingMiddleware(APIStub()) + def verify(method, url, action_name): + req = Request.blank(url) + req.method = method + action = middleware.get_action_name(req) + self.assertEqual(action, action_name) + verify('PUT', '/servers/4', 'PUT') + verify('DELETE', '/servers/4', 'DELETE') + verify('POST', '/images/4', 'POST') + verify('POST', '/servers/4', 'POST servers') + verify('GET', '/foo?a=4&changes-since=never&b=5', 'GET changes-since') + verify('GET', '/foo?a=4&monkeys-since=never&b=5', None) + verify('GET', '/servers/4', None) + verify('HEAD', '/servers/4', None) + + def exhaust(self, middleware, method, url, username, times): + req = Request.blank(url, dict(REQUEST_METHOD=method), + headers={'X-Auth-User': username}) + for i in range(times): + resp = req.get_response(middleware) + self.assertEqual(resp.status_int, 200) + resp = req.get_response(middleware) + self.assertEqual(resp.status_int, 413) + self.assertTrue('Retry-After' in resp.headers) + + def test_single_action(self): + middleware = RateLimitingMiddleware(APIStub()) + self.exhaust(middleware, 'DELETE', '/servers/4', 'usr1', 100) + self.exhaust(middleware, 'DELETE', '/servers/4', 'usr2', 100) + + def test_POST_servers_action_implies_POST_action(self): + middleware = RateLimitingMiddleware(APIStub()) + self.exhaust(middleware, 'POST', '/servers/4', 'usr1', 10) + self.exhaust(middleware, 'POST', '/images/4', 'usr2', 10) + self.assertTrue(set(middleware.limiter._levels) == + set(['usr1:POST', 'usr1:POST servers', 'usr2:POST'])) + + def test_POST_servers_action_correctly_ratelimited(self): + middleware = RateLimitingMiddleware(APIStub()) + # Use up all of our "POST" allowance for the minute, 5 times + for i in range(5): + self.exhaust(middleware, 'POST', '/servers/4', 'usr1', 10) + # Reset the 'POST' action counter. + del middleware.limiter._levels['usr1:POST'] + # All 50 daily "POST servers" actions should be all used up + self.exhaust(middleware, 'POST', '/servers/4', 'usr1', 0) + + def test_proxy_ctor_works(self): + middleware = RateLimitingMiddleware(APIStub()) + self.assertEqual(middleware.limiter.__class__.__name__, "Limiter") + middleware = RateLimitingMiddleware(APIStub(), service_host='foobar') + self.assertEqual(middleware.limiter.__class__.__name__, "WSGIAppProxy") diff --git a/nova/tests/api/rackspace/auth.py b/nova/tests/api/rackspace/auth.py new file mode 100644 index 000000000..429c22ad2 --- /dev/null +++ b/nova/tests/api/rackspace/auth.py @@ -0,0 +1,106 @@ +import webob +import webob.dec +import unittest +import stubout +import nova.api +import nova.api.rackspace.auth +from nova import auth +from nova.tests.api.rackspace import test_helper +import datetime + +class Test(unittest.TestCase): + def setUp(self): + self.stubs = stubout.StubOutForTesting() + self.stubs.Set(nova.api.rackspace.auth.BasicApiAuthManager, + '__init__', test_helper.fake_auth_init) + test_helper.FakeAuthManager.auth_data = {} + test_helper.FakeAuthDatabase.data = {} + self.stubs.Set(nova.api.rackspace, 'RateLimitingMiddleware', + test_helper.FakeRateLimiter) + + def tearDown(self): + self.stubs.UnsetAll() + test_helper.fake_data_store = {} + + def test_authorize_user(self): + f = test_helper.FakeAuthManager() + f.add_user('derp', { 'uid': 1, 'name':'herp' } ) + + req = webob.Request.blank('/v1.0/') + req.headers['X-Auth-User'] = 'herp' + req.headers['X-Auth-Key'] = 'derp' + result = req.get_response(nova.api.API()) + self.assertEqual(result.status, '204 No Content') + self.assertEqual(len(result.headers['X-Auth-Token']), 40) + self.assertEqual(result.headers['X-CDN-Management-Url'], + "") + self.assertEqual(result.headers['X-Storage-Url'], "") + + def test_authorize_token(self): + f = test_helper.FakeAuthManager() + f.add_user('derp', { 'uid': 1, 'name':'herp' } ) + + req = webob.Request.blank('/v1.0/') + req.headers['X-Auth-User'] = 'herp' + req.headers['X-Auth-Key'] = 'derp' + result = req.get_response(nova.api.API()) + self.assertEqual(result.status, '204 No Content') + self.assertEqual(len(result.headers['X-Auth-Token']), 40) + self.assertEqual(result.headers['X-Server-Management-Url'], + "https://foo/v1.0/") + self.assertEqual(result.headers['X-CDN-Management-Url'], + "") + self.assertEqual(result.headers['X-Storage-Url'], "") + + token = result.headers['X-Auth-Token'] + self.stubs.Set(nova.api.rackspace, 'APIRouter', + test_helper.FakeRouter) + req = webob.Request.blank('/v1.0/fake') + req.headers['X-Auth-Token'] = token + result = req.get_response(nova.api.API()) + self.assertEqual(result.status, '200 OK') + self.assertEqual(result.headers['X-Test-Success'], 'True') + + def test_token_expiry(self): + self.destroy_called = False + token_hash = 'bacon' + + def destroy_token_mock(meh, context, token): + self.destroy_called = True + + def bad_token(meh, context, token_hash): + return { 'token_hash':token_hash, + 'created_at':datetime.datetime(1990, 1, 1) } + + self.stubs.Set(test_helper.FakeAuthDatabase, 'auth_destroy_token', + destroy_token_mock) + + self.stubs.Set(test_helper.FakeAuthDatabase, 'auth_get_token', + bad_token) + + req = webob.Request.blank('/v1.0/') + req.headers['X-Auth-Token'] = 'bacon' + result = req.get_response(nova.api.API()) + self.assertEqual(result.status, '401 Unauthorized') + self.assertEqual(self.destroy_called, True) + + def test_bad_user(self): + req = webob.Request.blank('/v1.0/') + req.headers['X-Auth-User'] = 'herp' + req.headers['X-Auth-Key'] = 'derp' + result = req.get_response(nova.api.API()) + self.assertEqual(result.status, '401 Unauthorized') + + def test_no_user(self): + req = webob.Request.blank('/v1.0/') + result = req.get_response(nova.api.API()) + self.assertEqual(result.status, '401 Unauthorized') + + def test_bad_token(self): + req = webob.Request.blank('/v1.0/') + req.headers['X-Auth-Token'] = 'baconbaconbacon' + result = req.get_response(nova.api.API()) + self.assertEqual(result.status, '401 Unauthorized') + +if __name__ == '__main__': + unittest.main() diff --git a/nova/tests/api/rackspace/test_helper.py b/nova/tests/api/rackspace/test_helper.py new file mode 100644 index 000000000..be14e2de8 --- /dev/null +++ b/nova/tests/api/rackspace/test_helper.py @@ -0,0 +1,65 @@ +import webob +import webob.dec +import datetime +from nova.wsgi import Router +from nova import auth + +class Context(object): + pass + +class FakeRouter(Router): + def __init__(self): + pass + + @webob.dec.wsgify + def __call__(self, req): + res = webob.Response() + res.status = '200' + res.headers['X-Test-Success'] = 'True' + return res + +def fake_auth_init(self): + self.db = FakeAuthDatabase() + self.context = Context() + self.auth = FakeAuthManager() + self.host = 'foo' + +class FakeAuthDatabase(object): + data = {} + + @staticmethod + def auth_get_token(context, token_hash): + return FakeAuthDatabase.data.get(token_hash, None) + + @staticmethod + def auth_create_token(context, token): + token['created_at'] = datetime.datetime.now() + FakeAuthDatabase.data[token['token_hash']] = token + + @staticmethod + def auth_destroy_token(context, token): + if FakeAuthDatabase.data.has_key(token['token_hash']): + del FakeAuthDatabase.data['token_hash'] + +class FakeAuthManager(object): + auth_data = {} + + def add_user(self, key, user): + FakeAuthManager.auth_data[key] = user + + def get_user(self, uid): + for k, v in FakeAuthManager.auth_data.iteritems(): + if v['uid'] == uid: + return v + return None + + def get_user_from_access_key(self, key): + return FakeAuthManager.auth_data.get(key, None) + +class FakeRateLimiter(object): + def __init__(self, application): + self.application = application + + @webob.dec.wsgify + def __call__(self, req): + return self.application diff --git a/nova/tests/api_unittest.py b/nova/tests/api_unittest.py index 462d1b295..c040cdad3 100644 --- a/nova/tests/api_unittest.py +++ b/nova/tests/api_unittest.py @@ -23,60 +23,17 @@ from boto.ec2 import regioninfo import httplib import random import StringIO -from tornado import httpserver -from twisted.internet import defer +import webob from nova import flags from nova import test +from nova import api +from nova.api.ec2 import cloud from nova.auth import manager -from nova.endpoint import api -from nova.endpoint import cloud FLAGS = flags.FLAGS - - -# NOTE(termie): These are a bunch of helper methods and classes to short -# circuit boto calls and feed them into our tornado handlers, -# it's pretty damn circuitous so apologies if you have to fix -# a bug in it -# NOTE(jaypipes) The pylint disables here are for R0913 (too many args) which -# isn't controllable since boto's HTTPRequest needs that many -# args, and for the version-differentiated import of tornado's -# httputil. -# NOTE(jaypipes): The disable-msg=E1101 and E1103 below is because pylint is -# unable to introspect the deferred's return value properly - -def boto_to_tornado(method, path, headers, data, # pylint: disable-msg=R0913 - host, connection=None): - """ translate boto requests into tornado requests - - connection should be a FakeTornadoHttpConnection instance - """ - try: - headers = httpserver.HTTPHeaders() - except AttributeError: - from tornado import httputil # pylint: disable-msg=E0611 - headers = httputil.HTTPHeaders() - for k, v in headers.iteritems(): - headers[k] = v - - req = httpserver.HTTPRequest(method=method, - uri=path, - headers=headers, - body=data, - host=host, - remote_ip='127.0.0.1', - connection=connection) - return req - - -def raw_to_httpresponse(response_string): - """translate a raw tornado http response into an httplib.HTTPResponse""" - sock = FakeHttplibSocket(response_string) - resp = httplib.HTTPResponse(sock) - resp.begin() - return resp +FLAGS.FAKE_subdomain = 'ec2' class FakeHttplibSocket(object): @@ -89,85 +46,35 @@ class FakeHttplibSocket(object): return self._buffer -class FakeTornadoStream(object): - """a fake stream to satisfy tornado's assumptions, trivial""" - def set_close_callback(self, _func): - """Dummy callback for stream""" - pass - - -class FakeTornadoConnection(object): - """A fake connection object for tornado to pass to its handlers - - web requests are expected to write to this as they get data and call - finish when they are done with the request, we buffer the writes and - kick off a callback when it is done so that we can feed the result back - into boto. - """ - def __init__(self, deferred): - self._deferred = deferred - self._buffer = StringIO.StringIO() - - def write(self, chunk): - """Writes a chunk of data to the internal buffer""" - self._buffer.write(chunk) - - def finish(self): - """Finalizes the connection and returns the buffered data via the - deferred callback. - """ - data = self._buffer.getvalue() - self._deferred.callback(data) - - xheaders = None - - @property - def stream(self): # pylint: disable-msg=R0201 - """Required property for interfacing with tornado""" - return FakeTornadoStream() - - class FakeHttplibConnection(object): """A fake httplib.HTTPConnection for boto to use requests made via this connection actually get translated and routed into - our tornado app, we then wait for the response and turn it back into + our WSGI app, we then wait for the response and turn it back into the httplib.HTTPResponse that boto expects. """ def __init__(self, app, host, is_secure=False): self.app = app self.host = host - self.deferred = defer.Deferred() def request(self, method, path, data, headers): - """Creates a connection to a fake tornado and sets - up a deferred request with the supplied data and - headers""" - conn = FakeTornadoConnection(self.deferred) - request = boto_to_tornado(connection=conn, - method=method, - path=path, - headers=headers, - data=data, - host=self.host) - self.app(request) - self.deferred.addCallback(raw_to_httpresponse) + req = webob.Request.blank(path) + req.method = method + req.body = data + req.headers = headers + req.headers['Accept'] = 'text/html' + req.host = self.host + # Call the WSGI app, get the HTTP response + resp = str(req.get_response(self.app)) + # For some reason, the response doesn't have "HTTP/1.0 " prepended; I + # guess that's a function the web server usually provides. + resp = "HTTP/1.0 %s" % resp + sock = FakeHttplibSocket(resp) + self.http_response = httplib.HTTPResponse(sock) + self.http_response.begin() def getresponse(self): - """A bit of deferred magic for catching the response - from the previously deferred request""" - @defer.inlineCallbacks - def _waiter(): - """Callback that simply yields the deferred's - return value.""" - result = yield self.deferred - defer.returnValue(result) - d = _waiter() - # NOTE(termie): defer.returnValue above should ensure that - # this deferred has already been called by the time - # we get here, we are going to cheat and return - # the result of the callback - return d.result # pylint: disable-msg=E1101 + return self.http_response def close(self): """Required for compatibility with boto/tornado""" @@ -180,17 +87,16 @@ class ApiEc2TestCase(test.BaseTestCase): super(ApiEc2TestCase, self).setUp() self.manager = manager.AuthManager() - self.cloud = cloud.CloudController() self.host = '127.0.0.1' - self.app = api.APIServerApplication({'Cloud': self.cloud}) + self.app = api.API() self.ec2 = boto.connect_ec2( aws_access_key_id='fake', aws_secret_access_key='fake', is_secure=False, region=regioninfo.RegionInfo(None, 'test', self.host), - port=FLAGS.cc_port, + port=8773, path='/services/Cloud') self.mox.StubOutWithMock(self.ec2, 'new_http_connection') @@ -198,7 +104,7 @@ class ApiEc2TestCase(test.BaseTestCase): def expect_http(self, host=None, is_secure=False): """Returns a new EC2 connection""" http = FakeHttplibConnection( - self.app, '%s:%d' % (self.host, FLAGS.cc_port), False) + self.app, '%s:8773' % (self.host), False) # pylint: disable-msg=E1103 self.ec2.new_http_connection(host, is_secure).AndReturn(http) return http @@ -224,7 +130,8 @@ class ApiEc2TestCase(test.BaseTestCase): for x in range(random.randint(4, 8))) user = self.manager.create_user('fake', 'fake', 'fake') project = self.manager.create_project('fake', 'fake', 'fake') - self.manager.generate_key_pair(user.id, keyname) + # NOTE(vish): create depends on pool, so call helper directly + cloud._gen_key(None, user.id, keyname) rv = self.ec2.get_all_key_pairs() results = [k for k in rv if k.name == keyname] diff --git a/nova/tests/auth_unittest.py b/nova/tests/auth_unittest.py index b54e68274..3235dea39 100644 --- a/nova/tests/auth_unittest.py +++ b/nova/tests/auth_unittest.py @@ -17,8 +17,6 @@ # under the License. import logging -from M2Crypto import BIO -from M2Crypto import RSA from M2Crypto import X509 import unittest @@ -26,7 +24,7 @@ from nova import crypto from nova import flags from nova import test from nova.auth import manager -from nova.endpoint import cloud +from nova.api.ec2 import cloud FLAGS = flags.FLAGS @@ -65,35 +63,6 @@ class AuthTestCase(test.BaseTestCase): 'export S3_URL="http://127.0.0.1:3333/"\n' + 'export EC2_USER_ID="test1"\n') - def test_006_test_key_storage(self): - user = self.manager.get_user('test1') - user.create_key_pair('public', 'key', 'fingerprint') - key = user.get_key_pair('public') - self.assertEqual('key', key.public_key) - self.assertEqual('fingerprint', key.fingerprint) - - def test_007_test_key_generation(self): - user = self.manager.get_user('test1') - private_key, fingerprint = user.generate_key_pair('public2') - key = RSA.load_key_string(private_key, callback=lambda: None) - bio = BIO.MemoryBuffer() - public_key = user.get_key_pair('public2').public_key - key.save_pub_key_bio(bio) - converted = crypto.ssl_pub_to_ssh_pub(bio.read()) - # assert key fields are equal - self.assertEqual(public_key.split(" ")[1].strip(), - converted.split(" ")[1].strip()) - - def test_008_can_list_key_pairs(self): - keys = self.manager.get_user('test1').get_key_pairs() - self.assertTrue(filter(lambda k: k.name == 'public', keys)) - self.assertTrue(filter(lambda k: k.name == 'public2', keys)) - - def test_009_can_delete_key_pair(self): - self.manager.get_user('test1').delete_key_pair('public') - keys = self.manager.get_user('test1').get_key_pairs() - self.assertFalse(filter(lambda k: k.name == 'public', keys)) - def test_010_can_list_users(self): users = self.manager.get_users() logging.warn(users) @@ -204,6 +173,12 @@ class AuthTestCase(test.BaseTestCase): self.assert_(len(self.manager.get_projects()) > 1) self.assertEqual(len(self.manager.get_projects('test2')), 1) + def test_220_can_modify_project(self): + self.manager.modify_project('testproj', 'test2', 'new description') + project = self.manager.get_project('testproj') + self.assertEqual(project.project_manager_id, 'test2') + self.assertEqual(project.description, 'new description') + def test_299_can_delete_project(self): self.manager.delete_project('testproj') self.assertFalse(filter(lambda p: p.name == 'testproj', self.manager.get_projects())) diff --git a/nova/tests/cloud_unittest.py b/nova/tests/cloud_unittest.py index c36d5a34f..756ce519e 100644 --- a/nova/tests/cloud_unittest.py +++ b/nova/tests/cloud_unittest.py @@ -17,21 +17,25 @@ # under the License. import logging +from M2Crypto import BIO +from M2Crypto import RSA import StringIO import time -from tornado import ioloop + from twisted.internet import defer import unittest from xml.etree import ElementTree +from nova import crypto +from nova import db from nova import flags from nova import rpc from nova import test from nova import utils from nova.auth import manager from nova.compute import power_state -from nova.endpoint import api -from nova.endpoint import cloud +from nova.api.ec2 import context +from nova.api.ec2 import cloud FLAGS = flags.FLAGS @@ -55,16 +59,20 @@ class CloudTestCase(test.BaseTestCase): proxy=self.compute) self.injected.append(self.compute_consumer.attach_to_tornado(self.ioloop)) - try: - manager.AuthManager().create_user('admin', 'admin', 'admin') - except: pass - admin = manager.AuthManager().get_user('admin') - project = manager.AuthManager().create_project('proj', 'admin', 'proj') - self.context = api.APIRequestContext(handler=None,project=project,user=admin) + self.manager = manager.AuthManager() + self.user = self.manager.create_user('admin', 'admin', 'admin', True) + self.project = self.manager.create_project('proj', 'admin', 'proj') + self.context = context.APIRequestContext(user=self.user, + project=self.project) def tearDown(self): - manager.AuthManager().delete_project('proj') - manager.AuthManager().delete_user('admin') + self.manager.delete_project(self.project) + self.manager.delete_user(self.user) + super(CloudTestCase, self).setUp() + + def _create_key(self, name): + # NOTE(vish): create depends on pool, so just call helper directly + return cloud._gen_key(self.context, self.context.user.id, name) def test_console_output(self): if FLAGS.connection_type == 'fake': @@ -77,6 +85,33 @@ class CloudTestCase(test.BaseTestCase): self.assert_(output) rv = yield self.compute.terminate_instance(instance_id) + + def test_key_generation(self): + result = self._create_key('test') + private_key = result['private_key'] + key = RSA.load_key_string(private_key, callback=lambda: None) + bio = BIO.MemoryBuffer() + public_key = db.key_pair_get(self.context, + self.context.user.id, + 'test')['public_key'] + key.save_pub_key_bio(bio) + converted = crypto.ssl_pub_to_ssh_pub(bio.read()) + # assert key fields are equal + self.assertEqual(public_key.split(" ")[1].strip(), + converted.split(" ")[1].strip()) + + def test_describe_key_pairs(self): + self._create_key('test1') + self._create_key('test2') + result = self.cloud.describe_key_pairs(self.context) + keys = result["keypairsSet"] + self.assertTrue(filter(lambda k: k['keyName'] == 'test1', keys)) + self.assertTrue(filter(lambda k: k['keyName'] == 'test2', keys)) + + def test_delete_key_pair(self): + self._create_key('test') + self.cloud.delete_key_pair(self.context, 'test') + def test_run_instances(self): if FLAGS.connection_type == 'fake': logging.debug("Can't test instances without a real virtual env.") diff --git a/nova/tests/compute_unittest.py b/nova/tests/compute_unittest.py index de2bf3d3b..f5c0f1c09 100644 --- a/nova/tests/compute_unittest.py +++ b/nova/tests/compute_unittest.py @@ -50,6 +50,7 @@ class ComputeTestCase(test.TrialTestCase): def tearDown(self): # pylint: disable-msg=C0103 self.manager.delete_user(self.user) self.manager.delete_project(self.project) + super(ComputeTestCase, self).tearDown() def _create_instance(self): """Create a test instance""" @@ -83,21 +84,21 @@ class ComputeTestCase(test.TrialTestCase): @defer.inlineCallbacks def test_run_terminate_timestamps(self): - """Make sure it is possible to run and terminate instance""" + """Make sure timestamps are set for launched and destroyed""" instance_id = self._create_instance() instance_ref = db.instance_get(self.context, instance_id) self.assertEqual(instance_ref['launched_at'], None) - self.assertEqual(instance_ref['terminated_at'], None) + self.assertEqual(instance_ref['deleted_at'], None) launch = datetime.datetime.utcnow() yield self.compute.run_instance(self.context, instance_id) instance_ref = db.instance_get(self.context, instance_id) self.assert_(instance_ref['launched_at'] > launch) - self.assertEqual(instance_ref['terminated_at'], None) + self.assertEqual(instance_ref['deleted_at'], None) terminate = datetime.datetime.utcnow() yield self.compute.terminate_instance(self.context, instance_id) instance_ref = db.instance_get({'deleted': True}, instance_id) self.assert_(instance_ref['launched_at'] < terminate) - self.assert_(instance_ref['terminated_at'] > terminate) + self.assert_(instance_ref['deleted_at'] > terminate) @defer.inlineCallbacks def test_reboot(self): diff --git a/nova/tests/network_unittest.py b/nova/tests/network_unittest.py index dc5277f02..da65b50a2 100644 --- a/nova/tests/network_unittest.py +++ b/nova/tests/network_unittest.py @@ -28,7 +28,7 @@ from nova import flags from nova import test from nova import utils from nova.auth import manager -from nova.endpoint import api +from nova.api.ec2 import context FLAGS = flags.FLAGS @@ -49,7 +49,7 @@ class NetworkTestCase(test.TrialTestCase): self.user = self.manager.create_user('netuser', 'netuser', 'netuser') self.projects = [] self.network = utils.import_object(FLAGS.network_manager) - self.context = api.APIRequestContext(None, project=None, user=self.user) + self.context = context.APIRequestContext(project=None, user=self.user) for i in range(5): name = 'project%s' % i self.projects.append(self.manager.create_project(name, diff --git a/nova/tests/quota_unittest.py b/nova/tests/quota_unittest.py new file mode 100644 index 000000000..370ccd506 --- /dev/null +++ b/nova/tests/quota_unittest.py @@ -0,0 +1,152 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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 logging + +from nova import db +from nova import exception +from nova import flags +from nova import quota +from nova import test +from nova import utils +from nova.auth import manager +from nova.api.ec2 import cloud +from nova.api.ec2 import context + + +FLAGS = flags.FLAGS + + +class QuotaTestCase(test.TrialTestCase): + def setUp(self): # pylint: disable-msg=C0103 + logging.getLogger().setLevel(logging.DEBUG) + super(QuotaTestCase, self).setUp() + self.flags(connection_type='fake', + quota_instances=2, + quota_cores=4, + quota_volumes=2, + quota_gigabytes=20, + quota_floating_ips=1) + + self.cloud = cloud.CloudController() + self.manager = manager.AuthManager() + self.user = self.manager.create_user('admin', 'admin', 'admin', True) + self.project = self.manager.create_project('admin', 'admin', 'admin') + self.network = utils.import_object(FLAGS.network_manager) + self.context = context.APIRequestContext(project=self.project, + user=self.user) + + def tearDown(self): # pylint: disable-msg=C0103 + manager.AuthManager().delete_project(self.project) + manager.AuthManager().delete_user(self.user) + super(QuotaTestCase, self).tearDown() + + def _create_instance(self, cores=2): + """Create a test instance""" + inst = {} + inst['image_id'] = 'ami-test' + inst['reservation_id'] = 'r-fakeres' + inst['user_id'] = self.user.id + inst['project_id'] = self.project.id + inst['instance_type'] = 'm1.large' + inst['vcpus'] = cores + inst['mac_address'] = utils.generate_mac() + return db.instance_create(self.context, inst)['id'] + + def _create_volume(self, size=10): + """Create a test volume""" + vol = {} + vol['user_id'] = self.user.id + vol['project_id'] = self.project.id + vol['size'] = size + return db.volume_create(self.context, vol)['id'] + + def test_quota_overrides(self): + """Make sure overriding a projects quotas works""" + num_instances = quota.allowed_instances(self.context, 100, 'm1.small') + self.assertEqual(num_instances, 2) + db.quota_create(self.context, {'project_id': self.project.id, + 'instances': 10}) + num_instances = quota.allowed_instances(self.context, 100, 'm1.small') + self.assertEqual(num_instances, 4) + db.quota_update(self.context, self.project.id, {'cores': 100}) + num_instances = quota.allowed_instances(self.context, 100, 'm1.small') + self.assertEqual(num_instances, 10) + db.quota_destroy(self.context, self.project.id) + + def test_too_many_instances(self): + instance_ids = [] + for i in range(FLAGS.quota_instances): + instance_id = self._create_instance() + instance_ids.append(instance_id) + self.assertRaises(cloud.QuotaError, self.cloud.run_instances, + self.context, + min_count=1, + max_count=1, + instance_type='m1.small') + for instance_id in instance_ids: + db.instance_destroy(self.context, instance_id) + + def test_too_many_cores(self): + instance_ids = [] + instance_id = self._create_instance(cores=4) + instance_ids.append(instance_id) + self.assertRaises(cloud.QuotaError, self.cloud.run_instances, + self.context, + min_count=1, + max_count=1, + instance_type='m1.small') + for instance_id in instance_ids: + db.instance_destroy(self.context, instance_id) + + def test_too_many_volumes(self): + volume_ids = [] + for i in range(FLAGS.quota_volumes): + volume_id = self._create_volume() + volume_ids.append(volume_id) + self.assertRaises(cloud.QuotaError, self.cloud.create_volume, + self.context, + size=10) + for volume_id in volume_ids: + db.volume_destroy(self.context, volume_id) + + def test_too_many_gigabytes(self): + volume_ids = [] + volume_id = self._create_volume(size=20) + volume_ids.append(volume_id) + self.assertRaises(cloud.QuotaError, + self.cloud.create_volume, + self.context, + size=10) + for volume_id in volume_ids: + db.volume_destroy(self.context, volume_id) + + def test_too_many_addresses(self): + address = '192.168.0.100' + try: + db.floating_ip_get_by_address(None, address) + except exception.NotFound: + db.floating_ip_create(None, {'address': address, + 'host': FLAGS.host}) + float_addr = self.network.allocate_floating_ip(self.context, + self.project.id) + # NOTE(vish): This assert never fails. When cloud attempts to + # make an rpc.call, the test just finishes with OK. It + # appears to be something in the magic inline callbacks + # that is breaking. + self.assertRaises(cloud.QuotaError, self.cloud.allocate_address, self.context) diff --git a/nova/tests/scheduler_unittest.py b/nova/tests/scheduler_unittest.py new file mode 100644 index 000000000..fde30f81e --- /dev/null +++ b/nova/tests/scheduler_unittest.py @@ -0,0 +1,231 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. +""" +Tests For Scheduler +""" + +from nova import db +from nova import flags +from nova import service +from nova import test +from nova import rpc +from nova import utils +from nova.auth import manager as auth_manager +from nova.scheduler import manager +from nova.scheduler import driver + + +FLAGS = flags.FLAGS +flags.DECLARE('max_cores', 'nova.scheduler.simple') + +class TestDriver(driver.Scheduler): + """Scheduler Driver for Tests""" + def schedule(context, topic, *args, **kwargs): + return 'fallback_host' + + def schedule_named_method(context, topic, num): + return 'named_host' + +class SchedulerTestCase(test.TrialTestCase): + """Test case for scheduler""" + def setUp(self): # pylint: disable=C0103 + super(SchedulerTestCase, self).setUp() + self.flags(scheduler_driver='nova.tests.scheduler_unittest.TestDriver') + + def test_fallback(self): + scheduler = manager.SchedulerManager() + self.mox.StubOutWithMock(rpc, 'cast', use_mock_anything=True) + rpc.cast('topic.fallback_host', + {'method': 'noexist', + 'args': {'context': None, + 'num': 7}}) + self.mox.ReplayAll() + scheduler.noexist(None, 'topic', num=7) + + def test_named_method(self): + scheduler = manager.SchedulerManager() + self.mox.StubOutWithMock(rpc, 'cast', use_mock_anything=True) + rpc.cast('topic.named_host', + {'method': 'named_method', + 'args': {'context': None, + 'num': 7}}) + self.mox.ReplayAll() + scheduler.named_method(None, 'topic', num=7) + + +class SimpleDriverTestCase(test.TrialTestCase): + """Test case for simple driver""" + def setUp(self): # pylint: disable-msg=C0103 + super(SimpleDriverTestCase, self).setUp() + self.flags(connection_type='fake', + max_cores=4, + max_gigabytes=4, + volume_driver='nova.volume.driver.FakeAOEDriver', + scheduler_driver='nova.scheduler.simple.SimpleScheduler') + self.scheduler = manager.SchedulerManager() + self.context = None + self.manager = auth_manager.AuthManager() + self.user = self.manager.create_user('fake', 'fake', 'fake') + self.project = self.manager.create_project('fake', 'fake', 'fake') + self.context = None + + def tearDown(self): # pylint: disable-msg=C0103 + self.manager.delete_user(self.user) + self.manager.delete_project(self.project) + + def _create_instance(self): + """Create a test instance""" + inst = {} + inst['image_id'] = 'ami-test' + inst['reservation_id'] = 'r-fakeres' + inst['user_id'] = self.user.id + inst['project_id'] = self.project.id + inst['instance_type'] = 'm1.tiny' + inst['mac_address'] = utils.generate_mac() + inst['ami_launch_index'] = 0 + inst['vcpus'] = 1 + return db.instance_create(self.context, inst)['id'] + + def _create_volume(self): + """Create a test volume""" + vol = {} + vol['image_id'] = 'ami-test' + vol['reservation_id'] = 'r-fakeres' + vol['size'] = 1 + return db.volume_create(self.context, vol)['id'] + + def test_hosts_are_up(self): + """Ensures driver can find the hosts that are up""" + # NOTE(vish): constructing service without create method + # because we are going to use it without queue + compute1 = service.Service('host1', + 'nova-compute', + 'compute', + FLAGS.compute_manager) + compute2 = service.Service('host2', + 'nova-compute', + 'compute', + FLAGS.compute_manager) + hosts = self.scheduler.driver.hosts_up(self.context, 'compute') + self.assertEqual(len(hosts), 2) + compute1.kill() + compute2.kill() + + def test_least_busy_host_gets_instance(self): + """Ensures the host with less cores gets the next one""" + compute1 = service.Service('host1', + 'nova-compute', + 'compute', + FLAGS.compute_manager) + compute2 = service.Service('host2', + 'nova-compute', + 'compute', + FLAGS.compute_manager) + instance_id1 = self._create_instance() + compute1.run_instance(self.context, instance_id1) + instance_id2 = self._create_instance() + host = self.scheduler.driver.schedule_run_instance(self.context, + instance_id2) + self.assertEqual(host, 'host2') + compute1.terminate_instance(self.context, instance_id1) + db.instance_destroy(self.context, instance_id2) + compute1.kill() + compute2.kill() + + def test_too_many_cores(self): + """Ensures we don't go over max cores""" + compute1 = service.Service('host1', + 'nova-compute', + 'compute', + FLAGS.compute_manager) + compute2 = service.Service('host2', + 'nova-compute', + 'compute', + FLAGS.compute_manager) + instance_ids1 = [] + instance_ids2 = [] + for index in xrange(FLAGS.max_cores): + instance_id = self._create_instance() + compute1.run_instance(self.context, instance_id) + instance_ids1.append(instance_id) + instance_id = self._create_instance() + compute2.run_instance(self.context, instance_id) + instance_ids2.append(instance_id) + instance_id = self._create_instance() + self.assertRaises(driver.NoValidHost, + self.scheduler.driver.schedule_run_instance, + self.context, + instance_id) + for instance_id in instance_ids1: + compute1.terminate_instance(self.context, instance_id) + for instance_id in instance_ids2: + compute2.terminate_instance(self.context, instance_id) + compute1.kill() + compute2.kill() + + def test_least_busy_host_gets_volume(self): + """Ensures the host with less gigabytes gets the next one""" + volume1 = service.Service('host1', + 'nova-volume', + 'volume', + FLAGS.volume_manager) + volume2 = service.Service('host2', + 'nova-volume', + 'volume', + FLAGS.volume_manager) + volume_id1 = self._create_volume() + volume1.create_volume(self.context, volume_id1) + volume_id2 = self._create_volume() + host = self.scheduler.driver.schedule_create_volume(self.context, + volume_id2) + self.assertEqual(host, 'host2') + volume1.delete_volume(self.context, volume_id1) + db.volume_destroy(self.context, volume_id2) + volume1.kill() + volume2.kill() + + def test_too_many_gigabytes(self): + """Ensures we don't go over max gigabytes""" + volume1 = service.Service('host1', + 'nova-volume', + 'volume', + FLAGS.volume_manager) + volume2 = service.Service('host2', + 'nova-volume', + 'volume', + FLAGS.volume_manager) + volume_ids1 = [] + volume_ids2 = [] + for index in xrange(FLAGS.max_gigabytes): + volume_id = self._create_volume() + volume1.create_volume(self.context, volume_id) + volume_ids1.append(volume_id) + volume_id = self._create_volume() + volume2.create_volume(self.context, volume_id) + volume_ids2.append(volume_id) + volume_id = self._create_volume() + self.assertRaises(driver.NoValidHost, + self.scheduler.driver.schedule_create_volume, + self.context, + volume_id) + for volume_id in volume_ids1: + volume1.delete_volume(self.context, volume_id) + for volume_id in volume_ids2: + volume2.delete_volume(self.context, volume_id) + volume1.kill() + volume2.kill() diff --git a/nova/utils.py b/nova/utils.py index 011a5cb09..d18dd9843 100644 --- a/nova/utils.py +++ b/nova/utils.py @@ -33,22 +33,12 @@ from twisted.internet.threads import deferToThread from nova import exception from nova import flags +from nova.exception import ProcessExecutionError FLAGS = flags.FLAGS TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" -class ProcessExecutionError(IOError): - def __init__( self, stdout=None, stderr=None, exit_code=None, cmd=None, - description=None): - if description is None: - description = "Unexpected error while running command." - if exit_code is None: - exit_code = '-' - message = "%s\nCommand: %s\nExit code: %s\nStdout: %r\nStderr: %r" % ( - description, cmd, exit_code, stdout, stderr) - IOError.__init__(self, message) - def import_class(import_str): """Returns a class from a string including module and class""" mod_str, _sep, class_str = import_str.rpartition('.') @@ -129,8 +119,10 @@ def runthis(prompt, cmd, check_exit_code = True): exit_code = subprocess.call(cmd.split(" ")) logging.debug(prompt % (exit_code)) if check_exit_code and exit_code <> 0: - raise Exception( "Unexpected exit code: %s from cmd: %s" - % (exit_code, cmd)) + raise ProcessExecutionError(exit_code=exit_code, + stdout=None, + stderr=None, + cmd=cmd) def generate_uid(topic, size=8): diff --git a/nova/volume/manager.py b/nova/volume/manager.py index 174c036d6..034763512 100644 --- a/nova/volume/manager.py +++ b/nova/volume/manager.py @@ -22,6 +22,7 @@ destroying persistent storage volumes, ala EBS. """ import logging +import datetime from twisted.internet import defer @@ -72,7 +73,7 @@ class AOEManager(manager.Manager): self.db.volume_update(context, volume_id, - {'host': FLAGS.host}) + {'host': self.host}) size = volume_ref['size'] logging.debug("volume %s: creating lv of size %sG", volume_id, size) @@ -89,14 +90,13 @@ class AOEManager(manager.Manager): yield self.driver.create_export(volume_ref['str_id'], shelf_id, blade_id) - # TODO(joshua): We need to trigger a fanout message - # for aoe-discover on all the nodes - - self.db.volume_update(context, volume_id, {'status': 'available'}) logging.debug("volume %s: re-exporting all values", volume_id) yield self.driver.ensure_exports() + now = datetime.datetime.utcnow() + self.db.volume_update(context, volume_id, {'status': 'available', + 'launched_at': now}) logging.debug("volume %s: created successfully", volume_id) defer.returnValue(volume_id) @@ -107,7 +107,7 @@ class AOEManager(manager.Manager): volume_ref = self.db.volume_get(context, volume_id) if volume_ref['attach_status'] == "attached": raise exception.Error("Volume is still attached") - if volume_ref['host'] != FLAGS.host: + if volume_ref['host'] != self.host: raise exception.Error("Volume is not local to this node") shelf_id, blade_id = self.db.volume_get_shelf_and_blade(context, volume_id) diff --git a/run_tests.py b/run_tests.py index d5dc5f934..4121f4c06 100644 --- a/run_tests.py +++ b/run_tests.py @@ -58,7 +58,9 @@ from nova.tests.flags_unittest import * from nova.tests.network_unittest import * from nova.tests.objectstore_unittest import * from nova.tests.process_unittest import * +from nova.tests.quota_unittest import * from nova.tests.rpc_unittest import * +from nova.tests.scheduler_unittest import * from nova.tests.service_unittest import * from nova.tests.validator_unittest import * from nova.tests.volume_unittest import * diff --git a/tools/install_venv.py b/tools/install_venv.py index 5d2369a96..32c372352 100644 --- a/tools/install_venv.py +++ b/tools/install_venv.py @@ -88,6 +88,10 @@ def create_virtualenv(venv=VENV): def install_dependencies(venv=VENV): print 'Installing dependencies with pip (this can take a while)...' + # Install greenlet by hand - just listing it in the requires file does not + # get it in stalled in the right order + run_command(['tools/with_venv.sh', 'pip', 'install', '-E', venv, 'greenlet'], + redirect_output=False) run_command(['tools/with_venv.sh', 'pip', 'install', '-E', venv, '-r', PIP_REQUIRES], redirect_output=False) run_command(['tools/with_venv.sh', 'pip', 'install', '-E', venv, TWISTED_NOVA], diff --git a/tools/pip-requires b/tools/pip-requires index dd69708ce..1e2707be7 100644 --- a/tools/pip-requires +++ b/tools/pip-requires @@ -7,15 +7,16 @@ amqplib==0.6.1 anyjson==0.2.4 boto==2.0b1 carrot==0.10.5 -eventlet==0.9.10 +eventlet==0.9.12 lockfile==0.8 python-daemon==1.5.5 python-gflags==1.3 redis==2.0.0 routes==1.12.3 tornado==1.0 -webob==0.9.8 +WebOb==0.9.8 wsgiref==0.1.2 zope.interface==3.6.1 mox==0.5.0 -f http://pymox.googlecode.com/files/mox-0.5.0.tar.gz +greenlet==0.3.1 |
