diff --git a/bin/heat-api b/bin/heat-api new file mode 100755 index 0000000000..201abe9505 --- /dev/null +++ b/bin/heat-api @@ -0,0 +1,57 @@ +#!/usr/bin/env python +# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# +# 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. An OpenStack ReST API to Heat. +""" + +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 + +from heat.openstack.common import cfg +from heat.openstack.common import log as logging + +LOG = logging.getLogger('heat.api') + +if __name__ == '__main__': + try: + cfg.CONF(project='heat', prog='heat-api') + config.setup_logging() + config.register_api_opts() + + app = config.load_paste_app() + + port = cfg.CONF.bind_port + host = cfg.CONF.bind_host + LOG.info('Starting Heat ReST API on %s:%s' % (host, port)) + server = wsgi.Server() + server.start(app, cfg.CONF, default_port=port) + server.wait() + except RuntimeError, e: + sys.exit("ERROR: %s" % e) diff --git a/etc/heat/heat-api-paste.ini b/etc/heat/heat-api-paste.ini new file mode 100644 index 0000000000..fc8afd3db0 --- /dev/null +++ b/etc/heat/heat-api-paste.ini @@ -0,0 +1,83 @@ + +# Default pipeline +[pipeline:heat-api] +pipeline = versionnegotiation authtoken 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 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 authtoken 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 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 authtoken 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 auth-context cache cachemanage apiv1app + +[app:apiv1app] +paste.app_factory = heat.common.wsgi:app_factory +heat.app_factory = heat.api.openstack.v1:API + +[filter:versionnegotiation] +paste.filter_factory = heat.common.wsgi:filter_factory +heat.filter_factory = heat.api.openstack:version_negotiation_filter + +[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.context:ContextMiddleware_filter_factory + +[filter:authtoken] +paste.filter_factory = heat.common.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/v2.0 + +# These must be set to your local values in order for the token +# authentication to work. +admin_tenant_name = admin +admin_user = admin +admin_password = verybadpass + +[filter:auth-context] +paste.filter_factory = heat.common.wsgi:filter_factory +heat.filter_factory = keystone.middleware.heat_auth_token:KeystoneContextMiddleware diff --git a/etc/heat/heat-api.conf b/etc/heat/heat-api.conf new file mode 100644 index 0000000000..d708293fc2 --- /dev/null +++ b/etc/heat/heat-api.conf @@ -0,0 +1,27 @@ +[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 = 8004 + +# Log to this file. Make sure the user running heat-api-cfn 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 + +rpc_backend=heat.openstack.common.rpc.impl_qpid diff --git a/heat/api/openstack/__init__.py b/heat/api/openstack/__init__.py new file mode 100644 index 0000000000..ff263f7c77 --- /dev/null +++ b/heat/api/openstack/__init__.py @@ -0,0 +1,22 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# +# 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.api.middleware.version_negotiation import VersionNegotiationFilter +from heat.api.openstack import versions + + +def version_negotiation_filter(app, conf, **local_conf): + return VersionNegotiationFilter(versions.Controller, app, + conf, **local_conf) diff --git a/heat/api/openstack/v1/__init__.py b/heat/api/openstack/v1/__init__.py new file mode 100644 index 0000000000..9b793d5e73 --- /dev/null +++ b/heat/api/openstack/v1/__init__.py @@ -0,0 +1,81 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# +# 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 json +import urlparse +import httplib +import routes +import gettext + +gettext.install('heat', unicode=1) + +from heat.api.openstack.v1 import stacks +from heat.common import wsgi + +from webob import Request +import webob +from heat import utils +from heat.common import context + +from heat.openstack.common import log as logging + +logger = logging.getLogger(__name__) + + +class API(wsgi.Router): + + """ + WSGI router for Heat v1 ReST API requests. + """ + + def __init__(self, conf, **local_conf): + self.conf = conf + mapper = routes.Mapper() + + stacks_resource = stacks.create_resource(conf) + + # Stack collection + mapper.connect("stack", "/{tenant_id}/stacks", + controller=stacks_resource, action="index", + conditions={'method': 'GET'}) + mapper.connect("stack", "/{tenant_id}/stacks", + controller=stacks_resource, action="create", + conditions={'method': 'POST'}) + + # Stack data + mapper.connect("stack", "/{tenant_id}/stacks/{stack_name}", + controller=stacks_resource, action="lookup") + mapper.connect("stack", "/{tenant_id}/stacks/{stack_name}/{stack_id}", + controller=stacks_resource, action="show", + conditions={'method': 'GET'}) + mapper.connect("stack", + "/{tenant_id}/stacks/{stack_name}/{stack_id}/template", + controller=stacks_resource, action="template", + conditions={'method': 'GET'}) + + # Stack update/delete + mapper.connect("stack", "/{tenant_id}/stacks/{stack_name}/{stack_id}", + controller=stacks_resource, action="update", + conditions={'method': 'PUT'}) + mapper.connect("stack", "/{tenant_id}/stacks/{stack_name}/{stack_id}", + controller=stacks_resource, action="delete", + conditions={'method': 'DELETE'}) + + # Template handling + mapper.connect("stack", "/validate", + controller=stacks_resource, action="validate", + conditions={'method': 'POST'}) + + super(API, self).__init__(mapper) diff --git a/heat/api/openstack/v1/stacks.py b/heat/api/openstack/v1/stacks.py new file mode 100644 index 0000000000..c377af3138 --- /dev/null +++ b/heat/api/openstack/v1/stacks.py @@ -0,0 +1,341 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# +# 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 ReST API. +""" + +import httplib +import json +import os +import socket +import sys +import re +import urlparse +import webob +from webob import exc +from functools import wraps + +from heat.common import wsgi +from heat.common import config +from heat.common import context +from heat.common import exception +from heat import utils +from heat.engine import api as engine_api +from heat.engine import identifier +from heat.engine import rpcapi as engine_rpcapi + +from heat.openstack.common import rpc +import heat.openstack.common.rpc.common as rpc_common +from heat.openstack.common import log as logging + +logger = logging.getLogger('heat.api.openstack.v1.stacks') + +CREATE_PARAMS = ( + PARAM_STACK_NAME, + PARAM_TEMPLATE, + PARAM_TEMPLATE_URL, + PARAM_USER_PARAMS, +) = ( + 'stack_name', + 'template', + 'template_url', + 'parameters', +) + + +def json_parse(self, data, data_type): + try: + return json.loads(data) + except ValueError: + err_reason = "%s not in valid JSON format" % data_type + raise exc.HTTPBadRequest(explanation=err_reason) + + +def get_template(req): + """ + Get template file contents, either from local file or URL, in JSON format + """ + if PARAM_TEMPLATE in req.params: + return json_parse(req.params[PARAM_TEMPLATE], 'Template') + elif PARAM_TEMPLATE_URL in req.params: + logger.debug('Template URL %s' % req.params[PARAM_TEMPLATE_URL]) + url = urlparse.urlparse(req.params[PARAM_TEMPLATE_URL]) + err_reason = _("Could not retrieve template") + + try: + ConnType = (url.scheme == 'https' and httplib.HTTPSConnection + or httplib.HTTPConnection) + conn = ConnType(url.netloc) + + try: + conn.request("GET", url.path) + resp = conn.getresponse() + logger.info('status %d' % r1.status) + + if resp.status != 200: + raise exc.HTTPBadRequest(explanation=err_reason) + + return json_parse(resp.read(), 'Template') + finally: + conn.close() + except socket.gaierror: + raise exc.HTTPBadRequest(explanation=err_reason) + + raise exc.HTTPBadRequest(explanation=_("No template specified")) + + +def get_user_params(req): + """ + Get the user-supplied parameters for the stack in JSON format + """ + if PARAM_USER_PARAMS not in req.params: + return {} + + return json_parse(req.params[PARAM_USER_PARAMS], 'User Parameters') + + +def get_args(req): + params = req.params.items() + return dict((k, v) for k, v in params if k not in CREATE_PARAMS) + + +def tenant_local(handler): + @wraps(handler) + def handle_stack_method(controller, req, tenant_id, **kwargs): + req.context.tenant = tenant_id + return handler(controller, req, **kwargs) + + return handle_stack_method + + +def identified_stack(handler): + @tenant_local + @wraps(handler) + def handle_stack_method(controller, req, stack_name, stack_id, **kwargs): + stack_identity = identifier.HeatIdentifier(req.context.tenant, + stack_name, + stack_id) + return handler(controller, req, dict(stack_identity), **kwargs) + + return handle_stack_method + + +def stack_url(req, identity): + try: + stack_identity = identifier.HeatIdentifier(**identity) + except ValueError: + err_reason = _("Invalid Stack address") + raise exc.HTTPInternalServerError(explanation=err_reason) + + return req.relative_url(stack_identity.url_path(), True) + + +def format_stack(req, stack, keys=[]): + include_key = lambda k: k in keys if keys else True + + def transform(key, value): + if key == engine_api.STACK_ID: + return 'URL', stack_url(req, value) + elif key == engine_api.STACK_PARAMETERS: + return key, json.dumps(value) + + return key, value + + return dict(transform(k, v) for k, v in stack.items() if include_key(k)) + + +class StackController(object): + """ + WSGI controller for stacks resource in Heat v1 API + Implements the API actions + """ + + def __init__(self, options): + self.options = options + self.engine_rpcapi = engine_rpcapi.EngineAPI() + + def _remote_error(self, ex): + """ + Map rpc_common.RemoteError exceptions returned by the engine + to webob exceptions which can be used to return + properly formatted error responses. + """ + raise exc.HTTPBadRequest(explanation=str(ex)) + + def default(self, req, **args): + raise exc.HTTPNotFound() + + @tenant_local + def index(self, req): + """ + Lists summary information for all stacks + """ + + try: + # Note show_stack returns details for all stacks when called with + # no stack_name, we only use a subset of the result here though + stack_list = self.engine_rpcapi.show_stack(req.context, None) + except rpc_common.RemoteError as ex: + return self._remote_error(ex) + + summary_keys = (engine_api.STACK_ID, + engine_api.STACK_NAME, + engine_api.STACK_DESCRIPTION, + engine_api.STACK_STATUS, + engine_api.STACK_STATUS_DATA, + engine_api.STACK_CREATION_TIME, + engine_api.STACK_DELETION_TIME, + engine_api.STACK_UPDATED_TIME) + + stacks = stack_list['stacks'] + + return {'stacks': [format_stack(req, s, summary_keys) for s in stacks]} + + @tenant_local + def create(self, req): + """ + Create a new stack + """ + if PARAM_STACK_NAME not in req.params: + raise exc.HTTPBadRequest(explanation=_("No stack name specified")) + stack_name = req.params[PARAMS_STACK_NAME] + + stack_params = get_user_params(req) + template = get_template(req) + args = get_args(req) + + try: + identity = self.engine_rpcapi.create_stack(req.context, + stack_name, + template, + stack_params, + args) + except rpc_common.RemoteError as ex: + return self._remote_error(ex) + + raise exc.HTTPCreated(location=stack_url(req, identity)) + + @tenant_local + def lookup(self, req, stack_name): + """ + Redirect to the canonical URL for a stack + """ + + try: + identity = self.engine_rpcapi.identify_stack(req.context, + stack_name) + except rpc_common.RemoteError as ex: + return self._remote_error(ex) + + raise exc.HTTPFound(location=stack_url(req, identity)) + + @identified_stack + def show(self, req, identity): + """ + Gets detailed information for a stack + """ + + try: + stack_list = self.engine_rpcapi.show_stack(req.context, + identity) + except rpc_common.RemoteError as ex: + return self._remote_error(ex) + + if not stack_list['stacks']: + raise exc.HTTPNotFound() + + stack = stack_list['stacks'][0] + + return {'stack': format_stack(req, stack)} + + @identified_stack + def template(self, req, identity): + """ + Get the template body for an existing stack + """ + + try: + templ = self.engine_rpcapi.get_template(req.context, + identity) + except rpc_common.RemoteError as ex: + return self._remote_error(ex) + + if templ is None: + raise exc.HTTPNotFound() + + # TODO(zaneb): always set Content-type to application/json + return json.dumps(templ) + + @identified_stack + def update(self, req, identity): + """ + Update an existing stack with a new template and/or parameters + """ + stack_params = get_user_params(req) + template = get_template(req) + args = get_args(req) + + try: + res = self.engine_rpcapi.update_stack(req.context, + identity, + template, + stack_params, args) + except rpc_common.RemoteError as ex: + return self._remote_error(ex) + + raise exc.HTTPAccepted() + + @identified_stack + def delete(self, req, identity): + """ + Delete the specified stack + """ + + try: + res = self.engine_rpcapi.delete_stack(req.context, + identity, + cast=False) + + except rpc_common.RemoteError as ex: + return self._remote_error(ex) + + if res is not None: + raise exc.HTTPBadRequest(explanation=res['Error']) + + raise exc.HTTPNoContent() + + def validate_template(self, req): + """ + Implements the ValidateTemplate API action + Validates the specified template + """ + + template = get_template(req) + stack_params = get_user_params(req) + + try: + return self.engine_rpcapi.validate_template(req.context, + template, params) + except rpc_common.RemoteError as ex: + return self._remote_error(ex) + + +def create_resource(options): + """ + Stacks resource factory method. + """ + deserializer = wsgi.JSONRequestDeserializer() + return wsgi.Resource(StackController(options), deserializer) diff --git a/heat/api/openstack/versions.py b/heat/api/openstack/versions.py new file mode 100644 index 0000000000..a6407d4e2a --- /dev/null +++ b/heat/api/openstack/versions.py @@ -0,0 +1,59 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# +# 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 + + +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.0", + "status": "CURRENT", + "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/engine/identifier.py b/heat/engine/identifier.py index 61c93c03ce..7358cbc598 100644 --- a/heat/engine/identifier.py +++ b/heat/engine/identifier.py @@ -73,9 +73,9 @@ class HeatIdentifier(collections.Mapping): def url_path(self): ''' Return a URL-encoded path segment of a URL in the form: - //stacks// + /stacks// ''' - return '/%s/%s' % (urllib.quote(self.tenant, ''), self._tenant_path()) + return '/'.join((urllib.quote(self.tenant, ''), self._tenant_path())) def _tenant_path(self): ''' diff --git a/heat/tests/test_identifier.py b/heat/tests/test_identifier.py index 07ae958c39..aadf27ffb7 100644 --- a/heat/tests/test_identifier.py +++ b/heat/tests/test_identifier.py @@ -130,34 +130,34 @@ class IdentifierTest(unittest.TestCase): def test_url_path(self): hi = identifier.HeatIdentifier('t', 's', 'i', 'p') - self.assertEqual(hi.url_path(), '/t/stacks/s/i/p') + self.assertEqual(hi.url_path(), 't/stacks/s/i/p') def test_url_path_default(self): hi = identifier.HeatIdentifier('t', 's', 'i') - self.assertEqual(hi.url_path(), '/t/stacks/s/i') + self.assertEqual(hi.url_path(), 't/stacks/s/i') def test_tenant_escape(self): hi = identifier.HeatIdentifier(':/', 's', 'i') self.assertEqual(hi.tenant, ':/') - self.assertEqual(hi.url_path(), '/%3A%2F/stacks/s/i') + self.assertEqual(hi.url_path(), '%3A%2F/stacks/s/i') self.assertEqual(hi.arn(), 'arn:openstack:heat::%3A%2F:stacks/s/i') def test_name_escape(self): hi = identifier.HeatIdentifier('t', ':/', 'i') self.assertEqual(hi.stack_name, ':/') - self.assertEqual(hi.url_path(), '/t/stacks/%3A%2F/i') + self.assertEqual(hi.url_path(), 't/stacks/%3A%2F/i') self.assertEqual(hi.arn(), 'arn:openstack:heat::t:stacks/%3A%2F/i') def test_id_escape(self): hi = identifier.HeatIdentifier('t', 's', ':/') self.assertEqual(hi.stack_id, ':/') - self.assertEqual(hi.url_path(), '/t/stacks/s/%3A%2F') + self.assertEqual(hi.url_path(), 't/stacks/s/%3A%2F') self.assertEqual(hi.arn(), 'arn:openstack:heat::t:stacks/s/%3A%2F') def test_path_escape(self): hi = identifier.HeatIdentifier('t', 's', 'i', ':/') self.assertEqual(hi.path, '/:/') - self.assertEqual(hi.url_path(), '/t/stacks/s/i/%3A/') + self.assertEqual(hi.url_path(), 't/stacks/s/i/%3A/') self.assertEqual(hi.arn(), 'arn:openstack:heat::t:stacks/s/i/%3A/') def test_tenant_decode(self): diff --git a/install.sh b/install.sh index cf8aac9380..92bbd3cd21 100755 --- a/install.sh +++ b/install.sh @@ -14,22 +14,6 @@ LOG_DIR=/var/log/heat install -d $LOG_DIR -archive_file() { - local f=$1 - - if [ -e $CONF_PREFIX/$f ]; then - echo "Archiving configuration file $CONF_PREFIX/$f" >&2 - mv $CONF_PREFIX/$f $CONF_PREFIX/$f.bak - fi -} - -# Archive existing heat-api* config files in preparation -# for change to heat-api-cfn*, and future use of heat-api* -# for the OpenStack API. -archive_file etc/heat/heat-api.conf -archive_file etc/heat/heat-api-paste.ini - - install_dir() { local dir=$1 local prefix=$2 diff --git a/run_tests.sh b/run_tests.sh index 086a67690c..b867ef7b1a 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -46,7 +46,7 @@ function run_tests { function run_pep8 { echo "Running pep8..." PEP8_OPTIONS="--exclude=$PEP8_EXCLUDE --repeat" - PEP8_INCLUDE="bin/heat bin/heat-boto bin/heat-api-cfn bin/heat-engine heat tools setup.py heat/testing/runner.py" + PEP8_INCLUDE="bin/heat bin/heat-boto bin/heat-api-cfn bin/heat-api bin/heat-engine heat tools setup.py heat/testing/runner.py" ${wrapper} pep8 $PEP8_OPTIONS $PEP8_INCLUDE } diff --git a/setup.py b/setup.py index 360eb4a997..72b1d95683 100755 --- a/setup.py +++ b/setup.py @@ -44,6 +44,7 @@ setuptools.setup( 'Environment :: No Input/Output (Daemon)', ], scripts=['bin/heat', + 'bin/heat-api', 'bin/heat-api-cfn', 'bin/heat-api-cloudwatch', 'bin/heat-boto',