From 3b9c41fb6ccdd0e48d22d43ed4855ff03e0fdc88 Mon Sep 17 00:00:00 2001 From: Angus Salkeld Date: Tue, 13 Mar 2012 21:48:07 +1100 Subject: [PATCH] Initial commit (basics copied from glance) Signed-off-by: Angus Salkeld --- .gitignore | 6 + LICENSE | 176 +++ bin/heat | 377 +++++++ bin/heat-api | 54 + etc/heat-api | 25 + etc/heat-api-paste.ini | 80 ++ heat/__init__.py | 20 + heat/api/__init__.py | 16 + heat/api/middleware/__init__.py | 16 + heat/api/middleware/context.py | 64 ++ heat/api/middleware/version_negotiation.py | 123 +++ heat/api/v1/__init__.py | 21 + heat/api/v1/router.py | 54 + heat/api/v1/stacks.py | 157 +++ heat/api/versions.py | 68 ++ heat/client.py | 133 +++ heat/common/__init__.py | 16 + heat/common/auth.py | 267 +++++ heat/common/cfg.py | 1135 ++++++++++++++++++++ heat/common/client.py | 593 ++++++++++ heat/common/config.py | 184 ++++ heat/common/context.py | 124 +++ heat/common/crypt.py | 70 ++ heat/common/exception.py | 193 ++++ heat/common/policy.py | 182 ++++ heat/common/utils.py | 372 +++++++ heat/common/wsgi.py | 649 +++++++++++ heat/version.py | 46 + pylintrc | 27 + templates/getting_started.template | 40 + 30 files changed, 5288 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100755 bin/heat create mode 100755 bin/heat-api create mode 100644 etc/heat-api create mode 100644 etc/heat-api-paste.ini create mode 100644 heat/__init__.py create mode 100644 heat/api/__init__.py create mode 100644 heat/api/middleware/__init__.py create mode 100644 heat/api/middleware/context.py create mode 100644 heat/api/middleware/version_negotiation.py create mode 100644 heat/api/v1/__init__.py create mode 100644 heat/api/v1/router.py create mode 100644 heat/api/v1/stacks.py create mode 100644 heat/api/versions.py create mode 100644 heat/client.py create mode 100644 heat/common/__init__.py create mode 100644 heat/common/auth.py create mode 100644 heat/common/cfg.py create mode 100644 heat/common/client.py create mode 100644 heat/common/config.py create mode 100644 heat/common/context.py create mode 100644 heat/common/crypt.py create mode 100644 heat/common/exception.py create mode 100644 heat/common/policy.py create mode 100644 heat/common/utils.py create mode 100644 heat/common/wsgi.py create mode 100644 heat/version.py create mode 100644 pylintrc create mode 100644 templates/getting_started.template diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..89c25d9f3e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +*.swp +*.log +build +dist +heat/vcsversion.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..68c771a099 --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + diff --git a/bin/heat b/bin/heat new file mode 100755 index 0000000000..17533de0f0 --- /dev/null +++ b/bin/heat @@ -0,0 +1,377 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. + +""" +This is the administration program for heat. It is simply a command-line +interface for adding, modifying, and retrieving information about the stacks +belonging to a user. +""" + +import functools +import gettext +import optparse +import os +import sys +import time +import json + +from urlparse import urlparse + +# If ../heat/__init__.py exists, add ../ to Python search path, so that +# it will override what happens to be installed in /usr/(local/)lib/python... +possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]), + os.pardir, + os.pardir)) +if os.path.exists(os.path.join(possible_topdir, 'heat', '__init__.py')): + sys.path.insert(0, possible_topdir) + +gettext.install('heat', unicode=1) + +from heat import client as heat_client +from heat.common import exception +from heat import version + + +SUCCESS = 0 +FAILURE = 1 + +DEFAULT_PORT = 8000 + +def catch_error(action): + """Decorator to provide sensible default error handling for actions.""" + def wrap(func): + @functools.wraps(func) + def wrapper(*arguments, **kwargs): + try: + ret = func(*arguments, **kwargs) + return SUCCESS if ret is None else ret + except exception.NotAuthorized: + print "Not authorized to make this request. Check "\ + "your credentials (OS_AUTH_USER, OS_AUTH_KEY, ...)." + return FAILURE + except exception.ClientConfigurationError: + raise + except Exception, e: + options = arguments[0] + if options.debug: + raise + print "Failed to %s. Got error:" % action + pieces = unicode(e).split('\n') + for piece in pieces: + print piece + return FAILURE + + return wrapper + return wrap + +@catch_error('validate') +def template_validate(options, arguments): + ''' + ''' + pass + +@catch_error('gettemplate') +def get_template(options, arguments): + ''' + ''' + pass + +@catch_error('create') +def stack_create(options, arguments): + ''' + ''' + + parameters = {} + try: + parameters['StackName'] = arguments.pop(0) + except IndexError: + print "Please specify the stack name you wish to create " + print "as the first argument" + return FAILURE + + if options.parameters: + for p in options.parameters.split(';'): + (n, v) = p.split('=') + parameters[n] = v + + if options.template_file: + parameters['TemplateBody'] = open(options.template_file).read() + elif options.template_url: + parameters['TemplateUrl'] = options.template_url + else: + print 'Please specify a template file or url' + return FAILURE + + c = get_client(options) + result = c.create_stack(**parameters) + print json.dumps(result, indent=2) + +@catch_error('update') +def stack_update(options, arguments): + ''' + ''' + parameters = {} + try: + parameters['StackName'] = arguments.pop(0) + except IndexError: + print "Please specify the stack name you wish to update " + print "as the first argument" + return FAILURE + + c = get_client(options) + result = c.update_stack(parameters) + print json.dumps(result, indent=2) + +@catch_error('delete') +def stack_delete(options, arguments): + ''' + ''' + parameters = {} + try: + parameters['StackName'] = arguments.pop(0) + except IndexError: + print "Please specify the stack name you wish to delete " + print "as the first argument" + return FAILURE + + c = get_client(options) + result = c.delete_stack(parameters) + print json.dumps(result, indent=2) + +@catch_error('describe') +def stack_describe(options, arguments): + ''' + ''' + parameters = {} + try: + parameters['StackName'] = arguments.pop(0) + except IndexError: + print "Describing all stacks" + + c = get_client(options) + result = c.describe_stacks(parameters) + print json.dumps(result, indent=2) + +@catch_error('list') +def stack_list(options, arguments): + ''' + ''' + c = get_client(options) + result = c.list_stacks() + print json.dumps(result, indent=2) + +def get_client(options): + """ + Returns a new client object to a heat server + specified by the --host and --port options + supplied to the CLI + """ + return heat_client.get_client(host=options.host, + port=options.port, + username=options.username, + password=options.password, + auth_url=options.auth_url, + auth_strategy=options.auth_strategy, + auth_token=options.auth_token, + region=options.region, + insecure=options.insecure) + + +def create_options(parser): + """ + Sets up the CLI and config-file options that may be + parsed and program commands. + + :param parser: The option parser + """ + parser.add_option('-v', '--verbose', default=False, action="store_true", + help="Print more verbose output") + parser.add_option('-d', '--debug', default=False, action="store_true", + help="Print more verbose output") + parser.add_option('-H', '--host', metavar="ADDRESS", default="0.0.0.0", + help="Address of heat API host. " + "Default: %default") + parser.add_option('-p', '--port', dest="port", metavar="PORT", + type=int, default=DEFAULT_PORT, + help="Port the heat API host listens on. " + "Default: %default") + parser.add_option('-U', '--url', metavar="URL", default=None, + help="URL of heat service. This option can be used " + "to specify the hostname, port and protocol " + "(http/https) of the heat server, for example " + "-U https://localhost:" + str(DEFAULT_PORT) + + "/v1 Default: None") + parser.add_option('-k', '--insecure', dest="insecure", + default=False, action="store_true", + help="Explicitly allow heat to perform \"insecure\" " + "SSL (https) requests. The server's certificate will " + "not be verified against any certificate authorities. " + "This option should be used with caution.") + parser.add_option('-A', '--auth_token', dest="auth_token", + metavar="TOKEN", default=None, + help="Authentication token to use to identify the " + "client to the heat server") + parser.add_option('-I', '--username', dest="username", + metavar="USER", default=None, + help="User name used to acquire an authentication token") + parser.add_option('-K', '--password', dest="password", + metavar="PASSWORD", default=None, + help="Password used to acquire an authentication token") + parser.add_option('-R', '--region', dest="region", + metavar="REGION", default=None, + help="Region name. When using keystone authentication " + "version 2.0 or later this identifies the region " + "name to use when selecting the service endpoint. A " + "region name must be provided if more than one " + "region endpoint is available") + parser.add_option('-N', '--auth_url', dest="auth_url", + metavar="AUTH_URL", default=None, + help="Authentication URL") + parser.add_option('-S', '--auth_strategy', dest="auth_strategy", + metavar="STRATEGY", default=None, + help="Authentication strategy (keystone or noauth)") + + parser.add_option('-u', '--template-url', metavar="template_url", default=None, + help="URL of template. Default: None") + parser.add_option('-t', '--template-file', metavar="template_file", default=None, + help="Path to the template. Default: None") + + parser.add_option('-P', '--parameters', metavar="parameters", default=None, + help="Parameter values used to create the stack.") + +def parse_options(parser, cli_args): + """ + Returns the parsed CLI options, command to run and its arguments, merged + with any same-named options found in a configuration file + + :param parser: The option parser + """ + if not cli_args: + cli_args.append('-h') # Show options in usage output... + + (options, args) = parser.parse_args(cli_args) + if options.url is not None: + u = urlparse(options.url) + options.port = u.port + options.host = u.hostname + + options.use_ssl = (options.url is not None and u.scheme == 'https') + + # HACK(sirp): Make the parser available to the print_help method + # print_help is a command, so it only accepts (options, args); we could + # one-off have it take (parser, options, args), however, for now, I think + # this little hack will suffice + options.__parser = parser + + if not args: + parser.print_usage() + sys.exit(0) + + command_name = args.pop(0) + command = lookup_command(parser, command_name) + + return (options, command, args) + + +def print_help(options, args): + """ + Print help specific to a command + """ + if len(args) != 1: + sys.exit("Please specify a command") + + parser = options.__parser + command_name = args.pop() + command = lookup_command(parser, command_name) + + print command.__doc__ % {'prog': os.path.basename(sys.argv[0])} + + +def lookup_command(parser, command_name): + base_commands = {'help': print_help} + + image_commands = { + 'create': stack_create, + 'update': stack_update, + 'delete': stack_delete, + 'list': stack_list, + 'validate': template_validate, + 'gettemplate': get_template, + 'describe': stack_describe} + + commands = {} + for command_set in (base_commands, image_commands): + commands.update(command_set) + + try: + command = commands[command_name] + except KeyError: + parser.print_usage() + sys.exit("Unknown command: %s" % command_name) + + return command + +def main(): + ''' + ''' + usage = """ +%prog [options] [args] + +Commands: + + help Output help for one of the commands below + + create Create the stack + + delete Delete the stack + + describe Describe the stack + + update Update the stack + + list List the user's stacks + + gettemplate Get the template + + validate Validate a template + +""" + + oparser = optparse.OptionParser(version='%%prog %s' + % version.version_string(), + usage=usage.strip()) + create_options(oparser) + (opts, cmd, args) = parse_options(oparser, sys.argv[1:]) + + try: + start_time = time.time() + result = cmd(opts, args) + end_time = time.time() + if opts.verbose: + print "Completed in %-0.4f sec." % (end_time - start_time) + sys.exit(result) + except (RuntimeError, + NotImplementedError, + exception.ClientConfigurationError), ex: + oparser.print_usage() + print >> sys.stderr, "ERROR: ", ex + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/bin/heat-api b/bin/heat-api new file mode 100755 index 0000000000..f0e6900fb9 --- /dev/null +++ b/bin/heat-api @@ -0,0 +1,54 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# Copyright 2011 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. + +""" +Heat API Server +""" + +import gettext +import os +import sys + +# If ../heat/__init__.py exists, add ../ to Python search path, so that +# it will override what happens to be installed in /usr/(local/)lib/python... +possible_topdir = os.path.normpath(os.path.join(os.path.abspath(sys.argv[0]), + os.pardir, + os.pardir)) +if os.path.exists(os.path.join(possible_topdir, 'heat', '__init__.py')): + sys.path.insert(0, possible_topdir) + +gettext.install('heat', unicode=1) + +from heat.common import config +from heat.common import wsgi + + +if __name__ == '__main__': + try: + conf = config.HeatConfigOpts() + conf() + + app = config.load_paste_app(conf) + + server = wsgi.Server() + server.start(app, conf, default_port=9292) + server.wait() + except RuntimeError, e: + sys.exit("ERROR: %s" % e) diff --git a/etc/heat-api b/etc/heat-api new file mode 100644 index 0000000000..589102b08c --- /dev/null +++ b/etc/heat-api @@ -0,0 +1,25 @@ +[DEFAULT] +# Show more verbose log output (sets INFO log level output) +verbose = True + +# Show debugging output in logs (sets DEBUG log level output) +debug = True + +# Address to bind the server to +bind_host = 0.0.0.0 + +# Port the bind the server to +bind_port = 8000 + +# Log to this file. Make sure the user running heat-api has +# permissions to write to this file! +log_file = /var/log/heat/api.log + +# ================= Syslog Options ============================ + +# Send logs to syslog (/dev/log) instead of to file specified +# by `log_file` +use_syslog = False + +# Facility to use. If unset defaults to LOG_USER. +# syslog_log_facility = LOG_LOCAL0 diff --git a/etc/heat-api-paste.ini b/etc/heat-api-paste.ini new file mode 100644 index 0000000000..3f94af0714 --- /dev/null +++ b/etc/heat-api-paste.ini @@ -0,0 +1,80 @@ +# Default minimal pipeline +[pipeline:heat-api] +pipeline = versionnegotiation context apiv1app + +# Use the following pipeline for keystone auth +# i.e. in heat-api.conf: +# [paste_deploy] +# flavor = keystone +# +[pipeline:heat-api-keystone] +pipeline = versionnegotiation authtoken auth-context apiv1app + +# Use the following pipeline to enable transparent caching of image files +# i.e. in heat-api.conf: +# [paste_deploy] +# flavor = caching +# +[pipeline:heat-api-caching] +pipeline = versionnegotiation context cache apiv1app + +# Use the following pipeline for keystone auth with caching +# i.e. in heat-api.conf: +# [paste_deploy] +# flavor = keystone+caching +# +[pipeline:heat-api-keystone+caching] +pipeline = versionnegotiation authtoken auth-context cache apiv1app + +# Use the following pipeline to enable the Image Cache Management API +# i.e. in heat-api.conf: +# [paste_deploy] +# flavor = cachemanagement +# +[pipeline:heat-api-cachemanagement] +pipeline = versionnegotiation context cache cachemanage apiv1app + +# Use the following pipeline for keystone auth with cache management +# i.e. in heat-api.conf: +# [paste_deploy] +# flavor = keystone+cachemanagement +# +[pipeline:heat-api-keystone+cachemanagement] +pipeline = versionnegotiation authtoken auth-context cache cachemanage apiv1app + +[app:apiv1app] +paste.app_factory = heat.common.wsgi:app_factory +heat.app_factory = heat.api.v1.router:API + +[filter:versionnegotiation] +paste.filter_factory = heat.common.wsgi:filter_factory +heat.filter_factory = heat.api.middleware.version_negotiation:VersionNegotiationFilter + +[filter:cache] +paste.filter_factory = heat.common.wsgi:filter_factory +heat.filter_factory = heat.api.middleware.cache:CacheFilter + +[filter:cachemanage] +paste.filter_factory = heat.common.wsgi:filter_factory +heat.filter_factory = heat.api.middleware.cache_manage:CacheManageFilter + +[filter:context] +paste.filter_factory = heat.common.wsgi:filter_factory +heat.filter_factory = heat.common.context:ContextMiddleware + +[filter:authtoken] +paste.filter_factory = keystone.middleware.auth_token:filter_factory +service_protocol = http +service_host = 127.0.0.1 +service_port = 5000 +auth_host = 127.0.0.1 +auth_port = 35357 +auth_protocol = http +auth_uri = http://127.0.0.1:5000/ +admin_tenant_name = %SERVICE_TENANT_NAME% +admin_user = %SERVICE_USER% +admin_password = %SERVICE_PASSWORD% + +[filter:auth-context] +paste.filter_factory = heat.common.wsgi:filter_factory +heat.filter_factory = keystone.middleware.heat_auth_token:KeystoneContextMiddleware diff --git a/heat/__init__.py b/heat/__init__.py new file mode 100644 index 0000000000..0c7fc6ddcf --- /dev/null +++ b/heat/__init__.py @@ -0,0 +1,20 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010-2011 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 gettext + +gettext.install('heat', unicode=1) diff --git a/heat/api/__init__.py b/heat/api/__init__.py new file mode 100644 index 0000000000..d65c689a83 --- /dev/null +++ b/heat/api/__init__.py @@ -0,0 +1,16 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. diff --git a/heat/api/middleware/__init__.py b/heat/api/middleware/__init__.py new file mode 100644 index 0000000000..d65c689a83 --- /dev/null +++ b/heat/api/middleware/__init__.py @@ -0,0 +1,16 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. diff --git a/heat/api/middleware/context.py b/heat/api/middleware/context.py new file mode 100644 index 0000000000..6f480aa5cf --- /dev/null +++ b/heat/api/middleware/context.py @@ -0,0 +1,64 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. + +""" +Middleware that attaches a context to the WSGI request +""" + +from heat.common import utils +from heat.common import wsgi +from heat.common import context + + +class ContextMiddleware(wsgi.Middleware): + def __init__(self, app, options): + self.options = options + super(ContextMiddleware, self).__init__(app) + + def make_context(self, *args, **kwargs): + """ + Create a context with the given arguments. + """ + + # Determine the context class to use + ctxcls = context.RequestContext + if 'context_class' in self.options: + ctxcls = utils.import_class(self.options['context_class']) + + return ctxcls(*args, **kwargs) + + def process_request(self, req): + """ + Extract any authentication information in the request and + construct an appropriate context from it. + """ + # Use the default empty context, with admin turned on for + # backwards compatibility + req.context = self.make_context(is_admin=True) + + +def filter_factory(global_conf, **local_conf): + """ + Factory method for paste.deploy + """ + conf = global_conf.copy() + conf.update(local_conf) + + def filter(app): + return ContextMiddleware(app, conf) + + return filter diff --git a/heat/api/middleware/version_negotiation.py b/heat/api/middleware/version_negotiation.py new file mode 100644 index 0000000000..2d525d0374 --- /dev/null +++ b/heat/api/middleware/version_negotiation.py @@ -0,0 +1,123 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. + +""" +A filter middleware that inspects the requested URI for a version string +and/or Accept headers and attempts to negotiate an API controller to +return +""" + +import logging +import re + +import routes + +from heat.api import v1 +from heat.api import versions +from heat.common import wsgi + +logger = logging.getLogger('heat.api.middleware.version_negotiation') + + +class VersionNegotiationFilter(wsgi.Middleware): + + def __init__(self, app, conf, **local_conf): + self.versions_app = versions.Controller(conf) + self.version_uri_regex = re.compile(r"^v(\d+)\.?(\d+)?") + self.conf = conf + super(VersionNegotiationFilter, self).__init__(app) + + def process_request(self, req): + """ + If there is a version identifier in the URI, simply + return the correct API controller, otherwise, if we + find an Accept: header, process it + """ + # See if a version identifier is in the URI passed to + # us already. If so, simply return the right version + # API controller + msg = _("Processing request: %(method)s %(path)s Accept: " + "%(accept)s") % ({'method': req.method, + 'path': req.path, 'accept': req.accept}) + logger.debug(msg) + + # If the request is for /versions, just return the versions container + if req.path_info_peek() == "versions": + return self.versions_app + + match = self._match_version_string(req.path_info_peek(), req) + if match: + if (req.environ['api.major_version'] == 1 and + req.environ['api.minor_version'] == 0): + logger.debug(_("Matched versioned URI. Version: %d.%d"), + req.environ['api.major_version'], + req.environ['api.minor_version']) + # Strip the version from the path + req.path_info_pop() + return None + else: + logger.debug(_("Unknown version in versioned URI: %d.%d. " + "Returning version choices."), + req.environ['api.major_version'], + req.environ['api.minor_version']) + return self.versions_app + + accept = str(req.accept) + if accept.startswith('application/vnd.openstack.images-'): + token_loc = len('application/vnd.openstack.images-') + accept_version = accept[token_loc:] + match = self._match_version_string(accept_version, req) + if match: + if (req.environ['api.major_version'] == 1 and + req.environ['api.minor_version'] == 0): + logger.debug(_("Matched versioned media type. " + "Version: %d.%d"), + req.environ['api.major_version'], + req.environ['api.minor_version']) + return None + else: + logger.debug(_("Unknown version in accept header: %d.%d..." + "returning version choices."), + req.environ['api.major_version'], + req.environ['api.minor_version']) + return self.versions_app + else: + if req.accept not in ('*/*', ''): + logger.debug(_("Unknown accept header: %s..." + "returning version choices."), req.accept) + return self.versions_app + return None + + def _match_version_string(self, subject, req): + """ + Given a subject string, tries to match a major and/or + minor version number. If found, sets the api.major_version + and api.minor_version environ variables. + + Returns True if there was a match, false otherwise. + + :param subject: The string to check + :param req: Webob.Request object + """ + match = self.version_uri_regex.match(subject) + if match: + major_version, minor_version = match.groups(0) + major_version = int(major_version) + minor_version = int(minor_version) + req.environ['api.major_version'] = major_version + req.environ['api.minor_version'] = minor_version + return match is not None diff --git a/heat/api/v1/__init__.py b/heat/api/v1/__init__.py new file mode 100644 index 0000000000..a7c2da058b --- /dev/null +++ b/heat/api/v1/__init__.py @@ -0,0 +1,21 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. + +SUPPORTED_PARAMS = ('StackName', 'TemplateBody', 'NotificationARNs', 'Parameters', + 'Version', 'SignatureVersion', 'Timestamp', 'AWSAccessKeyId', + 'Signature') + diff --git a/heat/api/v1/router.py b/heat/api/v1/router.py new file mode 100644 index 0000000000..6e1871d5a9 --- /dev/null +++ b/heat/api/v1/router.py @@ -0,0 +1,54 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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 logging + +import routes + +from heat.api.v1 import stacks +from heat.common import wsgi + +logger = logging.getLogger(__name__) + +class API(wsgi.Router): + + """WSGI router for Heat v1 API requests.""" + #TODO + #DeleteStack + #GetTemplate + #UpdateStack + #ValidateTemplate + + + def __init__(self, conf, **local_conf): + self.conf = conf + mapper = routes.Mapper() + + stacks_resource = stacks.create_resource(conf) + + mapper.resource("stack", "stacks", controller=stacks_resource, + collection={'detail': 'GET'}) + + mapper.connect("/CreateStack", controller=stacks_resource, + action="create", conditions=dict(method=["POST"])) + mapper.connect("/", controller=stacks_resource, action="index") + mapper.connect("/ListStacks", controller=stacks_resource, + action="list", conditions=dict(method=["GET"])) + mapper.connect("/DescribeStacks", controller=stacks_resource, + action="show", conditions=dict(method=["GET"])) + + super(API, self).__init__(mapper) diff --git a/heat/api/v1/stacks.py b/heat/api/v1/stacks.py new file mode 100644 index 0000000000..222e2426c8 --- /dev/null +++ b/heat/api/v1/stacks.py @@ -0,0 +1,157 @@ +# 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. + +""" +/stack endpoint for heat v1 API +""" + +import httplib +import json +import logging +import sys + +import webob +from webob.exc import (HTTPNotFound, + HTTPConflict, + HTTPBadRequest) + +from heat.common import exception +from heat.common import wsgi + +logger = logging.getLogger('heat.api.v1.stacks') + +class StackController(object): + + """ + WSGI controller for stacks resource in heat v1 API + + """ + + def __init__(self, options): + self.options = options + + def list(self, req): + """ + Returns the following information for all stacks: + """ + return {'ListStacksResponse': [ + {'ListStacksResult': [ + {'StackSummaries': [ + {'member': [ + {'StackId': 'arn:aws:cloudformation:us-east-1:1234567:stack/TestCreate1/aaaaa', + 'StackStatus': 'CREATE_IN_PROGRESS', + 'StackName': 'vpc1', + 'CreationTime': '2011-05-23T15:47:44Z', + 'TemplateDescription': 'Creates one EC2 instance and a load balancer.', + }] + }, + {'member': [ + {'StackId': 'arn:aws:cloudformation:us-east-1:1234567:stack/TestDelete2/bbbbb', + 'StackStatus': 'DELETE_COMPLETE', + 'StackName': 'WP1', + 'CreationTime': '2011-03-05T19:57:58Z', + 'TemplateDescription': 'A simple basic Cloudformation Template.', + }] + } + ]}]}]} + + + def describe(self, req): + + return {'stack': [ + {'id': 'id', + 'name': '', + 'container_format': '' } ] } + + + def create(self, req): + for p in req.params: + print 'create %s=%s' % (p, req.params[p]) + + return {'CreateStackResult': [{'StackId': '007'}]} + + def update(self, req, id, image_meta, image_data): + """ + Updates an existing image with the registry. + + :param request: The WSGI/Webob Request object + :param id: The opaque image identifier + + :retval Returns the updated image information as a mapping + """ + + return {'image_meta': 'bla'} + + + def delete(self, req, id): + """ + Deletes the image and all its chunks from heat + + :param req: The WSGI/Webob Request object + :param id: The opaque image identifier + + :raises HttpBadRequest if image registry is invalid + :raises HttpNotFound if image or any chunk is not available + :raises HttpNotAuthorized if image or any chunk is not + deleteable by the requesting user + """ + + +class StackDeserializer(wsgi.JSONRequestDeserializer): + """Handles deserialization of specific controller method requests.""" + + def _deserialize(self, request): + result = {} + return result + + def create(self, request): + return self._deserialize(request) + + def update(self, request): + return self._deserialize(request) + + +class StackSerializer(wsgi.JSONResponseSerializer): + """Handles serialization of specific controller method responses.""" + + def _inject_location_header(self, response, image_meta): + response.headers['Location'] = 'location' + + def _inject_checksum_header(self, response, image_meta): + response.headers['ETag'] = 'checksum' + + def update(self, response, result): + return + + def create(self, response, result): + """ Create """ + response.status = 201 + response.headers['Content-Type'] = 'application/json' + response.body = self.to_json(dict(CreateStackResult=result)) + self._inject_location_header(response, result) + self._inject_checksum_header(response, result) + return response + +def handle_stack(self, req, id): + return {'got-stack-id': id} + +def create_resource(options): + """Stacks resource factory method""" + deserializer = StackDeserializer() + serializer = StackSerializer() + return wsgi.Resource(StackController(options), deserializer, serializer) diff --git a/heat/api/versions.py b/heat/api/versions.py new file mode 100644 index 0000000000..07d112e5fb --- /dev/null +++ b/heat/api/versions.py @@ -0,0 +1,68 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. + +""" +Controller that returns information on the heat API versions +""" + +import httplib +import json + +import webob.dec + +from heat.common import wsgi + + +class Controller(object): + + """ + A controller that produces information on the heat API versions. + """ + + def __init__(self, conf): + self.conf = conf + + @webob.dec.wsgify + def __call__(self, req): + """Respond to a request for all OpenStack API versions.""" + version_objs = [ + { + "id": "v1.1", + "status": "CURRENT", + "links": [ + { + "rel": "self", + "href": self.get_href(req)}]}, + { + "id": "v1.0", + "status": "SUPPORTED", + "links": [ + { + "rel": "self", + "href": self.get_href(req)}]}] + + body = json.dumps(dict(versions=version_objs)) + + response = webob.Response(request=req, + status=httplib.MULTIPLE_CHOICES, + content_type='application/json') + response.body = body + + return response + + def get_href(self, req): + return "%s/v1/" % req.host_url diff --git a/heat/client.py b/heat/client.py new file mode 100644 index 0000000000..1c399b04a5 --- /dev/null +++ b/heat/client.py @@ -0,0 +1,133 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010-2011 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. + +""" +Client classes for callers of a heat system +""" + +import errno +import httplib +import json +import logging +import os +import socket +import sys + +import heat.api.v1 +from heat.common import client as base_client +from heat.common import exception +from heat.common import utils + +logger = logging.getLogger(__name__) +SUPPORTED_PARAMS = heat.api.v1.SUPPORTED_PARAMS + + +class V1Client(base_client.BaseClient): + + """Main client class for accessing heat resources""" + + DEFAULT_PORT = 8000 + DEFAULT_DOC_ROOT = "/v1" + + def _insert_common_parameters(self, params): + params['Version'] = '2010-05-15' + params['SignatureVersion'] = '2' + params['SignatureMethod'] = 'HmacSHA256' + + def list_stacks(self, **kwargs): + params = self._extract_params({}, SUPPORTED_PARAMS) + self._insert_common_parameters(params) + + res = self.do_request("GET", "/ListStacks", params=params) + data = json.loads(res.read()) + return data + + def show_stack(self, **kwargs): + params = self._extract_params(kwargs, SUPPORTED_PARAMS) + self._insert_common_parameters(params) + + res = self.do_request("GET", "/DescribeStacks", params=params) + data = json.loads(res.read()) + return data + + def create_stack(self, **kwargs): + + params = self._extract_params(kwargs, SUPPORTED_PARAMS) + self._insert_common_parameters(params) + res = self.do_request("POST", "/CreateStack", params=params) + + data = json.loads(res.read()) + return data + + def update_stack(self, **kwargs): + return + + def delete_stack(self, **kwargs): + self._insert_common_parameters(params) + params = self._extract_params(kwargs, SUPPORTED_PARAMS) + self.do_request("DELETE", "/DeleteStack", params) + return True + +Client = V1Client + + +def get_client(host, port=None, username=None, + password=None, tenant=None, + auth_url=None, auth_strategy=None, + auth_token=None, region=None, + is_silent_upload=False, insecure=False): + """ + Returns a new client heat client object based on common kwargs. + If an option isn't specified falls back to common environment variable + defaults. + """ + + if auth_url or os.getenv('OS_AUTH_URL'): + force_strategy = 'keystone' + else: + force_strategy = None + + creds = dict(username=username or + os.getenv('OS_AUTH_USER', os.getenv('OS_USERNAME')), + password=password or + os.getenv('OS_AUTH_KEY', os.getenv('OS_PASSWORD')), + tenant=tenant or + os.getenv('OS_AUTH_TENANT', + os.getenv('OS_TENANT_NAME')), + auth_url=auth_url or os.getenv('OS_AUTH_URL'), + strategy=force_strategy or auth_strategy or + os.getenv('OS_AUTH_STRATEGY', 'noauth'), + region=region or os.getenv('OS_REGION_NAME'), + ) + + if creds['strategy'] == 'keystone' and not creds['auth_url']: + msg = ("--auth_url option or OS_AUTH_URL environment variable " + "required when keystone authentication strategy is enabled\n") + raise exception.ClientConfigurationError(msg) + + use_ssl = (creds['auth_url'] is not None and + creds['auth_url'].find('https') != -1) + + client = Client + + return client(host=host, + port=port, + use_ssl=use_ssl, + auth_tok=auth_token or + os.getenv('OS_TOKEN'), + creds=creds, + insecure=insecure) diff --git a/heat/common/__init__.py b/heat/common/__init__.py new file mode 100644 index 0000000000..b60695702c --- /dev/null +++ b/heat/common/__init__.py @@ -0,0 +1,16 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010-2011 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. diff --git a/heat/common/auth.py b/heat/common/auth.py new file mode 100644 index 0000000000..7484fc650f --- /dev/null +++ b/heat/common/auth.py @@ -0,0 +1,267 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. + +""" +This auth module is intended to allow Openstack client-tools to select from a +variety of authentication strategies, including NoAuth (the default), and +Keystone (an identity management system). + + > auth_plugin = AuthPlugin(creds) + + > auth_plugin.authenticate() + + > auth_plugin.auth_token + abcdefg + + > auth_plugin.management_url + http://service_endpoint/ +""" +import httplib2 +import json +import urlparse + +from heat.common import exception + + +class BaseStrategy(object): + def __init__(self): + self.auth_token = None + # TODO(sirp): Should expose selecting public/internal/admin URL. + self.management_url = None + + def authenticate(self): + raise NotImplementedError + + @property + def is_authenticated(self): + raise NotImplementedError + + @property + def strategy(self): + raise NotImplementedError + + +class NoAuthStrategy(BaseStrategy): + def authenticate(self): + pass + + @property + def is_authenticated(self): + return True + + @property + def strategy(self): + return 'noauth' + + +class KeystoneStrategy(BaseStrategy): + MAX_REDIRECTS = 10 + + def __init__(self, creds): + self.creds = creds + super(KeystoneStrategy, self).__init__() + + def check_auth_params(self): + # Ensure that supplied credential parameters are as required + for required in ('username', 'password', 'auth_url', + 'strategy'): + if required not in self.creds: + raise exception.MissingCredentialError(required=required) + if self.creds['strategy'] != 'keystone': + raise exception.BadAuthStrategy(expected='keystone', + received=self.creds['strategy']) + # For v2.0 also check tenant is present + if self.creds['auth_url'].rstrip('/').endswith('v2.0'): + if 'tenant' not in self.creds: + raise exception.MissingCredentialError(required='tenant') + + def authenticate(self): + """Authenticate with the Keystone service. + + There are a few scenarios to consider here: + + 1. Which version of Keystone are we using? v1 which uses headers to + pass the credentials, or v2 which uses a JSON encoded request body? + + 2. Keystone may respond back with a redirection using a 305 status + code. + + 3. We may attempt a v1 auth when v2 is what's called for. In this + case, we rewrite the url to contain /v2.0/ and retry using the v2 + protocol. + """ + def _authenticate(auth_url): + # If OS_AUTH_URL is missing a trailing slash add one + if not auth_url.endswith('/'): + auth_url += '/' + token_url = urlparse.urljoin(auth_url, "tokens") + # 1. Check Keystone version + is_v2 = auth_url.rstrip('/').endswith('v2.0') + if is_v2: + self._v2_auth(token_url) + else: + self._v1_auth(token_url) + + self.check_auth_params() + auth_url = self.creds['auth_url'] + for _ in range(self.MAX_REDIRECTS): + try: + _authenticate(auth_url) + except exception.AuthorizationRedirect as e: + # 2. Keystone may redirect us + auth_url = e.url + except exception.AuthorizationFailure: + # 3. In some configurations nova makes redirection to + # v2.0 keystone endpoint. Also, new location does not + # contain real endpoint, only hostname and port. + if 'v2.0' not in auth_url: + auth_url = urlparse.urljoin(auth_url, 'v2.0/') + else: + # If we sucessfully auth'd, then memorize the correct auth_url + # for future use. + self.creds['auth_url'] = auth_url + break + else: + # Guard against a redirection loop + raise exception.MaxRedirectsExceeded(redirects=self.MAX_REDIRECTS) + + def _v1_auth(self, token_url): + creds = self.creds + + headers = {} + headers['X-Auth-User'] = creds['username'] + headers['X-Auth-Key'] = creds['password'] + + tenant = creds.get('tenant') + if tenant: + headers['X-Auth-Tenant'] = tenant + + resp, resp_body = self._do_request(token_url, 'GET', headers=headers) + + def _management_url(self, resp): + for url_header in ('x-image-management-url', + 'x-server-management-url', + 'x-heat'): + try: + return resp[url_header] + except KeyError as e: + not_found = e + raise not_found + + if resp.status in (200, 204): + try: + self.management_url = _management_url(self, resp) + self.auth_token = resp['x-auth-token'] + except KeyError: + raise exception.AuthorizationFailure() + elif resp.status == 305: + raise exception.AuthorizationRedirect(resp['location']) + elif resp.status == 400: + raise exception.AuthBadRequest(url=token_url) + elif resp.status == 401: + raise exception.NotAuthorized() + elif resp.status == 404: + raise exception.AuthUrlNotFound(url=token_url) + else: + raise Exception(_('Unexpected response: %s' % resp.status)) + + def _v2_auth(self, token_url): + def get_endpoint(service_catalog): + """ + Select an endpoint from the service catalog + + We search the full service catalog for services + matching both type and region. If the client + supplied no region then any 'image' endpoint + is considered a match. There must be one -- and + only one -- successful match in the catalog, + otherwise we will raise an exception. + """ + # FIXME(sirp): for now just use the public url. + endpoint = None + region = self.creds.get('region') + for service in service_catalog: + if service['type'] == 'image': + for ep in service['endpoints']: + if region is None or region == ep['region']: + if endpoint is not None: + # This is a second match, abort + raise exception.RegionAmbiguity(region=region) + endpoint = ep + if endpoint is None: + raise exception.NoServiceEndpoint() + return endpoint['publicURL'] + + creds = self.creds + + creds = { + "auth": { + "tenantName": creds['tenant'], + "passwordCredentials": { + "username": creds['username'], + "password": creds['password'] + } + } + } + + headers = {} + headers['Content-Type'] = 'application/json' + req_body = json.dumps(creds) + + resp, resp_body = self._do_request( + token_url, 'POST', headers=headers, body=req_body) + + if resp.status == 200: + resp_auth = json.loads(resp_body)['access'] + self.management_url = get_endpoint(resp_auth['serviceCatalog']) + self.auth_token = resp_auth['token']['id'] + elif resp.status == 305: + raise exception.RedirectException(resp['location']) + elif resp.status == 400: + raise exception.AuthBadRequest(url=token_url) + elif resp.status == 401: + raise exception.NotAuthorized() + elif resp.status == 404: + raise exception.AuthUrlNotFound(url=token_url) + else: + raise Exception(_('Unexpected response: %s') % resp.status) + + @property + def is_authenticated(self): + return self.auth_token is not None + + @property + def strategy(self): + return 'keystone' + + @staticmethod + def _do_request(url, method, headers=None, body=None): + headers = headers or {} + conn = httplib2.Http() + conn.force_exception_to_status_code = True + headers['User-Agent'] = 'heat-client' + resp, resp_body = conn.request(url, method, headers=headers, body=body) + return resp, resp_body + + +def get_plugin_from_strategy(strategy, creds=None): + if strategy == 'noauth': + return NoAuthStrategy() + elif strategy == 'keystone': + return KeystoneStrategy(creds) + else: + raise Exception(_("Unknown auth strategy '%s'") % strategy) diff --git a/heat/common/cfg.py b/heat/common/cfg.py new file mode 100644 index 0000000000..258776d452 --- /dev/null +++ b/heat/common/cfg.py @@ -0,0 +1,1135 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 Red Hat, Inc. +# +# 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. + +r""" +Configuration options which may be set on the command line or in config files. + +The schema for each option is defined using the Opt sub-classes e.g. + + common_opts = [ + cfg.StrOpt('bind_host', + default='0.0.0.0', + help='IP address to listen on'), + cfg.IntOpt('bind_port', + default=9292, + help='Port number to listen on') + ] + +Options can be strings, integers, floats, booleans, lists or 'multi strings': + + enabled_apis_opt = \ + cfg.ListOpt('enabled_apis', + default=['ec2', 'osapi'], + help='List of APIs to enable by default') + + DEFAULT_EXTENSIONS = [ + 'nova.api.openstack.contrib.standard_extensions' + ] + osapi_extension_opt = \ + cfg.MultiStrOpt('osapi_extension', + default=DEFAULT_EXTENSIONS) + +Option schemas are registered with with the config manager at runtime, but +before the option is referenced: + + class ExtensionManager(object): + + enabled_apis_opt = cfg.ListOpt(...) + + def __init__(self, conf): + self.conf = conf + self.conf.register_opt(enabled_apis_opt) + ... + + def _load_extensions(self): + for ext_factory in self.conf.osapi_extension: + .... + +A common usage pattern is for each option schema to be defined in the module or +class which uses the option: + + opts = ... + + def add_common_opts(conf): + conf.register_opts(opts) + + def get_bind_host(conf): + return conf.bind_host + + def get_bind_port(conf): + return conf.bind_port + +An option may optionally be made available via the command line. Such options +must registered with the config manager before the command line is parsed (for +the purposes of --help and CLI arg validation): + + cli_opts = [ + cfg.BoolOpt('verbose', + short='v', + default=False, + help='Print more verbose output'), + cfg.BoolOpt('debug', + short='d', + default=False, + help='Print debugging output'), + ] + + def add_common_opts(conf): + conf.register_cli_opts(cli_opts) + +The config manager has a single CLI option defined by default, --config-file: + + class ConfigOpts(object): + + config_file_opt = \ + MultiStrOpt('config-file', + ... + + def __init__(self, ...): + ... + self.register_cli_opt(self.config_file_opt) + +Option values are parsed from any supplied config files using SafeConfigParser. +If none are specified, a default set is used e.g. glance-api.conf and +glance-common.conf: + + glance-api.conf: + [DEFAULT] + bind_port = 9292 + + glance-common.conf: + [DEFAULT] + bind_host = 0.0.0.0 + +Option values in config files override those on the command line. Config files +are parsed in order, with values in later files overriding those in earlier +files. + +The parsing of CLI args and config files is initiated by invoking the config +manager e.g. + + conf = ConfigOpts() + conf.register_opt(BoolOpt('verbose', ...)) + conf(sys.argv[1:]) + if conf.verbose: + ... + +Options can be registered as belonging to a group: + + rabbit_group = cfg.OptionGroup(name='rabbit', + title='RabbitMQ options') + + rabbit_host_opt = \ + cfg.StrOpt('host', + group='rabbit', + default='localhost', + help='IP/hostname to listen on'), + rabbit_port_opt = \ + cfg.IntOpt('port', + default=5672, + help='Port number to listen on') + rabbit_ssl_opt = \ + conf.BoolOpt('use_ssl', + default=False, + help='Whether to support SSL connections') + + def register_rabbit_opts(conf): + conf.register_group(rabbit_group) + # options can be registered under a group in any of these ways: + conf.register_opt(rabbit_host_opt) + conf.register_opt(rabbit_port_opt, group='rabbit') + conf.register_opt(rabbit_ssl_opt, group=rabbit_group) + +If no group is specified, options belong to the 'DEFAULT' section of config +files: + + glance-api.conf: + [DEFAULT] + bind_port = 9292 + ... + + [rabbit] + host = localhost + port = 5672 + use_ssl = False + userid = guest + password = guest + virtual_host = / + +Command-line options in a group are automatically prefixed with the group name: + + --rabbit-host localhost --rabbit-use-ssl False + +Option values in the default group are referenced as attributes/properties on +the config manager; groups are also attributes on the config manager, with +attributes for each of the options associated with the group: + + server.start(app, conf.bind_port, conf.bind_host, conf) + + self.connection = kombu.connection.BrokerConnection( + hostname=conf.rabbit.host, + port=conf.rabbit.port, + ...) + +Option values may reference other values using PEP 292 string substitution: + + opts = [ + cfg.StrOpt('state_path', + default=os.path.join(os.path.dirname(__file__), '../'), + help='Top-level directory for maintaining nova state'), + cfg.StrOpt('sqlite_db', + default='nova.sqlite', + help='file name for sqlite'), + cfg.StrOpt('sql_connection', + default='sqlite:///$state_path/$sqlite_db', + help='connection string for sql database'), + ] + +Note that interpolation can be avoided by using '$$'. +""" + +import sys +import ConfigParser +import copy +import optparse +import os +import string + + +class Error(Exception): + """Base class for cfg exceptions.""" + + def __init__(self, msg=None): + self.msg = msg + + def __str__(self): + return self.msg + + +class ArgsAlreadyParsedError(Error): + """Raised if a CLI opt is registered after parsing.""" + + def __str__(self): + ret = "arguments already parsed" + if self.msg: + ret += ": " + self.msg + return ret + + +class NoSuchOptError(Error): + """Raised if an opt which doesn't exist is referenced.""" + + def __init__(self, opt_name, group=None): + self.opt_name = opt_name + self.group = group + + def __str__(self): + if self.group is None: + return "no such option: %s" % self.opt_name + else: + return "no such option in group %s: %s" % (self.group.name, + self.opt_name) + + +class NoSuchGroupError(Error): + """Raised if a group which doesn't exist is referenced.""" + + def __init__(self, group_name): + self.group_name = group_name + + def __str__(self): + return "no such group: %s" % self.group_name + + +class DuplicateOptError(Error): + """Raised if multiple opts with the same name are registered.""" + + def __init__(self, opt_name): + self.opt_name = opt_name + + def __str__(self): + return "duplicate option: %s" % self.opt_name + + +class TemplateSubstitutionError(Error): + """Raised if an error occurs substituting a variable in an opt value.""" + + def __str__(self): + return "template substitution error: %s" % self.msg + + +class ConfigFilesNotFoundError(Error): + """Raised if one or more config files are not found.""" + + def __init__(self, config_files): + self.config_files = config_files + + def __str__(self): + return 'Failed to read some config files: %s' % \ + string.join(self.config_files, ',') + + +class ConfigFileParseError(Error): + """Raised if there is an error parsing a config file.""" + + def __init__(self, config_file, msg): + self.config_file = config_file + self.msg = msg + + def __str__(self): + return 'Failed to parse %s: %s' % (self.config_file, self.msg) + + +class ConfigFileValueError(Error): + """Raised if a config file value does not match its opt type.""" + pass + + +def find_config_files(project=None, prog=None, filetype="conf"): + """Return a list of default configuration files. + + We default to two config files: [${project}.conf, ${prog}.conf] + + And we look for those config files in the following directories: + + ~/.${project}/ + ~/ + /etc/${project}/ + /etc/ + + We return an absolute path for (at most) one of each the default config + files, for the topmost directory it exists in. + + For example, if project=foo, prog=bar and /etc/foo/foo.conf, /etc/bar.conf + and ~/.foo/bar.conf all exist, then we return ['/etc/foo/foo.conf', + '~/.foo/bar.conf'] + + If no project name is supplied, we only look for ${prog.conf}. + + :param project: an optional project name + :param prog: the program name, defaulting to the basename of sys.argv[0] + """ + if prog is None: + prog = os.path.basename(sys.argv[0]) + + fix_path = lambda p: os.path.abspath(os.path.expanduser(p)) + + cfg_dirs = [ + fix_path(os.path.join('~', '.' + project)) if project else None, + fix_path('~'), + os.path.join('/etc', project) if project else None, + '/etc', + 'etc', + ] + cfg_dirs = filter(bool, cfg_dirs) + + def search_dirs(dirs, basename): + for d in dirs: + path = os.path.join(d, basename) + if os.path.exists(path): + return path + + config_files = [] + + if project: + project_config = search_dirs(cfg_dirs, '%s.%s' % (project, filetype)) + config_files.append(project_config) + + config_files.append(search_dirs(cfg_dirs, '%s.%s' % (prog, filetype))) + + return filter(bool, config_files) + + +def _is_opt_registered(opts, opt): + """Check whether an opt with the same name is already registered. + + The same opt may be registered multiple times, with only the first + registration having any effect. However, it is an error to attempt + to register a different opt with the same name. + + :param opts: the set of opts already registered + :param opt: the opt to be registered + :returns: True if the opt was previously registered, False otherwise + :raises: DuplicateOptError if a naming conflict is detected + """ + if opt.dest in opts: + if opts[opt.dest]['opt'] is not opt: + raise DuplicateOptError(opt.name) + return True + else: + return False + + +class Opt(object): + + """Base class for all configuration options. + + An Opt object has no public methods, but has a number of public string + properties: + + name: + the name of the option, which may include hyphens + dest: + the (hyphen-less) ConfigOpts property which contains the option value + short: + a single character CLI option name + default: + the default value of the option + metavar: + the name shown as the argument to a CLI option in --help output + help: + an string explaining how the options value is used + """ + + def __init__(self, name, dest=None, short=None, + default=None, metavar=None, help=None): + """Construct an Opt object. + + The only required parameter is the option's name. However, it is + common to also supply a default and help string for all options. + + :param name: the option's name + :param dest: the name of the corresponding ConfigOpts property + :param short: a single character CLI option name + :param default: the default value of the option + :param metavar: the option argument to show in --help + :param help: an explanation of how the option is used + """ + self.name = name + if dest is None: + self.dest = self.name.replace('-', '_') + else: + self.dest = dest + self.short = short + self.default = default + self.metavar = metavar + self.help = help + + def _get_from_config_parser(self, cparser, section): + """Retrieves the option value from a ConfigParser object. + + This is the method ConfigOpts uses to look up the option value from + config files. Most opt types override this method in order to perform + type appropriate conversion of the returned value. + + :param cparser: a ConfigParser object + :param section: a section name + """ + return cparser.get(section, self.dest) + + def _add_to_cli(self, parser, group=None): + """Makes the option available in the command line interface. + + This is the method ConfigOpts uses to add the opt to the CLI interface + as appropriate for the opt type. Some opt types may extend this method, + others may just extend the helper methods it uses. + + :param parser: the CLI option parser + :param group: an optional OptGroup object + """ + container = self._get_optparse_container(parser, group) + kwargs = self._get_optparse_kwargs(group) + prefix = self._get_optparse_prefix('', group) + self._add_to_optparse(container, self.name, self.short, kwargs, prefix) + + def _add_to_optparse(self, container, name, short, kwargs, prefix=''): + """Add an option to an optparse parser or group. + + :param container: an optparse.OptionContainer object + :param name: the opt name + :param short: the short opt name + :param kwargs: the keyword arguments for add_option() + :param prefix: an optional prefix to prepend to the opt name + :raises: DuplicateOptError if a naming confict is detected + """ + args = ['--' + prefix + name] + if short: + args += ['-' + short] + for a in args: + if container.has_option(a): + raise DuplicateOptError(a) + container.add_option(*args, **kwargs) + + def _get_optparse_container(self, parser, group): + """Returns an optparse.OptionContainer. + + :param parser: an optparse.OptionParser + :param group: an (optional) OptGroup object + :returns: an optparse.OptionGroup if a group is given, else the parser + """ + if group is not None: + return group._get_optparse_group(parser) + else: + return parser + + def _get_optparse_kwargs(self, group, **kwargs): + """Build a dict of keyword arguments for optparse's add_option(). + + Most opt types extend this method to customize the behaviour of the + options added to optparse. + + :param group: an optional group + :param kwargs: optional keyword arguments to add to + :returns: a dict of keyword arguments + """ + dest = self.dest + if group is not None: + dest = group.name + '_' + dest + kwargs.update({ + 'dest': dest, + 'metavar': self.metavar, + 'help': self.help, + }) + return kwargs + + def _get_optparse_prefix(self, prefix, group): + """Build a prefix for the CLI option name, if required. + + CLI options in a group are prefixed with the group's name in order + to avoid conflicts between similarly named options in different + groups. + + :param prefix: an existing prefix to append to (e.g. 'no' or '') + :param group: an optional OptGroup object + :returns: a CLI option prefix including the group name, if appropriate + """ + if group is not None: + return group.name + '-' + prefix + else: + return prefix + + +class StrOpt(Opt): + """ + String opts do not have their values transformed and are returned as + str objects. + """ + pass + + +class BoolOpt(Opt): + + """ + Bool opts are set to True or False on the command line using --optname or + --noopttname respectively. + + In config files, boolean values are case insensitive and can be set using + 1/0, yes/no, true/false or on/off. + """ + + def _get_from_config_parser(self, cparser, section): + """Retrieve the opt value as a boolean from ConfigParser.""" + return cparser.getboolean(section, self.dest) + + def _add_to_cli(self, parser, group=None): + """Extends the base class method to add the --nooptname option.""" + super(BoolOpt, self)._add_to_cli(parser, group) + self._add_inverse_to_optparse(parser, group) + + def _add_inverse_to_optparse(self, parser, group): + """Add the --nooptname option to the option parser.""" + container = self._get_optparse_container(parser, group) + kwargs = self._get_optparse_kwargs(group, action='store_false') + prefix = self._get_optparse_prefix('no', group) + kwargs["help"] = "The inverse of --" + self.name + self._add_to_optparse(container, self.name, None, kwargs, prefix) + + def _get_optparse_kwargs(self, group, action='store_true', **kwargs): + """Extends the base optparse keyword dict for boolean options.""" + return super(BoolOpt, + self)._get_optparse_kwargs(group, action=action, **kwargs) + + +class IntOpt(Opt): + + """Int opt values are converted to integers using the int() builtin.""" + + def _get_from_config_parser(self, cparser, section): + """Retrieve the opt value as a integer from ConfigParser.""" + return cparser.getint(section, self.dest) + + def _get_optparse_kwargs(self, group, **kwargs): + """Extends the base optparse keyword dict for integer options.""" + return super(IntOpt, + self)._get_optparse_kwargs(group, type='int', **kwargs) + + +class FloatOpt(Opt): + + """Float opt values are converted to floats using the float() builtin.""" + + def _get_from_config_parser(self, cparser, section): + """Retrieve the opt value as a float from ConfigParser.""" + return cparser.getfloat(section, self.dest) + + def _get_optparse_kwargs(self, group, **kwargs): + """Extends the base optparse keyword dict for float options.""" + return super(FloatOpt, + self)._get_optparse_kwargs(group, type='float', **kwargs) + + +class ListOpt(Opt): + + """ + List opt values are simple string values separated by commas. The opt value + is a list containing these strings. + """ + + def _get_from_config_parser(self, cparser, section): + """Retrieve the opt value as a list from ConfigParser.""" + return cparser.get(section, self.dest).split(',') + + def _get_optparse_kwargs(self, group, **kwargs): + """Extends the base optparse keyword dict for list options.""" + return super(ListOpt, + self)._get_optparse_kwargs(group, + type='string', + action='callback', + callback=self._parse_list, + **kwargs) + + def _parse_list(self, option, opt, value, parser): + """An optparse callback for parsing an option value into a list.""" + setattr(parser.values, self.dest, value.split(',')) + + +class MultiStrOpt(Opt): + + """ + Multistr opt values are string opts which may be specified multiple times. + The opt value is a list containing all the string values specified. + """ + + def _get_from_config_parser(self, cparser, section): + """Retrieve the opt value as a multistr from ConfigParser.""" + # FIXME(markmc): values spread across the CLI and multiple + # config files should be appended + value = \ + super(MultiStrOpt, self)._get_from_config_parser(cparser, section) + return value if value is None else [value] + + def _get_optparse_kwargs(self, group, **kwargs): + """Extends the base optparse keyword dict for multi str options.""" + return super(MultiStrOpt, + self)._get_optparse_kwargs(group, action='append') + + +class OptGroup(object): + + """ + Represents a group of opts. + + CLI opts in the group are automatically prefixed with the group name. + + Each group corresponds to a section in config files. + + An OptGroup object has no public methods, but has a number of public string + properties: + + name: + the name of the group + title: + the group title as displayed in --help + help: + the group description as displayed in --help + """ + + def __init__(self, name, title=None, help=None): + """Constructs an OptGroup object. + + :param name: the group name + :param title: the group title for --help + :param help: the group description for --help + """ + self.name = name + if title is None: + self.title = "%s options" % title + else: + self.title = title + self.help = help + + self._opts = {} # dict of dicts of {opt:, override:, default:) + self._optparse_group = None + + def _register_opt(self, opt): + """Add an opt to this group. + + :param opt: an Opt object + :returns: False if previously registered, True otherwise + :raises: DuplicateOptError if a naming conflict is detected + """ + if _is_opt_registered(self._opts, opt): + return False + + self._opts[opt.dest] = {'opt': opt, 'override': None, 'default': None} + + return True + + def _get_optparse_group(self, parser): + """Build an optparse.OptionGroup for this group.""" + if self._optparse_group is None: + self._optparse_group = \ + optparse.OptionGroup(parser, self.title, self.help) + return self._optparse_group + + +class ConfigOpts(object): + + """ + Config options which may be set on the command line or in config files. + + ConfigOpts is a configuration option manager with APIs for registering + option schemas, grouping options, parsing option values and retrieving + the values of options. + """ + + def __init__(self, + project=None, + prog=None, + version=None, + usage=None, + default_config_files=None): + """Construct a ConfigOpts object. + + Automatically registers the --config-file option with either a supplied + list of default config files, or a list from find_config_files(). + + :param project: the toplevel project name, used to locate config files + :param prog: the name of the program (defaults to sys.argv[0] basename) + :param version: the program version (for --version) + :param usage: a usage string (%prog will be expanded) + :param default_config_files: config files to use by default + """ + if prog is None: + prog = os.path.basename(sys.argv[0]) + + if default_config_files is None: + default_config_files = find_config_files(project, prog) + + self.project = project + self.prog = prog + self.version = version + self.usage = usage + self.default_config_files = default_config_files + + self._opts = {} # dict of dicts of (opt:, override:, default:) + self._groups = {} + + self._args = None + self._cli_values = {} + + self._oparser = optparse.OptionParser(prog=self.prog, + version=self.version, + usage=self.usage) + self._cparser = None + + self.register_cli_opt(\ + MultiStrOpt('config-file', + default=self.default_config_files, + metavar='PATH', + help='Path to a config file to use. Multiple config ' + 'files can be specified, with values in later ' + 'files taking precedence. The default files used ' + 'are: %s' % (self.default_config_files, ))) + + def __call__(self, args=None): + """Parse command line arguments and config files. + + Calling a ConfigOpts object causes the supplied command line arguments + and config files to be parsed, causing opt values to be made available + as attributes of the object. + + The object may be called multiple times, each time causing the previous + set of values to be overwritten. + + :params args: command line arguments (defaults to sys.argv[1:]) + :returns: the list of arguments left over after parsing options + :raises: SystemExit, ConfigFilesNotFoundError, ConfigFileParseError + """ + self.reset() + + self._args = args + + (values, args) = self._oparser.parse_args(self._args) + + self._cli_values = vars(values) + + if self.config_file: + self._parse_config_files(self.config_file) + + return args + + def __getattr__(self, name): + """Look up an option value and perform string substitution. + + :param name: the opt name (or 'dest', more precisely) + :returns: the option value (after string subsititution) or a GroupAttr + :raises: NoSuchOptError,ConfigFileValueError,TemplateSubstitutionError + """ + return self._substitute(self._get(name)) + + def reset(self): + """Reset the state of the object to before it was called.""" + self._args = None + self._cli_values = None + self._cparser = None + + def register_opt(self, opt, group=None): + """Register an option schema. + + Registering an option schema makes any option value which is previously + or subsequently parsed from the command line or config files available + as an attribute of this object. + + :param opt: an instance of an Opt sub-class + :param group: an optional OptGroup object or group name + :return: False if the opt was already register, True otherwise + :raises: DuplicateOptError + """ + if group is not None: + return self._get_group(group)._register_opt(opt) + + if _is_opt_registered(self._opts, opt): + return False + + self._opts[opt.dest] = {'opt': opt, 'override': None, 'default': None} + + return True + + def register_opts(self, opts, group=None): + """Register multiple option schemas at once.""" + for opt in opts: + self.register_opt(opt, group) + + def register_cli_opt(self, opt, group=None): + """Register a CLI option schema. + + CLI option schemas must be registered before the command line and + config files are parsed. This is to ensure that all CLI options are + show in --help and option validation works as expected. + + :param opt: an instance of an Opt sub-class + :param group: an optional OptGroup object or group name + :return: False if the opt was already register, True otherwise + :raises: DuplicateOptError, ArgsAlreadyParsedError + """ + if self._args != None: + raise ArgsAlreadyParsedError("cannot register CLI option") + + if not self.register_opt(opt, group): + return False + + if group is not None: + group = self._get_group(group) + + opt._add_to_cli(self._oparser, group) + + return True + + def register_cli_opts(self, opts, group=None): + """Register multiple CLI option schemas at once.""" + for opt in opts: + self.register_cli_opt(opt, group) + + def register_group(self, group): + """Register an option group. + + An option group must be registered before options can be registered + with the group. + + :param group: an OptGroup object + """ + if group.name in self._groups: + return + + self._groups[group.name] = copy.copy(group) + + def set_override(self, name, override, group=None): + """Override an opt value. + + Override the command line, config file and default values of a + given option. + + :param name: the name/dest of the opt + :param override: the override value + :param group: an option OptGroup object or group name + :raises: NoSuchOptError, NoSuchGroupError + """ + opt_info = self._get_opt_info(name, group) + opt_info['override'] = override + + def set_default(self, name, default, group=None): + """Override an opt's default value. + + Override the default value of given option. A command line or + config file value will still take precedence over this default. + + :param name: the name/dest of the opt + :param default: the default value + :param group: an option OptGroup object or group name + :raises: NoSuchOptError, NoSuchGroupError + """ + opt_info = self._get_opt_info(name, group) + opt_info['default'] = default + + def log_opt_values(self, logger, lvl): + """Log the value of all registered opts. + + It's often useful for an app to log its configuration to a log file at + startup for debugging. This method dumps to the entire config state to + the supplied logger at a given log level. + + :param logger: a logging.Logger object + :param lvl: the log level (e.g. logging.DEBUG) arg to logger.log() + """ + logger.log(lvl, "*" * 80) + logger.log(lvl, "Configuration options gathered from:") + logger.log(lvl, "command line args: %s", self._args) + logger.log(lvl, "config files: %s", self.config_file) + logger.log(lvl, "=" * 80) + + for opt_name in sorted(self._opts): + logger.log(lvl, "%-30s = %s", opt_name, getattr(self, opt_name)) + + for group_name in self._groups: + group_attr = self.GroupAttr(self, group_name) + for opt_name in sorted(self._groups[group_name]._opts): + logger.log(lvl, "%-30s = %s", + "%s.%s" % (group_name, opt_name), + getattr(group_attr, opt_name)) + + logger.log(lvl, "*" * 80) + + def print_usage(self, file=None): + """Print the usage message for the current program.""" + self._oparser.print_usage(file) + + def _get(self, name, group=None): + """Look up an option value. + + :param name: the opt name (or 'dest', more precisely) + :param group: an option OptGroup + :returns: the option value, or a GroupAttr object + :raises: NoSuchOptError, NoSuchGroupError, ConfigFileValueError, + TemplateSubstitutionError + """ + if group is None and name in self._groups: + return self.GroupAttr(self, name) + + if group is not None: + group = self._get_group(group) + + info = self._get_opt_info(name, group) + default, opt, override = map(lambda k: info[k], sorted(info.keys())) + + if override is not None: + return override + + if self._cparser is not None: + section = group.name if group is not None else 'DEFAULT' + try: + return opt._get_from_config_parser(self._cparser, section) + except (ConfigParser.NoOptionError, + ConfigParser.NoSectionError): + pass + except ValueError, ve: + raise ConfigFileValueError(str(ve)) + + name = name if group is None else group.name + '_' + name + value = self._cli_values.get(name, None) + if value is not None: + return value + + if default is not None: + return default + + return opt.default + + def _substitute(self, value): + """Perform string template substitution. + + Substititue any template variables (e.g. $foo, ${bar}) in the supplied + string value(s) with opt values. + + :param value: the string value, or list of string values + :returns: the substituted string(s) + """ + if isinstance(value, list): + return [self._substitute(i) for i in value] + elif isinstance(value, str): + tmpl = string.Template(value) + return tmpl.safe_substitute(self.StrSubWrapper(self)) + else: + return value + + def _get_group(self, group_or_name): + """Looks up a OptGroup object. + + Helper function to return an OptGroup given a parameter which can + either be the group's name or an OptGroup object. + + The OptGroup object returned is from the internal dict of OptGroup + objects, which will be a copy of any OptGroup object that users of + the API have access to. + + :param group_or_name: the group's name or the OptGroup object itself + :raises: NoSuchGroupError + """ + if isinstance(group_or_name, OptGroup): + group_name = group_or_name.name + else: + group_name = group_or_name + + if not group_name in self._groups: + raise NoSuchGroupError(group_name) + + return self._groups[group_name] + + def _get_opt_info(self, opt_name, group=None): + """Return the (opt, override, default) dict for an opt. + + :param opt_name: an opt name/dest + :param group: an optional group name or OptGroup object + :raises: NoSuchOptError, NoSuchGroupError + """ + if group is None: + opts = self._opts + else: + group = self._get_group(group) + opts = group._opts + + if not opt_name in opts: + raise NoSuchOptError(opt_name, group) + + return opts[opt_name] + + def _parse_config_files(self, config_files): + """Parse the supplied configuration files. + + :raises: ConfigFilesNotFoundError, ConfigFileParseError + """ + self._cparser = ConfigParser.SafeConfigParser() + + try: + read_ok = self._cparser.read(config_files) + except ConfigParser.ParsingError, cpe: + raise ConfigFileParseError(cpe.filename, cpe.message) + + if read_ok != config_files: + not_read_ok = filter(lambda f: f not in read_ok, config_files) + raise ConfigFilesNotFoundError(not_read_ok) + + class GroupAttr(object): + + """ + A helper class representing the option values of a group as attributes. + """ + + def __init__(self, conf, group): + """Construct a GroupAttr object. + + :param conf: a ConfigOpts object + :param group: a group name or OptGroup object + """ + self.conf = conf + self.group = group + + def __getattr__(self, name): + """Look up an option value and perform template substitution.""" + return self.conf._substitute(self.conf._get(name, self.group)) + + class StrSubWrapper(object): + + """ + A helper class exposing opt values as a dict for string substitution. + """ + + def __init__(self, conf): + """Construct a StrSubWrapper object. + + :param conf: a ConfigOpts object + """ + self.conf = conf + + def __getitem__(self, key): + """Look up an opt value from the ConfigOpts object. + + :param key: an opt name + :returns: an opt value + :raises: TemplateSubstitutionError if attribute is a group + """ + value = getattr(self.conf, key) + if isinstance(value, self.conf.GroupAttr): + raise TemplateSubstitutionError( + 'substituting group %s not supported' % key) + return value + + +class CommonConfigOpts(ConfigOpts): + + DEFAULT_LOG_FORMAT = ('%(asctime)s %(process)d %(levelname)8s ' + '[%(name)s] %(message)s') + DEFAULT_LOG_DATE_FORMAT = "%Y-%m-%d %H:%M:%S" + + common_cli_opts = [ + BoolOpt('debug', + short='d', + default=False, + help='Print debugging output'), + BoolOpt('verbose', + short='v', + default=False, + help='Print more verbose output'), + ] + + logging_cli_opts = [ + StrOpt('log-config', + metavar='PATH', + help='If this option is specified, the logging configuration ' + 'file specified is used and overrides any other logging ' + 'options specified. Please see the Python logging module ' + 'documentation for details on logging configuration ' + 'files.'), + StrOpt('log-format', + default=DEFAULT_LOG_FORMAT, + metavar='FORMAT', + help='A logging.Formatter log message format string which may ' + 'use any of the available logging.LogRecord attributes. ' + 'Default: %default'), + StrOpt('log-date-format', + default=DEFAULT_LOG_DATE_FORMAT, + metavar='DATE_FORMAT', + help='Format string for %(asctime)s in log records. ' + 'Default: %default'), + StrOpt('log-file', + metavar='PATH', + help='(Optional) Name of log file to output to. ' + 'If not set, logging will go to stdout.'), + StrOpt('log-dir', + help='(Optional) The directory to keep log files in ' + '(will be prepended to --logfile)'), + BoolOpt('use-syslog', + default=False, + help='Use syslog for logging.'), + StrOpt('syslog-log-facility', + default='LOG_USER', + help='syslog facility to receive log lines') + ] + + def __init__(self, **kwargs): + super(CommonConfigOpts, self).__init__(**kwargs) + self.register_cli_opts(self.common_cli_opts) + self.register_cli_opts(self.logging_cli_opts) diff --git a/heat/common/client.py b/heat/common/client.py new file mode 100644 index 0000000000..9c5154dc55 --- /dev/null +++ b/heat/common/client.py @@ -0,0 +1,593 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010-2011 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. + +# HTTPSClientAuthConnection code comes courtesy of ActiveState website: +# http://code.activestate.com/recipes/ +# 577548-https-httplib-client-connection-with-certificate-v/ + +import collections +import errno +import functools +import httplib +import logging +import os +import urllib +import urlparse + +try: + from eventlet.green import socket, ssl +except ImportError: + import socket + import ssl + +try: + import sendfile + SENDFILE_SUPPORTED = True +except ImportError: + SENDFILE_SUPPORTED = False + +from heat.common import auth +from heat.common import exception, utils + + +# common chunk size for get and put +CHUNKSIZE = 65536 + + +def handle_unauthorized(func): + """ + Wrap a function to re-authenticate and retry. + """ + @functools.wraps(func) + def wrapped(self, *args, **kwargs): + try: + return func(self, *args, **kwargs) + except exception.NotAuthorized: + self._authenticate(force_reauth=True) + return func(self, *args, **kwargs) + return wrapped + + +def handle_redirects(func): + """ + Wrap the _do_request function to handle HTTP redirects. + """ + MAX_REDIRECTS = 5 + + @functools.wraps(func) + def wrapped(self, method, url, body, headers): + for _ in xrange(MAX_REDIRECTS): + try: + return func(self, method, url, body, headers) + except exception.RedirectException as redirect: + if redirect.url is None: + raise exception.InvalidRedirect() + url = redirect.url + raise exception.MaxRedirectsExceeded(redirects=MAX_REDIRECTS) + return wrapped + + +class ImageBodyIterator(object): + + """ + A class that acts as an iterator over an image file's + chunks of data. This is returned as part of the result + tuple from `heat.client.Client.get_image` + """ + + def __init__(self, source): + """ + Constructs the object from a readable image source + (such as an HTTPResponse or file-like object) + """ + self.source = source + + def __iter__(self): + """ + Exposes an iterator over the chunks of data in the + image file. + """ + while True: + chunk = self.source.read(CHUNKSIZE) + if chunk: + yield chunk + else: + break + + +class SendFileIterator: + """ + Emulate iterator pattern over sendfile, in order to allow + send progress be followed by wrapping the iteration. + """ + def __init__(self, connection, body): + self.connection = connection + self.body = body + self.offset = 0 + self.sending = True + + def __iter__(self): + class OfLength: + def __init__(self, len): + self.len = len + + def __len__(self): + return self.len + + while self.sending: + sent = sendfile.sendfile(self.connection.sock.fileno(), + self.body.fileno(), + self.offset, + CHUNKSIZE) + self.sending = (sent != 0) + self.offset += sent + yield OfLength(sent) + + +class HTTPSClientAuthConnection(httplib.HTTPSConnection): + """ + Class to make a HTTPS connection, with support for + full client-based SSL Authentication + + :see http://code.activestate.com/recipes/ + 577548-https-httplib-client-connection-with-certificate-v/ + """ + + def __init__(self, host, port, key_file, cert_file, + ca_file, timeout=None, insecure=False): + httplib.HTTPSConnection.__init__(self, host, port, key_file=key_file, + cert_file=cert_file) + self.key_file = key_file + self.cert_file = cert_file + self.ca_file = ca_file + self.timeout = timeout + self.insecure = insecure + + def connect(self): + """ + Connect to a host on a given (SSL) port. + If ca_file is pointing somewhere, use it to check Server Certificate. + + Redefined/copied and extended from httplib.py:1105 (Python 2.6.x). + This is needed to pass cert_reqs=ssl.CERT_REQUIRED as parameter to + ssl.wrap_socket(), which forces SSL to check server certificate against + our client certificate. + """ + sock = socket.create_connection((self.host, self.port), self.timeout) + if self._tunnel_host: + self.sock = sock + self._tunnel() + # Check CA file unless 'insecure' is specificed + if self.insecure is True: + self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, + cert_reqs=ssl.CERT_NONE) + else: + self.sock = ssl.wrap_socket(sock, self.key_file, self.cert_file, + ca_certs=self.ca_file, + cert_reqs=ssl.CERT_REQUIRED) + + +class BaseClient(object): + + """A base client class""" + + DEFAULT_PORT = 80 + DEFAULT_DOC_ROOT = None + # Standard CA file locations for Debian/Ubuntu, RedHat/Fedora, + # Suse, FreeBSD/OpenBSD + DEFAULT_CA_FILE_PATH = '/etc/ssl/certs/ca-certificates.crt:'\ + '/etc/pki/tls/certs/ca-bundle.crt:'\ + '/etc/ssl/ca-bundle.pem:'\ + '/etc/ssl/cert.pem' + + OK_RESPONSE_CODES = ( + httplib.OK, + httplib.CREATED, + httplib.ACCEPTED, + httplib.NO_CONTENT, + ) + + REDIRECT_RESPONSE_CODES = ( + httplib.MOVED_PERMANENTLY, + httplib.FOUND, + httplib.SEE_OTHER, + httplib.USE_PROXY, + httplib.TEMPORARY_REDIRECT, + ) + + def __init__(self, host, port=None, use_ssl=False, auth_tok=None, + creds=None, doc_root=None, key_file=None, + cert_file=None, ca_file=None, insecure=False, + configure_via_auth=True): + """ + Creates a new client to some service. + + :param host: The host where service resides + :param port: The port where service resides + :param use_ssl: Should we use HTTPS? + :param auth_tok: The auth token to pass to the server + :param creds: The credentials to pass to the auth plugin + :param doc_root: Prefix for all URLs we request from host + :param key_file: Optional PEM-formatted file that contains the private + key. + If use_ssl is True, and this param is None (the + default), then an environ variable + heat_CLIENT_KEY_FILE is looked for. If no such + environ variable is found, ClientConnectionError + will be raised. + :param cert_file: Optional PEM-formatted certificate chain file. + If use_ssl is True, and this param is None (the + default), then an environ variable + heat_CLIENT_CERT_FILE is looked for. If no such + environ variable is found, ClientConnectionError + will be raised. + :param ca_file: Optional CA cert file to use in SSL connections + If use_ssl is True, and this param is None (the + default), then an environ variable + heat_CLIENT_CA_FILE is looked for. + :param insecure: Optional. If set then the server's certificate + will not be verified. + """ + self.host = host + self.port = port or self.DEFAULT_PORT + self.use_ssl = use_ssl + self.auth_tok = auth_tok + self.creds = creds or {} + self.connection = None + self.configure_via_auth = configure_via_auth + # doc_root can be a nullstring, which is valid, and why we + # cannot simply do doc_root or self.DEFAULT_DOC_ROOT below. + self.doc_root = (doc_root if doc_root is not None + else self.DEFAULT_DOC_ROOT) + self.auth_plugin = self.make_auth_plugin(self.creds) + + self.key_file = key_file + self.cert_file = cert_file + self.ca_file = ca_file + self.insecure = insecure + self.connect_kwargs = self.get_connect_kwargs() + + def get_connect_kwargs(self): + connect_kwargs = {} + if self.use_ssl: + if self.key_file is None: + self.key_file = os.environ.get('heat_CLIENT_KEY_FILE') + if self.cert_file is None: + self.cert_file = os.environ.get('heat_CLIENT_CERT_FILE') + if self.ca_file is None: + self.ca_file = os.environ.get('heat_CLIENT_CA_FILE') + + # Check that key_file/cert_file are either both set or both unset + if self.cert_file is not None and self.key_file is None: + msg = _("You have selected to use SSL in connecting, " + "and you have supplied a cert, " + "however you have failed to supply either a " + "key_file parameter or set the " + "heat_CLIENT_KEY_FILE environ variable") + raise exception.ClientConnectionError(msg) + + if self.key_file is not None and self.cert_file is None: + msg = _("You have selected to use SSL in connecting, " + "and you have supplied a key, " + "however you have failed to supply either a " + "cert_file parameter or set the " + "heat_CLIENT_CERT_FILE environ variable") + raise exception.ClientConnectionError(msg) + + if (self.key_file is not None and + not os.path.exists(self.key_file)): + msg = _("The key file you specified %s does not " + "exist") % self.key_file + raise exception.ClientConnectionError(msg) + connect_kwargs['key_file'] = self.key_file + + if (self.cert_file is not None and + not os.path.exists(self.cert_file)): + msg = _("The cert file you specified %s does not " + "exist") % self.cert_file + raise exception.ClientConnectionError(msg) + connect_kwargs['cert_file'] = self.cert_file + + if (self.ca_file is not None and + not os.path.exists(self.ca_file)): + msg = _("The CA file you specified %s does not " + "exist") % self.ca_file + raise exception.ClientConnectionError(msg) + + if self.ca_file is None: + for ca in self.DEFAULT_CA_FILE_PATH.split(":"): + if os.path.exists(ca): + self.ca_file = ca + break + + connect_kwargs['ca_file'] = self.ca_file + connect_kwargs['insecure'] = self.insecure + + return connect_kwargs + + def set_auth_token(self, auth_tok): + """ + Updates the authentication token for this client connection. + """ + # FIXME(sirp): Nova image/heat.py currently calls this. Since this + # method isn't really doing anything useful[1], we should go ahead and + # rip it out, first in Nova, then here. Steps: + # + # 1. Change auth_tok in heat to auth_token + # 2. Change image/heat.py in Nova to use client.auth_token + # 3. Remove this method + # + # [1] http://mail.python.org/pipermail/tutor/2003-October/025932.html + self.auth_tok = auth_tok + + def configure_from_url(self, url): + """ + Setups the connection based on the given url. + + The form is: + + ://:port/doc_root + """ + parsed = urlparse.urlparse(url) + self.use_ssl = parsed.scheme == 'https' + self.host = parsed.hostname + self.port = parsed.port or 80 + self.doc_root = parsed.path + + # ensure connection kwargs are re-evaluated after the service catalog + # publicURL is parsed for potential SSL usage + self.connect_kwargs = self.get_connect_kwargs() + + def make_auth_plugin(self, creds): + """ + Returns an instantiated authentication plugin. + """ + strategy = creds.get('strategy', 'noauth') + plugin = auth.get_plugin_from_strategy(strategy, creds) + return plugin + + def get_connection_type(self): + """ + Returns the proper connection type + """ + if self.use_ssl: + return HTTPSClientAuthConnection + else: + return httplib.HTTPConnection + + def _authenticate(self, force_reauth=False): + """ + Use the authentication plugin to authenticate and set the auth token. + + :param force_reauth: For re-authentication to bypass cache. + """ + auth_plugin = self.auth_plugin + + if not auth_plugin.is_authenticated or force_reauth: + auth_plugin.authenticate() + + self.auth_tok = auth_plugin.auth_token + + management_url = auth_plugin.management_url + if management_url and self.configure_via_auth: + self.configure_from_url(management_url) + + @handle_unauthorized + def do_request(self, method, action, body=None, headers=None, + params=None): + """ + Make a request, returning an HTTP response object. + + :param method: HTTP verb (GET, POST, PUT, etc.) + :param action: Requested path to append to self.doc_root + :param body: Data to send in the body of the request + :param headers: Headers to send with the request + :param params: Key/value pairs to use in query string + :returns: HTTP response object + """ + if not self.auth_tok: + self._authenticate() + + url = self._construct_url(action, params) + return self._do_request(method=method, url=url, body=body, + headers=headers) + + def _construct_url(self, action, params=None): + """ + Create a URL object we can use to pass to _do_request(). + """ + path = '/'.join([self.doc_root or '', action.lstrip('/')]) + scheme = "https" if self.use_ssl else "http" + netloc = "%s:%d" % (self.host, self.port) + + if isinstance(params, dict): + for (key, value) in params.items(): + if value is None: + del params[key] + query = urllib.urlencode(params) + else: + query = None + + return urlparse.ParseResult(scheme, netloc, path, '', query, '') + + @handle_redirects + def _do_request(self, method, url, body, headers): + """ + Connects to the server and issues a request. Handles converting + any returned HTTP error status codes to OpenStack/heat exceptions + and closing the server connection. Returns the result data, or + raises an appropriate exception. + + :param method: HTTP method ("GET", "POST", "PUT", etc...) + :param url: urlparse.ParsedResult object with URL information + :param body: data to send (as string, filelike or iterable), + or None (default) + :param headers: mapping of key/value pairs to add as headers + + :note + + If the body param has a read attribute, and method is either + POST or PUT, this method will automatically conduct a chunked-transfer + encoding and use the body as a file object or iterable, transferring + chunks of data using the connection's send() method. This allows large + objects to be transferred efficiently without buffering the entire + body in memory. + """ + if url.query: + path = url.path + "?" + url.query + else: + path = url.path + + try: + connection_type = self.get_connection_type() + headers = headers or {} + + if 'x-auth-token' not in headers and self.auth_tok: + headers['x-auth-token'] = self.auth_tok + + c = connection_type(url.hostname, url.port, **self.connect_kwargs) + + def _pushing(method): + return method.lower() in ('post', 'put') + + def _simple(body): + return body is None or isinstance(body, basestring) + + def _filelike(body): + return hasattr(body, 'read') + + def _sendbody(connection, iter): + connection.endheaders() + for sent in iter: + # iterator has done the heavy lifting + pass + + def _chunkbody(connection, iter): + connection.putheader('Transfer-Encoding', 'chunked') + connection.endheaders() + for chunk in iter: + connection.send('%x\r\n%s\r\n' % (len(chunk), chunk)) + connection.send('0\r\n\r\n') + + # Do a simple request or a chunked request, depending + # on whether the body param is file-like or iterable and + # the method is PUT or POST + # + if not _pushing(method) or _simple(body): + # Simple request... + c.request(method, path, body, headers) + elif _filelike(body) or self._iterable(body): + c.putrequest(method, path) + + for header, value in headers.items(): + c.putheader(header, value) + + iter = self.image_iterator(c, headers, body) + + if self._sendable(body): + # send actual file without copying into userspace + _sendbody(c, iter) + else: + # otherwise iterate and chunk + _chunkbody(c, iter) + else: + raise TypeError('Unsupported image type: %s' % body.__class__) + + res = c.getresponse() + status_code = self.get_status_code(res) + if status_code in self.OK_RESPONSE_CODES: + return res + elif status_code in self.REDIRECT_RESPONSE_CODES: + raise exception.RedirectException(res.getheader('Location')) + elif status_code == httplib.UNAUTHORIZED: + raise exception.NotAuthorized(res.read()) + elif status_code == httplib.FORBIDDEN: + raise exception.NotAuthorized(res.read()) + elif status_code == httplib.NOT_FOUND: + raise exception.NotFound(res.read()) + elif status_code == httplib.CONFLICT: + raise exception.Duplicate(res.read()) + elif status_code == httplib.BAD_REQUEST: + raise exception.Invalid(res.read()) + elif status_code == httplib.MULTIPLE_CHOICES: + raise exception.MultipleChoices(body=res.read()) + elif status_code == httplib.INTERNAL_SERVER_ERROR: + raise Exception("Internal Server error: %s" % res.read()) + else: + raise Exception("Unknown error occurred! %s" % res.read()) + + except (socket.error, IOError), e: + raise exception.ClientConnectionError(e) + + def _seekable(self, body): + # pipes are not seekable, avoids sendfile() failure on e.g. + # cat /path/to/image | heat add ... + # or where add command is launched via popen + try: + os.lseek(body.fileno(), 0, os.SEEK_SET) + return True + except OSError as e: + return (e.errno != errno.ESPIPE) + + def _sendable(self, body): + return (SENDFILE_SUPPORTED and + hasattr(body, 'fileno') and + self._seekable(body) and + not self.use_ssl) + + def _iterable(self, body): + return isinstance(body, collections.Iterable) + + def image_iterator(self, connection, headers, body): + if self._sendable(body): + return SendFileIterator(connection, body) + elif self._iterable(body): + return utils.chunkreadable(body) + else: + return ImageBodyIterator(body) + + def get_status_code(self, response): + """ + Returns the integer status code from the response, which + can be either a Webob.Response (used in testing) or httplib.Response + """ + if hasattr(response, 'status_int'): + return response.status_int + else: + return response.status + + def _extract_params(self, actual_params, allowed_params): + """ + Extract a subset of keys from a dictionary. The filters key + will also be extracted, and each of its values will be returned + as an individual param. + + :param actual_params: dict of keys to filter + :param allowed_params: list of keys that 'actual_params' will be + reduced to + :retval subset of 'params' dict + """ + #result = {} + + #for allowed_param in allowed_params: + # if allowed_param in actual_params: + # result[allowed_param] = actual_params[allowed_param] + + #return result + + # allow user parameters + return actual_params diff --git a/heat/common/config.py b/heat/common/config.py new file mode 100644 index 0000000000..d17dd7559b --- /dev/null +++ b/heat/common/config.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. + +""" +Routines for configuring Heat +""" + +import logging +import logging.config +import logging.handlers +import os +import sys + +from heat import version +from heat.common import cfg +from heat.common import wsgi + + +paste_deploy_group = cfg.OptGroup('paste_deploy') +paste_deploy_opts = [ + cfg.StrOpt('flavor'), + cfg.StrOpt('config_file'), + ] + + +class HeatConfigOpts(cfg.CommonConfigOpts): + + def __init__(self, default_config_files=None, **kwargs): + super(HeatConfigOpts, self).__init__( + project='heat', + version='%%prog %s' % version.version_string(), + default_config_files=default_config_files, + **kwargs) + + +class HeatCacheConfigOpts(HeatConfigOpts): + + def __init__(self, **kwargs): + config_files = cfg.find_config_files(project='heat', + prog='heat-cache') + super(HeatCacheConfigOpts, self).__init__(config_files, **kwargs) + + +def setup_logging(conf): + """ + Sets up the logging options for a log with supplied name + + :param conf: a cfg.ConfOpts object + """ + + if conf.log_config: + # Use a logging configuration file for all settings... + if os.path.exists(conf.log_config): + logging.config.fileConfig(conf.log_config) + return + else: + raise RuntimeError("Unable to locate specified logging " + "config file: %s" % conf.log_config) + + root_logger = logging.root + if conf.debug: + root_logger.setLevel(logging.DEBUG) + elif conf.verbose: + root_logger.setLevel(logging.INFO) + else: + root_logger.setLevel(logging.WARNING) + + formatter = logging.Formatter(conf.log_format, conf.log_date_format) + + if conf.use_syslog: + try: + facility = getattr(logging.handlers.SysLogHandler, + conf.syslog_log_facility) + except AttributeError: + raise ValueError(_("Invalid syslog facility")) + + handler = logging.handlers.SysLogHandler(address='/dev/log', + facility=facility) + elif conf.log_file: + logfile = conf.log_file + if conf.log_dir: + logfile = os.path.join(conf.log_dir, logfile) + handler = logging.handlers.WatchedFileHandler(logfile) + else: + handler = logging.StreamHandler(sys.stdout) + + handler.setFormatter(formatter) + root_logger.addHandler(handler) + + +def _register_paste_deploy_opts(conf): + """ + Idempotent registration of paste_deploy option group + + :param conf: a cfg.ConfigOpts object + """ + conf.register_group(paste_deploy_group) + conf.register_opts(paste_deploy_opts, group=paste_deploy_group) + + +def _get_deployment_flavor(conf): + """ + Retrieve the paste_deploy.flavor config item, formatted appropriately + for appending to the application name. + + :param conf: a cfg.ConfigOpts object + """ + _register_paste_deploy_opts(conf) + flavor = conf.paste_deploy.flavor + return '' if not flavor else ('-' + flavor) + + +def _get_deployment_config_file(conf): + """ + Retrieve the deployment_config_file config item, formatted as an + absolute pathname. + + :param conf: a cfg.ConfigOpts object + """ + _register_paste_deploy_opts(conf) + config_file = conf.paste_deploy.config_file + if not config_file: + # Assume paste config is in a paste.ini file corresponding + # to the last config file + path = conf.config_file[-1].replace(".conf", "-paste.ini") + else: + path = config_file + return os.path.abspath(path) + + +def load_paste_app(conf, app_name=None): + """ + Builds and returns a WSGI app from a paste config file. + + We assume the last config file specified in the supplied ConfigOpts + object is the paste config file. + + :param conf: a cfg.ConfigOpts object + :param app_name: name of the application to load + + :raises RuntimeError when config file cannot be located or application + cannot be loaded from config file + """ + if app_name is None: + app_name = conf.prog + + # append the deployment flavor to the application name, + # in order to identify the appropriate paste pipeline + app_name += _get_deployment_flavor(conf) + + conf_file = _get_deployment_config_file(conf) + + try: + # Setup logging early + setup_logging(conf) + + logger = logging.getLogger(app_name) + + app = wsgi.paste_deploy_app(conf_file, app_name, conf) + + # Log the options used when starting if we're in debug mode... + if conf.debug: + conf.log_opt_values(logging.getLogger(app_name), logging.DEBUG) + + return app + except (LookupError, ImportError), e: + raise RuntimeError("Unable to load %(app_name)s from " + "configuration file %(conf_file)s." + "\nGot: %(e)r" % locals()) diff --git a/heat/common/context.py b/heat/common/context.py new file mode 100644 index 0000000000..8960ffc50d --- /dev/null +++ b/heat/common/context.py @@ -0,0 +1,124 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. + +from heat.common import cfg +from heat.common import exception +from heat.common import utils +from heat.common import wsgi + + +class RequestContext(object): + """ + Stores information about the security context under which the user + accesses the system, as well as additional request information. + """ + + def __init__(self, auth_tok=None, user=None, tenant=None, roles=None, + is_admin=False, read_only=False, show_deleted=False, + owner_is_tenant=True): + self.auth_tok = auth_tok + self.user = user + self.tenant = tenant + self.roles = roles or [] + self.is_admin = is_admin + self.read_only = read_only + self._show_deleted = show_deleted + self.owner_is_tenant = owner_is_tenant + + @property + def owner(self): + """Return the owner to correlate with an image.""" + return self.tenant if self.owner_is_tenant else self.user + + @property + def show_deleted(self): + """Admins can see deleted by default""" + if self._show_deleted or self.is_admin: + return True + return False + + +class ContextMiddleware(wsgi.Middleware): + + opts = [ + cfg.BoolOpt('owner_is_tenant', default=True), + ] + + def __init__(self, app, conf, **local_conf): + self.conf = conf + self.conf.register_opts(self.opts) + + # Determine the context class to use + self.ctxcls = RequestContext + if 'context_class' in local_conf: + self.ctxcls = utils.import_class(local_conf['context_class']) + + super(ContextMiddleware, self).__init__(app) + + def make_context(self, *args, **kwargs): + """ + Create a context with the given arguments. + """ + kwargs.setdefault('owner_is_tenant', self.conf.owner_is_tenant) + + return self.ctxcls(*args, **kwargs) + + def process_request(self, req): + """ + Extract any authentication information in the request and + construct an appropriate context from it. + + A few scenarios exist: + + 1. If X-Auth-Token is passed in, then consult TENANT and ROLE headers + to determine permissions. + + 2. An X-Auth-Token was passed in, but the Identity-Status is not + confirmed. For now, just raising a NotAuthorized exception. + + 3. X-Auth-Token is omitted. If we were using Keystone, then the + tokenauth middleware would have rejected the request, so we must be + using NoAuth. In that case, assume that is_admin=True. + """ + # TODO(sirp): should we be using the heat_tokeauth shim from + # Keystone here? If we do, we need to make sure it handles the NoAuth + # case + auth_tok = req.headers.get('X-Auth-Token', + req.headers.get('X-Storage-Token')) + if auth_tok: + if req.headers.get('X-Identity-Status') == 'Confirmed': + # 1. Auth-token is passed, check other headers + user = req.headers.get('X-User') + tenant = req.headers.get('X-Tenant') + roles = [r.strip() + for r in req.headers.get('X-Role', '').split(',')] + is_admin = 'Admin' in roles + else: + # 2. Indentity-Status not confirmed + # FIXME(sirp): not sure what the correct behavior in this case + # is; just raising NotAuthorized for now + raise exception.NotAuthorized() + else: + # 3. Auth-token is ommited, assume NoAuth + user = None + tenant = None + roles = [] + is_admin = True + + req.context = self.make_context( + auth_tok=auth_tok, user=user, tenant=tenant, roles=roles, + is_admin=is_admin) diff --git a/heat/common/crypt.py b/heat/common/crypt.py new file mode 100644 index 0000000000..cddd13b5b9 --- /dev/null +++ b/heat/common/crypt.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. + +""" +Routines for URL-safe encrypting/decrypting +""" + +import base64 +import string +import os + +from Crypto.Cipher import AES +from Crypto import Random +from Crypto.Random import random + + +def urlsafe_encrypt(key, plaintext, blocksize=16): + """ + Encrypts plaintext. Resulting ciphertext will contain URL-safe characters + :param key: AES secret key + :param plaintext: Input text to be encrypted + :param blocksize: Non-zero integer multiple of AES blocksize in bytes (16) + + :returns : Resulting ciphertext + """ + def pad(text): + """ + Pads text to be encrypted + """ + pad_length = (blocksize - len(text) % blocksize) + sr = random.StrongRandom() + pad = ''.join(chr(sr.randint(1, 0xFF)) for i in range(pad_length - 1)) + # We use chr(0) as a delimiter between text and padding + return text + chr(0) + pad + + # random initial 16 bytes for CBC + init_vector = Random.get_random_bytes(16) + cypher = AES.new(key, AES.MODE_CBC, init_vector) + padded = cypher.encrypt(pad(str(plaintext))) + return base64.urlsafe_b64encode(init_vector + padded) + + +def urlsafe_decrypt(key, ciphertext): + """ + Decrypts URL-safe base64 encoded ciphertext + :param key: AES secret key + :param ciphertext: The encrypted text to decrypt + + :returns : Resulting plaintext + """ + # Cast from unicode + ciphertext = base64.urlsafe_b64decode(str(ciphertext)) + cypher = AES.new(key, AES.MODE_CBC, ciphertext[:16]) + padded = cypher.decrypt(ciphertext[16:]) + return padded[:padded.rfind(chr(0))] diff --git a/heat/common/exception.py b/heat/common/exception.py new file mode 100644 index 0000000000..2a3a8ade1c --- /dev/null +++ b/heat/common/exception.py @@ -0,0 +1,193 @@ +# 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. + +"""Heat exception subclasses""" + +import urlparse + + +class RedirectException(Exception): + def __init__(self, url): + self.url = urlparse.urlparse(url) + + +class HeatException(Exception): + """ + Base Heat Exception + + To correctly use this class, inherit from it and define + a 'message' property. That message will get printf'd + with the keyword arguments provided to the constructor. + """ + message = _("An unknown exception occurred") + + def __init__(self, *args, **kwargs): + try: + self._error_string = self.message % kwargs + except Exception: + # at least get the core message out if something happened + self._error_string = self.message + if len(args) > 0: + # If there is a non-kwarg parameter, assume it's the error + # message or reason description and tack it on to the end + # of the exception message + # Convert all arguments into their string representations... + args = ["%s" % arg for arg in args] + self._error_string = (self._error_string + + "\nDetails: %s" % '\n'.join(args)) + + def __str__(self): + return self._error_string + + +class MissingArgumentError(HeatException): + message = _("Missing required argument.") + + +class MissingCredentialError(HeatException): + message = _("Missing required credential: %(required)s") + + +class BadAuthStrategy(HeatException): + message = _("Incorrect auth strategy, expected \"%(expected)s\" but " + "received \"%(received)s\"") + + +class NotFound(HeatException): + message = _("An object with the specified identifier was not found.") + + +class UnknownScheme(HeatException): + message = _("Unknown scheme '%(scheme)s' found in URI") + + +class BadStoreUri(HeatException): + message = _("The Store URI %(uri)s was malformed. Reason: %(reason)s") + + +class Duplicate(HeatException): + message = _("An object with the same identifier already exists.") + + +class StorageFull(HeatException): + message = _("There is not enough disk space on the image storage media.") + + +class StorageWriteDenied(HeatException): + message = _("Permission to write image storage media denied.") + + +class ImportFailure(HeatException): + message = _("Failed to import requested object/class: '%(import_str)s'. " + "Reason: %(reason)s") + + +class AuthBadRequest(HeatException): + message = _("Connect error/bad request to Auth service at URL %(url)s.") + + +class AuthUrlNotFound(HeatException): + message = _("Auth service at URL %(url)s not found.") + + +class AuthorizationFailure(HeatException): + message = _("Authorization failed.") + + +class NotAuthorized(HeatException): + message = _("You are not authorized to complete this action.") + + +class NotAuthorizedPublicImage(NotAuthorized): + message = _("You are not authorized to complete this action.") + + +class Invalid(HeatException): + message = _("Data supplied was not valid.") + + +class AuthorizationRedirect(HeatException): + message = _("Redirecting to %(uri)s for authorization.") + + +class DatabaseMigrationError(HeatException): + message = _("There was an error migrating the database.") + + +class ClientConnectionError(HeatException): + message = _("There was an error connecting to a server") + + +class ClientConfigurationError(HeatException): + message = _("There was an error configuring the client.") + + +class MultipleChoices(HeatException): + message = _("The request returned a 302 Multiple Choices. This generally " + "means that you have not included a version indicator in a " + "request URI.\n\nThe body of response returned:\n%(body)s") + + +class InvalidContentType(HeatException): + message = _("Invalid content type %(content_type)s") + + +class BadRegistryConnectionConfiguration(HeatException): + message = _("Registry was not configured correctly on API server. " + "Reason: %(reason)s") + + +class BadStoreConfiguration(HeatException): + message = _("Store %(store_name)s could not be configured correctly. " + "Reason: %(reason)s") + + +class BadDriverConfiguration(HeatException): + message = _("Driver %(driver_name)s could not be configured correctly. " + "Reason: %(reason)s") + + +class StoreDeleteNotSupported(HeatException): + message = _("Deleting images from this store is not supported.") + + +class StoreAddDisabled(HeatException): + message = _("Configuration for store failed. Adding images to this " + "store is disabled.") + + +class InvalidNotifierStrategy(HeatException): + message = _("'%(strategy)s' is not an available notifier strategy.") + + +class MaxRedirectsExceeded(HeatException): + message = _("Maximum redirects (%(redirects)s) was exceeded.") + + +class InvalidRedirect(HeatException): + message = _("Received invalid HTTP redirect.") + + +class NoServiceEndpoint(HeatException): + message = _("Response from Keystone does not contain a Heat endpoint.") + + +class RegionAmbiguity(HeatException): + message = _("Multiple 'image' service matches for region %(region)s. This " + "generally means that a region is required and you have not " + "supplied one.") diff --git a/heat/common/policy.py b/heat/common/policy.py new file mode 100644 index 0000000000..1579409ec5 --- /dev/null +++ b/heat/common/policy.py @@ -0,0 +1,182 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 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. + +"""Common Policy Engine Implementation""" + +import json + + +class NotAuthorized(Exception): + pass + + +_BRAIN = None + + +def set_brain(brain): + """Set the brain used by enforce(). + + Defaults use Brain() if not set. + + """ + global _BRAIN + _BRAIN = brain + + +def reset(): + """Clear the brain used by enforce().""" + global _BRAIN + _BRAIN = None + + +def enforce(match_list, target_dict, credentials_dict): + """Enforces authorization of some rules against credentials. + + :param match_list: nested tuples of data to match against + The basic brain supports three types of match lists: + 1) rules + looks like: ('rule:compute:get_instance',) + Retrieves the named rule from the rules dict and recursively + checks against the contents of the rule. + 2) roles + looks like: ('role:compute:admin',) + Matches if the specified role is in credentials_dict['roles']. + 3) generic + ('tenant_id:%(tenant_id)s',) + Substitutes values from the target dict into the match using + the % operator and matches them against the creds dict. + + Combining rules: + The brain returns True if any of the outer tuple of rules match + and also True if all of the inner tuples match. You can use this to + perform simple boolean logic. For example, the following rule would + return True if the creds contain the role 'admin' OR the if the + tenant_id matches the target dict AND the the creds contains the + role 'compute_sysadmin': + + { + "rule:combined": ( + 'role:admin', + ('tenant_id:%(tenant_id)s', 'role:compute_sysadmin') + ) + } + + + Note that rule and role are reserved words in the credentials match, so + you can't match against properties with those names. Custom brains may + also add new reserved words. For example, the HttpBrain adds http as a + reserved word. + + :param target_dict: dict of object properties + Target dicts contain as much information as we can about the object being + operated on. + + :param credentials_dict: dict of actor properties + Credentials dicts contain as much information as we can about the user + performing the action. + + :raises NotAuthorized if the check fails + + """ + global _BRAIN + if not _BRAIN: + _BRAIN = Brain() + if not _BRAIN.check(match_list, target_dict, credentials_dict): + raise NotAuthorized() + + +class Brain(object): + """Implements policy checking.""" + @classmethod + def load_json(cls, data, default_rule=None): + """Init a brain using json instead of a rules dictionary.""" + rules_dict = json.loads(data) + return cls(rules=rules_dict, default_rule=default_rule) + + def __init__(self, rules=None, default_rule=None): + self.rules = rules or {} + self.default_rule = default_rule + + def add_rule(self, key, match): + self.rules[key] = match + + def _check(self, match, target_dict, cred_dict): + match_kind, match_value = match.split(':', 1) + try: + f = getattr(self, '_check_%s' % match_kind) + except AttributeError: + if not self._check_generic(match, target_dict, cred_dict): + return False + else: + if not f(match_value, target_dict, cred_dict): + return False + return True + + def check(self, match_list, target_dict, cred_dict): + """Checks authorization of some rules against credentials. + + Detailed description of the check with examples in policy.enforce(). + + :param match_list: nested tuples of data to match against + :param target_dict: dict of object properties + :param credentials_dict: dict of actor properties + + :returns: True if the check passes + + """ + if not match_list: + return True + for and_list in match_list: + if isinstance(and_list, basestring): + and_list = (and_list,) + if all([self._check(item, target_dict, cred_dict) + for item in and_list]): + return True + return False + + def _check_rule(self, match, target_dict, cred_dict): + """Recursively checks credentials based on the brains rules.""" + try: + new_match_list = self.rules[match] + except KeyError: + if self.default_rule and match != self.default_rule: + new_match_list = ('rule:%s' % self.default_rule,) + else: + return False + + return self.check(new_match_list, target_dict, cred_dict) + + def _check_role(self, match, target_dict, cred_dict): + """Check that there is a matching role in the cred dict.""" + return match in cred_dict['roles'] + + def _check_generic(self, match, target_dict, cred_dict): + """Check an individual match. + + Matches look like: + + tenant:%(tenant_id)s + role:compute:admin + + """ + + # TODO(termie): do dict inspection via dot syntax + match = match % target_dict + key, value = match.split(':', 1) + if key in cred_dict: + return value == cred_dict[key] + return False diff --git a/heat/common/utils.py b/heat/common/utils.py new file mode 100644 index 0000000000..1f8604d1ec --- /dev/null +++ b/heat/common/utils.py @@ -0,0 +1,372 @@ +# 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. + +""" +System-level utilities and helper functions. +""" + +import datetime +import errno +import inspect +import logging +import os +import platform +import random +import subprocess +import socket +import sys +import uuid + +import iso8601 + +from heat.common import exception + + +logger = logging.getLogger(__name__) + +TIME_FORMAT = "%Y-%m-%dT%H:%M:%S" + + +def chunkreadable(iter, chunk_size=65536): + """ + Wrap a readable iterator with a reader yielding chunks of + a preferred size, otherwise leave iterator unchanged. + + :param iter: an iter which may also be readable + :param chunk_size: maximum size of chunk + """ + return chunkiter(iter, chunk_size) if hasattr(iter, 'read') else iter + + +def chunkiter(fp, chunk_size=65536): + """ + Return an iterator to a file-like obj which yields fixed size chunks + + :param fp: a file-like object + :param chunk_size: maximum size of chunk + """ + while True: + chunk = fp.read(chunk_size) + if chunk: + yield chunk + else: + break + + +def image_meta_to_http_headers(image_meta): + """ + Returns a set of image metadata into a dict + of HTTP headers that can be fed to either a Webob + Request object or an httplib.HTTP(S)Connection object + + :param image_meta: Mapping of image metadata + """ + headers = {} + for k, v in image_meta.items(): + if v is not None: + if k == 'properties': + for pk, pv in v.items(): + if pv is not None: + headers["x-image-meta-property-%s" + % pk.lower()] = unicode(pv) + else: + headers["x-image-meta-%s" % k.lower()] = unicode(v) + return headers + + +def add_features_to_http_headers(features, headers): + """ + Adds additional headers representing heat features to be enabled. + + :param headers: Base set of headers + :param features: Map of enabled features + """ + if features: + for k, v in features.items(): + if v is not None: + headers[k.lower()] = unicode(v) + + +def get_image_meta_from_headers(response): + """ + Processes HTTP headers from a supplied response that + match the x-image-meta and x-image-meta-property and + returns a mapping of image metadata and properties + + :param response: Response to process + """ + result = {} + properties = {} + + if hasattr(response, 'getheaders'): # httplib.HTTPResponse + headers = response.getheaders() + else: # webob.Response + headers = response.headers.items() + + for key, value in headers: + key = str(key.lower()) + if key.startswith('x-image-meta-property-'): + field_name = key[len('x-image-meta-property-'):].replace('-', '_') + properties[field_name] = value or None + elif key.startswith('x-image-meta-'): + field_name = key[len('x-image-meta-'):].replace('-', '_') + result[field_name] = value or None + result['properties'] = properties + if 'size' in result: + try: + result['size'] = int(result['size']) + except ValueError: + raise exception.Invalid + for key in ('is_public', 'deleted', 'protected'): + if key in result: + result[key] = bool_from_header_value(result[key]) + return result + + +def bool_from_header_value(value): + """ + Returns True if value is a boolean True or the + string 'true', case-insensitive, False otherwise + """ + if isinstance(value, bool): + return value + elif isinstance(value, (basestring, unicode)): + if str(value).lower() == 'true': + return True + return False + + +def bool_from_string(subject): + """ + Interpret a string as a boolean. + + Any string value in: + ('True', 'true', 'On', 'on', '1') + is interpreted as a boolean True. + + Useful for JSON-decoded stuff and config file parsing + """ + if isinstance(subject, bool): + return subject + elif isinstance(subject, int): + return subject == 1 + if hasattr(subject, 'startswith'): # str or unicode... + if subject.strip().lower() in ('true', 'on', '1'): + return True + return False + + +def import_class(import_str): + """Returns a class from a string including module and class""" + mod_str, _sep, class_str = import_str.rpartition('.') + try: + __import__(mod_str) + return getattr(sys.modules[mod_str], class_str) + except (ImportError, ValueError, AttributeError), e: + raise exception.ImportFailure(import_str=import_str, + reason=e) + + +def import_object(import_str): + """Returns an object including a module or module and class""" + try: + __import__(import_str) + return sys.modules[import_str] + except ImportError: + cls = import_class(import_str) + return cls() + + +def generate_uuid(): + return str(uuid.uuid4()) + + +def is_uuid_like(value): + try: + uuid.UUID(value) + return True + except Exception: + return False + + +def isotime(at=None): + """Stringify time in ISO 8601 format""" + if not at: + at = datetime.datetime.utcnow() + str = at.strftime(TIME_FORMAT) + tz = at.tzinfo.tzname(None) if at.tzinfo else 'UTC' + str += ('Z' if tz == 'UTC' else tz) + return str + + +def parse_isotime(timestr): + """Parse time from ISO 8601 format""" + try: + return iso8601.parse_date(timestr) + except iso8601.ParseError as e: + raise ValueError(e.message) + except TypeError as e: + raise ValueError(e.message) + + +def normalize_time(timestamp): + """Normalize time in arbitrary timezone to UTC""" + offset = timestamp.utcoffset() + return timestamp.replace(tzinfo=None) - offset if offset else timestamp + + +def safe_mkdirs(path): + try: + os.makedirs(path) + except OSError, e: + if e.errno != errno.EEXIST: + raise + + +def safe_remove(path): + try: + os.remove(path) + except OSError, e: + if e.errno != errno.ENOENT: + raise + + +class PrettyTable(object): + """Creates an ASCII art table for use in bin/heat + + Example: + + ID Name Size Hits + --- ----------------- ------------ ----- + 122 image 22 0 + """ + def __init__(self): + self.columns = [] + + def add_column(self, width, label="", just='l'): + """Add a column to the table + + :param width: number of characters wide the column should be + :param label: column heading + :param just: justification for the column, 'l' for left, + 'r' for right + """ + self.columns.append((width, label, just)) + + def make_header(self): + label_parts = [] + break_parts = [] + for width, label, _ in self.columns: + # NOTE(sirp): headers are always left justified + label_part = self._clip_and_justify(label, width, 'l') + label_parts.append(label_part) + + break_part = '-' * width + break_parts.append(break_part) + + label_line = ' '.join(label_parts) + break_line = ' '.join(break_parts) + return '\n'.join([label_line, break_line]) + + def make_row(self, *args): + row = args + row_parts = [] + for data, (width, _, just) in zip(row, self.columns): + row_part = self._clip_and_justify(data, width, just) + row_parts.append(row_part) + + row_line = ' '.join(row_parts) + return row_line + + @staticmethod + def _clip_and_justify(data, width, just): + # clip field to column width + clipped_data = str(data)[:width] + + if just == 'r': + # right justify + justified = clipped_data.rjust(width) + else: + # left justify + justified = clipped_data.ljust(width) + + return justified + + +def get_terminal_size(): + + def _get_terminal_size_posix(): + import fcntl + import struct + import termios + + height_width = None + + try: + height_width = struct.unpack('hh', fcntl.ioctl(sys.stderr.fileno(), + termios.TIOCGWINSZ, + struct.pack('HH', 0, 0))) + except: + pass + + if not height_width: + try: + p = subprocess.Popen(['stty', 'size'], + shell=false, + stdout=subprocess.PIPE) + return tuple(int(x) for x in p.communicate()[0].split()) + except: + pass + + return height_width + + def _get_terminal_size_win32(): + try: + from ctypes import windll, create_string_buffer + handle = windll.kernel32.GetStdHandle(-12) + csbi = create_string_buffer(22) + res = windll.kernel32.GetConsoleScreenBufferInfo(handle, csbi) + except: + return None + if res: + import struct + unpack_tmp = struct.unpack("hhhhHhhhhhh", csbi.raw) + (bufx, bufy, curx, cury, wattr, + left, top, right, bottom, maxx, maxy) = unpack_tmp + height = bottom - top + 1 + width = right - left + 1 + return (height, width) + else: + return None + + def _get_terminal_size_unknownOS(): + raise NotImplementedError + + func = {'posix': _get_terminal_size_posix, + 'win32': _get_terminal_size_win32} + + height_width = func.get(platform.os.name, _get_terminal_size_unknownOS)() + + if height_width == None: + raise exception.Invalid() + + for i in height_width: + if not isinstance(i, int) or i <= 0: + raise exception.Invalid() + + return height_width[0], height_width[1] diff --git a/heat/common/wsgi.py b/heat/common/wsgi.py new file mode 100644 index 0000000000..abe07c6ae8 --- /dev/null +++ b/heat/common/wsgi.py @@ -0,0 +1,649 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# 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. + +""" +Utility methods for working with WSGI servers +""" + +import datetime +import errno +import json +import logging +import os +import signal +import sys +import time + +import eventlet +import eventlet.greenio +from eventlet.green import socket, ssl +import eventlet.wsgi +from paste import deploy +import routes +import routes.middleware +import webob.dec +import webob.exc + +from heat.common import cfg +from heat.common import exception +from heat.common import utils + + +bind_opts = [ + cfg.StrOpt('bind_host', default='0.0.0.0'), + cfg.IntOpt('bind_port'), +] + +socket_opts = [ + cfg.IntOpt('backlog', default=4096), + cfg.StrOpt('cert_file'), + cfg.StrOpt('key_file'), +] + +workers_opt = cfg.IntOpt('workers', default=0) + + +class WritableLogger(object): + """A thin wrapper that responds to `write` and logs.""" + + def __init__(self, logger, level=logging.DEBUG): + self.logger = logger + self.level = level + + def write(self, msg): + self.logger.log(self.level, msg.strip("\n")) + + +def get_bind_addr(conf, default_port=None): + """Return the host and port to bind to.""" + conf.register_opts(bind_opts) + return (conf.bind_host, conf.bind_port or default_port) + + +def get_socket(conf, default_port): + """ + Bind socket to bind ip:port in conf + + note: Mostly comes from Swift with a few small changes... + + :param conf: a cfg.ConfigOpts object + :param default_port: port to bind to if none is specified in conf + + :returns : a socket object as returned from socket.listen or + ssl.wrap_socket if conf specifies cert_file + """ + bind_addr = get_bind_addr(conf, default_port) + + # TODO(jaypipes): eventlet's greened socket module does not actually + # support IPv6 in getaddrinfo(). We need to get around this in the + # future or monitor upstream for a fix + address_family = [addr[0] for addr in socket.getaddrinfo(bind_addr[0], + bind_addr[1], socket.AF_UNSPEC, socket.SOCK_STREAM) + if addr[0] in (socket.AF_INET, socket.AF_INET6)][0] + + conf.register_opts(socket_opts) + + cert_file = conf.cert_file + key_file = conf.key_file + use_ssl = cert_file or key_file + if use_ssl and (not cert_file or not key_file): + raise RuntimeError(_("When running server in SSL mode, you must " + "specify both a cert_file and key_file " + "option value in your configuration file")) + + sock = None + retry_until = time.time() + 30 + while not sock and time.time() < retry_until: + try: + sock = eventlet.listen(bind_addr, backlog=conf.backlog, + family=address_family) + if use_ssl: + sock = ssl.wrap_socket(sock, certfile=cert_file, + keyfile=key_file) + except socket.error, err: + if err.args[0] != errno.EADDRINUSE: + raise + eventlet.sleep(0.1) + if not sock: + raise RuntimeError(_("Could not bind to %s:%s after trying for 30 " + "seconds") % bind_addr) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + # in my experience, sockets can hang around forever without keepalive + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + + # This option isn't available in the OS X version of eventlet + if hasattr(socket, 'TCP_KEEPIDLE'): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 600) + + return sock + + +class Server(object): + """Server class to manage multiple WSGI sockets and applications.""" + + def __init__(self, threads=1000): + self.threads = threads + self.children = [] + self.running = True + + def start(self, application, conf, default_port): + """ + Run a WSGI server with the given application. + + :param application: The application to run in the WSGI server + :param conf: a cfg.ConfigOpts object + :param default_port: Port to bind to if none is specified in conf + """ + def kill_children(*args): + """Kills the entire process group.""" + self.logger.error(_('SIGTERM received')) + signal.signal(signal.SIGTERM, signal.SIG_IGN) + self.running = False + os.killpg(0, signal.SIGTERM) + + def hup(*args): + """ + Shuts down the server, but allows running requests to complete + """ + self.logger.error(_('SIGHUP received')) + signal.signal(signal.SIGHUP, signal.SIG_IGN) + self.running = False + + self.application = application + self.sock = get_socket(conf, default_port) + conf.register_opt(workers_opt) + + self.logger = logging.getLogger('eventlet.wsgi.server') + + if conf.workers == 0: + # Useful for profiling, test, debug etc. + self.pool = eventlet.GreenPool(size=self.threads) + self.pool.spawn_n(self._single_run, application, self.sock) + return + + self.logger.info(_("Starting %d workers") % conf.workers) + signal.signal(signal.SIGTERM, kill_children) + signal.signal(signal.SIGHUP, hup) + while len(self.children) < conf.workers: + self.run_child() + + def wait_on_children(self): + while self.running: + try: + pid, status = os.wait() + if os.WIFEXITED(status) or os.WIFSIGNALED(status): + self.logger.error(_('Removing dead child %s') % pid) + self.children.remove(pid) + self.run_child() + except OSError, err: + if err.errno not in (errno.EINTR, errno.ECHILD): + raise + except KeyboardInterrupt: + sys.exit(1) + self.logger.info(_('Caught keyboard interrupt. Exiting.')) + break + eventlet.greenio.shutdown_safe(self.sock) + self.sock.close() + self.logger.debug(_('Exited')) + + def wait(self): + """Wait until all servers have completed running.""" + try: + if self.children: + self.wait_on_children() + else: + self.pool.waitall() + except KeyboardInterrupt: + pass + + def run_child(self): + pid = os.fork() + if pid == 0: + signal.signal(signal.SIGHUP, signal.SIG_DFL) + signal.signal(signal.SIGTERM, signal.SIG_DFL) + self.run_server() + self.logger.info(_('Child %d exiting normally') % os.getpid()) + return + else: + self.logger.info(_('Started child %s') % pid) + self.children.append(pid) + + def run_server(self): + """Run a WSGI server.""" + eventlet.wsgi.HttpProtocol.default_request_version = "HTTP/1.0" + eventlet.hubs.use_hub('poll') + eventlet.patcher.monkey_patch(all=False, socket=True) + self.pool = eventlet.GreenPool(size=self.threads) + try: + eventlet.wsgi.server(self.sock, self.application, + log=WritableLogger(self.logger), custom_pool=self.pool) + except socket.error, err: + if err[0] != errno.EINVAL: + raise + self.pool.waitall() + + def _single_run(self, application, sock): + """Start a WSGI server in a new green thread.""" + self.logger.info(_("Starting single process server")) + eventlet.wsgi.server(sock, application, custom_pool=self.pool, + log=WritableLogger(self.logger)) + + +class Middleware(object): + """ + Base WSGI middleware wrapper. These classes require an application to be + initialized that will be called next. By default the middleware will + simply call its wrapped app, or you can override __call__ to customize its + behavior. + """ + + def __init__(self, application): + self.application = application + + def process_request(self, req): + """ + Called on each request. + + If this returns None, the next application down the stack will be + executed. If it returns a response then that response will be returned + and execution will stop here. + + """ + return None + + def process_response(self, response): + """Do whatever you'd like to the response.""" + return response + + @webob.dec.wsgify + def __call__(self, req): + response = self.process_request(req) + if response: + return response + response = req.get_response(self.application) + return self.process_response(response) + + +class Debug(Middleware): + """ + Helper class that can be inserted into any WSGI application chain + to get information about the request and response. + """ + + @webob.dec.wsgify + def __call__(self, req): + print ("*" * 40) + " REQUEST ENVIRON" + for key, value in req.environ.items(): + print key, "=", value + print + resp = req.get_response(self.application) + + print ("*" * 40) + " RESPONSE HEADERS" + for (key, value) in resp.headers.iteritems(): + print key, "=", value + print + + resp.app_iter = self.print_generator(resp.app_iter) + + return resp + + @staticmethod + def print_generator(app_iter): + """ + Iterator that prints the contents of a wrapper string iterator + when iterated. + """ + print ("*" * 40) + " BODY" + for part in app_iter: + sys.stdout.write(part) + sys.stdout.flush() + yield part + print + + +class Router(object): + """ + WSGI middleware that maps incoming requests to WSGI apps. + """ + + def __init__(self, mapper): + """ + Create a router for the given routes.Mapper. + + Each route in `mapper` must specify a 'controller', which is a + WSGI app to call. You'll probably want to specify an 'action' as + well and have your controller be a wsgi.Controller, who will route + the request to the action method. + + Examples: + mapper = routes.Mapper() + sc = ServerController() + + # Explicit mapping of one route to a controller+action + mapper.connect(None, "/svrlist", controller=sc, action="list") + + # Actions are all implicitly defined + mapper.resource("server", "servers", controller=sc) + + # Pointing to an arbitrary WSGI app. You can specify the + # {path_info:.*} parameter so the target app can be handed just that + # section of the URL. + mapper.connect(None, "/v1.0/{path_info:.*}", controller=BlogApp()) + """ + self.map = mapper + self._router = routes.middleware.RoutesMiddleware(self._dispatch, + self.map) + + @webob.dec.wsgify + def __call__(self, req): + """ + Route the incoming request to a controller based on self.map. + If no match, return a 404. + """ + return self._router + + @staticmethod + @webob.dec.wsgify + def _dispatch(req): + """ + Called by self._router after matching the incoming request to a route + and putting the information into req.environ. Either returns 404 + or the routed WSGI app's response. + """ + match = req.environ['wsgiorg.routing_args'][1] + if not match: + return webob.exc.HTTPNotFound() + app = match['controller'] + return app + + +class Request(webob.Request): + """Add some Openstack API-specific logic to the base webob.Request.""" + + def best_match_content_type(self): + """Determine the requested response content-type.""" + supported = ('application/json',) + bm = self.accept.best_match(supported) + return bm or 'application/json' + + def get_content_type(self, allowed_content_types): + """Determine content type of the request body.""" + if not "Content-Type" in self.headers: + raise exception.InvalidContentType(content_type=None) + + content_type = self.content_type + + if content_type not in allowed_content_types: + raise exception.InvalidContentType(content_type=content_type) + else: + return content_type + + +class JSONRequestDeserializer(object): + def has_body(self, request): + """ + Returns whether a Webob.Request object will possess an entity body. + + :param request: Webob.Request object + """ + if 'transfer-encoding' in request.headers: + return True + elif request.content_length > 0: + return True + + return False + + def from_json(self, datastring): + return json.loads(datastring) + + def default(self, request): + if self.has_body(request): + return {'body': self.from_json(request.body)} + else: + return {} + + +class JSONResponseSerializer(object): + + def to_json(self, data): + def sanitizer(obj): + if isinstance(obj, datetime.datetime): + return obj.isoformat() + return obj + + return json.dumps(data, default=sanitizer) + + def default(self, response, result): + response.content_type = 'application/json' + response.body = self.to_json(result) + + +class Resource(object): + """ + WSGI app that handles (de)serialization and controller dispatch. + + Reads routing information supplied by RoutesMiddleware and calls + the requested action method upon its deserializer, controller, + and serializer. Those three objects may implement any of the basic + controller action methods (create, update, show, index, delete) + along with any that may be specified in the api router. A 'default' + method may also be implemented to be used in place of any + non-implemented actions. Deserializer methods must accept a request + argument and return a dictionary. Controller methods must accept a + request argument. Additionally, they must also accept keyword + arguments that represent the keys returned by the Deserializer. They + may raise a webob.exc exception or return a dict, which will be + serialized by requested content type. + """ + def __init__(self, controller, deserializer, serializer): + """ + :param controller: object that implement methods created by routes lib + :param deserializer: object that supports webob request deserialization + through controller-like actions + :param serializer: object that supports webob response serialization + through controller-like actions + """ + self.controller = controller + self.serializer = serializer + self.deserializer = deserializer + + @webob.dec.wsgify(RequestClass=Request) + def __call__(self, request): + """WSGI method that controls (de)serialization and method dispatch.""" + action_args = self.get_action_args(request.environ) + action = action_args.pop('action', None) + + deserialized_request = self.dispatch(self.deserializer, + action, request) + action_args.update(deserialized_request) + + action_result = self.dispatch(self.controller, action, + request, **action_args) + try: + response = webob.Response(request=request) + self.dispatch(self.serializer, action, response, action_result) + return response + + # return unserializable result (typically a webob exc) + except Exception: + return action_result + + def dispatch(self, obj, action, *args, **kwargs): + """Find action-specific method on self and call it.""" + try: + method = getattr(obj, action) + except AttributeError: + method = getattr(obj, 'default') + + return method(*args, **kwargs) + + def get_action_args(self, request_environment): + """Parse dictionary created by routes library.""" + try: + args = request_environment['wsgiorg.routing_args'][1].copy() + except Exception: + return {} + + try: + del args['controller'] + except KeyError: + pass + + try: + del args['format'] + except KeyError: + pass + + return args + + +class BasePasteFactory(object): + + """A base class for paste app and filter factories. + + Sub-classes must override the KEY class attribute and provide + a __call__ method. + """ + + KEY = None + + def __init__(self, conf): + self.conf = conf + + def __call__(self, global_conf, **local_conf): + raise NotImplementedError + + def _import_factory(self, local_conf): + """Import an app/filter class. + + Lookup the KEY from the PasteDeploy local conf and import the + class named there. This class can then be used as an app or + filter factory. + + Note we support the : format. + + Note also that if you do e.g. + + key = + value + + then ConfigParser returns a value with a leading newline, so + we strip() the value before using it. + """ + class_name = local_conf[self.KEY].replace(':', '.').strip() + return utils.import_class(class_name) + + +class AppFactory(BasePasteFactory): + + """A Generic paste.deploy app factory. + + This requires heat.app_factory to be set to a callable which returns a + WSGI app when invoked. The format of the name is : e.g. + + [app:apiv1app] + paste.app_factory = heat.common.wsgi:app_factory + heat.app_factory = heat.api.v1:API + + The WSGI app constructor must accept a ConfigOpts object and a local config + dict as its two arguments. + """ + + KEY = 'heat.app_factory' + + def __call__(self, global_conf, **local_conf): + """The actual paste.app_factory protocol method.""" + factory = self._import_factory(local_conf) + return factory(self.conf, **local_conf) + + +class FilterFactory(AppFactory): + + """A Generic paste.deploy filter factory. + + This requires heat.filter_factory to be set to a callable which returns a + WSGI filter when invoked. The format is : e.g. + + [filter:cache] + paste.filter_factory = heat.common.wsgi:filter_factory + heat.filter_factory = heat.api.middleware.cache:CacheFilter + + The WSGI filter constructor must accept a WSGI app, a ConfigOpts object and + a local config dict as its three arguments. + """ + + KEY = 'heat.filter_factory' + + def __call__(self, global_conf, **local_conf): + """The actual paste.filter_factory protocol method.""" + factory = self._import_factory(local_conf) + + def filter(app): + return factory(app, self.conf, **local_conf) + + return filter + + +def setup_paste_factories(conf): + """Set up the generic paste app and filter factories. + + Set things up so that: + + paste.app_factory = heat.common.wsgi:app_factory + + and + + paste.filter_factory = heat.common.wsgi:filter_factory + + work correctly while loading PasteDeploy configuration. + + The app factories are constructed at runtime to allow us to pass a + ConfigOpts object to the WSGI classes. + + :param conf: a ConfigOpts object + """ + global app_factory, filter_factory + app_factory = AppFactory(conf) + filter_factory = FilterFactory(conf) + + +def teardown_paste_factories(): + """Reverse the effect of setup_paste_factories().""" + global app_factory, filter_factory + del app_factory + del filter_factory + + +def paste_deploy_app(paste_config_file, app_name, conf): + """Load a WSGI app from a PasteDeploy configuration. + + Use deploy.loadapp() to load the app from the PasteDeploy configuration, + ensuring that the supplied ConfigOpts object is passed to the app and + filter constructors. + + :param paste_config_file: a PasteDeploy config file + :param app_name: the name of the app/pipeline to load from the file + :param conf: a ConfigOpts object to supply to the app and its filters + :returns: the WSGI app + """ + setup_paste_factories(conf) + try: + return deploy.loadapp("config:%s" % paste_config_file, name=app_name) + finally: + teardown_paste_factories() diff --git a/heat/version.py b/heat/version.py new file mode 100644 index 0000000000..e87460bdd6 --- /dev/null +++ b/heat/version.py @@ -0,0 +1,46 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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. + +try: + from glance.vcsversion import version_info +except ImportError: + version_info = {'branch_nick': u'LOCALBRANCH', + 'revision_id': 'LOCALREVISION', + 'revno': 0} + +GLANCE_VERSION = ['2012', '1', None] +YEAR, COUNT, REVSISION = GLANCE_VERSION + +FINAL = False # This becomes true at Release Candidate time + + +def canonical_version_string(): + return '.'.join(filter(None, GLANCE_VERSION)) + + +def version_string(): + if FINAL: + return canonical_version_string() + else: + return '%s-dev' % (canonical_version_string(),) + + +def vcs_version_string(): + return "%s:%s" % (version_info['branch_nick'], version_info['revision_id']) + + +def version_string_with_vcs(): + return "%s-%s" % (canonical_version_string(), vcs_version_string()) diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000000..0248028351 --- /dev/null +++ b/pylintrc @@ -0,0 +1,27 @@ +[Messages Control] +# W0511: TODOs in code comments are fine. +# W0142: *args and **kwargs are fine. +# W0622: Redefining id is fine. +disable-msg=W0511,W0142,W0622 + +[Basic] +# Variable names can be 1 to 31 characters long, with lowercase and underscores +variable-rgx=[a-z_][a-z0-9_]{0,30}$ + +# Argument names can be 2 to 31 characters long, with lowercase and underscores +argument-rgx=[a-z_][a-z0-9_]{1,30}$ + +# Method names should be at least 3 characters long +# and be lowecased with underscores +method-rgx=[a-z_][a-z0-9_]{2,50}$ + +# Module names matching nova-* are ok (files in bin/) +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+)|(nova-[a-z0-9_-]+))$ + +# Don't require docstrings on tests. +no-docstring-rgx=((__.*__)|([tT]est.*)|setUp|tearDown)$ + +[Design] +max-public-methods=100 +min-public-methods=0 +max-args=6 diff --git a/templates/getting_started.template b/templates/getting_started.template new file mode 100644 index 0000000000..4fd4b7ac21 --- /dev/null +++ b/templates/getting_started.template @@ -0,0 +1,40 @@ +{ + "Parameters" : { + "KeyName" : { + "Description" : "Name of an existing EC2 KeyPair to enable SSH access to the instance", + "Type" : "String" + } + }, + + "Mappings" : { + "RegionMap" : { + "us-east-1" : { + "AMI" : "ami-76f0061f" + }, + "us-west-1" : { + "AMI" : "ami-655a0a20" + }, + "eu-west-1" : { + "AMI" : "ami-7fd4e10b" + }, + "ap-southeast-1" : { + "AMI" : "ami-72621c20" + }, + "ap-northeast-1" : { + "AMI" : "ami-8e08a38f" + } + } + }, + + "Resources" : { + "Ec2Instance" : { + "Type" : "AWS::EC2::Instance", + "Properties" : { + "KeyName" : { "Ref" : "KeyName" }, + "ImageId" : { "Fn::FindInMap" : [ "RegionMap", { "Ref" : "AWS::Region" }, "AMI" ]}, + "UserData" : { "Fn::Base64" : "80" } + } + } + } +} +