Add the beginnings of an OpenStack ReST API

So far only access to stacks (not resources and events) is implemented.

Change-Id: I9655e9441087ef60c06e67e2d6ae68ec4a3b2d11
Signed-off-by: Zane Bitter <zbitter@redhat.com>
This commit is contained in:
Zane Bitter 2012-09-25 16:13:35 +02:00
parent f26b218831
commit 9e237f5a66
12 changed files with 680 additions and 25 deletions

57
bin/heat-api Executable file
View File

@ -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)

View File

@ -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

27
etc/heat/heat-api.conf Normal file
View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -73,9 +73,9 @@ class HeatIdentifier(collections.Mapping):
def url_path(self):
'''
Return a URL-encoded path segment of a URL in the form:
/<tenant>/stacks/<stack_name>/<stack_id><path>
<tenant>/stacks/<stack_name>/<stack_id><path>
'''
return '/%s/%s' % (urllib.quote(self.tenant, ''), self._tenant_path())
return '/'.join((urllib.quote(self.tenant, ''), self._tenant_path()))
def _tenant_path(self):
'''

View File

@ -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):

View File

@ -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

View File

@ -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
}

View File

@ -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',