From 7b6cd50ecd9c539431dab2d3627aa42f1a2be6fd Mon Sep 17 00:00:00 2001 From: Dina Belova Date: Mon, 2 Sep 2013 19:47:26 +0400 Subject: [PATCH] Implement REST API. Change-Id: I7b63c692f153d766ddd3ab261bdc0c1715934c0b --- climate/api/__init__.py | 14 +++ climate/api/app.py | 121 ++++++++++++++++++++ climate/api/context.py | 28 +++++ climate/api/service.py | 71 ++++++++++++ climate/api/utils.py | 227 ++++++++++++++++++++++++++++++++++++++ climate/api/v1_0.py | 67 +++++++++++ climate/api/validation.py | 62 +++++++++++ climate/cmd/api.py | 72 ++++++++++++ climate/config.py | 39 +++++++ climate/exceptions.py | 50 +++++++++ climate/utils/service.py | 13 --- requirements.txt | 4 + setup.cfg | 1 + 13 files changed, 756 insertions(+), 13 deletions(-) create mode 100644 climate/api/__init__.py create mode 100644 climate/api/app.py create mode 100644 climate/api/context.py create mode 100644 climate/api/service.py create mode 100644 climate/api/utils.py create mode 100644 climate/api/v1_0.py create mode 100644 climate/api/validation.py create mode 100644 climate/cmd/api.py create mode 100644 climate/config.py create mode 100644 climate/exceptions.py diff --git a/climate/api/__init__.py b/climate/api/__init__.py new file mode 100644 index 00000000..e3f3e8d4 --- /dev/null +++ b/climate/api/__init__.py @@ -0,0 +1,14 @@ +# Copyright (c) 2013 Mirantis 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. diff --git a/climate/api/app.py b/climate/api/app.py new file mode 100644 index 00000000..0e438f04 --- /dev/null +++ b/climate/api/app.py @@ -0,0 +1,121 @@ +# Copyright (c) 2013 Mirantis 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. + +import eventlet +import flask +from keystoneclient.middleware import auth_token +from oslo.config import cfg +from werkzeug import exceptions as werkzeug_exceptions + +from climate.api import utils as api_utils +from climate.api import v1_0 as api_v1_0 +from climate import context +from climate.openstack.common import log +from climate.openstack.common.middleware import debug + +LOG = log.getLogger(__name__) + +eventlet.monkey_patch( + os=True, select=True, socket=True, thread=True, time=True) + + +opts = [ + cfg.StrOpt('os_auth_protocol', + default='http', + help='Protocol used to access OpenStack Identity service'), + cfg.StrOpt('os_auth_host', + default='127.0.0.1', + help='IP or hostname of machine on which OpenStack Identity ' + 'service is located'), + cfg.StrOpt('os_auth_port', + default='35357', + help='Port of OpenStack Identity service'), + cfg.StrOpt('os_admin_username', + default='admin', + help='This OpenStack user is used to verify provided tokens. ' + 'The user must have admin role in ' + 'tenant'), + cfg.StrOpt('os_admin_password', + default='nova', + help='Password of the admin user'), + cfg.StrOpt('os_admin_tenant_name', + default='admin', + help='Name of tenant where the user is admin'), + cfg.StrOpt('os_auth_version', + default='v2.0', + help='By default use Keystone API v2.0.'), +] + +CONF = cfg.CONF +CONF.register_opts(opts) + + +def make_json_error(ex): + if isinstance(ex, werkzeug_exceptions.HTTPException): + status_code = ex.code + description = ex.description + else: + status_code = 500 + description = str(ex) + return api_utils.render({'error': status_code, + 'error_message': description}, + status=status_code) + + +def version_list(): + return api_utils.render({ + "versions": [ + {"id": "v1.0", "status": "CURRENT"}, + ], + }) + + +def teardown_request(_ex=None): + context.Context.clear() + + +def make_app(): + """App builder (wsgi). + + Entry point for Climate REST API server. + """ + app = flask.Flask('climate.api') + + app.route('/', methods=['GET'])(version_list) + app.teardown_request(teardown_request) + app.register_blueprint(api_v1_0.rest, url_prefix='/v1') + + for code in werkzeug_exceptions.default_exceptions.iterkeys(): + app.error_handler_spec[None][code] = make_json_error + + if CONF.debug and not CONF.log_exchange: + LOG.debug('Logging of request/response exchange could be enabled using' + ' flag --log-exchange') + + if CONF.log_exchange: + app.wsgi_app = debug.Debug.factory(app.config)(app.wsgi_app) + + app.wsgi_app = auth_token.filter_factory( + app.config, + auth_host=CONF.os_auth_host, + auth_port=CONF.os_auth_port, + auth_protocol=CONF.os_auth_protocol, + admin_user=CONF.os_admin_username, + admin_password=CONF.os_admin_password, + admin_tenant_name=CONF.os_admin_tenant_name, + auth_version=CONF.os_auth_version, + )(app.wsgi_app) + + return app diff --git a/climate/api/context.py b/climate/api/context.py new file mode 100644 index 00000000..e7bb9d86 --- /dev/null +++ b/climate/api/context.py @@ -0,0 +1,28 @@ +# Copyright (c) 2013 Mirantis 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. + +from climate import context + + +def ctx_from_headers(headers): + return context.Context( + user_id=headers['X-User-Id'], + tenant_id=headers['X-Tenant-Id'], + auth_token=headers['X-Auth-Token'], + service_catalog=headers['X-Service-Catalog'], + user_name=headers['X-User-Name'], + tenant_name=headers['X-Tenant-Name'], + roles=map(unicode.strip, headers['X-Roles'].split(',')), + ) diff --git a/climate/api/service.py b/climate/api/service.py new file mode 100644 index 00000000..d9aeeb17 --- /dev/null +++ b/climate/api/service.py @@ -0,0 +1,71 @@ +# Copyright (c) 2013 Mirantis 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. + +from climate.openstack.common import log as logging + + +LOG = logging.getLogger(__name__) + + +## Leases operations + +def get_leases(): + """List all existing leases.""" + pass + + +def create_lease(data): + """Create new lease. + + :param data: New lease characteristics. + :type data: dict + """ + pass + + +def get_lease(lease_id): + """Get lease by its ID. + + :param lease_id: ID of the lease in Climate DB. + :type lease_id: str + """ + pass + + +def update_lease(lease_id, data): + """Update lease. Only name changing and prolonging may be proceeded. + + :param lease_id: ID of the lease in Climate DB. + :type lease_id: str + :param data: New lease characteristics. + :type data: dict + """ + pass + + +def delete_lease(lease_id): + """Delete specified lease. + + :param lease_id: ID of the lease in Climate DB. + :type lease_id: str + """ + pass + + +## Plugins operations + +def get_plugins(): + """List all possible plugins.""" + pass diff --git a/climate/api/utils.py b/climate/api/utils.py new file mode 100644 index 00000000..3d63bfbf --- /dev/null +++ b/climate/api/utils.py @@ -0,0 +1,227 @@ +# Copyright (c) 2013 Mirantis 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. + +import traceback + +import flask +from werkzeug import datastructures + +from climate.api import context +from climate import exceptions as ex +from climate.openstack.common.deprecated import wsgi +from climate.openstack.common import log as logging + +LOG = logging.getLogger(__name__) + + +class Rest(flask.Blueprint): + """REST helper class.""" + + def get(self, rule, status_code=200): + return self._mroute('GET', rule, status_code) + + def post(self, rule, status_code=202): + return self._mroute('POST', rule, status_code) + + def put(self, rule, status_code=202): + return self._mroute('PUT', rule, status_code) + + def delete(self, rule, status_code=204): + return self._mroute('DELETE', rule, status_code) + + def _mroute(self, methods, rule, status_code=None, **kw): + """Route helper method.""" + if type(methods) is str: + methods = [methods] + return self.route(rule, methods=methods, status_code=status_code, **kw) + + def route(self, rule, **options): + """Routes REST method and its params to the actual request.""" + status = options.pop('status_code', None) + file_upload = options.pop('file_upload', False) + + def decorator(func): + endpoint = options.pop('endpoint', func.__name__) + + def handler(**kwargs): + LOG.debug("Rest.route.decorator.handler, kwargs=%s", kwargs) + + _init_resp_type(file_upload) + + # update status code + if status: + flask.request.status_code = status + + if flask.request.method in ['POST', 'PUT']: + kwargs['data'] = request_data() + + with context.ctx_from_headers(flask.request.headers): + try: + return func(**kwargs) + except ex.ClimateException as e: + return bad_request(e) + except Exception as e: + return internal_error(500, 'Internal Server Error', e) + + self.add_url_rule(rule, endpoint, handler, **options) + self.add_url_rule(rule + '.json', endpoint, handler, **options) + + return func + + return decorator + + +RT_JSON = datastructures.MIMEAccept([("application/json", 1)]) + + +def _init_resp_type(file_upload): + """Extracts response content type.""" + + # get content type from Accept header + resp_type = flask.request.accept_mimetypes + + # url /foo.json + if flask.request.path.endswith('.json'): + resp_type = RT_JSON + + flask.request.resp_type = resp_type + + # set file upload flag + flask.request.file_upload = file_upload + + +def render(result=None, response_type=None, status=None, **kwargs): + """Render response to return.""" + if not result: + result = {} + if type(result) is dict: + result.update(kwargs) + elif kwargs: + # can't merge kwargs into the non-dict res + abort_and_log(500, "Non-dict and non-empty kwargs passed to render.") + + status_code = getattr(flask.request, 'status_code', None) + if status: + status_code = status + if not status_code: + status_code = 200 + + if not response_type: + response_type = getattr(flask.request, 'resp_type', RT_JSON) + + serializer = None + if "application/json" in response_type: + response_type = RT_JSON + serializer = wsgi.JSONDictSerializer() + else: + abort_and_log(400, "Content type '%s' isn't supported" % response_type) + + body = serializer.serialize(result) + response_type = str(response_type) + + return flask.Response(response=body, status=status_code, + mimetype=response_type) + + +def request_data(): + """Method called to process POST and PUT REST methods.""" + if hasattr(flask.request, 'parsed_data'): + return flask.request.parsed_data + + if not flask.request.content_length > 0: + LOG.debug("Empty body provided in request") + return dict() + + if flask.request.file_upload: + return flask.request.data + + deserializer = None + content_type = flask.request.mimetype + if not content_type or content_type in RT_JSON: + deserializer = wsgi.JSONDeserializer() + else: + abort_and_log(400, "Content type '%s' isn't supported" % content_type) + + # parsed request data to avoid unwanted re-parsings + parsed_data = deserializer.deserialize(flask.request.data)['body'] + flask.request.parsed_data = parsed_data + + return flask.request.parsed_data + + +def get_request_args(): + return flask.request.args + + +def abort_and_log(status_code, descr, exc=None): + """Process occurred errors.""" + LOG.error("Request aborted with status code %s and message '%s'", + status_code, descr) + + if exc is not None: + LOG.error(traceback.format_exc()) + + flask.abort(status_code, description=descr) + + +def render_error_message(error_code, error_message, error_name): + """Render nice error message.""" + message = { + "error_code": error_code, + "error_message": error_message, + "error_name": error_name + } + + resp = render(message) + resp.status_code = error_code + + return resp + + +def internal_error(status_code, descr, exc=None): + """Called if internal error occurred.""" + LOG.error("Request aborted with status code %s and message '%s'", + status_code, descr) + + if exc is not None: + LOG.error(traceback.format_exc()) + + error_code = "INTERNAL_SERVER_ERROR" + if status_code == 501: + error_code = "NOT_IMPLEMENTED_ERROR" + + return render_error_message(status_code, descr, error_code) + + +def bad_request(error): + """Called if Climate exception occurred.""" + error_code = 400 + + LOG.debug("Validation Error occurred: " + "error_code=%s, error_message=%s, error_name=%s", + error_code, error.message, error.code) + + return render_error_message(error_code, error.message, error.code) + + +def not_found(error): + """Called if object was not found.""" + error_code = 404 + + LOG.debug("Not Found exception occurred: " + "error_code=%s, error_message=%s, error_name=%s", + error_code, error.message, error.code) + + return render_error_message(error_code, error.message, error.code) diff --git a/climate/api/v1_0.py b/climate/api/v1_0.py new file mode 100644 index 00000000..4c7d4c12 --- /dev/null +++ b/climate/api/v1_0.py @@ -0,0 +1,67 @@ +# Copyright (c) 2013 Mirantis 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. + +from climate.api import service +from climate.api import utils as api_utils +from climate.api import validation +from climate.openstack.common import log as logging + +LOG = logging.getLogger(__name__) + +rest = api_utils.Rest('v1_0', __name__) + + +## Leases operations + +@rest.get('/leases') +def leases_list(): + """List all existing leases.""" + return api_utils.render(leases=service.get_leases()) + + +@rest.post('/leases') +def leases_create(data): + """Create new lease.""" + return api_utils.render(lease=service.create_lease(data)) + + +@rest.get('/leases/') +@validation.check_exists(service.get_lease, 'lease_id') +def leases_get(lease_id): + """Get lease by its ID.""" + return api_utils.render(lease=service.get_lease(lease_id)) + + +@rest.put('/leases/') +@validation.check_exists(service.get_lease, 'lease_id') +def leases_update(lease_id, data): + """Update lease. Only name changing and prolonging may be proceeded.""" + return api_utils.render(lease=service.update_lease(lease_id, data)) + + +@rest.delete('/leases/') +@validation.check_exists(service.get_lease, 'lease_id') +def leases_delete(lease_id): + """Delete specified lease.""" + service.delete_lease(lease_id) + return api_utils.render() + + +## Plugins operations + +@rest.get('/plugins') +def plugins_list(): + """List all possible plugins.""" + return api_utils.render(plugins=service.get_plugins()) diff --git a/climate/api/validation.py b/climate/api/validation.py new file mode 100644 index 00000000..16376abc --- /dev/null +++ b/climate/api/validation.py @@ -0,0 +1,62 @@ +# Copyright (c) 2013 Mirantis 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. + +import functools + +import six + +from climate.api import utils as api_utils +from climate import exceptions + + +def check_exists(get_function, object_id=None, **get_args): + """Check object exists. + + :param get_function: Method to call to get object. + :type get_function: function + :param object_id: ID of the object to get. + :type object_id: str + :param get_args: Other params to pass to the get_function method. + :type get_args: dict + """ + def decorator(func): + """Decorate method to check object existing.""" + if object_id is not None: + get_args['id'] = object_id + + @functools.wraps(func) + def handler(*args, **kwargs): + """Decorator handler.""" + + get_kwargs = {} + for k, v in six.iteritems(get_args): + get_kwargs[k] = kwargs[v] + + try: + obj = get_function(**get_kwargs) + except exceptions.NotFound: + obj = None + if obj is None: + e = exceptions.NotFound( + get_kwargs, + template='Object with %s not found', + ) + return api_utils.not_found(e) + + return func(*args, **kwargs) + + return handler + + return decorator diff --git a/climate/cmd/api.py b/climate/cmd/api.py new file mode 100644 index 00000000..52cc6949 --- /dev/null +++ b/climate/cmd/api.py @@ -0,0 +1,72 @@ +# Copyright (c) 2013 Mirantis 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. + +import gettext +import os +import socket +import sys + +import eventlet +from eventlet import wsgi +from oslo.config import cfg + + +gettext.install('climate', unicode=1) + +from climate.api import app as api_app +from climate import config +from climate.openstack.common import log as logging +from climate.utils import service as service_utils + + +opts = [ + cfg.StrOpt('host', default=socket.getfqdn(), + help='Name of this node. This can be an opaque identifier. ' + 'It is not necessarily a hostname, FQDN, or IP address. ' + 'However, the node name must be valid within ' + 'an AMQP key, and if using ZeroMQ, a valid ' + 'hostname, FQDN, or IP address'), + cfg.IntOpt('port', default=1234, + help='Port that will be used to listen on'), +] + + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF +CONF.register_cli_opts(opts) + + +def main(): + """Entry point to start Climate API wsgi server.""" + possible_topdir = os.path.join(os.path.abspath(sys.argv[0]), + os.pardir, os.pardir) + possible_topdir = os.path.normpath(possible_topdir) + + dev_conf = os.path.join(possible_topdir, 'etc', 'climate', 'climate.conf') + config_files = None + + if os.path.exists(dev_conf): + config_files = [dev_conf] + + config.parse_configs(sys.argv[1:], config_files) + service_utils.prepare_service(sys.argv) + logging.setup("climate") + app = api_app.make_app() + + wsgi.server(eventlet.listen((CONF.host, CONF.port), backlog=500), app) + + +if __name__ == '__main__': + main() diff --git a/climate/config.py b/climate/config.py new file mode 100644 index 00000000..5f4f07b4 --- /dev/null +++ b/climate/config.py @@ -0,0 +1,39 @@ +# Copyright (c) 2013 Mirantis 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. + +from oslo.config import cfg + +cli_opts = [ + cfg.BoolOpt('log-exchange', default=False, + help='Log request/response exchange details: environ, ' + 'headers and bodies'), +] + +CONF = cfg.CONF +CONF.register_cli_opts(cli_opts) + +ARGV = [] + + +def parse_configs(argv=None, conf_files=None): + """Parse Climate configuration file.""" + if argv is not None: + global ARGV + ARGV = argv + try: + CONF(ARGV, project='climate', default_config_files=conf_files) + except cfg.RequiredOptError as roe: + raise RuntimeError("Option '%s' is required for config group " + "'%s'" % (roe.opt_name, roe.group.name)) diff --git a/climate/exceptions.py b/climate/exceptions.py new file mode 100644 index 00000000..eb2546d0 --- /dev/null +++ b/climate/exceptions.py @@ -0,0 +1,50 @@ +# Copyright (c) 2013 Mirantis 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. + + +class ClimateException(Exception): + """Base Exception for the Climate. + + To correctly use this class, inherit from it and define + a 'message' and 'code' properties. + """ + template = "An unknown exception occurred" + code = "UNKNOWN_EXCEPTION" + + def __init__(self, *args, **kwargs): + super(ClimateException, self).__init__(*args) + template = kwargs.pop('template', None) + if template: + self.template = template + + def __str__(self): + return self.template % self.args + + def __repr__(self): + if self.template != type(self).template: + tmpl = ", template=%r" % (self.template,) + else: + tmpl = "" + args = ", ".join(map(repr, self.args)) + return "%s(%s%s)" % (type(self).__name__, args, tmpl) + + +class NotFound(ClimateException): + """Object not found exception.""" + template = "Object not found" + code = "NOT_FOUND" + + def __init__(self, *args, **kwargs): + super(NotFound, self).__init__(*args, **kwargs) diff --git a/climate/utils/service.py b/climate/utils/service.py index 34368f62..8e66de4d 100644 --- a/climate/utils/service.py +++ b/climate/utils/service.py @@ -15,25 +15,12 @@ # License for the specific language governing permissions and limitations # under the License. -import socket - from oslo.config import cfg from climate.openstack.common import log from climate.openstack.common import rpc -cfg.CONF.register_opts([ - cfg.StrOpt('host', - default=socket.getfqdn(), - help='Name of this node. This can be an opaque identifier. ' - 'It is not necessarily a hostname, FQDN, or IP address. ' - 'However, the node name must be valid within ' - 'an AMQP key, and if using ZeroMQ, a valid ' - 'hostname, FQDN, or IP address'), -]) - - def prepare_service(argv=[]): rpc.set_defaults(control_exchange='climate') cfg.set_defaults(log.log_opts, diff --git a/requirements.txt b/requirements.txt index f106daef..c208a7cf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,12 @@ pbr>=0.5.21,<1.0 eventlet>=0.13.0 +Flask>=0.10,<1.0a0 iso8601>=0.1.4 oslo.config>=1.2.0 python-novaclient>=2.15.0 netaddr +python-keystoneclient>=0.3.2 +Routes>=1.12.3 SQLAlchemy>=0.7.8,<=0.7.99 +WebOb>=1.2.3,<1.3a0 diff --git a/setup.cfg b/setup.cfg index 2730e772..de44ba1d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,6 +28,7 @@ packages = [entry_points] console_scripts = + climate-api=climate.cmd.api:main climate-scheduler=climate.cmd.scheduler:main climate-rpc-zmq-receiver=climate.cmd.rpc_zmq_receiver:main