]> review.fuel-infra Code Review - openstack-build/heat-build.git/commitdiff
Initial commit (basics copied from glance)
authorAngus Salkeld <asalkeld@redhat.com>
Tue, 13 Mar 2012 10:48:07 +0000 (21:48 +1100)
committerAngus Salkeld <asalkeld@redhat.com>
Tue, 13 Mar 2012 10:48:07 +0000 (21:48 +1100)
Signed-off-by: Angus Salkeld <asalkeld@redhat.com>
30 files changed:
.gitignore [new file with mode: 0644]
LICENSE [new file with mode: 0644]
bin/heat [new file with mode: 0755]
bin/heat-api [new file with mode: 0755]
etc/heat-api [new file with mode: 0644]
etc/heat-api-paste.ini [new file with mode: 0644]
heat/__init__.py [new file with mode: 0644]
heat/api/__init__.py [new file with mode: 0644]
heat/api/middleware/__init__.py [new file with mode: 0644]
heat/api/middleware/context.py [new file with mode: 0644]
heat/api/middleware/version_negotiation.py [new file with mode: 0644]
heat/api/v1/__init__.py [new file with mode: 0644]
heat/api/v1/router.py [new file with mode: 0644]
heat/api/v1/stacks.py [new file with mode: 0644]
heat/api/versions.py [new file with mode: 0644]
heat/client.py [new file with mode: 0644]
heat/common/__init__.py [new file with mode: 0644]
heat/common/auth.py [new file with mode: 0644]
heat/common/cfg.py [new file with mode: 0644]
heat/common/client.py [new file with mode: 0644]
heat/common/config.py [new file with mode: 0644]
heat/common/context.py [new file with mode: 0644]
heat/common/crypt.py [new file with mode: 0644]
heat/common/exception.py [new file with mode: 0644]
heat/common/policy.py [new file with mode: 0644]
heat/common/utils.py [new file with mode: 0644]
heat/common/wsgi.py [new file with mode: 0644]
heat/version.py [new file with mode: 0644]
pylintrc [new file with mode: 0644]
templates/getting_started.template [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..89c25d9
--- /dev/null
@@ -0,0 +1,6 @@
+*.pyc
+*.swp
+*.log
+build
+dist
+heat/vcsversion.py
diff --git a/LICENSE b/LICENSE
new file mode 100644 (file)
index 0000000..68c771a
--- /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 (executable)
index 0000000..17533de
--- /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: No<F3>ne")
+    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 <command> [options] [args]
+
+Commands:
+
+    help <command>  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 (executable)
index 0000000..f0e6900
--- /dev/null
@@ -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 (file)
index 0000000..589102b
--- /dev/null
@@ -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 (file)
index 0000000..3f94af0
--- /dev/null
@@ -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 (file)
index 0000000..0c7fc6d
--- /dev/null
@@ -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 (file)
index 0000000..d65c689
--- /dev/null
@@ -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 (file)
index 0000000..d65c689
--- /dev/null
@@ -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 (file)
index 0000000..6f480aa
--- /dev/null
@@ -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 (file)
index 0000000..2d525d0
--- /dev/null
@@ -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 (file)
index 0000000..a7c2da0
--- /dev/null
@@ -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 (file)
index 0000000..6e1871d
--- /dev/null
@@ -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 (file)
index 0000000..222e242
--- /dev/null
@@ -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': '<stack NAME',
+                 'disk_format': '<DISK_FORMAT>',
+                 'container_format': '<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 (file)
index 0000000..07d112e
--- /dev/null
@@ -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 (file)
index 0000000..1c399b0
--- /dev/null
@@ -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 (file)
index 0000000..b606957
--- /dev/null
@@ -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 (file)
index 0000000..7484fc6
--- /dev/null
@@ -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 (file)
index 0000000..258776d
--- /dev/null
@@ -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 (file)
index 0000000..9c5154d
--- /dev/null
@@ -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:
+
+            <http|https>://<host>: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 (file)
index 0000000..d17dd75
--- /dev/null
@@ -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 (file)
index 0000000..8960ffc
--- /dev/null
@@ -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 (file)
index 0000000..cddd13b
--- /dev/null
@@ -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 (file)
index 0000000..2a3a8ad
--- /dev/null
@@ -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 (file)
index 0000000..1579409
--- /dev/null
@@ -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 (file)
index 0000000..1f8604d
--- /dev/null
@@ -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 (file)
index 0000000..abe07c6
--- /dev/null
@@ -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 <module>:<class> 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 <module>:<callable> 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 <module>:<callable> 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 (file)
index 0000000..e87460b
--- /dev/null
@@ -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 (file)
index 0000000..0248028
--- /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 (file)
index 0000000..4fd4b7a
--- /dev/null
@@ -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" }
+      }
+    }
+  }
+}
+